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 {
80 let base = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
81 let home = std::env::var("HOME").unwrap_or_default();
82 format!("{home}/.local/share")
83 });
84 format!("{base}/opencode")
85}
86
87pub fn default_path() -> String {
89 if let Ok(path) = std::env::var("OPENCODE_DB") {
90 return path;
91 }
92 format!("{}/opencode.db", opencode_home())
93}
94
95pub fn credential_key() -> Option<String> {
105 credential_key_at(&default_path())
106}
107
108fn credential_key_at(path: &str) -> Option<String> {
109 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
110 let mut statement = connection
111 .prepare(
112 "SELECT value FROM credential WHERE integration_id IN ('opencode-go', 'opencode') \
115 ORDER BY (integration_id = 'opencode-go') DESC, time_updated DESC",
116 )
117 .ok()?;
118 let values: Vec<String> = statement
119 .query_map([], |row| row.get::<_, String>(0))
120 .ok()?
121 .flatten()
122 .collect();
123 drop(statement);
124 values
125 .iter()
126 .filter_map(|value| serde_json::from_str::<Credential>(value).ok())
127 .filter_map(|credential| credential.key)
128 .find(|key| !key.is_empty())
129}
130
131#[derive(Deserialize)]
134struct Credential {
135 key: Option<String>,
136}
137
138pub fn read_rows(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
147 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
148 .map_err(|e| format!("open {path}: {e}"))?;
149 if has_v2(&connection) {
150 read_v2(&connection, since_ms)
151 } else {
152 read_legacy(&connection, since_ms)
153 }
154}
155
156fn has_v2(connection: &Connection) -> bool {
158 connection
159 .query_row(
160 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_message'",
161 [],
162 |_| Ok(()),
163 )
164 .is_ok()
165}
166
167fn read_v2(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
171 let mut statement = connection
172 .prepare(
173 "SELECT data, time_created, session_id FROM session_message
174 WHERE type = 'assistant' AND time_created >= ?",
175 )
176 .map_err(|e| format!("prepare: {e}"))?;
177 let mut rows = statement
178 .query([since_ms])
179 .map_err(|e| format!("query: {e}"))?;
180
181 let mut out = Vec::new();
182 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
183 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
184 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
185 let session: String = row.get(2).unwrap_or_default();
186 let Ok(parsed) = serde_json::from_str::<V2AssistantData>(&data) else {
187 continue;
188 };
189 let Some(tokens) = parsed.tokens else {
190 continue; };
192 let model = parsed.model.unwrap_or_default();
193 let provider = model.provider.unwrap_or_default();
194 if !PROVIDERS.contains(&provider.as_str()) {
195 continue;
196 }
197 out.push(Row {
198 at_ms,
199 session,
200 provider,
201 model: model.id.unwrap_or_default(),
202 cost_usd: parsed.cost.unwrap_or(0.0),
203 input: tokens.input.unwrap_or(0),
204 output: tokens.output.unwrap_or(0),
205 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
206 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
207 });
208 }
209 Ok(out)
210}
211
212fn read_legacy(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
214 let mut statement = connection
215 .prepare(
216 "SELECT data, time_created, session_id FROM message
217 WHERE data LIKE '%\"tokens\"%' AND time_created >= ?",
218 )
219 .map_err(|e| format!("prepare: {e}"))?;
220 let mut rows = statement
221 .query([since_ms])
222 .map_err(|e| format!("query: {e}"))?;
223
224 let mut out = Vec::new();
225 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
226 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
227 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
228 let session: String = row.get(2).unwrap_or_default();
229 let Ok(parsed) = serde_json::from_str::<AssistantData>(&data) else {
230 continue;
231 };
232 if parsed.role.as_deref() != Some("assistant") {
233 continue;
234 }
235 let provider = parsed.provider.unwrap_or_default();
236 if !PROVIDERS.contains(&provider.as_str()) {
237 continue;
238 }
239 let tokens = parsed.tokens.unwrap_or(Tokens {
240 input: None,
241 output: None,
242 cache: None,
243 });
244 out.push(Row {
245 at_ms,
246 session,
247 provider,
248 model: parsed.model.unwrap_or_default(),
249 cost_usd: parsed.cost.unwrap_or(0.0),
250 input: tokens.input.unwrap_or(0),
251 output: tokens.output.unwrap_or(0),
252 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
253 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
254 });
255 }
256 Ok(out)
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn fixture() -> (tempfile::TempDir, String) {
265 let dir = tempfile::tempdir().unwrap();
266 let path = dir.path().join("opencode.db");
267 let connection = Connection::open(&path).unwrap();
268 connection
269 .execute_batch(
270 "CREATE TABLE message (session_id TEXT, data TEXT, time_created INTEGER);",
271 )
272 .unwrap();
273 let insert = |data: &str, at: i64| {
274 connection
275 .execute(
276 "INSERT INTO message VALUES ('ses_fixture', ?, ?)",
277 (data, at),
278 )
279 .unwrap();
280 };
281 insert(
282 r#"{"role":"assistant","providerID":"opencode-go","modelID":"glm-5.3-flash","cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
283 1_000,
284 );
285 insert(
286 r#"{"role":"assistant","providerID":"opencode","modelID":"gpt-5.5","cost":1.25,"tokens":{"input":1,"output":1}}"#,
287 2_000,
288 );
289 insert(
290 r#"{"role":"user","providerID":"opencode-go","tokens":{"input":1}}"#,
291 3_000,
292 );
293 insert(
294 r#"{"role":"assistant","providerID":"command-code-openai","modelID":"deepseek-v4.1-flash","cost":9.0,"tokens":{"input":1}}"#,
295 4_000,
296 );
297 (dir, path.to_string_lossy().into_owned())
298 }
299
300 #[test]
301 fn reads_only_opencode_assistant_rows_in_window() {
302 let (_dir, path) = fixture();
303 let rows = read_rows(&path, 0).unwrap();
304 assert_eq!(rows.len(), 2, "{rows:#?}");
305 assert_eq!(rows[0].provider, "opencode-go");
306 assert_eq!(rows[0].cache_read, 7);
307 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
308 assert_eq!(rows[1].model, "gpt-5.5");
309
310 let later = read_rows(&path, 1_500).unwrap();
311 assert_eq!(later.len(), 1);
312 assert_eq!(later[0].provider, "opencode");
313 }
314
315 #[test]
319 fn the_live_credential_comes_from_the_store() {
320 let dir = tempfile::tempdir().unwrap();
321 let path = dir.path().join("opencode.db");
322 let connection = Connection::open(&path).unwrap();
323 connection
324 .execute_batch(
325 "CREATE TABLE credential (\
326 id TEXT, integration_id TEXT, value TEXT, time_updated INTEGER);",
327 )
328 .unwrap();
329 let insert = |integration: &str, key: &str, at: i64| {
330 connection
331 .execute(
332 "INSERT INTO credential VALUES (?, ?, ?, ?)",
333 (
334 format!("cred_{at}"),
335 integration,
336 format!(r#"{{"type":"key","key":"{key}"}}"#),
337 at,
338 ),
339 )
340 .unwrap();
341 };
342 insert("opencode", "zen", 9_000);
343 insert("opencode-go", "go-old", 1_000);
344 insert("opencode-go", "go-new", 2_000);
345 let path = path.to_string_lossy().into_owned();
346 assert_eq!(credential_key_at(&path).as_deref(), Some("go-new"));
349
350 connection
352 .execute(
353 "INSERT INTO credential VALUES ('cred_empty', 'opencode-go', '{\"type\":\"key\",\"key\":\"\"}', 3_000)",
354 [],
355 )
356 .unwrap();
357 assert_eq!(credential_key_at(&path).as_deref(), Some("go-new"));
358
359 let legacy = dir.path().join("legacy.db");
361 Connection::open(&legacy).unwrap();
362 assert!(credential_key_at(&legacy.to_string_lossy()).is_none());
363 }
364
365 fn v2_fixture() -> (tempfile::TempDir, String) {
367 let dir = tempfile::tempdir().unwrap();
368 let path = dir.path().join("opencode.db");
369 let connection = Connection::open(&path).unwrap();
370 connection
371 .execute_batch(
372 "CREATE TABLE session_message (\
373 id TEXT, session_id TEXT, type TEXT, seq INTEGER, \
374 time_created INTEGER, time_updated INTEGER, data TEXT);",
375 )
376 .unwrap();
377 let insert = |kind: &str, data: &str, at: i64| {
378 connection
379 .execute(
380 "INSERT INTO session_message \
381 (id, session_id, type, seq, time_created, time_updated, data) \
382 VALUES (?, 'ses_v2', ?, 0, ?, ?, ?)",
383 (format!("msg_{at}"), kind, at, at, data),
384 )
385 .unwrap();
386 };
387 insert(
388 "assistant",
389 r#"{"model":{"id":"glm-5.3-flash","providerID":"opencode-go"},"cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
390 1_000,
391 );
392 insert(
393 "assistant",
394 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"},"cost":1.25,"tokens":{"input":1,"output":1}}"#,
395 2_000,
396 );
397 insert(
399 "assistant",
400 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"}}"#,
401 3_000,
402 );
403 insert("user", r#"{"text":"hi"}"#, 4_000);
405 insert(
406 "assistant",
407 r#"{"model":{"id":"deepseek","providerID":"command-code-openai"},"cost":9.0,"tokens":{"input":1}}"#,
408 5_000,
409 );
410 (dir, path.to_string_lossy().into_owned())
411 }
412
413 #[test]
414 fn reads_v2_rows_skipping_inflight_and_foreign_providers() {
415 let (_dir, path) = v2_fixture();
416 let rows = read_rows(&path, 0).unwrap();
417 assert_eq!(rows.len(), 2, "{rows:#?}");
418 assert_eq!(rows[0].provider, "opencode-go");
419 assert_eq!(rows[0].model, "glm-5.3-flash");
420 assert_eq!(rows[0].cache_read, 7);
421 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
422 assert_eq!(rows[1].model, "gpt-5.5");
423
424 let later = read_rows(&path, 1_500).unwrap();
425 assert_eq!(later.len(), 1);
426 assert_eq!(later[0].provider, "opencode");
427 }
428
429 #[test]
430 fn missing_database_is_an_error_not_a_panic() {
431 let error = read_rows("/nonexistent/opencode.db", 0).unwrap_err();
432 assert!(error.contains("open"), "{error}");
433 }
434}