Skip to main content

alex_core/
lib.rs

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