Skip to main content

lean_ctx/proxy/
usage.rs

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