6Pure metric extraction + aggregation for the benchmark suite.
10Turn a raw provider ``usage`` dict (the one ``presenter.show_usage``
11receives — the only place ``reasoning_tokens`` and DeepSeek's prefix-
12cache split survive; ``provider_plugin.UsageStats`` drops the former
13and the harness wants both) into a typed :class:`CallMetrics`, then
14aggregate calls → turns → run totals. **Hit and miss tokens are kept
15distinct and never summed** — cache-hit is ~120x cheaper on DeepSeek
16V4-pro, so collapsing them would erase the very economics the suite
19All functions are pure: no I/O, no network, no global state. This is
20the hermetically-tested core (plan 136 §6 tests 1-2).
24- :class:`CallMetrics` — one API call's token figures.
25- :class:`TurnMetrics` — one scripted turn (its calls + TTFT + tool
26 names + wall-clock + iteration/tool counts + cold/warm label).
27- :func:`extract_call_metrics` — raw usage dict → :class:`CallMetrics`.
28- :func:`aggregate_turn` / :func:`aggregate_run` — sum the token
29 fields, hit/miss kept separate.
30- :func:`derived_full_miss_cost` — the deterministic cold-turn cost
31 (``prompt_tokens x miss-rate``), Plan 135's win #2.
43- ``extract_call_metrics`` never raises on a malformed/partial usage
44 dict — missing fields yield zeros, not ``KeyError``.
45- Cache hit and miss are independent fields; no function sums them.
46- Cache extraction delegates to the production
47 :func:`oc_chat.core.cache_usage.usage_prompt_cache_hit_miss` so the
48 numbers match the runtime exactly (and inherit its
49 DeepSeek-then-OpenAI fallback).
53- oc_diagnostics (structured debug logging via _dbg)
54- oc_chat.core.cache_usage (usage_prompt_cache_hit_miss — read-only)
56from __future__
import annotations
58from dataclasses
import dataclass, field
60from oc_diagnostics
import _dbg
64_dbg(
"OC_CHAT.MAIN",
"module",
"metrics loaded", 4)
67@dataclass(frozen=True)
69 """Per-API-call token figures, normalised from the raw usage dict."""
71 prompt_tokens: int = 0
72 completion_tokens: int = 0
74 reasoning_tokens: int = 0
75 cache_hit_tokens: int = 0
76 cache_miss_tokens: int = 0
81 """One scripted turn's measurements.
83 ``ttft_ms`` is the first iteration's time-to-first-token (``None``
84 when an iteration emitted only tool calls — never fabricated).
85 ``cold_label`` is one of ``"cold_unprewarmed"`` / ``"prewarmed"``
86 for turn 1, or ``"warmed"`` for turns 2+ (warmup-aware, plan §3.3).
90 cold_label: str =
"warmed"
91 calls: list[CallMetrics] = field(default_factory=list)
92 ttft_ms: float |
None =
None
93 wall_clock_ms: float = 0.0
95 main_loop_tool_names: list[str] = field(default_factory=list)
96 sub_loop_count: int = 0
97 sub_loop_tool_names_available: bool =
False
101 """Coerce a usage value to int, treating ``None``/garbage as 0."""
103 return int(value
or 0)
104 except (TypeError, ValueError):
109 """Extract reasoning tokens (DeepSeek nests them under details).
111 Primary location: ``completion_tokens_details.reasoning_tokens``.
112 Fallback: a top-level ``reasoning_tokens`` if a provider emits one.
114 details = raw_usage.get(
"completion_tokens_details")
115 if isinstance(details, dict)
and details.get(
"reasoning_tokens")
is not None:
116 return _as_int(details.get(
"reasoning_tokens"))
117 return _as_int(raw_usage.get(
"reasoning_tokens"))
121 """Normalise a single raw provider ``usage`` dict into :class:`CallMetrics`.
123 Never raises: a ``None`` or partial dict yields zero-filled metrics.
124 Cache hit/miss come from the production helper so they match the
125 runtime; reasoning tokens come from the raw nested field that
126 ``UsageStats`` does not carry.
128 if not isinstance(raw_usage, dict):
130 hit, miss = usage_prompt_cache_hit_miss(raw_usage)
132 prompt_tokens=
_as_int(raw_usage.get(
"prompt_tokens")),
133 completion_tokens=
_as_int(raw_usage.get(
"completion_tokens")),
134 total_tokens=
_as_int(raw_usage.get(
"total_tokens")),
136 cache_hit_tokens=int(hit),
137 cache_miss_tokens=int(miss),
142 """Sum a list of :class:`CallMetrics`. Hit and miss stay separate."""
144 prompt_tokens=sum(c.prompt_tokens
for c
in calls),
145 completion_tokens=sum(c.completion_tokens
for c
in calls),
146 total_tokens=sum(c.total_tokens
for c
in calls),
147 reasoning_tokens=sum(c.reasoning_tokens
for c
in calls),
148 cache_hit_tokens=sum(c.cache_hit_tokens
for c
in calls),
149 cache_miss_tokens=sum(c.cache_miss_tokens
for c
in calls),
154 """Sum one turn's calls into a single :class:`CallMetrics`."""
159 """Sum every call across every turn into the run total."""
160 all_calls: list[CallMetrics] = []
162 all_calls.extend(turn.calls)
167 """The deterministic cold-turn cost: a full cache-miss of the prompt.
169 ``prompt_tokens x miss-rate`` (rate is $/Mtok). This follows purely
170 from prompt size, so it proves Plan 135's win #2 (a slim prompt is
171 cheaper on the guaranteed-cold first turn) regardless of whatever
172 server cache state the run actually hit.
174 return (max(0, int(prompt_tokens)) / 1_000_000.0) * float(miss_rate_per_mtok)
180 "extract_call_metrics",
184 "derived_full_miss_cost",
int _as_int(object value)
CallMetrics extract_call_metrics(dict|None raw_usage)
int _reasoning_tokens(dict raw_usage)
CallMetrics aggregate_calls(list[CallMetrics] calls)
CallMetrics aggregate_turn(TurnMetrics turn)
CallMetrics aggregate_run(list[TurnMetrics] turns)
float derived_full_miss_cost(int prompt_tokens, float miss_rate_per_mtok)