1pub mod translate;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum Provider {
9 Anthropic,
10 Openai,
11 Gemini,
12 Xai,
13}
14
15impl Provider {
16 pub fn as_str(self) -> &'static str {
17 match self {
18 Provider::Anthropic => "anthropic",
19 Provider::Openai => "openai",
20 Provider::Gemini => "gemini",
21 Provider::Xai => "xai",
22 }
23 }
24
25 pub fn from_str_loose(s: &str) -> Option<Provider> {
26 match s.to_lowercase().as_str() {
27 "anthropic" | "claude" => Some(Provider::Anthropic),
28 "openai" | "codex" | "chatgpt" => Some(Provider::Openai),
29 "gemini" | "google" => Some(Provider::Gemini),
30 "xai" | "grok" => Some(Provider::Xai),
31 _ => None,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ClientFormat {
38 AnthropicMessages,
39 OpenaiChat,
40 OpenaiResponses,
41}
42
43impl ClientFormat {
44 pub fn as_str(self) -> &'static str {
45 match self {
46 ClientFormat::AnthropicMessages => "anthropic",
47 ClientFormat::OpenaiChat => "openai-chat",
48 ClientFormat::OpenaiResponses => "openai-responses",
49 }
50 }
51
52 pub fn default_provider(self) -> Provider {
53 match self {
54 ClientFormat::AnthropicMessages => Provider::Anthropic,
55 ClientFormat::OpenaiChat | ClientFormat::OpenaiResponses => Provider::Openai,
56 }
57 }
58}
59
60const PREFIXES: &[(&str, Provider)] = &[
61 ("claude:", Provider::Anthropic),
62 ("anthropic:", Provider::Anthropic),
63 ("openai:", Provider::Openai),
64 ("codex:", Provider::Openai),
65 ("gemini:", Provider::Gemini),
66 ("grok:", Provider::Xai),
67 ("xai:", Provider::Xai),
68 ("claude/", Provider::Anthropic),
69 ("anthropic/", Provider::Anthropic),
70 ("openai/", Provider::Openai),
71 ("codex/", Provider::Openai),
72 ("chatgpt/", Provider::Openai),
73 ("gemini/", Provider::Gemini),
74 ("google/", Provider::Gemini),
75 ("grok/", Provider::Xai),
76 ("xai/", Provider::Xai),
77];
78
79const PASSTHROUGH: &[&str] = &["cove/", "alexandria/"];
80
81const ALIASES: &[(&str, &str)] = &[
82 ("opus-4.8", "claude-opus-4-8"),
83 ("opus-4.5", "claude-opus-4-5"),
84 ("sonnet-5", "claude-sonnet-5"),
85 ("sonnet-4.5", "claude-sonnet-4-5"),
86 ("haiku-4.5", "claude-haiku-4-5"),
87];
88
89pub fn model_aliases() -> &'static [(&'static str, &'static str)] {
90 ALIASES
91}
92
93fn hs<'a>(h: &'a Value, key: &str) -> Option<&'a str> {
94 h.get(key).and_then(|v| v.as_str())
95}
96
97fn hf(h: &Value, key: &str) -> Option<f64> {
98 hs(h, key).and_then(|s| s.parse().ok())
99}
100
101fn hi(h: &Value, key: &str) -> Option<i64> {
102 hs(h, key).and_then(|s| s.parse().ok())
103}
104
105pub fn parse_limit_headers(provider: Provider, h: &Value) -> Value {
106 match provider {
107 Provider::Anthropic => {
108 let mut windows = Vec::new();
109 for (name, prefix) in [
110 ("5h", "anthropic-ratelimit-unified-5h"),
111 ("7d", "anthropic-ratelimit-unified-7d"),
112 ] {
113 if let Some(util) = hf(h, &format!("{prefix}-utilization")) {
114 windows.push(serde_json::json!({
115 "window": name,
116 "used_pct": util * 100.0,
117 "status": hs(h, &format!("{prefix}-status")),
118 "resets_at_s": hi(h, &format!("{prefix}-reset")),
119 }));
120 }
121 }
122 serde_json::json!({
123 "windows": windows,
124 "representative_window": hs(h, "anthropic-ratelimit-unified-representative-claim"),
125 "overage": {
126 "status": hs(h, "anthropic-ratelimit-unified-overage-status"),
127 "reason": hs(h, "anthropic-ratelimit-unified-overage-disabled-reason"),
128 },
129 })
130 }
131 Provider::Openai => {
132 let mut windows = Vec::new();
133 for prefix in ["x-codex-primary", "x-codex-secondary"] {
134 if let Some(used) = hf(h, &format!("{prefix}-used-percent")) {
135 let minutes = hi(h, &format!("{prefix}-window-minutes"));
136 let name = match minutes {
137 Some(300) => "5h".to_string(),
138 Some(10080) => "7d".to_string(),
139 Some(m) => format!("{m}m"),
140 None => "unknown".to_string(),
141 };
142 windows.push(serde_json::json!({
143 "window": name,
144 "used_pct": used,
145 "resets_at_s": hi(h, &format!("{prefix}-reset-at")),
146 }));
147 }
148 }
149 serde_json::json!({
150 "plan": hs(h, "x-codex-plan-type"),
151 "active_limit": hs(h, "x-codex-active-limit"),
152 "windows": windows,
153 "credits": {
154 "balance": hs(h, "x-codex-credits-balance"),
155 "has_credits": hs(h, "x-codex-credits-has-credits"),
156 "unlimited": hs(h, "x-codex-credits-unlimited"),
157 },
158 })
159 }
160 Provider::Xai => serde_json::json!({
161 "requests": {
162 "limit": hi(h, "x-ratelimit-limit-requests"),
163 "remaining": hi(h, "x-ratelimit-remaining-requests"),
164 },
165 "tokens": {
166 "limit": hi(h, "x-ratelimit-limit-tokens"),
167 "remaining": hi(h, "x-ratelimit-remaining-tokens"),
168 },
169 }),
170 Provider::Gemini => Value::Null,
171 }
172}
173
174fn resolve_alias(model: &str) -> String {
175 for (alias, full) in ALIASES {
176 if model == *alias {
177 return full.to_string();
178 }
179 }
180 model.to_string()
181}
182
183pub fn route_model(model: &str) -> (Option<Provider>, String) {
184 for prefix in PASSTHROUGH {
185 if let Some(rest) = model.strip_prefix(prefix) {
186 return route_model(rest);
187 }
188 }
189 for (prefix, provider) in PREFIXES {
190 if let Some(rest) = model.strip_prefix(prefix) {
191 return (Some(*provider), resolve_alias(rest));
192 }
193 }
194 let model = resolve_alias(model);
195 let lower = model.to_lowercase();
196 let inferred = if lower.starts_with("claude") {
197 Some(Provider::Anthropic)
198 } else if lower.starts_with("gpt")
199 || lower.starts_with("codex")
200 || lower.starts_with("chatgpt")
201 || is_o_series(&lower)
202 {
203 Some(Provider::Openai)
204 } else if lower.starts_with("gemini") {
205 Some(Provider::Gemini)
206 } else if lower.starts_with("grok") {
207 Some(Provider::Xai)
208 } else {
209 None
210 };
211 (inferred, model.to_string())
212}
213
214fn is_o_series(lower: &str) -> bool {
215 let mut chars = lower.chars();
216 chars.next() == Some('o') && chars.next().map(|c| c.is_ascii_digit()).unwrap_or(false)
217}
218
219#[derive(Debug, Default, Clone, Serialize, Deserialize)]
220pub struct Usage {
221 pub input_tokens: Option<i64>,
222 pub cached_input_tokens: Option<i64>,
223 pub cache_creation_tokens: Option<i64>,
224 pub output_tokens: Option<i64>,
225 pub reasoning_tokens: Option<i64>,
226}
227
228impl Usage {
229 pub fn merge(&mut self, other: Usage) {
230 if other.input_tokens.is_some() {
231 self.input_tokens = other.input_tokens;
232 }
233 if other.cached_input_tokens.is_some() {
234 self.cached_input_tokens = other.cached_input_tokens;
235 }
236 if other.cache_creation_tokens.is_some() {
237 self.cache_creation_tokens = other.cache_creation_tokens;
238 }
239 if other.output_tokens.is_some() {
240 self.output_tokens = other.output_tokens;
241 }
242 if other.reasoning_tokens.is_some() {
243 self.reasoning_tokens = other.reasoning_tokens;
244 }
245 }
246
247 pub fn is_empty(&self) -> bool {
248 self.input_tokens.is_none() && self.output_tokens.is_none()
249 }
250}
251
252fn path_i64(v: &Value, path: &[&str]) -> Option<i64> {
253 let mut cur = v;
254 for p in path {
255 cur = cur.get(p)?;
256 }
257 cur.as_i64()
258}
259
260pub fn usage_from_obj(o: &Value) -> Usage {
261 Usage {
262 input_tokens: path_i64(o, &["input_tokens"]).or_else(|| path_i64(o, &["prompt_tokens"])),
263 cached_input_tokens: path_i64(o, &["cache_read_input_tokens"])
264 .or_else(|| path_i64(o, &["prompt_tokens_details", "cached_tokens"]))
265 .or_else(|| path_i64(o, &["input_tokens_details", "cached_tokens"])),
266 cache_creation_tokens: path_i64(o, &["cache_creation_input_tokens"]),
267 output_tokens: path_i64(o, &["output_tokens"])
268 .or_else(|| path_i64(o, &["completion_tokens"])),
269 reasoning_tokens: path_i64(o, &["completion_tokens_details", "reasoning_tokens"])
270 .or_else(|| path_i64(o, &["output_tokens_details", "reasoning_tokens"])),
271 }
272}
273
274pub fn usage_from_json(v: &Value) -> Usage {
275 let mut usage = Usage::default();
276 for loc in [&v["usage"], &v["message"]["usage"], &v["response"]["usage"]] {
277 if loc.is_object() {
278 usage.merge(usage_from_obj(loc));
279 }
280 }
281 usage
282}
283
284pub fn parse_sse_usage(body: &str) -> Usage {
285 let mut usage = Usage::default();
286 for line in body.lines() {
287 let Some(data) = line.strip_prefix("data:") else {
288 continue;
289 };
290 let data = data.trim();
291 if data.is_empty() || data == "[DONE]" {
292 continue;
293 }
294 let Ok(v) = serde_json::from_str::<Value>(data) else {
295 continue;
296 };
297 usage.merge(usage_from_json(&v));
298 }
299 usage
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct Pricing {
304 pub input_per_m: f64,
305 pub cached_input_per_m: f64,
306 pub cache_creation_per_m: f64,
307 pub output_per_m: f64,
308}
309
310pub fn compute_cost(usage: &Usage, pricing: &Pricing, input_includes_cached: bool) -> f64 {
311 let input = usage.input_tokens.unwrap_or(0) as f64;
312 let cached = usage.cached_input_tokens.unwrap_or(0) as f64;
313 let creation = usage.cache_creation_tokens.unwrap_or(0) as f64;
314 let output = usage.output_tokens.unwrap_or(0) as f64;
315 let uncached_input = if input_includes_cached {
316 (input - cached).max(0.0)
317 } else {
318 input
319 };
320 (uncached_input * pricing.input_per_m
321 + cached * pricing.cached_input_per_m
322 + creation * pricing.cache_creation_per_m
323 + output * pricing.output_per_m)
324 / 1_000_000.0
325}
326
327#[derive(Debug, Default, Clone, Serialize, Deserialize)]
328pub struct TraceRecord {
329 pub id: String,
330 pub ts_request_ms: i64,
331 pub ts_response_ms: Option<i64>,
332 pub session_id: Option<String>,
333 pub harness: Option<String>,
334 pub client_format: Option<String>,
335 pub upstream_provider: Option<String>,
336 pub upstream_format: Option<String>,
337 pub requested_model: Option<String>,
338 pub routed_model: Option<String>,
339 pub method: Option<String>,
340 pub path: Option<String>,
341 pub status: Option<i64>,
342 pub streamed: Option<bool>,
343 pub usage: Usage,
344 pub cost_usd: Option<f64>,
345 pub billing_bucket: Option<String>,
346 pub req_body_path: Option<String>,
347 pub upstream_req_body_path: Option<String>,
348 pub resp_body_path: Option<String>,
349 pub req_headers_json: Option<String>,
350 pub resp_headers_json: Option<String>,
351 pub error: Option<String>,
352 pub account_id: Option<String>,
353 pub run_id: Option<String>,
354 pub tags: Option<String>,
355 pub client_ip: Option<String>,
356 pub key_fingerprint: Option<String>,
357}
358
359pub fn parse_trace_tags(values: &[&str]) -> Value {
360 let mut map = serde_json::Map::new();
361 for v in values {
362 for piece in v.split(',') {
363 let Some((k, val)) = piece.split_once('=') else {
364 continue;
365 };
366 let k = k.trim();
367 if k.is_empty() {
368 continue;
369 }
370 map.insert(k.to_string(), Value::String(val.trim().to_string()));
371 }
372 }
373 Value::Object(map)
374}
375
376pub fn conversation_root(format: ClientFormat, body: &Value) -> Option<String> {
377 let (system, user) = match format {
378 ClientFormat::AnthropicMessages => {
379 let system = translate::txt(&body["system"]);
380 let user = body["messages"]
381 .as_array()
382 .into_iter()
383 .flatten()
384 .find(|m| m["role"] == "user")
385 .map(|m| translate::txt(&m["content"]))
386 .unwrap_or_default();
387 (system, user)
388 }
389 ClientFormat::OpenaiChat => {
390 let msgs = body["messages"].as_array();
391 let find = |roles: &[&str]| {
392 msgs.into_iter()
393 .flatten()
394 .find(|m| roles.contains(&m["role"].as_str().unwrap_or("")))
395 .map(|m| translate::txt(&m["content"]))
396 .unwrap_or_default()
397 };
398 (find(&["system", "developer"]), find(&["user"]))
399 }
400 ClientFormat::OpenaiResponses => {
401 let system = body["instructions"].as_str().unwrap_or("").to_string();
402 let user = match &body["input"] {
403 Value::String(s) => s.clone(),
404 Value::Array(items) => items
405 .iter()
406 .find(|it| {
407 it["role"] == "user"
408 && it["type"].as_str().unwrap_or("message") == "message"
409 })
410 .map(|it| translate::txt(&it["content"]))
411 .unwrap_or_default(),
412 _ => String::new(),
413 };
414 (system, user)
415 }
416 };
417 let system = system.trim();
418 let user = user.trim();
419 if system.is_empty() && user.is_empty() {
420 None
421 } else {
422 Some(format!("{system}\n{user}"))
423 }
424}
425
426pub fn parse_since(s: &str, now_ms: i64) -> Option<i64> {
427 let s = s.trim();
428 if s.is_empty() {
429 return None;
430 }
431 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
432 return Some(dt.timestamp_millis());
433 }
434 let unit = s.chars().last()?;
435 let num = &s[..s.len() - unit.len_utf8()];
436 let n: i64 = num.parse().ok()?;
437 if n < 0 {
438 return None;
439 }
440 let ms = match unit {
441 's' => n.checked_mul(1_000)?,
442 'm' => n.checked_mul(60_000)?,
443 'h' => n.checked_mul(3_600_000)?,
444 'd' => n.checked_mul(86_400_000)?,
445 _ => return None,
446 };
447 Some(now_ms - ms)
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn routes_prefixes() {
456 assert_eq!(
457 route_model("claude:claude-sonnet-4-5").0,
458 Some(Provider::Anthropic)
459 );
460 assert_eq!(route_model("openai:gpt-5.1").1, "gpt-5.1");
461 assert_eq!(route_model("gpt-5-codex").0, Some(Provider::Openai));
462 assert_eq!(route_model("o3-mini").0, Some(Provider::Openai));
463 assert_eq!(route_model("mystery-model").0, None);
464 }
465
466 #[test]
467 fn routes_slash_prefixes() {
468 assert_eq!(
469 route_model("claude/claude-sonnet-4-5"),
470 (Some(Provider::Anthropic), "claude-sonnet-4-5".to_string())
471 );
472 assert_eq!(route_model("anthropic/x").0, Some(Provider::Anthropic));
473 assert_eq!(route_model("openai/gpt-5.5").1, "gpt-5.5");
474 assert_eq!(route_model("codex/gpt-5-codex").0, Some(Provider::Openai));
475 assert_eq!(route_model("chatgpt/gpt-5.1").0, Some(Provider::Openai));
476 assert_eq!(route_model("gemini/gemini-3-pro").0, Some(Provider::Gemini));
477 assert_eq!(route_model("google/gemini-3-pro").0, Some(Provider::Gemini));
478 assert_eq!(route_model("grok/grok-4").0, Some(Provider::Xai));
479 assert_eq!(route_model("xai/grok-4").0, Some(Provider::Xai));
480 }
481
482 #[test]
483 fn routes_passthrough_prefixes() {
484 assert_eq!(
485 route_model("alexandria/gpt-5.5"),
486 (Some(Provider::Openai), "gpt-5.5".to_string())
487 );
488 assert_eq!(
489 route_model("cove/claude-opus-4-8"),
490 (Some(Provider::Anthropic), "claude-opus-4-8".to_string())
491 );
492 assert_eq!(
493 route_model("cove/openai:gpt-5.1"),
494 (Some(Provider::Openai), "gpt-5.1".to_string())
495 );
496 }
497
498 #[test]
499 fn routes_aliases() {
500 assert_eq!(
501 route_model("opus-4.8"),
502 (Some(Provider::Anthropic), "claude-opus-4-8".to_string())
503 );
504 assert_eq!(
505 route_model("opus-4.5"),
506 (Some(Provider::Anthropic), "claude-opus-4-5".to_string())
507 );
508 assert_eq!(
509 route_model("sonnet-5"),
510 (Some(Provider::Anthropic), "claude-sonnet-5".to_string())
511 );
512 assert_eq!(
513 route_model("sonnet-4.5"),
514 (Some(Provider::Anthropic), "claude-sonnet-4-5".to_string())
515 );
516 assert_eq!(
517 route_model("haiku-4.5"),
518 (Some(Provider::Anthropic), "claude-haiku-4-5".to_string())
519 );
520 assert_eq!(
521 route_model("claude/opus-4.8"),
522 (Some(Provider::Anthropic), "claude-opus-4-8".to_string())
523 );
524 assert_eq!(
525 route_model("alexandria/sonnet-5"),
526 (Some(Provider::Anthropic), "claude-sonnet-5".to_string())
527 );
528 assert_eq!(model_aliases().len(), 5);
529 }
530
531 #[test]
532 fn parses_anthropic_sse() {
533 let sse = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":10,\"cache_read_input_tokens\":4,\"output_tokens\":1}}}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"usage\":{\"output_tokens\":25}}\n\n";
534 let u = parse_sse_usage(sse);
535 assert_eq!(u.input_tokens, Some(10));
536 assert_eq!(u.cached_input_tokens, Some(4));
537 assert_eq!(u.output_tokens, Some(25));
538 }
539
540 #[test]
541 fn parses_trace_tags() {
542 let v = parse_trace_tags(&["suite=swebench", "case=astropy-123", "malformed", "=nokey"]);
543 assert_eq!(v["suite"], "swebench");
544 assert_eq!(v["case"], "astropy-123");
545 assert_eq!(v.as_object().unwrap().len(), 2);
546 assert_eq!(parse_trace_tags(&[]), serde_json::json!({}));
547 let padded = parse_trace_tags(&[" k = v "]);
548 assert_eq!(padded["k"], "v");
549 }
550
551 #[test]
552 fn parses_coalesced_trace_tags() {
553 let v = parse_trace_tags(&["harness=codex,task=smoke,model=gpt-5.5"]);
554 assert_eq!(v["harness"], "codex");
555 assert_eq!(v["task"], "smoke");
556 assert_eq!(v["model"], "gpt-5.5");
557 assert_eq!(v.as_object().unwrap().len(), 3);
558 let mixed = parse_trace_tags(&["a=1, b = 2 ,junk,=x", "c=3"]);
559 assert_eq!(mixed["a"], "1");
560 assert_eq!(mixed["b"], "2");
561 assert_eq!(mixed["c"], "3");
562 assert_eq!(mixed.as_object().unwrap().len(), 3);
563 }
564
565 #[test]
566 fn conversation_root_anthropic() {
567 let body = serde_json::json!({
568 "system": [{"type": "text", "text": "sys"}],
569 "messages": [
570 {"role": "assistant", "content": "prior"},
571 {"role": "user", "content": [{"type": "text", "text": "hi"}]},
572 ],
573 });
574 assert_eq!(
575 conversation_root(ClientFormat::AnthropicMessages, &body),
576 Some("sys\nhi".to_string())
577 );
578 let plain = serde_json::json!({
579 "system": "s",
580 "messages": [{"role": "user", "content": "u"}],
581 });
582 assert_eq!(
583 conversation_root(ClientFormat::AnthropicMessages, &plain),
584 Some("s\nu".to_string())
585 );
586 assert_eq!(
587 conversation_root(ClientFormat::AnthropicMessages, &serde_json::json!({})),
588 None
589 );
590 }
591
592 #[test]
593 fn conversation_root_openai_chat() {
594 let body = serde_json::json!({
595 "messages": [
596 {"role": "developer", "content": "dev"},
597 {"role": "user", "content": [{"type": "text", "text": "q"}]},
598 ],
599 });
600 assert_eq!(
601 conversation_root(ClientFormat::OpenaiChat, &body),
602 Some("dev\nq".to_string())
603 );
604 let user_only = serde_json::json!({"messages": [{"role": "user", "content": "solo"}]});
605 assert_eq!(
606 conversation_root(ClientFormat::OpenaiChat, &user_only),
607 Some("\nsolo".to_string())
608 );
609 assert_eq!(
610 conversation_root(ClientFormat::OpenaiChat, &serde_json::json!({"messages": []})),
611 None
612 );
613 }
614
615 #[test]
616 fn conversation_root_openai_responses() {
617 let body = serde_json::json!({
618 "instructions": "inst",
619 "input": [
620 {"type": "message", "role": "user",
621 "content": [{"type": "input_text", "text": "first"}]},
622 ],
623 });
624 assert_eq!(
625 conversation_root(ClientFormat::OpenaiResponses, &body),
626 Some("inst\nfirst".to_string())
627 );
628 let string_input = serde_json::json!({"input": "plain"});
629 assert_eq!(
630 conversation_root(ClientFormat::OpenaiResponses, &string_input),
631 Some("\nplain".to_string())
632 );
633 assert_eq!(
634 conversation_root(ClientFormat::OpenaiResponses, &serde_json::json!({"input": []})),
635 None
636 );
637 }
638
639 #[test]
640 fn parses_since_relative() {
641 let now = 1_000_000_000_000;
642 assert_eq!(parse_since("45s", now), Some(now - 45_000));
643 assert_eq!(parse_since("30m", now), Some(now - 1_800_000));
644 assert_eq!(parse_since("2h", now), Some(now - 7_200_000));
645 assert_eq!(parse_since("7d", now), Some(now - 604_800_000));
646 }
647
648 #[test]
649 fn parses_since_rfc3339() {
650 assert_eq!(
651 parse_since("2024-01-01T00:00:00Z", 0),
652 Some(1_704_067_200_000)
653 );
654 assert_eq!(
655 parse_since("2024-01-01T02:00:00+02:00", 0),
656 Some(1_704_067_200_000)
657 );
658 }
659
660 #[test]
661 fn rejects_garbage_since() {
662 assert_eq!(parse_since("", 0), None);
663 assert_eq!(parse_since("yesterday", 0), None);
664 assert_eq!(parse_since("30x", 0), None);
665 assert_eq!(parse_since("m", 0), None);
666 assert_eq!(parse_since("-5m", 0), None);
667 assert_eq!(parse_since("3é", 0), None);
668 }
669
670 #[test]
671 fn parses_openai_responses_sse() {
672 let sse = "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":100,\"input_tokens_details\":{\"cached_tokens\":20},\"output_tokens\":30,\"output_tokens_details\":{\"reasoning_tokens\":5}}}}\n";
673 let u = parse_sse_usage(sse);
674 assert_eq!(u.input_tokens, Some(100));
675 assert_eq!(u.cached_input_tokens, Some(20));
676 assert_eq!(u.reasoning_tokens, Some(5));
677 }
678}