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