1use futures::{Stream, StreamExt};
22use serde_json::Value;
23
24pub use lean_ctx::core::proxy_usage::{ResponseObservation, UsageBoundary};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Provider {
29 Anthropic,
30 OpenAi,
33 Gemini,
34}
35
36impl Provider {
37 pub fn from_label(label: &str) -> Self {
39 match label {
40 "Anthropic" | "Bedrock" => Self::Anthropic,
41 "OpenAI" | "ChatGPT" | "Azure" => Self::OpenAi,
42 _ => Self::Gemini,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Default, PartialEq)]
52pub struct RealUsage {
53 pub model: String,
54 pub input_tokens: u64,
56 pub output_tokens: u64,
58 pub cache_read_tokens: u64,
60 pub cache_write_tokens: u64,
63 pub reasoning_tokens: u64,
65 pub provider_cost_usd: Option<f64>,
72 pub cohort: super::holdout::Cohorts,
76 pub wire: Option<Box<WireContext>>,
81 pub response_observation: Option<ResponseObservation>,
84}
85
86#[derive(Debug, Clone, Default, PartialEq)]
92pub struct WireContext {
93 pub provider: String,
95 pub person: Option<String>,
97 pub team: Option<String>,
99 pub project: Option<String>,
101 pub project_key: Option<String>,
104 pub app_session: Option<String>,
107 pub saved_tokens: u64,
110 pub uncompressed_input_tokens: u64,
113 pub is_local: bool,
116 pub routed_from: Option<String>,
119 pub counterfactual: Option<super::counterfactual::CounterfactualSlot>,
124 pub lineage: Option<lean_ctx::core::ocla::OclaRequestContext>,
127}
128
129impl WireContext {
130 #[must_use]
131 pub fn ocla_request_context(&self) -> Option<&lean_ctx::core::ocla::OclaRequestContext> {
132 self.lineage.as_ref()
133 }
134}
135
136impl RealUsage {
137 pub fn to_ocla_usage_record(
141 &self,
142 ) -> lean_ctx::core::ocla::OclaResult<Option<lean_ctx::core::ocla::UsageRecord>> {
143 use lean_ctx::core::ocla::{OclaError, UsageRecord};
144
145 let Some(context) = self
146 .wire
147 .as_deref()
148 .and_then(WireContext::ocla_request_context)
149 .cloned()
150 else {
151 return Ok(None);
152 };
153 context.validate()?;
154 if self.model.trim().is_empty() {
155 return Err(OclaError::InvalidRequest("model is required".into()));
156 }
157 let input_tokens = self
158 .input_tokens
159 .checked_add(self.cache_read_tokens)
160 .and_then(|value| value.checked_add(self.cache_write_tokens))
161 .ok_or_else(|| OclaError::InvalidRequest("input token total overflow".into()))?;
162 let provider_billed_tokens = input_tokens
163 .checked_add(self.output_tokens)
164 .ok_or_else(|| OclaError::InvalidRequest("billed token total overflow".into()))?;
165
166 Ok(Some(UsageRecord {
167 context,
168 model: self.model.clone(),
169 input_tokens,
170 output_tokens: self.output_tokens,
171 provider_billed_tokens,
172 }))
173 }
174
175 fn is_meaningful(&self) -> bool {
179 !self.model.is_empty()
180 || self.input_tokens > 0
181 || self.output_tokens > 0
182 || self.cache_read_tokens > 0
183 || self.cache_write_tokens > 0
184 || self.provider_cost_usd.is_some()
185 }
186}
187
188const MAX_LINE_BYTES: usize = 1 << 20; pub struct Scanner {
198 provider: Provider,
199 url_model: Option<String>,
201 cohort: super::holdout::Cohorts,
203 wire: Option<Box<WireContext>>,
205 header_cost: Option<f64>,
208 buf: Vec<u8>,
209 usage: RealUsage,
210 terminal_usage_seen: bool,
211 anthropic_final_usage_seen: bool,
212 response_clock: Option<(std::time::Instant, u16)>,
213 stream_failed: bool,
214}
215
216impl Scanner {
217 pub fn new(provider: Provider, url_model: Option<String>) -> Self {
218 Self {
219 provider,
220 url_model,
221 cohort: super::holdout::Cohorts::default(),
222 wire: None,
223 header_cost: None,
224 buf: Vec::new(),
225 usage: RealUsage::default(),
226 terminal_usage_seen: false,
227 anthropic_final_usage_seen: false,
228 response_clock: None,
229 stream_failed: false,
230 }
231 }
232
233 #[must_use]
235 #[cfg(feature = "http-server")]
236 pub(crate) fn with_response_clock(
237 mut self,
238 started: Option<std::time::Instant>,
239 status: u16,
240 ) -> Self {
241 self.response_clock = started.map(|started| (started, status));
242 self
243 }
244
245 pub(super) fn mark_stream_error(&mut self) {
246 self.stream_failed = true;
247 }
248
249 #[must_use]
251 pub fn with_cohort(mut self, cohort: super::holdout::Cohorts) -> Self {
252 self.cohort = cohort;
253 self
254 }
255
256 #[must_use]
259 pub fn with_wire_context(mut self, wire: Option<Box<WireContext>>) -> Self {
260 self.wire = wire;
261 self
262 }
263
264 #[must_use]
270 pub fn with_header_cost(mut self, cost: Option<f64>) -> Self {
271 self.header_cost = cost.filter(|c| c.is_finite() && *c >= 0.0);
272 self
273 }
274
275 pub fn feed(&mut self, chunk: &[u8]) {
277 self.buf.extend_from_slice(chunk);
278 while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
279 let mut line: Vec<u8> = self.buf.drain(..=nl).collect();
280 line.pop(); if line.last() == Some(&b'\r') {
282 line.pop();
283 }
284 self.scan_line(&line);
285 }
286 if self.buf.len() > MAX_LINE_BYTES {
287 self.buf.clear();
288 }
289 }
290
291 pub fn feed_body(&mut self, body: &[u8]) {
293 if let Ok(v) = serde_json::from_slice::<Value>(body) {
294 self.absorb(&v);
295 }
296 }
297
298 pub(super) fn has_terminal_usage(&self) -> bool {
300 self.terminal_usage_seen && (self.usage.is_meaningful() || self.header_cost.is_some())
301 }
302
303 pub(super) fn finalize_terminal(self) -> Option<RealUsage> {
305 if self.has_terminal_usage() {
306 self.finalize_at(UsageBoundary::ProviderTerminalUsage)
307 } else {
308 None
309 }
310 }
311
312 pub fn finalize(self) -> Option<RealUsage> {
315 self.finalize_at(UsageBoundary::StreamEnd)
316 }
317
318 pub(crate) fn finalize_at(mut self, boundary: UsageBoundary) -> Option<RealUsage> {
319 if !self.buf.is_empty() {
320 let line = std::mem::take(&mut self.buf);
321 self.scan_line(&line);
322 }
323 if self.usage.provider_cost_usd.is_none() {
326 self.usage.provider_cost_usd = self.header_cost;
327 }
328 if self.usage.is_meaningful() {
329 self.usage.cohort = self.cohort;
330 self.usage.wire = self.wire;
331 self.usage.response_observation =
332 self.response_clock
333 .map(|(started, status)| ResponseObservation {
334 schema_version: 1,
335 http_status: status,
336 usage_observed_ms: u64::try_from(started.elapsed().as_millis())
337 .unwrap_or(u64::MAX),
338 boundary: if self.stream_failed {
339 UsageBoundary::StreamError
340 } else {
341 boundary
342 },
343 provider_cost_usd: self.usage.provider_cost_usd,
344 });
345 Some(self.usage)
346 } else {
347 None
348 }
349 }
350
351 fn scan_line(&mut self, line: &[u8]) {
352 let Ok(text) = std::str::from_utf8(line) else {
353 return;
354 };
355 let trimmed = text.trim();
356 if trimmed.is_empty() {
357 return;
358 }
359 if !self.line_might_be_relevant(trimmed) {
362 return;
363 }
364 let json_str = if let Some(rest) = trimmed.strip_prefix("data:") {
365 let r = rest.trim();
367 if r.is_empty() || r == "[DONE]" {
368 return;
369 }
370 r
371 } else if trimmed.starts_with('{') {
372 trimmed
375 .trim_start_matches([',', '['])
376 .trim_end_matches([',', ']'])
377 .trim()
378 } else {
379 return;
380 };
381 if let Ok(v) = serde_json::from_str::<Value>(json_str) {
382 self.absorb(&v);
383 }
384 }
385
386 fn line_might_be_relevant(&self, s: &str) -> bool {
387 match self.provider {
388 Provider::Anthropic => s.contains("usage") || s.contains("message_stop"),
391 Provider::OpenAi => s.contains("usage"),
392 Provider::Gemini => s.contains("usageMetadata"),
393 }
394 }
395
396 fn absorb(&mut self, v: &Value) {
397 match self.provider {
398 Provider::Anthropic => absorb_anthropic(&mut self.usage, v),
399 Provider::OpenAi => absorb_openai(&mut self.usage, v),
400 Provider::Gemini => absorb_gemini(&mut self.usage, v, self.url_model.as_deref()),
401 }
402 match self.provider {
403 Provider::Anthropic => {
404 if v.get("type").and_then(Value::as_str) == Some("message_delta")
405 && v.pointer("/usage/output_tokens")
406 .and_then(Value::as_u64)
407 .is_some()
408 {
409 self.anthropic_final_usage_seen = true;
410 }
411 self.terminal_usage_seen |= self.anthropic_final_usage_seen
412 && v.get("type").and_then(Value::as_str) == Some("message_stop");
413 }
414 Provider::OpenAi => {
415 let response_terminal = matches!(
416 v.get("type").and_then(Value::as_str),
417 Some("response.completed" | "response.incomplete" | "response.failed")
418 ) && has_reported_totals(
419 v.pointer("/response/usage"),
420 "input_tokens",
421 "output_tokens",
422 );
423 let chat_terminal =
424 has_reported_totals(v.get("usage"), "prompt_tokens", "completion_tokens")
425 && v.get("choices")
426 .and_then(Value::as_array)
427 .is_some_and(Vec::is_empty);
428 self.terminal_usage_seen |= response_terminal || chat_terminal;
429 }
430 Provider::Gemini => {}
433 }
434 }
435}
436
437fn has_reported_totals(usage: Option<&Value>, input: &str, output: &str) -> bool {
438 usage.is_some_and(|usage| {
439 (usage.get(input).and_then(Value::as_u64).is_some()
440 && usage.get(output).and_then(Value::as_u64).is_some())
441 || usage
442 .get("cost")
443 .and_then(Value::as_f64)
444 .is_some_and(|cost| cost.is_finite() && cost >= 0.0)
445 })
446}
447
448fn absorb_anthropic(u: &mut RealUsage, v: &Value) {
453 let msg = v.get("message").unwrap_or(v);
454 if let Some(model) = msg.get("model").and_then(Value::as_str)
455 && !model.is_empty()
456 {
457 u.model = model.to_string();
458 }
459 let Some(usage) = msg.get("usage").or_else(|| v.get("usage")) else {
460 return;
461 };
462 if let Some(n) = usage.get("input_tokens").and_then(Value::as_u64) {
463 u.input_tokens = n;
464 }
465 if let Some(n) = usage.get("cache_read_input_tokens").and_then(Value::as_u64) {
466 u.cache_read_tokens = n;
467 }
468 if let Some(n) = usage
469 .get("cache_creation_input_tokens")
470 .and_then(Value::as_u64)
471 {
472 u.cache_write_tokens = n;
473 }
474 if let Some(n) = usage.get("output_tokens").and_then(Value::as_u64)
475 && n > 0
476 {
477 u.output_tokens = n;
478 }
479}
480
481fn absorb_openai(u: &mut RealUsage, v: &Value) {
494 let root = v.get("response").unwrap_or(v);
495 if let Some(model) = root.get("model").and_then(Value::as_str)
496 && !model.is_empty()
497 {
498 u.model = model.to_string();
499 }
500 let Some(usage) = root.get("usage") else {
501 return;
502 };
503 if usage.is_null() {
504 return;
506 }
507
508 if let Some(cost) = usage.get("cost").and_then(Value::as_f64) {
512 let upstream = usage
513 .get("cost_details")
514 .and_then(|d| d.get("upstream_inference_cost"))
515 .and_then(Value::as_f64)
516 .unwrap_or(0.0);
517 let byok_upstream = if upstream > 0.0 && upstream != cost {
522 upstream
523 } else {
524 0.0
525 };
526 u.provider_cost_usd = Some(cost + byok_upstream);
527 }
528
529 let total_input = usage
530 .get("input_tokens")
531 .or_else(|| usage.get("prompt_tokens"))
532 .and_then(Value::as_u64)
533 .unwrap_or(0);
534 let total_output = usage
535 .get("output_tokens")
536 .or_else(|| usage.get("completion_tokens"))
537 .and_then(Value::as_u64)
538 .unwrap_or(0);
539 let input_details = usage
540 .get("input_tokens_details")
541 .or_else(|| usage.get("prompt_tokens_details"));
542 let cached = input_details
543 .and_then(|d| d.get("cached_tokens"))
544 .and_then(Value::as_u64)
545 .unwrap_or(0);
546 let cache_write = input_details
547 .and_then(|d| d.get("cache_write_tokens"))
548 .and_then(Value::as_u64)
549 .unwrap_or(0);
550 let reasoning = usage
551 .get("output_tokens_details")
552 .or_else(|| usage.get("completion_tokens_details"))
553 .and_then(|d| d.get("reasoning_tokens"))
554 .and_then(Value::as_u64)
555 .unwrap_or(0);
556
557 if total_input == 0 && total_output == 0 {
558 return;
559 }
560 u.input_tokens = total_input
564 .saturating_sub(cached)
565 .saturating_sub(cache_write);
566 u.cache_read_tokens = cached;
567 u.cache_write_tokens = cache_write;
568 u.output_tokens = total_output;
569 u.reasoning_tokens = reasoning;
570}
571
572fn absorb_gemini(u: &mut RealUsage, v: &Value, url_model: Option<&str>) {
576 if let Some(mv) = v.get("modelVersion").and_then(Value::as_str)
577 && !mv.is_empty()
578 {
579 u.model = mv.to_string();
580 } else if u.model.is_empty()
581 && let Some(m) = url_model
582 && !m.is_empty()
583 {
584 u.model = m.to_string();
585 }
586 let Some(um) = v.get("usageMetadata") else {
587 return;
588 };
589 let prompt = um
590 .get("promptTokenCount")
591 .and_then(Value::as_u64)
592 .unwrap_or(0);
593 let candidates = um
594 .get("candidatesTokenCount")
595 .and_then(Value::as_u64)
596 .unwrap_or(0);
597 let cached = um
598 .get("cachedContentTokenCount")
599 .and_then(Value::as_u64)
600 .unwrap_or(0);
601 let thoughts = um
602 .get("thoughtsTokenCount")
603 .and_then(Value::as_u64)
604 .unwrap_or(0);
605 if prompt == 0 && candidates == 0 && thoughts == 0 {
606 return;
607 }
608 u.input_tokens = prompt.saturating_sub(cached);
609 u.cache_read_tokens = cached;
610 u.cache_write_tokens = 0;
611 u.output_tokens = candidates + thoughts;
612 u.reasoning_tokens = thoughts;
613}
614
615pub fn gemini_model_from_path(path: &str) -> Option<String> {
618 let after = path.rsplit_once("/models/").map(|(_, m)| m)?;
619 let model = after.split(':').next().unwrap_or(after).trim();
620 if model.is_empty() {
621 None
622 } else {
623 Some(model.to_string())
624 }
625}
626
627pub fn tee_stream<S, B, E>(
631 inner: S,
632 scanner: Scanner,
633) -> impl Stream<Item = Result<B, E>> + Send + 'static
634where
635 S: Stream<Item = Result<B, E>> + Send + Unpin + 'static,
636 B: AsRef<[u8]> + Send + 'static,
637 E: Send + 'static,
638{
639 futures::stream::unfold(
640 (inner, Some(scanner)),
641 |(mut inner, mut scanner)| async move {
642 match inner.next().await {
643 Some(Ok(chunk)) => {
644 if let Some(s) = scanner.as_mut() {
645 s.feed(chunk.as_ref());
646 }
647 if scanner.as_ref().is_some_and(Scanner::has_terminal_usage)
648 && let Some(usage) = scanner.take().and_then(Scanner::finalize_terminal)
649 {
650 super::usage_meter::record(&usage);
651 }
652 Some((Ok(chunk), (inner, scanner)))
653 }
654 Some(err) => {
655 if let Some(mut s) = scanner.take() {
656 s.mark_stream_error();
657 if let Some(usage) = s.finalize_at(UsageBoundary::StreamError) {
658 super::usage_meter::record(&usage);
659 }
660 }
661 Some((err, (inner, scanner)))
662 }
663 None => {
664 if let Some(s) = scanner.take()
665 && let Some(usage) = s.finalize()
666 {
667 super::usage_meter::record(&usage);
668 }
669 None
670 }
671 }
672 },
673 )
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 fn feed_lines(
681 provider: Provider,
682 url_model: Option<&str>,
683 lines: &[&str],
684 ) -> Option<RealUsage> {
685 let mut s = Scanner::new(provider, url_model.map(str::to_string));
686 for line in lines {
687 s.feed(line.as_bytes());
688 s.feed(b"\n");
689 }
690 s.finalize()
691 }
692
693 #[test]
694 fn anthropic_merges_message_start_and_delta() {
695 let u = feed_lines(
696 Provider::Anthropic,
697 None,
698 &[
699 r#"data: {"type":"message_start","message":{"model":"claude-opus-4-5-20251101","usage":{"input_tokens":100,"cache_read_input_tokens":2000,"cache_creation_input_tokens":50,"output_tokens":1}}}"#,
700 r#"data: {"type":"content_block_delta","index":0,"delta":{"text":"hello"}}"#,
701 r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":73}}"#,
702 "data: {\"type\":\"message_stop\"}",
703 ],
704 )
705 .expect("usage");
706 assert_eq!(u.model, "claude-opus-4-5-20251101");
707 assert_eq!(u.input_tokens, 100);
708 assert_eq!(u.cache_read_tokens, 2000);
709 assert_eq!(u.cache_write_tokens, 50);
710 assert_eq!(u.output_tokens, 73);
711 }
712
713 #[test]
714 fn anthropic_non_streaming_body() {
715 let mut s = Scanner::new(Provider::Anthropic, None);
716 s.feed_body(
717 br#"{"model":"claude-sonnet-4-5","usage":{"input_tokens":24,"output_tokens":18,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}"#,
718 );
719 let u = s.finalize().expect("usage");
720 assert_eq!(u.model, "claude-sonnet-4-5");
721 assert_eq!(u.input_tokens, 24);
722 assert_eq!(u.output_tokens, 18);
723 }
724
725 #[test]
726 fn scanner_stamps_wire_context_onto_usage() {
727 let wire = Box::new(WireContext {
730 provider: "Anthropic".into(),
731 person: Some("yves".into()),
732 team: None,
733 project: Some("billing".into()),
734 project_key: None,
735 app_session: None,
736 saved_tokens: 42,
737 uncompressed_input_tokens: 500,
738 is_local: false,
739 routed_from: None,
740 counterfactual: None,
741 lineage: None,
742 });
743 let mut s = Scanner::new(Provider::Anthropic, None).with_wire_context(Some(wire.clone()));
744 s.feed_body(
745 br#"{"model":"claude-sonnet-4-5","usage":{"input_tokens":24,"output_tokens":18}}"#,
746 );
747 let u = s.finalize().expect("usage");
748 assert_eq!(u.wire, Some(wire));
749 }
750
751 fn managed_context() -> lean_ctx::core::ocla::OclaRequestContext {
752 lean_ctx::core::ocla::OclaRequestContext {
753 request_id: "req-1".into(),
754 session_id: "session-1".into(),
755 agent_id: "agent-1".into(),
756 content_ref: "blake3:abc".into(),
757 tenant_id: None,
758 trace_id: "tr-unit".into(),
759 task_id: None,
760 parent_task_id: None,
761 }
762 }
763
764 #[test]
765 fn ocla_projection_includes_cache_tokens_once() {
766 let usage = RealUsage {
767 model: "gpt-5".into(),
768 input_tokens: 100,
769 output_tokens: 40,
770 cache_read_tokens: 20,
771 cache_write_tokens: 5,
772 wire: Some(Box::new(WireContext {
773 lineage: Some(managed_context()),
774 ..Default::default()
775 })),
776 ..Default::default()
777 };
778 let record = usage
779 .to_ocla_usage_record()
780 .expect("valid projection")
781 .expect("managed record");
782 assert_eq!(record.input_tokens, 125);
783 assert_eq!(record.output_tokens, 40);
784 assert_eq!(record.provider_billed_tokens, 165);
785 assert_eq!(record.context, managed_context());
786 }
787
788 #[test]
789 fn ocla_projection_keeps_missing_lineage_unmanaged() {
790 let usage = RealUsage {
791 model: "gpt-5".into(),
792 input_tokens: 1,
793 ..Default::default()
794 };
795 assert_eq!(usage.to_ocla_usage_record().unwrap(), None);
796 }
797
798 #[test]
799 fn ocla_projection_fails_closed_on_overflow_or_invalid_context() {
800 let overflow = RealUsage {
801 model: "gpt-5".into(),
802 input_tokens: u64::MAX,
803 cache_read_tokens: 1,
804 wire: Some(Box::new(WireContext {
805 lineage: Some(managed_context()),
806 ..Default::default()
807 })),
808 ..Default::default()
809 };
810 assert!(overflow.to_ocla_usage_record().is_err());
811
812 let mut invalid = managed_context();
813 invalid.request_id.clear();
814 let invalid = RealUsage {
815 model: "gpt-5".into(),
816 wire: Some(Box::new(WireContext {
817 lineage: Some(invalid),
818 ..Default::default()
819 })),
820 ..Default::default()
821 };
822 assert!(invalid.to_ocla_usage_record().is_err());
823 }
824
825 #[test]
826 fn openai_responses_completed_event() {
827 let u = feed_lines(
828 Provider::OpenAi,
829 None,
830 &[
831 r#"data: {"type":"response.created","response":{"model":"gpt-5.4","usage":null}}"#,
832 r#"data: {"type":"response.completed","response":{"model":"gpt-5.4","usage":{"input_tokens":1289,"input_tokens_details":{"cached_tokens":289},"output_tokens":685,"output_tokens_details":{"reasoning_tokens":640},"total_tokens":1974}}}"#,
833 ],
834 )
835 .expect("usage");
836 assert_eq!(u.model, "gpt-5.4");
837 assert_eq!(u.input_tokens, 1000); assert_eq!(u.cache_read_tokens, 289);
839 assert_eq!(u.output_tokens, 685);
840 assert_eq!(u.reasoning_tokens, 640);
841 }
842
843 #[test]
844 fn openai_chat_final_usage_chunk() {
845 let u = feed_lines(
846 Provider::OpenAi,
847 None,
848 &[
849 r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-5.4-mini"}"#,
850 r#"data: {"choices":[],"model":"gpt-5.4-mini","usage":{"prompt_tokens":500,"prompt_tokens_details":{"cached_tokens":100},"completion_tokens":40,"total_tokens":540}}"#,
851 "data: [DONE]",
852 ],
853 )
854 .expect("usage");
855 assert_eq!(u.model, "gpt-5.4-mini");
856 assert_eq!(u.input_tokens, 400);
857 assert_eq!(u.cache_read_tokens, 100);
858 assert_eq!(u.output_tokens, 40);
859 assert_eq!(u.provider_cost_usd, None, "OpenAI reports no usage.cost");
860 }
861
862 #[test]
863 fn openrouter_cost_and_cache_writes_are_measured() {
864 let u = feed_lines(
868 Provider::OpenAi,
869 None,
870 &[
871 r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"deepseek/deepseek-v4-flash-20260423"}"#,
872 r#"data: {"choices":[],"model":"deepseek/deepseek-v4-flash-20260423","usage":{"prompt_tokens":700,"prompt_tokens_details":{"cached_tokens":150,"cache_write_tokens":50},"completion_tokens":40,"cost":0.0123,"cost_details":{"upstream_inference_cost":null},"total_tokens":740}}"#,
873 "data: [DONE]",
874 ],
875 )
876 .expect("usage");
877 assert_eq!(u.input_tokens, 500, "700 - 150 cached - 50 cache-write");
878 assert_eq!(u.cache_read_tokens, 150);
879 assert_eq!(u.cache_write_tokens, 50);
880 assert_eq!(u.output_tokens, 40);
881 let cost = u.provider_cost_usd.expect("measured cost");
882 assert!((cost - 0.0123).abs() < 1e-12);
883 }
884
885 #[test]
886 fn openrouter_byok_adds_upstream_inference_cost() {
887 let mut s = Scanner::new(Provider::OpenAi, None);
888 s.feed_body(
889 br#"{"model":"anthropic/claude-sonnet-5","usage":{"prompt_tokens":100,"completion_tokens":10,"cost":0.05,"cost_details":{"upstream_inference_cost":0.95}}}"#,
890 );
891 let u = s.finalize().expect("usage");
892 let cost = u.provider_cost_usd.expect("measured cost");
893 assert!(
894 (cost - 1.0).abs() < 1e-12,
895 "OpenRouter fee + BYOK upstream bill"
896 );
897 }
898
899 #[test]
902 fn non_byok_upstream_equal_to_cost_is_not_doubled() {
903 let mut s = Scanner::new(Provider::OpenAi, None);
904 s.feed_body(
905 br#"{"model":"deepseek/deepseek-v4-flash","usage":{"prompt_tokens":50,"completion_tokens":5,"cost":0.000001568,"cost_details":{"upstream_inference_cost":0.000001568}}}"#,
906 );
907 let u = s.finalize().expect("usage");
908 let cost = u.provider_cost_usd.expect("measured cost");
909 assert!(
910 (cost - 0.000001568).abs() < 1e-15,
911 "#746: must book cost once, not 2x; got {cost}"
912 );
913 }
914
915 #[test]
916 fn gateway_header_cost_fills_when_body_has_none() {
917 let mut s = Scanner::new(Provider::OpenAi, None).with_header_cost(Some(0.0042));
920 s.feed_body(
921 br#"{"model":"azure-gpt-4o","usage":{"prompt_tokens":100,"completion_tokens":10}}"#,
922 );
923 let u = s.finalize().expect("usage");
924 assert_eq!(u.provider_cost_usd, Some(0.0042), "header is measured");
925 }
926
927 #[test]
928 fn body_reported_cost_beats_the_header_figure() {
929 let mut s = Scanner::new(Provider::OpenAi, None).with_header_cost(Some(9.99));
931 s.feed_body(
932 br#"{"model":"deepseek/deepseek-v4-flash","usage":{"prompt_tokens":100,"completion_tokens":10,"cost":0.05}}"#,
933 );
934 let u = s.finalize().expect("usage");
935 assert_eq!(u.provider_cost_usd, Some(0.05), "body wins over header");
936 }
937
938 #[test]
939 fn openrouter_free_model_reports_zero_cost_as_measured() {
940 let mut s = Scanner::new(Provider::OpenAi, None);
941 s.feed_body(
942 br#"{"model":"poolside/laguna-xs-2.1:free","usage":{"prompt_tokens":80,"completion_tokens":20,"cost":0}}"#,
943 );
944 let u = s.finalize().expect("usage");
945 assert_eq!(u.provider_cost_usd, Some(0.0), "free is a price, not a gap");
946 }
947
948 #[test]
949 fn gemini_usage_metadata_with_url_model() {
950 let u = feed_lines(
951 Provider::Gemini,
952 Some("gemini-2.5-pro"),
953 &[
954 r#"data: {"candidates":[{"content":{"parts":[{"text":"hi"}]}}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":7,"thoughtsTokenCount":39,"totalTokenCount":71}}"#,
955 ],
956 )
957 .expect("usage");
958 assert_eq!(u.model, "gemini-2.5-pro");
959 assert_eq!(u.input_tokens, 25);
960 assert_eq!(u.output_tokens, 46); assert_eq!(u.reasoning_tokens, 39);
962 }
963
964 #[test]
965 fn gemini_prefers_model_version_over_url() {
966 let u = feed_lines(
967 Provider::Gemini,
968 Some("gemini-2.5-pro"),
969 &[
970 r#"data: {"modelVersion":"gemini-2.5-pro-002","usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"cachedContentTokenCount":4,"totalTokenCount":15}}"#,
971 ],
972 )
973 .expect("usage");
974 assert_eq!(u.model, "gemini-2.5-pro-002");
975 assert_eq!(u.input_tokens, 6); assert_eq!(u.cache_read_tokens, 4);
977 assert_eq!(u.output_tokens, 5);
978 }
979
980 #[test]
981 fn split_chunks_reassemble_across_feed_calls() {
982 let mut s = Scanner::new(Provider::Anthropic, None);
983 let line = r#"data: {"type":"message_delta","delta":{},"usage":{"output_tokens":42}}"#;
984 let bytes = format!("{line}\n");
985 let (a, b) = bytes.as_bytes().split_at(20);
986 s.feed(a);
987 s.feed(b);
988 let u = s.finalize().expect("usage");
989 assert_eq!(u.output_tokens, 42);
990 }
991
992 #[test]
993 fn no_usage_yields_none() {
994 let out = feed_lines(
995 Provider::OpenAi,
996 None,
997 &[r#"data: {"choices":[{"delta":{"content":"hi"}}],"model":"gpt-5.4"}"#],
998 );
999 assert!(out.is_none(), "content-only stream reports no usage");
1000 }
1001
1002 #[test]
1003 fn final_event_without_trailing_newline() {
1004 let mut s = Scanner::new(Provider::Anthropic, None);
1005 s.feed(br#"data: {"type":"message_delta","delta":{},"usage":{"output_tokens":7}}"#);
1006 let u = s.finalize().expect("flushes trailing partial line");
1007 assert_eq!(u.output_tokens, 7);
1008 }
1009
1010 #[test]
1011 fn gemini_model_from_path_extracts() {
1012 assert_eq!(
1013 gemini_model_from_path("/v1beta/models/gemini-2.5-pro:streamGenerateContent")
1014 .as_deref(),
1015 Some("gemini-2.5-pro")
1016 );
1017 assert_eq!(
1018 gemini_model_from_path("/v1beta/models/gemini-2.5-flash:generateContent").as_deref(),
1019 Some("gemini-2.5-flash")
1020 );
1021 assert_eq!(gemini_model_from_path("/v1/chat/completions"), None);
1022 }
1023
1024 #[test]
1025 fn terminal_usage_requires_provider_completion_and_reported_totals() {
1026 for kind in [
1027 "response.completed",
1028 "response.incomplete",
1029 "response.failed",
1030 ] {
1031 let mut scanner = Scanner::new(Provider::OpenAi, None);
1032 scanner.feed_body(serde_json::json!({
1033 "type": kind,
1034 "response": {"model": "terminal", "usage": {"input_tokens": 5, "output_tokens": 2}}
1035 }).to_string().as_bytes());
1036 assert!(scanner.has_terminal_usage());
1037 }
1038 for (provider, event) in [
1039 (
1040 Provider::OpenAi,
1041 serde_json::json!({"type":"response.in_progress","response":{"usage":{"input_tokens":5,"output_tokens":2}}}),
1042 ),
1043 (
1044 Provider::OpenAi,
1045 serde_json::json!({"type":"response.completed","response":{"usage":{"input_tokens":5}}}),
1046 ),
1047 (
1048 Provider::OpenAi,
1049 serde_json::json!({"choices":[],"usage":null}),
1050 ),
1051 (
1052 Provider::Gemini,
1053 serde_json::json!({"candidates":[{"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":2}}),
1054 ),
1055 ] {
1056 let mut scanner = Scanner::new(provider, None);
1057 scanner.feed_body(event.to_string().as_bytes());
1058 assert!(!scanner.has_terminal_usage());
1059 }
1060 let mut chat = Scanner::new(Provider::OpenAi, None);
1061 chat.feed_body(
1062 br#"{"model":"chat","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2}}"#,
1063 );
1064 assert!(chat.has_terminal_usage());
1065 let mut anthropic = Scanner::new(Provider::Anthropic, None);
1066 anthropic.feed(b"data: {\"type\":\"message_start\",\"message\":{\"model\":\"anthropic\",\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n");
1067 anthropic.feed(b"data: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":2}}\n");
1068 assert!(!anthropic.has_terminal_usage());
1069 anthropic.feed(b"data: {\"type\":\"message_stop\"}\n");
1070 assert!(anthropic.has_terminal_usage());
1071 }
1072
1073 fn terminal_usage_frame(model: &str) -> Vec<u8> {
1074 format!(
1075 "data: {}\n\n",
1076 serde_json::json!({
1077 "type": "response.completed",
1078 "response": {
1079 "model": model,
1080 "usage": {
1081 "input_tokens": 120,
1082 "output_tokens": 7,
1083 "input_tokens_details": {"cached_tokens": 20},
1084 "cost": 0.0125
1085 }
1086 }
1087 })
1088 )
1089 .into_bytes()
1090 }
1091
1092 fn measured_requests(model: &str) -> u64 {
1093 super::super::usage_meter::snapshot()
1094 .into_iter()
1095 .find(|usage| usage.model == model)
1096 .map_or(0, |usage| usage.requests)
1097 }
1098
1099 #[tokio::test]
1100 async fn terminal_usage_clean_eof_records_once() {
1101 let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1102 let model = "terminal-evidence-clean-eof";
1103 let before = measured_requests(model);
1104 let frame = terminal_usage_frame(model);
1105 let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1106 let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::OpenAi, None)));
1107 assert_eq!(teed.next().await, Some(Ok(frame)));
1108 assert_eq!(teed.next().await, None);
1109 drop(teed);
1110 assert_eq!(measured_requests(model), before + 1);
1111 }
1112
1113 #[tokio::test]
1114 async fn terminal_usage_received_before_drop_is_recorded_once() {
1115 let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1116 let model = "terminal-evidence-client-drop";
1117 let before = measured_requests(model);
1118 let frame = terminal_usage_frame(model);
1119 let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1120 let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::OpenAi, None)));
1121 assert_eq!(teed.next().await, Some(Ok(frame)));
1122 drop(teed);
1123 assert_eq!(measured_requests(model), before + 1);
1124 }
1125
1126 #[tokio::test]
1127 async fn terminal_usage_received_before_error_is_recorded_once() {
1128 let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1129 let model = "terminal-evidence-upstream-error";
1130 let before = measured_requests(model);
1131 let frame = terminal_usage_frame(model);
1132 let source = futures::stream::iter(vec![Ok(frame.clone()), Err(())]);
1133 let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::OpenAi, None)));
1134 assert_eq!(teed.next().await, Some(Ok(frame)));
1135 assert_eq!(teed.next().await, Some(Err(())));
1136 drop(teed);
1137 assert_eq!(measured_requests(model), before + 1);
1138 }
1139
1140 #[tokio::test]
1141 async fn terminal_usage_retains_header_cost_without_model_or_body_tokens() {
1142 let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1143 let before = super::super::usage_meter::snapshot()
1144 .into_iter()
1145 .find(|usage| usage.model == "unknown");
1146 let before_requests = before.as_ref().map_or(0, |usage| usage.requests);
1147 let before_cost = before.as_ref().map_or(0.0, |usage| usage.cost_usd);
1148 let frame = b"data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n".to_vec();
1149 let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1150 let scanner = Scanner::new(Provider::OpenAi, None).with_header_cost(Some(0.0042));
1151 let mut teed = Box::pin(tee_stream(source, scanner));
1152 assert_eq!(teed.next().await, Some(Ok(frame)));
1153 drop(teed);
1154 let measured = super::super::usage_meter::snapshot()
1155 .into_iter()
1156 .find(|usage| usage.model == "unknown")
1157 .unwrap();
1158 assert_eq!(measured.requests, before_requests + 1);
1159 assert!((measured.cost_usd - before_cost - 0.0042).abs() < 1e-9);
1160 }
1161
1162 #[tokio::test]
1163 async fn terminal_usage_partial_input_does_not_become_completed_spend_on_drop() {
1164 let _isolation = lean_ctx::core::data_dir::isolated_data_dir();
1165 let model = "terminal-evidence-partial-input";
1166 let before = measured_requests(model);
1167 let frame = format!(
1168 "data: {}\n\n",
1169 serde_json::json!({
1170 "type": "message_start",
1171 "message": {
1172 "model": model,
1173 "usage": {"input_tokens": 5, "output_tokens": 1}
1174 }
1175 })
1176 )
1177 .into_bytes();
1178 let source = futures::stream::iter(vec![Ok::<_, ()>(frame.clone())]);
1179 let mut teed = Box::pin(tee_stream(source, Scanner::new(Provider::Anthropic, None)));
1180 assert_eq!(teed.next().await, Some(Ok(frame)));
1181 drop(teed);
1182 assert_eq!(measured_requests(model), before);
1183 }
1184
1185 #[tokio::test]
1186 async fn tee_stream_passes_bytes_through_and_records() {
1187 let chunks: Vec<Result<Vec<u8>, std::convert::Infallible>> = vec![
1188 Ok(b"data: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4.5\",\"usage\":{\"input_tokens\":5,\"output_tokens\":1}}}\n".to_vec()),
1189 Ok(b"data: {\"type\":\"message_delta\",\"delta\":{},\"usage\":{\"output_tokens\":9}}\n".to_vec()),
1190 ];
1191 let inner = futures::stream::iter(chunks);
1192 let scanner = Scanner::new(Provider::Anthropic, None);
1193 let teed = tee_stream(inner, scanner);
1194 let collected: Vec<_> = teed.collect().await;
1195 assert_eq!(collected.len(), 2);
1197 assert!(collected.iter().all(std::result::Result::is_ok));
1198 }
1199}