Skip to main content

lean_ctx_proxy/
usage.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Real provider-reported token usage extraction.
3//!
4//! The proxy already rewrites requests; this module reads the *response* so the
5//! dashboard/terminal can show **measured** cost (the user's real provider bill)
6//! instead of an estimate. All three providers report the exact model and the
7//! billed token breakdown — including prompt-cache reads/writes — in the final
8//! event of a stream (or the body of a non-streaming response):
9//!
10//! - Anthropic: `message_start` carries model + input/cache tokens, the final
11//!   `message_delta` carries `output_tokens`. Non-streaming: one `usage` object.
12//! - OpenAI Responses: the `response.completed` event nests `response.usage`.
13//! - OpenAI Chat Completions: the final chunk carries `usage` (needs
14//!   `stream_options.include_usage`, which the proxy injects).
15//! - Gemini: every chunk carries `usageMetadata`; the last one has the totals.
16//!
17//! [`RealUsage`] normalizes every provider onto the four billable buckets that
18//! `lean_ctx::core::gain::model_pricing::ModelCost::estimate_usd` prices:
19//! uncached input, output (incl. reasoning/thoughts), cache-read, cache-write.
20
21use futures::{Stream, StreamExt};
22use serde_json::Value;
23
24pub use lean_ctx::core::proxy_usage::{ResponseObservation, UsageBoundary};
25
26/// LLM provider whose response shape a [`Scanner`] understands.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Provider {
29    Anthropic,
30    /// Covers both Chat Completions and the Responses API (same `usage` dialects
31    /// are detected by field name).
32    OpenAi,
33    Gemini,
34}
35
36impl Provider {
37    /// Maps the proxy's `provider_label` (`"Anthropic"`, `"OpenAI"`/`"ChatGPT"`, else Gemini).
38    pub fn from_label(label: &str) -> Self {
39        match label {
40            "Anthropic" | "Bedrock" => Self::Anthropic,
41            "OpenAI" | "ChatGPT" | "Azure" => Self::OpenAi,
42            _ => Self::Gemini,
43        }
44    }
45}
46
47/// One LLM turn's real, provider-reported usage, normalized to billable buckets.
48///
49/// `output_tokens` already includes reasoning/thinking tokens (they are billed at
50/// the output rate); `reasoning_tokens` is retained only for display.
51#[derive(Debug, Clone, Default, PartialEq)]
52pub struct RealUsage {
53    pub model: String,
54    /// Input tokens billed at the input rate (cache reads/writes excluded).
55    pub input_tokens: u64,
56    /// Output tokens billed at the output rate (includes reasoning/thoughts).
57    pub output_tokens: u64,
58    /// Input tokens served from the prompt cache (billed at the cache-read rate).
59    pub cache_read_tokens: u64,
60    /// Input tokens written to the prompt cache (Anthropic + OpenRouter models
61    /// with explicit cache-write pricing; 0 elsewhere).
62    pub cache_write_tokens: u64,
63    /// Reasoning/thinking subset of `output_tokens` (display only).
64    pub reasoning_tokens: u64,
65    /// USD the provider *actually charged* for this turn, when the response
66    /// reports it. OpenRouter: `usage.cost` in credits (≡ USD); for BYOK
67    /// requests the separate `cost_details.upstream_inference_cost` is added
68    /// when it differs from `cost` (#746 — non-BYOK mirrors cost there).
69    /// The measured figure beats any price-table estimate wherever both exist.
70    /// `None` for providers that report tokens only (Anthropic/OpenAI/Gemini).
71    pub provider_cost_usd: Option<f64>,
72    /// Holdout arms for this turn: output shaping (#895 Track B) and input
73    /// compression (#1905), each `None` when that holdout is off. Stamped from
74    /// the request, not parsed from the response.
75    pub cohort: super::holdout::Cohorts,
76    /// Request-side gateway context (enterprise#11/#17/#18): identity tags,
77    /// compression savings and baseline inputs, stamped from the request before
78    /// it left for the upstream. `None` outside the forward path (e.g. tests
79    /// that only parse response bodies).
80    pub wire: Option<Box<WireContext>>,
81    /// Present only for measured upstream responses, never cache hits or
82    /// synthetic parser inputs. Kept local in the existing usage meter.
83    pub response_observation: Option<ResponseObservation>,
84}
85
86/// Request-side context the forward path knows and the response scanner does
87/// not: who sent the request (gateway identity, enterprise#11), what the proxy
88/// saved on the wire, and the counterfactual-baseline inputs (enterprise#18).
89/// Travels inside [`RealUsage`] so `usage_meter::record` stays the single
90/// choke-point through which every measured turn flows.
91#[derive(Debug, Clone, Default, PartialEq)]
92pub struct WireContext {
93    /// Provider label (`Anthropic|OpenAI|ChatGPT|Gemini`) of the serving route.
94    pub provider: String,
95    /// Person tag from the gateway key (enterprise#11).
96    pub person: Option<String>,
97    /// Team tag from the gateway key.
98    pub team: Option<String>,
99    /// Project: `x-leanctx-project` header wins over the key's default project.
100    pub project: Option<String>,
101    /// Local project key of the folder the agent named in the request
102    /// (`super::project_attribution`); splits measured spend per project.
103    pub project_key: Option<String>,
104    /// Estimated tokens this request saved through wire compression
105    /// (bytes/4 heuristic, same basis as the proxy stats).
106    pub saved_tokens: u64,
107    /// Estimated request tokens BEFORE lean-ctx compression (bytes/4) — the
108    /// SEE-attribution input of the avoided-cost baseline (enterprise#18).
109    pub uncompressed_input_tokens: u64,
110    /// True when the serving upstream is a local/loopback endpoint — billed via
111    /// the transparent `local_shadow_rate`, never 0 (enterprise#15/#18).
112    pub is_local: bool,
113    /// Originally requested model when the router downgraded/aliased it
114    /// (enterprise#13); `None` for passthrough.
115    pub routed_from: Option<String>,
116    /// Provider-counted input tokens of the ORIGINAL (uncompressed) request,
117    /// from the free `count_tokens` probe (#701). `None` when metering is off
118    /// or no probe was spawned; an empty slot at read time (probe failed or
119    /// still in flight) degrades the row to the local estimate.
120    pub counterfactual: Option<super::counterfactual::CounterfactualSlot>,
121    /// Complete, validated OCLA lineage for managed HTTP requests. `None` keeps
122    /// legacy, malformed and non-HTTP traffic explicitly unmanaged.
123    pub lineage: Option<lean_ctx::core::ocla::OclaRequestContext>,
124}
125
126impl WireContext {
127    #[must_use]
128    pub fn ocla_request_context(&self) -> Option<&lean_ctx::core::ocla::OclaRequestContext> {
129        self.lineage.as_ref()
130    }
131}
132
133impl RealUsage {
134    /// Projects measured proxy usage into the payload-free canonical OCLA record.
135    /// Missing lineage is unmanaged; invalid data and arithmetic overflow fail
136    /// closed for OCLA without disturbing the existing usage pipeline.
137    pub fn to_ocla_usage_record(
138        &self,
139    ) -> lean_ctx::core::ocla::OclaResult<Option<lean_ctx::core::ocla::UsageRecord>> {
140        use lean_ctx::core::ocla::{OclaError, UsageRecord};
141
142        let Some(context) = self
143            .wire
144            .as_deref()
145            .and_then(WireContext::ocla_request_context)
146            .cloned()
147        else {
148            return Ok(None);
149        };
150        context.validate()?;
151        if self.model.trim().is_empty() {
152            return Err(OclaError::InvalidRequest("model is required".into()));
153        }
154        let input_tokens = self
155            .input_tokens
156            .checked_add(self.cache_read_tokens)
157            .and_then(|value| value.checked_add(self.cache_write_tokens))
158            .ok_or_else(|| OclaError::InvalidRequest("input token total overflow".into()))?;
159        let provider_billed_tokens = input_tokens
160            .checked_add(self.output_tokens)
161            .ok_or_else(|| OclaError::InvalidRequest("billed token total overflow".into()))?;
162
163        Ok(Some(UsageRecord {
164            context,
165            model: self.model.clone(),
166            input_tokens,
167            output_tokens: self.output_tokens,
168            provider_billed_tokens,
169        }))
170    }
171
172    /// True once any model, token or measured-cost field has been observed —
173    /// the gate for recording. Avoids emitting empty rows for streams that
174    /// never reported usage.
175    fn is_meaningful(&self) -> bool {
176        !self.model.is_empty()
177            || self.input_tokens > 0
178            || self.output_tokens > 0
179            || self.cache_read_tokens > 0
180            || self.cache_write_tokens > 0
181            || self.provider_cost_usd.is_some()
182    }
183}
184
185/// Upper bound on a single buffered line before we give up on it. Usage events
186/// are tiny; this only guards against a pathological newline-free stream.
187const MAX_LINE_BYTES: usize = 1 << 20; // 1 MiB
188
189/// Incrementally extracts [`RealUsage`] from a response stream (or a full body).
190///
191/// `feed` is called with raw response chunks and keeps only the trailing partial
192/// line buffered (O(1) memory beyond one line); `finalize` returns the merged
193/// usage once the stream ends.
194pub struct Scanner {
195    provider: Provider,
196    /// Model parsed from the request URL (Gemini puts it there, not in the body).
197    url_model: Option<String>,
198    /// Holdout arms (#895, #1905), stamped onto the usage at finalize.
199    cohort: super::holdout::Cohorts,
200    /// Request-side gateway context (enterprise#11/#18), stamped at finalize.
201    wire: Option<Box<WireContext>>,
202    /// Billed USD from a gateway response header (#1189), stamped at finalize
203    /// unless the body already reported the charge.
204    header_cost: Option<f64>,
205    buf: Vec<u8>,
206    usage: RealUsage,
207    terminal_usage_seen: bool,
208    anthropic_final_usage_seen: bool,
209    response_clock: Option<(std::time::Instant, u16)>,
210    stream_failed: bool,
211}
212
213impl Scanner {
214    pub fn new(provider: Provider, url_model: Option<String>) -> Self {
215        Self {
216            provider,
217            url_model,
218            cohort: super::holdout::Cohorts::default(),
219            wire: None,
220            header_cost: None,
221            buf: Vec::new(),
222            usage: RealUsage::default(),
223            terminal_usage_seen: false,
224            anthropic_final_usage_seen: false,
225            response_clock: None,
226            stream_failed: false,
227        }
228    }
229
230    /// Starts measurement at the caller's actual upstream dispatch boundary.
231    #[must_use]
232    #[cfg(feature = "http-server")]
233    pub(crate) fn with_response_clock(
234        mut self,
235        started: Option<std::time::Instant>,
236        status: u16,
237    ) -> Self {
238        self.response_clock = started.map(|started| (started, status));
239        self
240    }
241
242    pub(super) fn mark_stream_error(&mut self) {
243        self.stream_failed = true;
244    }
245
246    /// Tags the usage this scanner produces with its output and compression holdout arms.
247    #[must_use]
248    pub fn with_cohort(mut self, cohort: super::holdout::Cohorts) -> Self {
249        self.cohort = cohort;
250        self
251    }
252
253    /// Attaches the request-side gateway context (identity tags, wire savings,
254    /// baseline inputs — enterprise#11/#17/#18) stamped onto the usage record.
255    #[must_use]
256    pub fn with_wire_context(mut self, wire: Option<Box<WireContext>>) -> Self {
257        self.wire = wire;
258        self
259    }
260
261    /// Attaches a billed USD figure reported by the upstream via a response
262    /// header (LiteLLM `x-litellm-response-cost`, or the operator-configured
263    /// `[proxy] cost_response_header`, #1189). Applied at finalize only when
264    /// the body did not already carry a measured cost — a body figure
265    /// (OpenRouter `usage.cost`) is the bill itself and always wins.
266    #[must_use]
267    pub fn with_header_cost(mut self, cost: Option<f64>) -> Self {
268        self.header_cost = cost.filter(|c| c.is_finite() && *c >= 0.0);
269        self
270    }
271
272    /// Feeds a raw streaming chunk, scanning every complete line it completes.
273    pub fn feed(&mut self, chunk: &[u8]) {
274        self.buf.extend_from_slice(chunk);
275        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
276            let mut line: Vec<u8> = self.buf.drain(..=nl).collect();
277            line.pop(); // drop '\n'
278            if line.last() == Some(&b'\r') {
279                line.pop();
280            }
281            self.scan_line(&line);
282        }
283        if self.buf.len() > MAX_LINE_BYTES {
284            self.buf.clear();
285        }
286    }
287
288    /// Feeds a complete non-streaming JSON response body.
289    pub fn feed_body(&mut self, body: &[u8]) {
290        if let Ok(v) = serde_json::from_slice::<Value>(body) {
291            self.absorb(&v);
292        }
293    }
294
295    /// A provider terminal measurement is independent of later transport EOF.
296    pub(super) fn has_terminal_usage(&self) -> bool {
297        self.terminal_usage_seen && (self.usage.is_meaningful() || self.header_cost.is_some())
298    }
299
300    /// Retain only an observed terminal measurement on an interrupted stream.
301    pub(super) fn finalize_terminal(self) -> Option<RealUsage> {
302        if self.has_terminal_usage() {
303            self.finalize_at(UsageBoundary::ProviderTerminalUsage)
304        } else {
305            None
306        }
307    }
308
309    /// Consumes the scanner, flushing any trailing partial line (a final event
310    /// may arrive without a newline) and returning the merged usage if any.
311    pub fn finalize(self) -> Option<RealUsage> {
312        self.finalize_at(UsageBoundary::StreamEnd)
313    }
314
315    pub(crate) fn finalize_at(mut self, boundary: UsageBoundary) -> Option<RealUsage> {
316        if !self.buf.is_empty() {
317            let line = std::mem::take(&mut self.buf);
318            self.scan_line(&line);
319        }
320        // Gateway header cost (#1189): measured, but the body figure is the
321        // bill itself (OpenRouter usage.cost) and keeps priority when present.
322        if self.usage.provider_cost_usd.is_none() {
323            self.usage.provider_cost_usd = self.header_cost;
324        }
325        if self.usage.is_meaningful() {
326            self.usage.cohort = self.cohort;
327            self.usage.wire = self.wire;
328            self.usage.response_observation =
329                self.response_clock
330                    .map(|(started, status)| ResponseObservation {
331                        schema_version: 1,
332                        http_status: status,
333                        usage_observed_ms: u64::try_from(started.elapsed().as_millis())
334                            .unwrap_or(u64::MAX),
335                        boundary: if self.stream_failed {
336                            UsageBoundary::StreamError
337                        } else {
338                            boundary
339                        },
340                        provider_cost_usd: self.usage.provider_cost_usd,
341                    });
342            Some(self.usage)
343        } else {
344            None
345        }
346    }
347
348    fn scan_line(&mut self, line: &[u8]) {
349        let Ok(text) = std::str::from_utf8(line) else {
350            return;
351        };
352        let trimmed = text.trim();
353        if trimmed.is_empty() {
354            return;
355        }
356        // Cheap pre-filter: skip the bulk of the stream (content deltas) and only
357        // JSON-parse lines that can carry usage or the model name.
358        if !self.line_might_be_relevant(trimmed) {
359            return;
360        }
361        let json_str = if let Some(rest) = trimmed.strip_prefix("data:") {
362            // SSE (Anthropic, OpenAI, Gemini with alt=sse).
363            let r = rest.trim();
364            if r.is_empty() || r == "[DONE]" {
365                return;
366            }
367            r
368        } else if trimmed.starts_with('{') {
369            // NDJSON / array-element line (Gemini x-ndjson). Tolerate the array
370            // punctuation a streamed JSON array puts around an element.
371            trimmed
372                .trim_start_matches([',', '['])
373                .trim_end_matches([',', ']'])
374                .trim()
375        } else {
376            return;
377        };
378        if let Ok(v) = serde_json::from_str::<Value>(json_str) {
379            self.absorb(&v);
380        }
381    }
382
383    fn line_might_be_relevant(&self, s: &str) -> bool {
384        match self.provider {
385            // Anthropic `message_start`/`message_delta` and OpenAI `usage`/
386            // `response.*` events all contain the substring "usage".
387            Provider::Anthropic => s.contains("usage") || s.contains("message_stop"),
388            Provider::OpenAi => s.contains("usage"),
389            Provider::Gemini => s.contains("usageMetadata"),
390        }
391    }
392
393    fn absorb(&mut self, v: &Value) {
394        match self.provider {
395            Provider::Anthropic => absorb_anthropic(&mut self.usage, v),
396            Provider::OpenAi => absorb_openai(&mut self.usage, v),
397            Provider::Gemini => absorb_gemini(&mut self.usage, v, self.url_model.as_deref()),
398        }
399        match self.provider {
400            Provider::Anthropic => {
401                if v.get("type").and_then(Value::as_str) == Some("message_delta")
402                    && v.pointer("/usage/output_tokens")
403                        .and_then(Value::as_u64)
404                        .is_some()
405                {
406                    self.anthropic_final_usage_seen = true;
407                }
408                self.terminal_usage_seen |= self.anthropic_final_usage_seen
409                    && v.get("type").and_then(Value::as_str) == Some("message_stop");
410            }
411            Provider::OpenAi => {
412                let response_terminal = matches!(
413                    v.get("type").and_then(Value::as_str),
414                    Some("response.completed" | "response.incomplete" | "response.failed")
415                ) && has_reported_totals(
416                    v.pointer("/response/usage"),
417                    "input_tokens",
418                    "output_tokens",
419                );
420                let chat_terminal =
421                    has_reported_totals(v.get("usage"), "prompt_tokens", "completion_tokens")
422                        && v.get("choices")
423                            .and_then(Value::as_array)
424                            .is_some_and(Vec::is_empty);
425                self.terminal_usage_seen |= response_terminal || chat_terminal;
426            }
427            // finishReason is per candidate, not proof that the entire request
428            // has finished. Preserve EOF accounting without inventing a terminal.
429            Provider::Gemini => {}
430        }
431    }
432}
433
434fn has_reported_totals(usage: Option<&Value>, input: &str, output: &str) -> bool {
435    usage.is_some_and(|usage| {
436        (usage.get(input).and_then(Value::as_u64).is_some()
437            && usage.get(output).and_then(Value::as_u64).is_some())
438            || usage
439                .get("cost")
440                .and_then(Value::as_f64)
441                .is_some_and(|cost| cost.is_finite() && cost >= 0.0)
442    })
443}
444
445/// Anthropic: model + input/cache live on `message` (streaming `message_start`
446/// or a non-streaming body); `output_tokens` arrives later on the event-level
447/// `usage` of `message_delta`. Latest non-zero wins, so the cumulative final
448/// delta is authoritative.
449fn absorb_anthropic(u: &mut RealUsage, v: &Value) {
450    let msg = v.get("message").unwrap_or(v);
451    if let Some(model) = msg.get("model").and_then(Value::as_str)
452        && !model.is_empty()
453    {
454        u.model = model.to_string();
455    }
456    let Some(usage) = msg.get("usage").or_else(|| v.get("usage")) else {
457        return;
458    };
459    if let Some(n) = usage.get("input_tokens").and_then(Value::as_u64) {
460        u.input_tokens = n;
461    }
462    if let Some(n) = usage.get("cache_read_input_tokens").and_then(Value::as_u64) {
463        u.cache_read_tokens = n;
464    }
465    if let Some(n) = usage
466        .get("cache_creation_input_tokens")
467        .and_then(Value::as_u64)
468    {
469        u.cache_write_tokens = n;
470    }
471    if let Some(n) = usage.get("output_tokens").and_then(Value::as_u64)
472        && n > 0
473    {
474        u.output_tokens = n;
475    }
476}
477
478/// OpenAI Chat Completions + Responses. `response.completed` nests the payload
479/// under `response`; chat chunks and non-streaming bodies are top-level. Both
480/// `usage` dialects are accepted (Responses: `input_tokens`/`output_tokens`;
481/// Chat: `prompt_tokens`/`completion_tokens`). `cached_tokens` is the cache-read
482/// portion of the reported input; OpenAI bills no separate cache write, but
483/// OpenRouter reports one (`prompt_tokens_details.cache_write_tokens`) for
484/// models with explicit cache-write pricing.
485///
486/// OpenRouter usage accounting additionally carries the money actually charged:
487/// `usage.cost` (credits ≡ USD) and, for BYOK requests, the upstream provider's
488/// own bill under `cost_details.upstream_inference_cost`. Their sum is this
489/// turn's real price — measured, not table-derived.
490fn absorb_openai(u: &mut RealUsage, v: &Value) {
491    let root = v.get("response").unwrap_or(v);
492    if let Some(model) = root.get("model").and_then(Value::as_str)
493        && !model.is_empty()
494    {
495        u.model = model.to_string();
496    }
497    let Some(usage) = root.get("usage") else {
498        return;
499    };
500    if usage.is_null() {
501        // `response.created` / `response.in_progress` carry `usage: null`.
502        return;
503    }
504
505    // Measured cost (OpenRouter dialect). Parsed before the token guard so a
506    // cost-bearing usage object is never lost, and `0` is preserved — a
507    // `:free` model's real price IS zero, not "unknown".
508    if let Some(cost) = usage.get("cost").and_then(Value::as_f64) {
509        let upstream = usage
510            .get("cost_details")
511            .and_then(|d| d.get("upstream_inference_cost"))
512            .and_then(Value::as_f64)
513            .unwrap_or(0.0);
514        // #746: non-BYOK responses may mirror `cost` in upstream_inference_cost;
515        // summing both would double-count. BYOK responses split the total:
516        // `cost` = OpenRouter fee (small), `upstream` = provider bill (large,
517        // always different from cost). Only add when genuinely distinct.
518        let byok_upstream = if upstream > 0.0 && upstream != cost {
519            upstream
520        } else {
521            0.0
522        };
523        u.provider_cost_usd = Some(cost + byok_upstream);
524    }
525
526    let total_input = usage
527        .get("input_tokens")
528        .or_else(|| usage.get("prompt_tokens"))
529        .and_then(Value::as_u64)
530        .unwrap_or(0);
531    let total_output = usage
532        .get("output_tokens")
533        .or_else(|| usage.get("completion_tokens"))
534        .and_then(Value::as_u64)
535        .unwrap_or(0);
536    let input_details = usage
537        .get("input_tokens_details")
538        .or_else(|| usage.get("prompt_tokens_details"));
539    let cached = input_details
540        .and_then(|d| d.get("cached_tokens"))
541        .and_then(Value::as_u64)
542        .unwrap_or(0);
543    let cache_write = input_details
544        .and_then(|d| d.get("cache_write_tokens"))
545        .and_then(Value::as_u64)
546        .unwrap_or(0);
547    let reasoning = usage
548        .get("output_tokens_details")
549        .or_else(|| usage.get("completion_tokens_details"))
550        .and_then(|d| d.get("reasoning_tokens"))
551        .and_then(Value::as_u64)
552        .unwrap_or(0);
553
554    if total_input == 0 && total_output == 0 {
555        return;
556    }
557    // OpenRouter counts cache writes inside prompt_tokens (unlike Anthropic's
558    // separate bucket) — subtract both cached reads and writes so the three
559    // buckets stay disjoint and are never double-priced.
560    u.input_tokens = total_input
561        .saturating_sub(cached)
562        .saturating_sub(cache_write);
563    u.cache_read_tokens = cached;
564    u.cache_write_tokens = cache_write;
565    u.output_tokens = total_output;
566    u.reasoning_tokens = reasoning;
567}
568
569/// Gemini: `usageMetadata` carries the counts; `modelVersion` (or the request
570/// URL) the model. `thoughtsTokenCount` is billed at the output rate and is not
571/// part of `candidatesTokenCount`, so it is added into `output_tokens`.
572fn absorb_gemini(u: &mut RealUsage, v: &Value, url_model: Option<&str>) {
573    if let Some(mv) = v.get("modelVersion").and_then(Value::as_str)
574        && !mv.is_empty()
575    {
576        u.model = mv.to_string();
577    } else if u.model.is_empty()
578        && let Some(m) = url_model
579        && !m.is_empty()
580    {
581        u.model = m.to_string();
582    }
583    let Some(um) = v.get("usageMetadata") else {
584        return;
585    };
586    let prompt = um
587        .get("promptTokenCount")
588        .and_then(Value::as_u64)
589        .unwrap_or(0);
590    let candidates = um
591        .get("candidatesTokenCount")
592        .and_then(Value::as_u64)
593        .unwrap_or(0);
594    let cached = um
595        .get("cachedContentTokenCount")
596        .and_then(Value::as_u64)
597        .unwrap_or(0);
598    let thoughts = um
599        .get("thoughtsTokenCount")
600        .and_then(Value::as_u64)
601        .unwrap_or(0);
602    if prompt == 0 && candidates == 0 && thoughts == 0 {
603        return;
604    }
605    u.input_tokens = prompt.saturating_sub(cached);
606    u.cache_read_tokens = cached;
607    u.cache_write_tokens = 0;
608    u.output_tokens = candidates + thoughts;
609    u.reasoning_tokens = thoughts;
610}
611
612/// Extracts the model from a Gemini request path
613/// (`/v1beta/models/{model}:generateContent`). Returns `None` for other paths.
614pub fn gemini_model_from_path(path: &str) -> Option<String> {
615    let after = path.rsplit_once("/models/").map(|(_, m)| m)?;
616    let model = after.split(':').next().unwrap_or(after).trim();
617    if model.is_empty() {
618        None
619    } else {
620        Some(model.to_string())
621    }
622}
623
624/// Wraps a response byte stream so every chunk is forwarded **byte-for-byte**
625/// while a [`Scanner`] observes it; on stream end the merged usage is recorded.
626/// Memory overhead is one buffered SSE line, never the whole response.
627pub fn tee_stream<S, B, E>(
628    inner: S,
629    scanner: Scanner,
630) -> impl Stream<Item = Result<B, E>> + Send + 'static
631where
632    S: Stream<Item = Result<B, E>> + Send + Unpin + 'static,
633    B: AsRef<[u8]> + Send + 'static,
634    E: Send + 'static,
635{
636    futures::stream::unfold(
637        (inner, Some(scanner)),
638        |(mut inner, mut scanner)| async move {
639            match inner.next().await {
640                Some(Ok(chunk)) => {
641                    if let Some(s) = scanner.as_mut() {
642                        s.feed(chunk.as_ref());
643                    }
644                    if scanner.as_ref().is_some_and(Scanner::has_terminal_usage)
645                        && let Some(usage) = scanner.take().and_then(Scanner::finalize_terminal)
646                    {
647                        super::usage_meter::record(&usage);
648                    }
649                    Some((Ok(chunk), (inner, scanner)))
650                }
651                Some(err) => {
652                    if let Some(mut s) = scanner.take() {
653                        s.mark_stream_error();
654                        if let Some(usage) = s.finalize_at(UsageBoundary::StreamError) {
655                            super::usage_meter::record(&usage);
656                        }
657                    }
658                    Some((err, (inner, scanner)))
659                }
660                None => {
661                    if let Some(s) = scanner.take()
662                        && let Some(usage) = s.finalize()
663                    {
664                        super::usage_meter::record(&usage);
665                    }
666                    None
667                }
668            }
669        },
670    )
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    fn feed_lines(
678        provider: Provider,
679        url_model: Option<&str>,
680        lines: &[&str],
681    ) -> Option<RealUsage> {
682        let mut s = Scanner::new(provider, url_model.map(str::to_string));
683        for line in lines {
684            s.feed(line.as_bytes());
685            s.feed(b"\n");
686        }
687        s.finalize()
688    }
689
690    #[test]
691    fn anthropic_merges_message_start_and_delta() {
692        let u = feed_lines(
693            Provider::Anthropic,
694            None,
695            &[
696                r#"data: {"type":"message_start","message":{"model":"claude-opus-4-5-20251101","usage":{"input_tokens":100,"cache_read_input_tokens":2000,"cache_creation_input_tokens":50,"output_tokens":1}}}"#,
697                r#"data: {"type":"content_block_delta","index":0,"delta":{"text":"hello"}}"#,
698                r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":73}}"#,
699                "data: {\"type\":\"message_stop\"}",
700            ],
701        )
702        .expect("usage");
703        assert_eq!(u.model, "claude-opus-4-5-20251101");
704        assert_eq!(u.input_tokens, 100);
705        assert_eq!(u.cache_read_tokens, 2000);
706        assert_eq!(u.cache_write_tokens, 50);
707        assert_eq!(u.output_tokens, 73);
708    }
709
710    #[test]
711    fn anthropic_non_streaming_body() {
712        let mut s = Scanner::new(Provider::Anthropic, None);
713        s.feed_body(
714            br#"{"model":"claude-sonnet-4-5","usage":{"input_tokens":24,"output_tokens":18,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}"#,
715        );
716        let u = s.finalize().expect("usage");
717        assert_eq!(u.model, "claude-sonnet-4-5");
718        assert_eq!(u.input_tokens, 24);
719        assert_eq!(u.output_tokens, 18);
720    }
721
722    #[test]
723    fn scanner_stamps_wire_context_onto_usage() {
724        // enterprise#11/#17: the request-side context must survive scanning and
725        // arrive on the finalized record that usage_meter/store consume.
726        let wire = Box::new(WireContext {
727            provider: "Anthropic".into(),
728            person: Some("yves".into()),
729            team: None,
730            project: Some("billing".into()),
731            project_key: None,
732            saved_tokens: 42,
733            uncompressed_input_tokens: 500,
734            is_local: false,
735            routed_from: None,
736            counterfactual: None,
737            lineage: None,
738        });
739        let mut s = Scanner::new(Provider::Anthropic, None).with_wire_context(Some(wire.clone()));
740        s.feed_body(
741            br#"{"model":"claude-sonnet-4-5","usage":{"input_tokens":24,"output_tokens":18}}"#,
742        );
743        let u = s.finalize().expect("usage");
744        assert_eq!(u.wire, Some(wire));
745    }
746
747    fn managed_context() -> lean_ctx::core::ocla::OclaRequestContext {
748        lean_ctx::core::ocla::OclaRequestContext {
749            request_id: "req-1".into(),
750            session_id: "session-1".into(),
751            agent_id: "agent-1".into(),
752            content_ref: "blake3:abc".into(),
753            tenant_id: None,
754            trace_id: "tr-unit".into(),
755            task_id: None,
756            parent_task_id: None,
757        }
758    }
759
760    #[test]
761    fn ocla_projection_includes_cache_tokens_once() {
762        let usage = RealUsage {
763            model: "gpt-5".into(),
764            input_tokens: 100,
765            output_tokens: 40,
766            cache_read_tokens: 20,
767            cache_write_tokens: 5,
768            wire: Some(Box::new(WireContext {
769                lineage: Some(managed_context()),
770                ..Default::default()
771            })),
772            ..Default::default()
773        };
774        let record = usage
775            .to_ocla_usage_record()
776            .expect("valid projection")
777            .expect("managed record");
778        assert_eq!(record.input_tokens, 125);
779        assert_eq!(record.output_tokens, 40);
780        assert_eq!(record.provider_billed_tokens, 165);
781        assert_eq!(record.context, managed_context());
782    }
783
784    #[test]
785    fn ocla_projection_keeps_missing_lineage_unmanaged() {
786        let usage = RealUsage {
787            model: "gpt-5".into(),
788            input_tokens: 1,
789            ..Default::default()
790        };
791        assert_eq!(usage.to_ocla_usage_record().unwrap(), None);
792    }
793
794    #[test]
795    fn ocla_projection_fails_closed_on_overflow_or_invalid_context() {
796        let overflow = RealUsage {
797            model: "gpt-5".into(),
798            input_tokens: u64::MAX,
799            cache_read_tokens: 1,
800            wire: Some(Box::new(WireContext {
801                lineage: Some(managed_context()),
802                ..Default::default()
803            })),
804            ..Default::default()
805        };
806        assert!(overflow.to_ocla_usage_record().is_err());
807
808        let mut invalid = managed_context();
809        invalid.request_id.clear();
810        let invalid = RealUsage {
811            model: "gpt-5".into(),
812            wire: Some(Box::new(WireContext {
813                lineage: Some(invalid),
814                ..Default::default()
815            })),
816            ..Default::default()
817        };
818        assert!(invalid.to_ocla_usage_record().is_err());
819    }
820
821    #[test]
822    fn openai_responses_completed_event() {
823        let u = feed_lines(
824            Provider::OpenAi,
825            None,
826            &[
827                r#"data: {"type":"response.created","response":{"model":"gpt-5.4","usage":null}}"#,
828                r#"data: {"type":"response.completed","response":{"model":"gpt-5.4","usage":{"input_tokens":1289,"input_tokens_details":{"cached_tokens":289},"output_tokens":685,"output_tokens_details":{"reasoning_tokens":640},"total_tokens":1974}}}"#,
829            ],
830        )
831        .expect("usage");
832        assert_eq!(u.model, "gpt-5.4");
833        assert_eq!(u.input_tokens, 1000); // 1289 - 289 cached
834        assert_eq!(u.cache_read_tokens, 289);
835        assert_eq!(u.output_tokens, 685);
836        assert_eq!(u.reasoning_tokens, 640);
837    }
838
839    #[test]
840    fn openai_chat_final_usage_chunk() {
841        let u = feed_lines(
842            Provider::OpenAi,
843            None,
844            &[
845                r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-5.4-mini"}"#,
846                r#"data: {"choices":[],"model":"gpt-5.4-mini","usage":{"prompt_tokens":500,"prompt_tokens_details":{"cached_tokens":100},"completion_tokens":40,"total_tokens":540}}"#,
847                "data: [DONE]",
848            ],
849        )
850        .expect("usage");
851        assert_eq!(u.model, "gpt-5.4-mini");
852        assert_eq!(u.input_tokens, 400);
853        assert_eq!(u.cache_read_tokens, 100);
854        assert_eq!(u.output_tokens, 40);
855        assert_eq!(u.provider_cost_usd, None, "OpenAI reports no usage.cost");
856    }
857
858    #[test]
859    fn openrouter_cost_and_cache_writes_are_measured() {
860        // #1179: OpenRouter usage accounting — the final streamed chunk carries
861        // the billed USD (`cost`) and the cache-write bucket inside
862        // prompt_tokens_details. All three token buckets must stay disjoint.
863        let u = feed_lines(
864            Provider::OpenAi,
865            None,
866            &[
867                r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"deepseek/deepseek-v4-flash-20260423"}"#,
868                r#"data: {"choices":[],"model":"deepseek/deepseek-v4-flash-20260423","usage":{"prompt_tokens":700,"prompt_tokens_details":{"cached_tokens":150,"cache_write_tokens":50},"completion_tokens":40,"cost":0.0123,"cost_details":{"upstream_inference_cost":null},"total_tokens":740}}"#,
869                "data: [DONE]",
870            ],
871        )
872        .expect("usage");
873        assert_eq!(u.input_tokens, 500, "700 - 150 cached - 50 cache-write");
874        assert_eq!(u.cache_read_tokens, 150);
875        assert_eq!(u.cache_write_tokens, 50);
876        assert_eq!(u.output_tokens, 40);
877        let cost = u.provider_cost_usd.expect("measured cost");
878        assert!((cost - 0.0123).abs() < 1e-12);
879    }
880
881    #[test]
882    fn openrouter_byok_adds_upstream_inference_cost() {
883        let mut s = Scanner::new(Provider::OpenAi, None);
884        s.feed_body(
885            br#"{"model":"anthropic/claude-sonnet-5","usage":{"prompt_tokens":100,"completion_tokens":10,"cost":0.05,"cost_details":{"upstream_inference_cost":0.95}}}"#,
886        );
887        let u = s.finalize().expect("usage");
888        let cost = u.provider_cost_usd.expect("measured cost");
889        assert!(
890            (cost - 1.0).abs() < 1e-12,
891            "OpenRouter fee + BYOK upstream bill"
892        );
893    }
894
895    /// #746: non-BYOK responses mirror `cost` in `upstream_inference_cost`.
896    /// The gateway must not sum both — that would double-count.
897    #[test]
898    fn non_byok_upstream_equal_to_cost_is_not_doubled() {
899        let mut s = Scanner::new(Provider::OpenAi, None);
900        s.feed_body(
901            br#"{"model":"deepseek/deepseek-v4-flash","usage":{"prompt_tokens":50,"completion_tokens":5,"cost":0.000001568,"cost_details":{"upstream_inference_cost":0.000001568}}}"#,
902        );
903        let u = s.finalize().expect("usage");
904        let cost = u.provider_cost_usd.expect("measured cost");
905        assert!(
906            (cost - 0.000001568).abs() < 1e-15,
907            "#746: must book cost once, not 2x; got {cost}"
908        );
909    }
910
911    #[test]
912    fn gateway_header_cost_fills_when_body_has_none() {
913        // #1189: a LiteLLM-style gateway reports the bill in a header; tokens
914        // come from a normal OpenAI-shaped body without `usage.cost`.
915        let mut s = Scanner::new(Provider::OpenAi, None).with_header_cost(Some(0.0042));
916        s.feed_body(
917            br#"{"model":"azure-gpt-4o","usage":{"prompt_tokens":100,"completion_tokens":10}}"#,
918        );
919        let u = s.finalize().expect("usage");
920        assert_eq!(u.provider_cost_usd, Some(0.0042), "header is measured");
921    }
922
923    #[test]
924    fn body_reported_cost_beats_the_header_figure() {
925        // OpenRouter behind another gateway: `usage.cost` is the bill itself.
926        let mut s = Scanner::new(Provider::OpenAi, None).with_header_cost(Some(9.99));
927        s.feed_body(
928            br#"{"model":"deepseek/deepseek-v4-flash","usage":{"prompt_tokens":100,"completion_tokens":10,"cost":0.05}}"#,
929        );
930        let u = s.finalize().expect("usage");
931        assert_eq!(u.provider_cost_usd, Some(0.05), "body wins over header");
932    }
933
934    #[test]
935    fn openrouter_free_model_reports_zero_cost_as_measured() {
936        let mut s = Scanner::new(Provider::OpenAi, None);
937        s.feed_body(
938            br#"{"model":"poolside/laguna-xs-2.1:free","usage":{"prompt_tokens":80,"completion_tokens":20,"cost":0}}"#,
939        );
940        let u = s.finalize().expect("usage");
941        assert_eq!(u.provider_cost_usd, Some(0.0), "free is a price, not a gap");
942    }
943
944    #[test]
945    fn gemini_usage_metadata_with_url_model() {
946        let u = feed_lines(
947            Provider::Gemini,
948            Some("gemini-2.5-pro"),
949            &[
950                r#"data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":7,"thoughtsTokenCount":39,"totalTokenCount":71}}"#,
951            ],
952        )
953        .expect("usage");
954        assert_eq!(u.model, "gemini-2.5-pro");
955        assert_eq!(u.input_tokens, 25);
956        assert_eq!(u.output_tokens, 46); // candidates 7 + thoughts 39
957        assert_eq!(u.reasoning_tokens, 39);
958    }
959
960    #[test]
961    fn gemini_prefers_model_version_over_url() {
962        let u = feed_lines(
963            Provider::Gemini,
964            Some("gemini-2.5-pro"),
965            &[
966                r#"data: {"modelVersion":"gemini-2.5-pro-002","usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"cachedContentTokenCount":4,"totalTokenCount":15}}"#,
967            ],
968        )
969        .expect("usage");
970        assert_eq!(u.model, "gemini-2.5-pro-002");
971        assert_eq!(u.input_tokens, 6); // 10 - 4 cached
972        assert_eq!(u.cache_read_tokens, 4);
973        assert_eq!(u.output_tokens, 5);
974    }
975
976    #[test]
977    fn split_chunks_reassemble_across_feed_calls() {
978        let mut s = Scanner::new(Provider::Anthropic, None);
979        let line = r#"data: {"type":"message_delta","delta":{},"usage":{"output_tokens":42}}"#;
980        let bytes = format!("{line}\n");
981        let (a, b) = bytes.as_bytes().split_at(20);
982        s.feed(a);
983        s.feed(b);
984        let u = s.finalize().expect("usage");
985        assert_eq!(u.output_tokens, 42);
986    }
987
988    #[test]
989    fn no_usage_yields_none() {
990        let out = feed_lines(
991            Provider::OpenAi,
992            None,
993            &[r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-5.4"}"#],
994        );
995        assert!(out.is_none(), "content-only stream reports no usage");
996    }
997
998    #[test]
999    fn final_event_without_trailing_newline() {
1000        let mut s = Scanner::new(Provider::Anthropic, None);
1001        s.feed(br#"data: {"type":"message_delta","delta":{},"usage":{"output_tokens":7}}"#);
1002        let u = s.finalize().expect("flushes trailing partial line");
1003        assert_eq!(u.output_tokens, 7);
1004    }
1005
1006    #[test]
1007    fn gemini_model_from_path_extracts() {
1008        assert_eq!(
1009            gemini_model_from_path("/v1beta/models/gemini-2.5-pro:streamGenerateContent")
1010                .as_deref(),
1011            Some("gemini-2.5-pro")
1012        );
1013        assert_eq!(
1014            gemini_model_from_path("/v1beta/models/gemini-2.5-flash:generateContent").as_deref(),
1015            Some("gemini-2.5-flash")
1016        );
1017        assert_eq!(gemini_model_from_path("/v1/chat/completions"), None);
1018    }
1019
1020    #[test]
1021    fn terminal_usage_requires_provider_completion_and_reported_totals() {
1022        for kind in [
1023            "response.completed",
1024            "response.incomplete",
1025            "response.failed",
1026        ] {
1027            let mut scanner = Scanner::new(Provider::OpenAi, None);
1028            scanner.feed_body(serde_json::json!({
1029                "type": kind,
1030                "response": {"model": "terminal", "usage": {"input_tokens": 5, "output_tokens": 2}}
1031            }).to_string().as_bytes());
1032            assert!(scanner.has_terminal_usage());
1033        }
1034        for (provider, event) in [
1035            (
1036                Provider::OpenAi,
1037                serde_json::json!({"type":"response.in_progress","response":{"usage":{"input_tokens":5,"output_tokens":2}}}),
1038            ),
1039            (
1040                Provider::OpenAi,
1041                serde_json::json!({"type":"response.completed","response":{"usage":{"input_tokens":5}}}),
1042            ),
1043            (
1044                Provider::OpenAi,
1045                serde_json::json!({"choices":[],"usage":null}),
1046            ),
1047            (
1048                Provider::Gemini,
1049                serde_json::json!({"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2}}),
1050            ),
1051        ] {
1052            let mut scanner = Scanner::new(provider, None);
1053            scanner.feed_body(event.to_string().as_bytes());
1054            assert!(!scanner.has_terminal_usage());
1055        }
1056        let mut chat = Scanner::new(Provider::OpenAi, None);
1057        chat.feed_body(
1058            br#"{"model":"chat","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2}}"#,
1059        );
1060        assert!(chat.has_terminal_usage());
1061        let mut anthropic = Scanner::new(Provider::Anthropic, None);
1062        anthropic.feed(b"data: {\"type\":\"message_start\",\"message\":{\"model\":\"anthropic\",\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n");
1063        anthropic.feed(b"data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":2}}\n");
1064        assert!(!anthropic.has_terminal_usage());
1065        anthropic.feed(b"data: {\"type\":\"message_stop\"}\n");
1066        assert!(anthropic.has_terminal_usage());
1067    }
1068
1069    fn terminal_usage_frame(model: &str) -> Vec<u8> {
1070        format!(
1071            "data: {}\n\n",
1072            serde_json::json!({
1073                "type": "response.completed",
1074                "response": {
1075                    "model": model,
1076                    "usage": {
1077                        "input_tokens": 120,
1078                        "output_tokens": 7,
1079                        "input_tokens_details": {"cached_tokens": 20},
1080                        "cost": 0.0125
1081                    }
1082                }
1083            })
1084        )
1085        .into_bytes()
1086    }
1087
1088    fn measured_requests(model: &str) -> u64 {
1089        super::super::usage_meter::snapshot()
1090            .into_iter()
1091            .find(|usage| usage.model == model)
1092            .map_or(0, |usage| usage.requests)
1093    }
1094
1095    #[tokio::test]
1096    async fn terminal_usage_clean_eof_records_once() {
1097        let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1098        let model = "terminal-evidence-clean-eof";
1099        let before = measured_requests(model);
1100        let frame = terminal_usage_frame(model);
1101        let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1102        let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::OpenAi, None)));
1103        assert_eq!(teed.next().await, Some(Ok(frame)));
1104        assert_eq!(teed.next().await, None);
1105        drop(teed);
1106        assert_eq!(measured_requests(model), before + 1);
1107    }
1108
1109    #[tokio::test]
1110    async fn terminal_usage_received_before_drop_is_recorded_once() {
1111        let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1112        let model = "terminal-evidence-client-drop";
1113        let before = measured_requests(model);
1114        let frame = terminal_usage_frame(model);
1115        let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1116        let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::OpenAi, None)));
1117        assert_eq!(teed.next().await, Some(Ok(frame)));
1118        drop(teed);
1119        assert_eq!(measured_requests(model), before + 1);
1120    }
1121
1122    #[tokio::test]
1123    async fn terminal_usage_received_before_error_is_recorded_once() {
1124        let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1125        let model = "terminal-evidence-upstream-error";
1126        let before = measured_requests(model);
1127        let frame = terminal_usage_frame(model);
1128        let source = futures::stream::iter(vec![Ok(frame.clone()), Err(())]);
1129        let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::OpenAi, None)));
1130        assert_eq!(teed.next().await, Some(Ok(frame)));
1131        assert_eq!(teed.next().await, Some(Err(())));
1132        drop(teed);
1133        assert_eq!(measured_requests(model), before + 1);
1134    }
1135
1136    #[tokio::test]
1137    async fn terminal_usage_retains_header_cost_without_model_or_body_tokens() {
1138        let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1139        let before = super::super::usage_meter::snapshot()
1140            .into_iter()
1141            .find(|usage| usage.model == "unknown");
1142        let before_requests = before.as_ref().map_or(0, |usage| usage.requests);
1143        let before_cost = before.as_ref().map_or(0.0, |usage| usage.cost_usd);
1144        let frame = b"data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n".to_vec();
1145        let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1146        let scanner = Scanner::new(Provider::OpenAi, None).with_header_cost(Some(0.0042));
1147        let mut teed = Box::pin(tee_stream(source, scanner));
1148        assert_eq!(teed.next().await, Some(Ok(frame)));
1149        drop(teed);
1150        let measured = super::super::usage_meter::snapshot()
1151            .into_iter()
1152            .find(|usage| usage.model == "unknown")
1153            .unwrap();
1154        assert_eq!(measured.requests, before_requests + 1);
1155        assert!((measured.cost_usd - before_cost - 0.0042).abs() < 1e-9);
1156    }
1157
1158    #[tokio::test]
1159    async fn terminal_usage_partial_input_does_not_become_completed_spend_on_drop() {
1160        let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1161        let model = "terminal-evidence-partial-input";
1162        let before = measured_requests(model);
1163        let frame = format!(
1164            "data: {}\n\n",
1165            serde_json::json!({
1166                "type": "message_start",
1167                "message": {
1168                    "model": model,
1169                    "usage": {"input_tokens": 5, "output_tokens": 1}
1170                }
1171            })
1172        )
1173        .into_bytes();
1174        let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1175        let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::Anthropic, None)));
1176        assert_eq!(teed.next().await, Some(Ok(frame)));
1177        drop(teed);
1178        assert_eq!(measured_requests(model), before);
1179    }
1180
1181    #[tokio::test]
1182    async fn tee_stream_passes_bytes_through_and_records() {
1183        let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> = vec![
1184            Ok(b"data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4.5\",\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n".to_vec()),
1185            Ok(b"data: {\"type\":\"message_delta\",\"delta\":{},\"usage\":{\"output_tokens\":9}}\n".to_vec()),
1186        ];
1187        let inner = futures::stream::iter(chunks);
1188        let scanner = Scanner::new(Provider::Anthropic, None);
1189        let teed = tee_stream(inner, scanner);
1190        let collected: Vec<_> = teed.collect().await;
1191        // Byte-for-byte passthrough preserved.
1192        assert_eq!(collected.len(), 2);
1193        assert!(collected.iter().all(std::result::Result::is_ok));
1194    }
1195}