obol_core/transcript/
mod.rs1pub 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
24pub 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 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}