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}