Skip to main content

obol_core/transcript/
copilot.rs

1//! Copilot CLI events.jsonl -> Vec<MessageUsage>.
2//! Reconciled with AgentsView internal/parser/copilot.go (MIT, © 2026 Kenn Software LLC).
3//! Authoritative per-model usage is the `session.shutdown` aggregate
4//! (`data.modelMetrics.<model>.usage`). `inputTokens` is a total -> uncached is
5//! input - cacheRead - cacheWrite. `reasoningTokens` billed as output. No shutdown
6//! event -> no usage (documented limitation).
7
8use crate::error::ObolError;
9use crate::model::{MessageUsage, Provider};
10use serde_json::Value;
11
12pub fn parse(bytes: &[u8]) -> Result<Vec<MessageUsage>, ObolError> {
13    let text = std::str::from_utf8(bytes).map_err(|e| ObolError::MalformedTranscript {
14        line: 0,
15        msg: e.to_string(),
16    })?;
17    let mut out = Vec::new();
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        if v.get("type").and_then(Value::as_str) != Some("session.shutdown") {
28            continue;
29        }
30        let metrics = match v.pointer("/data/modelMetrics").and_then(Value::as_object) {
31            Some(m) => m,
32            None => continue,
33        };
34        for (model, mv) in metrics {
35            let usage = match mv.get("usage") {
36                Some(u) if u.is_object() => u,
37                _ => continue,
38            };
39            let g = |k: &str| usage.get(k).and_then(Value::as_u64).unwrap_or(0);
40            let total_input = g("inputTokens");
41            let cache_read = g("cacheReadTokens");
42            let cache_write = g("cacheWriteTokens");
43            out.push(MessageUsage {
44                model: model.clone(),
45                provider: route_model(model),
46                namespace: "litellm".into(),
47                input_uncached: total_input
48                    .saturating_sub(cache_read)
49                    .saturating_sub(cache_write),
50                cache_read,
51                cache_write_5m: cache_write,
52                cache_write_1h: 0,
53                output: g("outputTokens") + g("reasoningTokens"),
54                request_input_tokens: total_input,
55                service_tier: None,
56            });
57        }
58    }
59    Ok(out)
60}
61
62fn route_model(model: &str) -> Provider {
63    let m = model.to_ascii_lowercase();
64    if m.contains("claude") {
65        Provider::Anthropic
66    } else if m.contains("gpt") || m.contains("o1") || m.contains("o3") {
67        Provider::OpenAI
68    } else if m.contains("gemini") {
69        Provider::Other("google".into())
70    } else {
71        Provider::Other("copilot".into())
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::model::Provider;
79
80    #[test]
81    fn reads_shutdown_aggregate_and_subtracts_cache() {
82        let u = parse(include_bytes!("../../tests/fixtures/copilot-mini.jsonl")).unwrap();
83        assert_eq!(u.len(), 1, "{u:?}");
84        assert_eq!(u[0].model, "claude-sonnet-4-5");
85        assert_eq!(u[0].provider, Provider::Anthropic);
86        assert_eq!(u[0].input_uncached, 2830); // 52030 - 48000 - 1200
87        assert_eq!(u[0].cache_read, 48000);
88        assert_eq!(u[0].cache_write_5m, 1200);
89        assert_eq!(u[0].output, 3140); // 3100 + 40 reasoning
90        assert_eq!(u[0].request_input_tokens, 52030);
91    }
92}