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" | "Bedrock" => Self::Anthropic,
38 "OpenAI" | "ChatGPT" | "Azure" => 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,
60 pub reasoning_tokens: u64,
62 pub provider_cost_usd: Option<f64>,
69 pub cohort: Option<super::holdout::Arm>,
73 pub wire: Option<Box<WireContext>>,
78}
79
80#[derive(Debug, Clone, Default, PartialEq)]
86pub struct WireContext {
87 pub provider: String,
89 pub person: Option<String>,
91 pub team: Option<String>,
93 pub project: Option<String>,
95 pub saved_tokens: u64,
98 pub uncompressed_input_tokens: u64,
101 pub is_local: bool,
104 pub routed_from: Option<String>,
107 pub counterfactual: Option<super::counterfactual::CounterfactualSlot>,
112 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 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 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
176const MAX_LINE_BYTES: usize = 1 << 20; pub struct Scanner {
186 provider: Provider,
187 url_model: Option<String>,
189 cohort: Option<super::holdout::Arm>,
191 wire: Option<Box<WireContext>>,
193 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 #[must_use]
215 pub fn with_cohort(mut self, cohort: Option<super::holdout::Arm>) -> Self {
216 self.cohort = cohort;
217 self
218 }
219
220 #[must_use]
223 pub fn with_wire_context(mut self, wire: Option<Box<WireContext>>) -> Self {
224 self.wire = wire;
225 self
226 }
227
228 #[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 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(); 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 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 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 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 if !self.line_might_be_relevant(trimmed) {
294 return;
295 }
296 let json_str = if let Some(rest) = trimmed.strip_prefix("data:") {
297 let r = rest.trim();
299 if r.is_empty() || r == "[DONE]" {
300 return;
301 }
302 r
303 } else if trimmed.starts_with('{') {
304 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 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
336fn 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
369fn 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 return;
394 }
395
396 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 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 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
460fn 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
503pub 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
515pub 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 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); 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 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 #[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 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 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); 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); 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 assert_eq!(collected.len(), 2);
907 assert!(collected.iter().all(std::result::Result::is_ok));
908 }
909}