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