Skip to main content

obol_core/transcript/
opencode.rs

1//! OpenCode `opencode export` JSON -> Vec<MessageUsage>.
2//! Reconciled with AgentsView internal/parser/opencode.go (MIT, © 2026 Kenn Software LLC).
3//! Single document {info, messages:[...]}; per-assistant usage on the message or its
4//! `step-finish` part. `tokens.reasoning` is a separate additive bucket billed as output.
5//! Model = `modelID` (bare); provider routed by `providerID`.
6
7use crate::error::ObolError;
8use crate::model::{MessageUsage, Provider};
9use serde_json::Value;
10
11pub fn parse(bytes: &[u8]) -> Result<Vec<MessageUsage>, ObolError> {
12    let doc: Value = serde_json::from_slice(bytes).map_err(|e| ObolError::MalformedTranscript {
13        line: 0,
14        msg: e.to_string(),
15    })?;
16    let messages = match doc.get("messages").and_then(Value::as_array) {
17        Some(m) => m,
18        None => return Ok(Vec::new()),
19    };
20    let mut out = Vec::new();
21    for msg in messages {
22        if msg.get("role").and_then(Value::as_str) != Some("assistant") {
23            continue;
24        }
25        let tok = msg
26            .get("tokens")
27            .filter(|t| t.is_object())
28            .or_else(|| step_finish_tokens(msg));
29        let tok = match tok {
30            Some(t) => t,
31            None => continue,
32        };
33        let g = |k: &str| tok.get(k).and_then(Value::as_u64).unwrap_or(0);
34        let input = g("input");
35        let cache_read = tok
36            .pointer("/cache/read")
37            .and_then(Value::as_u64)
38            .unwrap_or(0);
39        let cache_write = tok
40            .pointer("/cache/write")
41            .and_then(Value::as_u64)
42            .unwrap_or(0);
43        let output = g("output") + g("reasoning");
44        let model = msg
45            .get("modelID")
46            .and_then(Value::as_str)
47            .or_else(|| msg.pointer("/model/modelID").and_then(Value::as_str))
48            .unwrap_or("")
49            .to_string();
50        let provider_id = msg.get("providerID").and_then(Value::as_str).unwrap_or("");
51        out.push(MessageUsage {
52            model,
53            provider: route_provider(provider_id),
54            namespace: "litellm".into(),
55            input_uncached: input,
56            cache_read,
57            cache_write_5m: cache_write,
58            cache_write_1h: 0,
59            output,
60            request_input_tokens: input + cache_read + cache_write,
61            service_tier: None,
62        });
63    }
64    Ok(out)
65}
66
67fn step_finish_tokens(msg: &Value) -> Option<&Value> {
68    msg.get("parts")?
69        .as_array()?
70        .iter()
71        .find(|p| p.get("type").and_then(Value::as_str) == Some("step-finish"))
72        .and_then(|p| p.get("tokens"))
73        .filter(|t| t.is_object())
74}
75
76fn route_provider(provider_id: &str) -> Provider {
77    match provider_id {
78        "anthropic" => Provider::Anthropic,
79        "openai" => Provider::OpenAI,
80        "" => Provider::Other("opencode".into()),
81        other => Provider::Other(other.to_string()),
82    }
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use crate::model::Provider;
89
90    #[test]
91    fn reads_message_and_step_finish_tokens() {
92        let u = parse(include_bytes!("../../tests/fixtures/opencode-mini.json")).unwrap();
93        assert_eq!(u.len(), 2, "{u:?}");
94        assert_eq!(u[0].model, "gpt-5.5");
95        assert_eq!(u[0].provider, Provider::OpenAI);
96        assert_eq!(u[0].input_uncached, 7035);
97        assert_eq!(u[0].output, 12);
98        // fallback to the step-finish part; reasoning folds into output
99        assert_eq!(u[1].input_uncached, 100);
100        assert_eq!(u[1].cache_read, 50);
101        assert_eq!(u[1].cache_write_5m, 7); // proves the nested /cache/write read
102        assert_eq!(u[1].output, 7); // 5 + 2 reasoning
103        assert_eq!(u[1].request_input_tokens, 157); // 100 + 50 + 7
104    }
105}