Skip to main content

obol_core/transcript/
mod.rs

1pub mod claude;
2pub mod codex;
3pub mod copilot;
4pub mod gemini;
5pub mod kimi;
6pub mod obol;
7pub mod opencode;
8pub mod pi;
9pub mod provider;
10
11use crate::error::ObolError;
12use crate::model::MessageUsage;
13use serde_json::Value;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Dialect {
17    Claude,
18    Codex,
19    Copilot,
20    Gemini,
21    Kimi,
22    Obol,
23    Opencode,
24    Pi,
25}
26
27/// Detect dialect from content: Codex lines carry a top-level `payload`
28/// (session_meta/response_item/event_msg); Claude lines carry `message` with
29/// type user/assistant.
30pub fn detect(bytes: &[u8]) -> Result<Dialect, ObolError> {
31    let text = std::str::from_utf8(bytes).map_err(|_| ObolError::UnknownDialect)?;
32    for line in text.lines().take(20) {
33        let line = line.trim();
34        if line.is_empty() {
35            continue;
36        }
37        let v: Value = match serde_json::from_str(line) {
38            Ok(v) => v,
39            Err(_) => continue,
40        };
41        if v.get("payload").is_some() {
42            return Ok(Dialect::Codex);
43        }
44        if matches!(
45            v.get("type").and_then(Value::as_str),
46            Some("session.shutdown") | Some("assistant.message") | Some("session.start")
47        ) {
48            return Ok(Dialect::Copilot);
49        }
50        if v.get("type").and_then(Value::as_str) == Some("usage.record") {
51            return Ok(Dialect::Kimi);
52        }
53        if v.get("type").and_then(Value::as_str) == Some("obol.usage") {
54            return Ok(Dialect::Obol);
55        }
56        if v.get("type").and_then(Value::as_str) == Some("session") {
57            return Ok(Dialect::Pi);
58        }
59        if v.get("type").and_then(Value::as_str) == Some("gemini")
60            || v.pointer("/$set/messages").is_some()
61            || (v.get("projectHash").is_some() && v.get("kind").is_some())
62        {
63            return Ok(Dialect::Gemini);
64        }
65        let ty = v.get("type").and_then(Value::as_str);
66        if matches!(ty, Some("user") | Some("assistant")) && v.get("message").is_some() {
67            return Ok(Dialect::Claude);
68        }
69    }
70    // Single-document JSON formats (the line loop above can't see these).
71    if let Ok(doc) = serde_json::from_slice::<Value>(bytes) {
72        if doc.get("info").is_some() && doc.get("messages").is_some() {
73            return Ok(Dialect::Opencode);
74        }
75    }
76    Err(ObolError::UnknownDialect)
77}
78
79pub fn parse(bytes: &[u8], dialect: Dialect) -> Result<Vec<MessageUsage>, ObolError> {
80    match dialect {
81        Dialect::Claude => Ok(claude::parse(bytes)?.usages),
82        Dialect::Codex => codex::parse(bytes),
83        Dialect::Copilot => copilot::parse(bytes),
84        Dialect::Gemini => gemini::parse(bytes),
85        Dialect::Kimi => kimi::parse(bytes),
86        Dialect::Obol => obol::parse(bytes),
87        Dialect::Opencode => opencode::parse(bytes),
88        Dialect::Pi => pi::parse(bytes),
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn detects_claude_and_codex() {
98        let claude = include_bytes!("../../tests/fixtures/claude-mini.jsonl");
99        let codex = include_bytes!("../../tests/fixtures/codex-mini.jsonl");
100        assert_eq!(detect(claude).unwrap(), Dialect::Claude);
101        assert_eq!(detect(codex).unwrap(), Dialect::Codex);
102    }
103
104    #[test]
105    fn detects_pi() {
106        let pi = include_bytes!("../../tests/fixtures/pi-mini.jsonl");
107        assert_eq!(detect(pi).unwrap(), Dialect::Pi);
108    }
109
110    #[test]
111    fn detects_obol() {
112        let obol = include_bytes!("../../tests/fixtures/obol-usage-mini.jsonl");
113        assert_eq!(detect(obol).unwrap(), Dialect::Obol);
114    }
115
116    #[test]
117    fn unknown_dialect_errors() {
118        assert!(matches!(detect(b"{}\n{}"), Err(ObolError::UnknownDialect)));
119    }
120}