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,
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}