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