Skip to main content

alex_core/
lib.rs

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}