1use futures::{Stream, StreamExt};
21use serde_json::Value;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Provider {
26 Anthropic,
27 OpenAi,
30 Gemini,
31}
32
33impl Provider {
34 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#[derive(Debug, Clone, Default, PartialEq)]
49pub struct RealUsage {
50 pub model: String,
51 pub input_tokens: u64,
53 pub output_tokens: u64,
55 pub cache_read_tokens: u64,
57 pub cache_write_tokens: u64,
59 pub reasoning_tokens: u64,
61}
62
63impl RealUsage {
64 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
75const MAX_LINE_BYTES: usize = 1 << 20; pub struct Scanner {
85 provider: Provider,
86 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 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(); 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 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 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 if !self.line_might_be_relevant(trimmed) {
150 return;
151 }
152 let json_str = if let Some(rest) = trimmed.strip_prefix("data:") {
153 let r = rest.trim();
155 if r.is_empty() || r == "[DONE]" {
156 return;
157 }
158 r
159 } else if trimmed.starts_with('{') {
160 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 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
192fn 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
225fn 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 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
278fn 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
321pub 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
333pub 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); 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); 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); 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 assert_eq!(collected.len(), 2);
542 assert!(collected.iter().all(std::result::Result::is_ok));
543 }
544}