Skip to main content

alex_core/
lib.rs

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