obol_core/transcript/
opencode.rs1use 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 assert_eq!(u[1].input_uncached, 100);
100 assert_eq!(u[1].cache_read, 50);
101 assert_eq!(u[1].cache_write_5m, 7); assert_eq!(u[1].output, 7); assert_eq!(u[1].request_input_tokens, 157); }
105}