Skip to main content

obol_core/transcript/
codex.rs

1//! Codex rollout JSONL -> Vec<MessageUsage>.
2//! Reconciled with AgentsView internal/parser/codex.go (MIT, © 2026 Kenn Software LLC).
3
4use 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            // updates running model; empty string CLEARS it
32            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        // skip streaming retransmit (identical raw)
47        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            // Reasoning is billed as output and reported separately from output_tokens; fold it
65            // in (consistent with the gemini/opencode/copilot/kimi dialects). PRI-2124.
66            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); // 1000 - 800
85        assert_eq!(u[0].cache_read, 800);
86        assert_eq!(u[0].output, 60); // 50 output_tokens + 10 reasoning_output_tokens
87        assert_eq!(u[1].input_uncached, 100); // 2000 - 1900
88        assert_eq!(u[1].cache_read, 1900);
89        assert_eq!(u[1].output, 80); // 80 output_tokens + 0 reasoning_output_tokens
90    }
91}