obol_core/transcript/
codex.rs1use crate::error::ObolError;
5use crate::model::{MessageUsage, Provider};
6use serde_json::Value;
7
8pub fn parse(bytes: &[u8]) -> Result<Vec<MessageUsage>, ObolError> {
9 let text = std::str::from_utf8(bytes).map_err(|e| ObolError::MalformedTranscript {
10 line: 0,
11 msg: e.to_string(),
12 })?;
13
14 let mut current_model = String::new();
15 let mut last_raw: Option<String> = None;
16 let mut out = Vec::new();
17
18 for line in text.lines() {
19 let line = line.trim();
20 if line.is_empty() {
21 continue;
22 }
23 let v: Value = match serde_json::from_str(line) {
24 Ok(v) => v,
25 Err(_) => continue,
26 };
27 let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
28 let payload = v.get("payload").cloned().unwrap_or(Value::Null);
29
30 if ty == "turn_context" {
31 current_model = payload
33 .get("model")
34 .and_then(Value::as_str)
35 .unwrap_or("")
36 .to_string();
37 continue;
38 }
39 if ty != "event_msg" || payload.get("type").and_then(Value::as_str) != Some("token_count") {
40 continue;
41 }
42 let last = match payload.pointer("/info/last_token_usage") {
43 Some(u) if u.is_object() => u,
44 _ => continue,
45 };
46 let raw = last.to_string();
48 if last_raw.as_deref() == Some(raw.as_str()) {
49 continue;
50 }
51 last_raw = Some(raw);
52
53 let g = |k: &str| last.get(k).and_then(Value::as_u64).unwrap_or(0);
54 let input = g("input_tokens");
55 let cached = g("cached_input_tokens");
56 out.push(MessageUsage {
57 model: current_model.clone(),
58 provider: Provider::OpenAI,
59 namespace: "litellm".into(),
60 input_uncached: input.saturating_sub(cached),
61 cache_read: cached,
62 cache_write_5m: 0,
63 cache_write_1h: 0,
64 output: g("output_tokens") + g("reasoning_output_tokens"),
67 request_input_tokens: input,
68 service_tier: None,
69 });
70 }
71 Ok(out)
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn per_call_usage_dedups_and_subtracts_cache() {
80 let bytes = include_bytes!("../../tests/fixtures/codex-mini.jsonl");
81 let u = parse(bytes).unwrap();
82 assert_eq!(u.len(), 2, "duplicate token_count should be skipped: {u:?}");
83 assert_eq!(u[0].model, "gpt-5.5");
84 assert_eq!(u[0].input_uncached, 200); assert_eq!(u[0].cache_read, 800);
86 assert_eq!(u[0].output, 60); assert_eq!(u[1].input_uncached, 100); assert_eq!(u[1].cache_read, 1900);
89 assert_eq!(u[1].output, 80); }
91}