1use rusqlite::{Connection, OpenFlags};
17use serde::Deserialize;
18
19#[derive(Debug, Clone, Default)]
20pub struct Row {
21 pub at_ms: i64,
22 pub session: String,
23 pub provider: String,
24 pub model: String,
25 pub cost_usd: f64,
26 pub input: u64,
27 pub output: u64,
28 pub cache_read: u64,
29 pub cache_write: u64,
30}
31
32#[derive(Deserialize)]
33struct AssistantData {
34 role: Option<String>,
35 #[serde(rename = "providerID")]
36 provider: Option<String>,
37 #[serde(rename = "modelID")]
38 model: Option<String>,
39 cost: Option<f64>,
40 tokens: Option<Tokens>,
41}
42
43#[derive(Deserialize, Default)]
46struct V2AssistantData {
47 model: Option<V2Model>,
48 cost: Option<f64>,
49 tokens: Option<Tokens>,
50}
51
52#[derive(Deserialize, Default)]
53struct V2Model {
54 id: Option<String>,
55 #[serde(rename = "providerID")]
56 provider: Option<String>,
57}
58
59#[derive(Deserialize)]
60struct Tokens {
61 input: Option<u64>,
62 output: Option<u64>,
63 cache: Option<Cache>,
64}
65
66#[derive(Deserialize)]
67struct Cache {
68 read: Option<u64>,
69 write: Option<u64>,
70}
71
72pub const PROVIDERS: [&str; 2] = ["opencode-go", "opencode"];
75
76pub fn default_path() -> String {
78 if let Ok(path) = std::env::var("OPENCODE_DB") {
79 return path;
80 }
81 let base = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
82 let home = std::env::var("HOME").unwrap_or_default();
83 format!("{home}/.local/share")
84 });
85 format!("{base}/opencode/opencode.db")
86}
87
88pub fn read_rows(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
97 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
98 .map_err(|e| format!("open {path}: {e}"))?;
99 if has_v2(&connection) {
100 read_v2(&connection, since_ms)
101 } else {
102 read_legacy(&connection, since_ms)
103 }
104}
105
106fn has_v2(connection: &Connection) -> bool {
108 connection
109 .query_row(
110 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_message'",
111 [],
112 |_| Ok(()),
113 )
114 .is_ok()
115}
116
117fn read_v2(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
121 let mut statement = connection
122 .prepare(
123 "SELECT data, time_created, session_id FROM session_message
124 WHERE type = 'assistant' AND time_created >= ?",
125 )
126 .map_err(|e| format!("prepare: {e}"))?;
127 let mut rows = statement
128 .query([since_ms])
129 .map_err(|e| format!("query: {e}"))?;
130
131 let mut out = Vec::new();
132 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
133 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
134 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
135 let session: String = row.get(2).unwrap_or_default();
136 let Ok(parsed) = serde_json::from_str::<V2AssistantData>(&data) else {
137 continue;
138 };
139 let Some(tokens) = parsed.tokens else {
140 continue; };
142 let model = parsed.model.unwrap_or_default();
143 let provider = model.provider.unwrap_or_default();
144 if !PROVIDERS.contains(&provider.as_str()) {
145 continue;
146 }
147 out.push(Row {
148 at_ms,
149 session,
150 provider,
151 model: model.id.unwrap_or_default(),
152 cost_usd: parsed.cost.unwrap_or(0.0),
153 input: tokens.input.unwrap_or(0),
154 output: tokens.output.unwrap_or(0),
155 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
156 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
157 });
158 }
159 Ok(out)
160}
161
162fn read_legacy(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
164 let mut statement = connection
165 .prepare(
166 "SELECT data, time_created, session_id FROM message
167 WHERE data LIKE '%\"tokens\"%' AND time_created >= ?",
168 )
169 .map_err(|e| format!("prepare: {e}"))?;
170 let mut rows = statement
171 .query([since_ms])
172 .map_err(|e| format!("query: {e}"))?;
173
174 let mut out = Vec::new();
175 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
176 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
177 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
178 let session: String = row.get(2).unwrap_or_default();
179 let Ok(parsed) = serde_json::from_str::<AssistantData>(&data) else {
180 continue;
181 };
182 if parsed.role.as_deref() != Some("assistant") {
183 continue;
184 }
185 let provider = parsed.provider.unwrap_or_default();
186 if !PROVIDERS.contains(&provider.as_str()) {
187 continue;
188 }
189 let tokens = parsed.tokens.unwrap_or(Tokens {
190 input: None,
191 output: None,
192 cache: None,
193 });
194 out.push(Row {
195 at_ms,
196 session,
197 provider,
198 model: parsed.model.unwrap_or_default(),
199 cost_usd: parsed.cost.unwrap_or(0.0),
200 input: tokens.input.unwrap_or(0),
201 output: tokens.output.unwrap_or(0),
202 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
203 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
204 });
205 }
206 Ok(out)
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn fixture() -> (tempfile::TempDir, String) {
215 let dir = tempfile::tempdir().unwrap();
216 let path = dir.path().join("opencode.db");
217 let connection = Connection::open(&path).unwrap();
218 connection
219 .execute_batch(
220 "CREATE TABLE message (session_id TEXT, data TEXT, time_created INTEGER);",
221 )
222 .unwrap();
223 let insert = |data: &str, at: i64| {
224 connection
225 .execute(
226 "INSERT INTO message VALUES ('ses_fixture', ?, ?)",
227 (data, at),
228 )
229 .unwrap();
230 };
231 insert(
232 r#"{"role":"assistant","providerID":"opencode-go","modelID":"glm-5.3-flash","cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
233 1_000,
234 );
235 insert(
236 r#"{"role":"assistant","providerID":"opencode","modelID":"gpt-5.5","cost":1.25,"tokens":{"input":1,"output":1}}"#,
237 2_000,
238 );
239 insert(
240 r#"{"role":"user","providerID":"opencode-go","tokens":{"input":1}}"#,
241 3_000,
242 );
243 insert(
244 r#"{"role":"assistant","providerID":"command-code-openai","modelID":"deepseek-v4.1-flash","cost":9.0,"tokens":{"input":1}}"#,
245 4_000,
246 );
247 (dir, path.to_string_lossy().into_owned())
248 }
249
250 #[test]
251 fn reads_only_opencode_assistant_rows_in_window() {
252 let (_dir, path) = fixture();
253 let rows = read_rows(&path, 0).unwrap();
254 assert_eq!(rows.len(), 2, "{rows:#?}");
255 assert_eq!(rows[0].provider, "opencode-go");
256 assert_eq!(rows[0].cache_read, 7);
257 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
258 assert_eq!(rows[1].model, "gpt-5.5");
259
260 let later = read_rows(&path, 1_500).unwrap();
261 assert_eq!(later.len(), 1);
262 assert_eq!(later[0].provider, "opencode");
263 }
264
265 fn v2_fixture() -> (tempfile::TempDir, String) {
267 let dir = tempfile::tempdir().unwrap();
268 let path = dir.path().join("opencode.db");
269 let connection = Connection::open(&path).unwrap();
270 connection
271 .execute_batch(
272 "CREATE TABLE session_message (\
273 id TEXT, session_id TEXT, type TEXT, seq INTEGER, \
274 time_created INTEGER, time_updated INTEGER, data TEXT);",
275 )
276 .unwrap();
277 let insert = |kind: &str, data: &str, at: i64| {
278 connection
279 .execute(
280 "INSERT INTO session_message \
281 (id, session_id, type, seq, time_created, time_updated, data) \
282 VALUES (?, 'ses_v2', ?, 0, ?, ?, ?)",
283 (format!("msg_{at}"), kind, at, at, data),
284 )
285 .unwrap();
286 };
287 insert(
288 "assistant",
289 r#"{"model":{"id":"glm-5.3-flash","providerID":"opencode-go"},"cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
290 1_000,
291 );
292 insert(
293 "assistant",
294 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"},"cost":1.25,"tokens":{"input":1,"output":1}}"#,
295 2_000,
296 );
297 insert(
299 "assistant",
300 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"}}"#,
301 3_000,
302 );
303 insert("user", r#"{"text":"hi"}"#, 4_000);
305 insert(
306 "assistant",
307 r#"{"model":{"id":"deepseek","providerID":"command-code-openai"},"cost":9.0,"tokens":{"input":1}}"#,
308 5_000,
309 );
310 (dir, path.to_string_lossy().into_owned())
311 }
312
313 #[test]
314 fn reads_v2_rows_skipping_inflight_and_foreign_providers() {
315 let (_dir, path) = v2_fixture();
316 let rows = read_rows(&path, 0).unwrap();
317 assert_eq!(rows.len(), 2, "{rows:#?}");
318 assert_eq!(rows[0].provider, "opencode-go");
319 assert_eq!(rows[0].model, "glm-5.3-flash");
320 assert_eq!(rows[0].cache_read, 7);
321 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
322 assert_eq!(rows[1].model, "gpt-5.5");
323
324 let later = read_rows(&path, 1_500).unwrap();
325 assert_eq!(later.len(), 1);
326 assert_eq!(later[0].provider, "opencode");
327 }
328
329 #[test]
330 fn missing_database_is_an_error_not_a_panic() {
331 let error = read_rows("/nonexistent/opencode.db", 0).unwrap_err();
332 assert!(error.contains("open"), "{error}");
333 }
334}