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