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"`, else Gemini).
35    pub fn from_label(label: &str) -> Self {
36        match label {
37            "Anthropic" => Self::Anthropic,
38            "OpenAI" => 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 only; 0 elsewhere).
58    pub cache_write_tokens: u64,
59    /// Reasoning/thinking subset of `output_tokens` (display only).
60    pub reasoning_tokens: u64,
61}
62
63impl RealUsage {
64    /// True once any model or token field has been observed — the gate for
65    /// recording. Avoids emitting empty rows for streams that never reported usage.
66    fn is_meaningful(&self) -> bool {
67        !self.model.is_empty()
68            || self.input_tokens > 0
69            || self.output_tokens > 0
70            || self.cache_read_tokens > 0
71            || self.cache_write_tokens > 0
72    }
73}
74
75/// Upper bound on a single buffered line before we give up on it. Usage events
76/// are tiny; this only guards against a pathological newline-free stream.
77const MAX_LINE_BYTES: usize = 1 << 20; // 1 MiB
78
79/// Incrementally extracts [`RealUsage`] from a response stream (or a full body).
80///
81/// `feed` is called with raw response chunks and keeps only the trailing partial
82/// line buffered (O(1) memory beyond one line); `finalize` returns the merged
83/// usage once the stream ends.
84pub struct Scanner {
85    provider: Provider,
86    /// Model parsed from the request URL (Gemini puts it there, not in the body).
87    url_model: Option<String>,
88    buf: Vec<u8>,
89    usage: RealUsage,
90}
91
92impl Scanner {
93    pub fn new(provider: Provider, url_model: Option<String>) -> Self {
94        Self {
95            provider,
96            url_model,
97            buf: Vec::new(),
98            usage: RealUsage::default(),
99        }
100    }
101
102    /// Feeds a raw streaming chunk, scanning every complete line it completes.
103    pub fn feed(&mut self, chunk: &[u8]) {
104        self.buf.extend_from_slice(chunk);
105        while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
106            let mut line: Vec<u8> = self.buf.drain(..=nl).collect();
107            line.pop(); // drop '\n'
108            if line.last() == Some(&b'\r') {
109                line.pop();
110            }
111            self.scan_line(&line);
112        }
113        if self.buf.len() > MAX_LINE_BYTES {
114            self.buf.clear();
115        }
116    }
117
118    /// Feeds a complete non-streaming JSON response body.
119    pub fn feed_body(&mut self, body: &[u8]) {
120        if let Ok(v) = serde_json::from_slice::<Value>(body) {
121            self.absorb(&v);
122        }
123    }
124
125    /// Consumes the scanner, flushing any trailing partial line (a final event
126    /// may arrive without a newline) and returning the merged usage if any.
127    pub fn finalize(mut self) -> Option<RealUsage> {
128        if !self.buf.is_empty() {
129            let line = std::mem::take(&mut self.buf);
130            self.scan_line(&line);
131        }
132        if self.usage.is_meaningful() {
133            Some(self.usage)
134        } else {
135            None
136        }
137    }
138
139    fn scan_line(&mut self, line: &[u8]) {
140        let Ok(text) = std::str::from_utf8(line) else {
141            return;
142        };
143        let trimmed = text.trim();
144        if trimmed.is_empty() {
145            return;
146        }
147        // Cheap pre-filter: skip the bulk of the stream (content deltas) and only
148        // JSON-parse lines that can carry usage or the model name.
149        if !self.line_might_be_relevant(trimmed) {
150            return;
151        }
152        let json_str = if let Some(rest) = trimmed.strip_prefix("data:") {
153            // SSE (Anthropic, OpenAI, Gemini with alt=sse).
154            let r = rest.trim();
155            if r.is_empty() || r == "[DONE]" {
156                return;
157            }
158            r
159        } else if trimmed.starts_with('{') {
160            // NDJSON / array-element line (Gemini x-ndjson). Tolerate the array
161            // punctuation a streamed JSON array puts around an element.
162            trimmed
163                .trim_start_matches([',', '['])
164                .trim_end_matches([',', ']'])
165                .trim()
166        } else {
167            return;
168        };
169        if let Ok(v) = serde_json::from_str::<Value>(json_str) {
170            self.absorb(&v);
171        }
172    }
173
174    fn line_might_be_relevant(&self, s: &str) -> bool {
175        match self.provider {
176            // Anthropic `message_start`/`message_delta` and OpenAI `usage`/
177            // `response.*` events all contain the substring "usage".
178            Provider::Anthropic | Provider::OpenAi => s.contains("usage"),
179            Provider::Gemini => s.contains("usageMetadata"),
180        }
181    }
182
183    fn absorb(&mut self, v: &Value) {
184        match self.provider {
185            Provider::Anthropic => absorb_anthropic(&mut self.usage, v),
186            Provider::OpenAi => absorb_openai(&mut self.usage, v),
187            Provider::Gemini => absorb_gemini(&mut self.usage, v, self.url_model.as_deref()),
188        }
189    }
190}
191
192/// Anthropic: model + input/cache live on `message` (streaming `message_start`
193/// or a non-streaming body); `output_tokens` arrives later on the event-level
194/// `usage` of `message_delta`. Latest non-zero wins, so the cumulative final
195/// delta is authoritative.
196fn absorb_anthropic(u: &mut RealUsage, v: &Value) {
197    let msg = v.get("message").unwrap_or(v);
198    if let Some(model) = msg.get("model").and_then(Value::as_str)
199        && !model.is_empty()
200    {
201        u.model = model.to_string();
202    }
203    let Some(usage) = msg.get("usage").or_else(|| v.get("usage")) else {
204        return;
205    };
206    if let Some(n) = usage.get("input_tokens").and_then(Value::as_u64) {
207        u.input_tokens = n;
208    }
209    if let Some(n) = usage.get("cache_read_input_tokens").and_then(Value::as_u64) {
210        u.cache_read_tokens = n;
211    }
212    if let Some(n) = usage
213        .get("cache_creation_input_tokens")
214        .and_then(Value::as_u64)
215    {
216        u.cache_write_tokens = n;
217    }
218    if let Some(n) = usage.get("output_tokens").and_then(Value::as_u64)
219        && n > 0
220    {
221        u.output_tokens = n;
222    }
223}
224
225/// OpenAI Chat Completions + Responses. `response.completed` nests the payload
226/// under `response`; chat chunks and non-streaming bodies are top-level. Both
227/// `usage` dialects are accepted (Responses: `input_tokens`/`output_tokens`;
228/// Chat: `prompt_tokens`/`completion_tokens`). `cached_tokens` is the cache-read
229/// portion of the reported input; OpenAI bills no separate cache write.
230fn absorb_openai(u: &mut RealUsage, v: &Value) {
231    let root = v.get("response").unwrap_or(v);
232    if let Some(model) = root.get("model").and_then(Value::as_str)
233        && !model.is_empty()
234    {
235        u.model = model.to_string();
236    }
237    let Some(usage) = root.get("usage") else {
238        return;
239    };
240    if usage.is_null() {
241        // `response.created` / `response.in_progress` carry `usage: null`.
242        return;
243    }
244
245    let total_input = usage
246        .get("input_tokens")
247        .or_else(|| usage.get("prompt_tokens"))
248        .and_then(Value::as_u64)
249        .unwrap_or(0);
250    let total_output = usage
251        .get("output_tokens")
252        .or_else(|| usage.get("completion_tokens"))
253        .and_then(Value::as_u64)
254        .unwrap_or(0);
255    let cached = usage
256        .get("input_tokens_details")
257        .or_else(|| usage.get("prompt_tokens_details"))
258        .and_then(|d| d.get("cached_tokens"))
259        .and_then(Value::as_u64)
260        .unwrap_or(0);
261    let reasoning = usage
262        .get("output_tokens_details")
263        .or_else(|| usage.get("completion_tokens_details"))
264        .and_then(|d| d.get("reasoning_tokens"))
265        .and_then(Value::as_u64)
266        .unwrap_or(0);
267
268    if total_input == 0 && total_output == 0 {
269        return;
270    }
271    u.input_tokens = total_input.saturating_sub(cached);
272    u.cache_read_tokens = cached;
273    u.cache_write_tokens = 0;
274    u.output_tokens = total_output;
275    u.reasoning_tokens = reasoning;
276}
277
278/// Gemini: `usageMetadata` carries the counts; `modelVersion` (or the request
279/// URL) the model. `thoughtsTokenCount` is billed at the output rate and is not
280/// part of `candidatesTokenCount`, so it is added into `output_tokens`.
281fn absorb_gemini(u: &mut RealUsage, v: &Value, url_model: Option<&str>) {
282    if let Some(mv) = v.get("modelVersion").and_then(Value::as_str)
283        && !mv.is_empty()
284    {
285        u.model = mv.to_string();
286    } else if u.model.is_empty()
287        && let Some(m) = url_model
288        && !m.is_empty()
289    {
290        u.model = m.to_string();
291    }
292    let Some(um) = v.get("usageMetadata") else {
293        return;
294    };
295    let prompt = um
296        .get("promptTokenCount")
297        .and_then(Value::as_u64)
298        .unwrap_or(0);
299    let candidates = um
300        .get("candidatesTokenCount")
301        .and_then(Value::as_u64)
302        .unwrap_or(0);
303    let cached = um
304        .get("cachedContentTokenCount")
305        .and_then(Value::as_u64)
306        .unwrap_or(0);
307    let thoughts = um
308        .get("thoughtsTokenCount")
309        .and_then(Value::as_u64)
310        .unwrap_or(0);
311    if prompt == 0 && candidates == 0 && thoughts == 0 {
312        return;
313    }
314    u.input_tokens = prompt.saturating_sub(cached);
315    u.cache_read_tokens = cached;
316    u.cache_write_tokens = 0;
317    u.output_tokens = candidates + thoughts;
318    u.reasoning_tokens = thoughts;
319}
320
321/// Extracts the model from a Gemini request path
322/// (`/v1beta/models/{model}:generateContent`). Returns `None` for other paths.
323pub fn gemini_model_from_path(path: &str) -> Option<String> {
324    let after = path.rsplit_once("/models/").map(|(_, m)| m)?;
325    let model = after.split(':').next().unwrap_or(after).trim();
326    if model.is_empty() {
327        None
328    } else {
329        Some(model.to_string())
330    }
331}
332
333/// Wraps a response byte stream so every chunk is forwarded **byte-for-byte**
334/// while a [`Scanner`] observes it; on stream end the merged usage is recorded.
335/// Memory overhead is one buffered SSE line, never the whole response.
336pub fn tee_stream<S, B, E>(
337    inner: S,
338    scanner: Scanner,
339) -> impl Stream<Item = Result<B, E>> + Send + 'static
340where
341    S: Stream<Item = Result<B, E>> + Send + Unpin + 'static,
342    B: AsRef<[u8]> + Send + 'static,
343    E: Send + 'static,
344{
345    futures::stream::unfold(
346        (inner, Some(scanner)),
347        |(mut inner, mut scanner)| async move {
348            match inner.next().await {
349                Some(Ok(chunk)) => {
350                    if let Some(s) = scanner.as_mut() {
351                        s.feed(chunk.as_ref());
352                    }
353                    Some((Ok(chunk), (inner, scanner)))
354                }
355                Some(err) => Some((err, (inner, scanner))),
356                None => {
357                    if let Some(s) = scanner.take()
358                        && let Some(usage) = s.finalize()
359                    {
360                        super::usage_meter::record(&usage);
361                    }
362                    None
363                }
364            }
365        },
366    )
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn feed_lines(
374        provider: Provider,
375        url_model: Option<&str>,
376        lines: &[&str],
377    ) -> Option<RealUsage> {
378        let mut s = Scanner::new(provider, url_model.map(str::to_string));
379        for line in lines {
380            s.feed(line.as_bytes());
381            s.feed(b"\n");
382        }
383        s.finalize()
384    }
385
386    #[test]
387    fn anthropic_merges_message_start_and_delta() {
388        let u = feed_lines(
389            Provider::Anthropic,
390            None,
391            &[
392                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}}}"#,
393                r#"data: {"type":"content_block_delta","index":0,"delta":{"text":"hello"}}"#,
394                r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":73}}"#,
395                "data: {\"type\":\"message_stop\"}",
396            ],
397        )
398        .expect("usage");
399        assert_eq!(u.model, "claude-opus-4-5-20251101");
400        assert_eq!(u.input_tokens, 100);
401        assert_eq!(u.cache_read_tokens, 2000);
402        assert_eq!(u.cache_write_tokens, 50);
403        assert_eq!(u.output_tokens, 73);
404    }
405
406    #[test]
407    fn anthropic_non_streaming_body() {
408        let mut s = Scanner::new(Provider::Anthropic, None);
409        s.feed_body(
410            br#"{"model":"claude-sonnet-4-5","usage":{"input_tokens":24,"output_tokens":18,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}"#,
411        );
412        let u = s.finalize().expect("usage");
413        assert_eq!(u.model, "claude-sonnet-4-5");
414        assert_eq!(u.input_tokens, 24);
415        assert_eq!(u.output_tokens, 18);
416    }
417
418    #[test]
419    fn openai_responses_completed_event() {
420        let u = feed_lines(
421            Provider::OpenAi,
422            None,
423            &[
424                r#"data: {"type":"response.created","response":{"model":"gpt-5.4","usage":null}}"#,
425                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}}}"#,
426            ],
427        )
428        .expect("usage");
429        assert_eq!(u.model, "gpt-5.4");
430        assert_eq!(u.input_tokens, 1000); // 1289 - 289 cached
431        assert_eq!(u.cache_read_tokens, 289);
432        assert_eq!(u.output_tokens, 685);
433        assert_eq!(u.reasoning_tokens, 640);
434    }
435
436    #[test]
437    fn openai_chat_final_usage_chunk() {
438        let u = feed_lines(
439            Provider::OpenAi,
440            None,
441            &[
442                r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-5.4-mini"}"#,
443                r#"data: {"choices":[],"model":"gpt-5.4-mini","usage":{"prompt_tokens":500,"prompt_tokens_details":{"cached_tokens":100},"completion_tokens":40,"total_tokens":540}}"#,
444                "data: [DONE]",
445            ],
446        )
447        .expect("usage");
448        assert_eq!(u.model, "gpt-5.4-mini");
449        assert_eq!(u.input_tokens, 400);
450        assert_eq!(u.cache_read_tokens, 100);
451        assert_eq!(u.output_tokens, 40);
452    }
453
454    #[test]
455    fn gemini_usage_metadata_with_url_model() {
456        let u = feed_lines(
457            Provider::Gemini,
458            Some("gemini-2.5-pro"),
459            &[
460                r#"data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":7,"thoughtsTokenCount":39,"totalTokenCount":71}}"#,
461            ],
462        )
463        .expect("usage");
464        assert_eq!(u.model, "gemini-2.5-pro");
465        assert_eq!(u.input_tokens, 25);
466        assert_eq!(u.output_tokens, 46); // candidates 7 + thoughts 39
467        assert_eq!(u.reasoning_tokens, 39);
468    }
469
470    #[test]
471    fn gemini_prefers_model_version_over_url() {
472        let u = feed_lines(
473            Provider::Gemini,
474            Some("gemini-2.5-pro"),
475            &[
476                r#"data: {"modelVersion":"gemini-2.5-pro-002","usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"cachedContentTokenCount":4,"totalTokenCount":15}}"#,
477            ],
478        )
479        .expect("usage");
480        assert_eq!(u.model, "gemini-2.5-pro-002");
481        assert_eq!(u.input_tokens, 6); // 10 - 4 cached
482        assert_eq!(u.cache_read_tokens, 4);
483        assert_eq!(u.output_tokens, 5);
484    }
485
486    #[test]
487    fn split_chunks_reassemble_across_feed_calls() {
488        let mut s = Scanner::new(Provider::Anthropic, None);
489        let line = r#"data: {"type":"message_delta","delta":{},"usage":{"output_tokens":42}}"#;
490        let bytes = format!("{line}\n");
491        let (a, b) = bytes.as_bytes().split_at(20);
492        s.feed(a);
493        s.feed(b);
494        let u = s.finalize().expect("usage");
495        assert_eq!(u.output_tokens, 42);
496    }
497
498    #[test]
499    fn no_usage_yields_none() {
500        let out = feed_lines(
501            Provider::OpenAi,
502            None,
503            &[r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-5.4"}"#],
504        );
505        assert!(out.is_none(), "content-only stream reports no usage");
506    }
507
508    #[test]
509    fn final_event_without_trailing_newline() {
510        let mut s = Scanner::new(Provider::Anthropic, None);
511        s.feed(br#"data: {"type":"message_delta","delta":{},"usage":{"output_tokens":7}}"#);
512        let u = s.finalize().expect("flushes trailing partial line");
513        assert_eq!(u.output_tokens, 7);
514    }
515
516    #[test]
517    fn gemini_model_from_path_extracts() {
518        assert_eq!(
519            gemini_model_from_path("/v1beta/models/gemini-2.5-pro:streamGenerateContent")
520                .as_deref(),
521            Some("gemini-2.5-pro")
522        );
523        assert_eq!(
524            gemini_model_from_path("/v1beta/models/gemini-2.5-flash:generateContent").as_deref(),
525            Some("gemini-2.5-flash")
526        );
527        assert_eq!(gemini_model_from_path("/v1/chat/completions"), None);
528    }
529
530    #[tokio::test]
531    async fn tee_stream_passes_bytes_through_and_records() {
532        let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> = vec![
533            Ok(b"data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4.5\",\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n".to_vec()),
534            Ok(b"data: {\"type\":\"message_delta\",\"delta\":{},\"usage\":{\"output_tokens\":9}}\n".to_vec()),
535        ];
536        let inner = futures::stream::iter(chunks);
537        let scanner = Scanner::new(Provider::Anthropic, None);
538        let teed = tee_stream(inner, scanner);
539        let collected: Vec<_> = teed.collect().await;
540        // Byte-for-byte passthrough preserved.
541        assert_eq!(collected.len(), 2);
542        assert!(collected.iter().all(std::result::Result::is_ok));
543    }
544}