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