obol_core/transcript/
copilot.rs1use 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); assert_eq!(u[0].cache_read, 48000);
88 assert_eq!(u[0].cache_write_5m, 1200);
89 assert_eq!(u[0].output, 3140); assert_eq!(u[0].request_input_tokens, 52030);
91 }
92}