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 opencode_home() -> String {
79 let base = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
80 let home = std::env::var("HOME").unwrap_or_default();
81 format!("{home}/.local/share")
82 });
83 format!("{base}/opencode")
84}
85
86pub fn default_path() -> String {
88 if let Ok(path) = std::env::var("OPENCODE_DB") {
89 return path;
90 }
91 format!("{}/opencode.db", opencode_home())
92}
93
94pub fn read_rows(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
103 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
104 .map_err(|e| format!("open {path}: {e}"))?;
105 if has_v2(&connection) {
106 read_v2(&connection, since_ms)
107 } else {
108 read_legacy(&connection, since_ms)
109 }
110}
111
112fn has_v2(connection: &Connection) -> bool {
114 connection
115 .query_row(
116 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_message'",
117 [],
118 |_| Ok(()),
119 )
120 .is_ok()
121}
122
123fn read_v2(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
127 let mut statement = connection
128 .prepare(
129 "SELECT data, time_created, session_id FROM session_message
130 WHERE type = 'assistant' AND time_created >= ?",
131 )
132 .map_err(|e| format!("prepare: {e}"))?;
133 let mut rows = statement
134 .query([since_ms])
135 .map_err(|e| format!("query: {e}"))?;
136
137 let mut out = Vec::new();
138 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
139 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
140 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
141 let session: String = row.get(2).unwrap_or_default();
142 let Ok(parsed) = serde_json::from_str::<V2AssistantData>(&data) else {
143 continue;
144 };
145 let Some(tokens) = parsed.tokens else {
146 continue; };
148 let model = parsed.model.unwrap_or_default();
149 let provider = model.provider.unwrap_or_default();
150 if !PROVIDERS.contains(&provider.as_str()) {
151 continue;
152 }
153 out.push(Row {
154 at_ms,
155 session,
156 provider,
157 model: model.id.unwrap_or_default(),
158 cost_usd: parsed.cost.unwrap_or(0.0),
159 input: tokens.input.unwrap_or(0),
160 output: tokens.output.unwrap_or(0),
161 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
162 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
163 });
164 }
165 Ok(out)
166}
167
168fn read_legacy(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
170 let mut statement = connection
171 .prepare(
172 "SELECT data, time_created, session_id FROM message
173 WHERE data LIKE '%\"tokens\"%' AND time_created >= ?",
174 )
175 .map_err(|e| format!("prepare: {e}"))?;
176 let mut rows = statement
177 .query([since_ms])
178 .map_err(|e| format!("query: {e}"))?;
179
180 let mut out = Vec::new();
181 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
182 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
183 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
184 let session: String = row.get(2).unwrap_or_default();
185 let Ok(parsed) = serde_json::from_str::<AssistantData>(&data) else {
186 continue;
187 };
188 if parsed.role.as_deref() != Some("assistant") {
189 continue;
190 }
191 let provider = parsed.provider.unwrap_or_default();
192 if !PROVIDERS.contains(&provider.as_str()) {
193 continue;
194 }
195 let tokens = parsed.tokens.unwrap_or(Tokens {
196 input: None,
197 output: None,
198 cache: None,
199 });
200 out.push(Row {
201 at_ms,
202 session,
203 provider,
204 model: parsed.model.unwrap_or_default(),
205 cost_usd: parsed.cost.unwrap_or(0.0),
206 input: tokens.input.unwrap_or(0),
207 output: tokens.output.unwrap_or(0),
208 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
209 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
210 });
211 }
212 Ok(out)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn fixture() -> (tempfile::TempDir, String) {
221 let dir = tempfile::tempdir().unwrap();
222 let path = dir.path().join("opencode.db");
223 let connection = Connection::open(&path).unwrap();
224 connection
225 .execute_batch(
226 "CREATE TABLE message (session_id TEXT, data TEXT, time_created INTEGER);",
227 )
228 .unwrap();
229 let insert = |data: &str, at: i64| {
230 connection
231 .execute(
232 "INSERT INTO message VALUES ('ses_fixture', ?, ?)",
233 (data, at),
234 )
235 .unwrap();
236 };
237 insert(
238 r#"{"role":"assistant","providerID":"opencode-go","modelID":"glm-5.3-flash","cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
239 1_000,
240 );
241 insert(
242 r#"{"role":"assistant","providerID":"opencode","modelID":"gpt-5.5","cost":1.25,"tokens":{"input":1,"output":1}}"#,
243 2_000,
244 );
245 insert(
246 r#"{"role":"user","providerID":"opencode-go","tokens":{"input":1}}"#,
247 3_000,
248 );
249 insert(
250 r#"{"role":"assistant","providerID":"command-code-openai","modelID":"deepseek-v4.1-flash","cost":9.0,"tokens":{"input":1}}"#,
251 4_000,
252 );
253 (dir, path.to_string_lossy().into_owned())
254 }
255
256 #[test]
257 fn reads_only_opencode_assistant_rows_in_window() {
258 let (_dir, path) = fixture();
259 let rows = read_rows(&path, 0).unwrap();
260 assert_eq!(rows.len(), 2, "{rows:#?}");
261 assert_eq!(rows[0].provider, "opencode-go");
262 assert_eq!(rows[0].cache_read, 7);
263 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
264 assert_eq!(rows[1].model, "gpt-5.5");
265
266 let later = read_rows(&path, 1_500).unwrap();
267 assert_eq!(later.len(), 1);
268 assert_eq!(later[0].provider, "opencode");
269 }
270
271 fn v2_fixture() -> (tempfile::TempDir, String) {
273 let dir = tempfile::tempdir().unwrap();
274 let path = dir.path().join("opencode.db");
275 let connection = Connection::open(&path).unwrap();
276 connection
277 .execute_batch(
278 "CREATE TABLE session_message (\
279 id TEXT, session_id TEXT, type TEXT, seq INTEGER, \
280 time_created INTEGER, time_updated INTEGER, data TEXT);",
281 )
282 .unwrap();
283 let insert = |kind: &str, data: &str, at: i64| {
284 connection
285 .execute(
286 "INSERT INTO session_message \
287 (id, session_id, type, seq, time_created, time_updated, data) \
288 VALUES (?, 'ses_v2', ?, 0, ?, ?, ?)",
289 (format!("msg_{at}"), kind, at, at, data),
290 )
291 .unwrap();
292 };
293 insert(
294 "assistant",
295 r#"{"model":{"id":"glm-5.3-flash","providerID":"opencode-go"},"cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
296 1_000,
297 );
298 insert(
299 "assistant",
300 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"},"cost":1.25,"tokens":{"input":1,"output":1}}"#,
301 2_000,
302 );
303 insert(
305 "assistant",
306 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"}}"#,
307 3_000,
308 );
309 insert("user", r#"{"text":"hi"}"#, 4_000);
311 insert(
312 "assistant",
313 r#"{"model":{"id":"deepseek","providerID":"command-code-openai"},"cost":9.0,"tokens":{"input":1}}"#,
314 5_000,
315 );
316 (dir, path.to_string_lossy().into_owned())
317 }
318
319 #[test]
320 fn reads_v2_rows_skipping_inflight_and_foreign_providers() {
321 let (_dir, path) = v2_fixture();
322 let rows = read_rows(&path, 0).unwrap();
323 assert_eq!(rows.len(), 2, "{rows:#?}");
324 assert_eq!(rows[0].provider, "opencode-go");
325 assert_eq!(rows[0].model, "glm-5.3-flash");
326 assert_eq!(rows[0].cache_read, 7);
327 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
328 assert_eq!(rows[1].model, "gpt-5.5");
329
330 let later = read_rows(&path, 1_500).unwrap();
331 assert_eq!(later.len(), 1);
332 assert_eq!(later[0].provider, "opencode");
333 }
334
335 #[test]
336 fn missing_database_is_an_error_not_a_panic() {
337 let error = read_rows("/nonexistent/opencode.db", 0).unwrap_err();
338 assert!(error.contains("open"), "{error}");
339 }
340}