1use rusqlite::{Connection, OpenFlags};
17use serde::Deserialize;
18
19#[derive(Debug, Clone, Default)]
20pub struct Row {
21 pub client: &'static str,
25 pub at_ms: i64,
26 pub session: String,
27 pub provider: String,
28 pub model: String,
29 pub cost_usd: f64,
30 pub input: u64,
31 pub output: u64,
32 pub reasoning: u64,
33 pub cache_read: u64,
34 pub cache_write: u64,
35}
36
37#[derive(Deserialize)]
38struct AssistantData {
39 role: Option<String>,
40 #[serde(rename = "providerID")]
41 provider: Option<String>,
42 #[serde(rename = "modelID")]
43 model: Option<String>,
44 cost: Option<f64>,
45 tokens: Option<Tokens>,
46}
47
48#[derive(Deserialize, Default)]
51struct V2AssistantData {
52 model: Option<V2Model>,
53 cost: Option<f64>,
54 tokens: Option<Tokens>,
55}
56
57#[derive(Deserialize, Default)]
58struct V2Model {
59 id: Option<String>,
60 #[serde(rename = "providerID")]
61 provider: Option<String>,
62}
63
64#[derive(Deserialize)]
65struct Tokens {
66 input: Option<u64>,
67 output: Option<u64>,
68 reasoning: Option<u64>,
70 cache: Option<Cache>,
71}
72
73#[derive(Deserialize)]
74struct Cache {
75 read: Option<u64>,
76 write: Option<u64>,
77}
78
79pub const PROVIDERS: [&str; 2] = ["opencode-go", "opencode"];
82
83pub fn opencode_home() -> String {
87 let base = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
88 let home = std::env::var("HOME").unwrap_or_default();
89 format!("{home}/.local/share")
90 });
91 format!("{base}/opencode")
92}
93
94pub fn default_path() -> String {
96 if let Ok(path) = std::env::var("OPENCODE_DB") {
97 return path;
98 }
99 format!("{}/opencode.db", opencode_home())
100}
101
102pub fn credential_key() -> Option<String> {
112 credential_key_at(&default_path())
113}
114
115fn credential_key_at(path: &str) -> Option<String> {
116 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
117 let mut statement = connection
118 .prepare(
119 "SELECT value FROM credential WHERE integration_id IN ('opencode-go', 'opencode') \
122 ORDER BY (integration_id = 'opencode-go') DESC, time_updated DESC",
123 )
124 .ok()?;
125 let values: Vec<String> = statement
126 .query_map([], |row| row.get::<_, String>(0))
127 .ok()?
128 .flatten()
129 .collect();
130 drop(statement);
131 values
132 .iter()
133 .filter_map(|value| serde_json::from_str::<Credential>(value).ok())
134 .filter_map(|credential| credential.key)
135 .find(|key| !key.is_empty())
136}
137
138#[derive(Deserialize)]
141struct Credential {
142 key: Option<String>,
143}
144
145pub fn read_rows(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
154 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
155 .map_err(|e| format!("open {path}: {e}"))?;
156 if has_v2(&connection) {
157 read_v2(&connection, since_ms)
158 } else {
159 read_legacy(&connection, since_ms)
160 }
161}
162
163pub fn read_all(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
170 let mut rows = read_rows(path, since_ms)?;
171 rows.extend(external(&crate::stores::Dirs::from_env(), since_ms));
172 Ok(rows)
173}
174
175fn external(dirs: &crate::stores::Dirs, since_ms: i64) -> Vec<Row> {
178 let catalog = crate::ocuse::zen::catalog();
179 crate::stores::read(dirs, since_ms)
180 .into_iter()
181 .filter(|turn| PROVIDERS.contains(&turn.provider.as_str()))
182 .map(|turn| {
183 let priced = catalog
187 .price(
188 &turn.model,
189 turn.at_ms,
190 turn.input,
191 turn.output,
192 turn.reasoning,
193 turn.cache_read,
194 turn.cache_write,
195 )
196 .unwrap_or(turn.cost_usd);
197 Row {
198 client: turn.client,
199 at_ms: turn.at_ms,
200 session: turn.session,
201 provider: turn.provider,
202 model: turn.model,
203 cost_usd: priced,
204 input: turn.input,
205 output: turn.output,
206 reasoning: turn.reasoning,
207 cache_read: turn.cache_read,
208 cache_write: turn.cache_write,
209 }
210 })
211 .collect()
212}
213
214fn has_v2(connection: &Connection) -> bool {
216 connection
217 .query_row(
218 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_message'",
219 [],
220 |_| Ok(()),
221 )
222 .is_ok()
223}
224
225fn read_v2(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
229 let mut statement = connection
230 .prepare(
231 "SELECT data, time_created, session_id FROM session_message
232 WHERE type = 'assistant' AND time_created >= ?",
233 )
234 .map_err(|e| format!("prepare: {e}"))?;
235 let mut rows = statement
236 .query([since_ms])
237 .map_err(|e| format!("query: {e}"))?;
238
239 let mut out = Vec::new();
240 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
241 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
242 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
243 let session: String = row.get(2).unwrap_or_default();
244 let Ok(parsed) = serde_json::from_str::<V2AssistantData>(&data) else {
245 continue;
246 };
247 let Some(tokens) = parsed.tokens else {
248 continue; };
250 let model = parsed.model.unwrap_or_default();
251 let provider = model.provider.unwrap_or_default();
252 if !PROVIDERS.contains(&provider.as_str()) {
253 continue;
254 }
255 out.push(Row {
256 client: crate::stores::OPENCODE,
257 at_ms,
258 session,
259 provider,
260 model: model.id.unwrap_or_default(),
261 cost_usd: parsed.cost.unwrap_or(0.0),
262 input: tokens.input.unwrap_or(0),
263 output: tokens.output.unwrap_or(0),
264 reasoning: tokens.reasoning.unwrap_or(0),
265 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
266 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
267 });
268 }
269 Ok(out)
270}
271
272fn read_legacy(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
274 let mut statement = connection
275 .prepare(
276 "SELECT data, time_created, session_id FROM message
277 WHERE data LIKE '%\"tokens\"%' AND time_created >= ?",
278 )
279 .map_err(|e| format!("prepare: {e}"))?;
280 let mut rows = statement
281 .query([since_ms])
282 .map_err(|e| format!("query: {e}"))?;
283
284 let mut out = Vec::new();
285 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
286 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
287 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
288 let session: String = row.get(2).unwrap_or_default();
289 let Ok(parsed) = serde_json::from_str::<AssistantData>(&data) else {
290 continue;
291 };
292 if parsed.role.as_deref() != Some("assistant") {
293 continue;
294 }
295 let provider = parsed.provider.unwrap_or_default();
296 if !PROVIDERS.contains(&provider.as_str()) {
297 continue;
298 }
299 let tokens = parsed.tokens.unwrap_or(Tokens {
300 input: None,
301 output: None,
302 reasoning: None,
303 cache: None,
304 });
305 out.push(Row {
306 client: crate::stores::OPENCODE,
307 at_ms,
308 session,
309 provider,
310 model: parsed.model.unwrap_or_default(),
311 cost_usd: parsed.cost.unwrap_or(0.0),
312 input: tokens.input.unwrap_or(0),
313 output: tokens.output.unwrap_or(0),
314 reasoning: tokens.reasoning.unwrap_or(0),
315 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
316 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
317 });
318 }
319 Ok(out)
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 fn fixture() -> (tempfile::TempDir, String) {
328 let dir = tempfile::tempdir().unwrap();
329 let path = dir.path().join("opencode.db");
330 let connection = Connection::open(&path).unwrap();
331 connection
332 .execute_batch(
333 "CREATE TABLE message (session_id TEXT, data TEXT, time_created INTEGER);",
334 )
335 .unwrap();
336 let insert = |data: &str, at: i64| {
337 connection
338 .execute(
339 "INSERT INTO message VALUES ('ses_fixture', ?, ?)",
340 (data, at),
341 )
342 .unwrap();
343 };
344 insert(
345 r#"{"role":"assistant","providerID":"opencode-go","modelID":"glm-5.3-flash","cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
346 1_000,
347 );
348 insert(
349 r#"{"role":"assistant","providerID":"opencode","modelID":"gpt-5.5","cost":1.25,"tokens":{"input":1,"output":1}}"#,
350 2_000,
351 );
352 insert(
353 r#"{"role":"user","providerID":"opencode-go","tokens":{"input":1}}"#,
354 3_000,
355 );
356 insert(
357 r#"{"role":"assistant","providerID":"command-code-openai","modelID":"deepseek-v4.1-flash","cost":9.0,"tokens":{"input":1}}"#,
358 4_000,
359 );
360 (dir, path.to_string_lossy().into_owned())
361 }
362
363 #[test]
364 fn reads_only_opencode_assistant_rows_in_window() {
365 let (_dir, path) = fixture();
366 let rows = read_rows(&path, 0).unwrap();
367 assert_eq!(rows.len(), 2, "{rows:#?}");
368 assert_eq!(rows[0].provider, "opencode-go");
369 assert_eq!(rows[0].cache_read, 7);
370 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
371 assert_eq!(rows[1].model, "gpt-5.5");
372 assert_eq!(rows[0].client, crate::stores::OPENCODE);
373
374 let later = read_rows(&path, 1_500).unwrap();
375 assert_eq!(later.len(), 1);
376 assert_eq!(later[0].provider, "opencode");
377 }
378
379 #[test]
382 fn other_clients_keep_only_go_and_zen_rows() {
383 let dir = tempfile::tempdir().unwrap();
384 let pi = dir.path().join("pi/--p--");
385 std::fs::create_dir_all(&pi).unwrap();
386 std::fs::write(
387 pi.join("s.jsonl"),
388 concat!(
389 r#"{"type":"message","message":{"role":"assistant","provider":"opencode-go","model":"glm-5.2","timestamp":1000,"usage":{"input":10,"output":1}}}"#,
390 "\n",
391 r#"{"type":"message","message":{"role":"assistant","provider":"openrouter","model":"glm-5.2","timestamp":1000,"usage":{"input":10,"output":1}}}"#,
392 "\n",
393 ),
394 )
395 .unwrap();
396 let dirs = crate::stores::Dirs {
397 dsh: dir.path().join("dsh"),
398 pi: dir.path().join("pi"),
399 };
400 let rows = external(&dirs, 0);
401 assert_eq!(rows.len(), 1, "{rows:#?}");
402 assert_eq!(rows[0].client, crate::stores::PI);
403 assert_eq!(rows[0].provider, "opencode-go");
404 assert_eq!(rows[0].input, 10);
405 }
406
407 #[test]
411 fn the_live_credential_comes_from_the_store() {
412 let dir = tempfile::tempdir().unwrap();
413 let path = dir.path().join("opencode.db");
414 let connection = Connection::open(&path).unwrap();
415 connection
416 .execute_batch(
417 "CREATE TABLE credential (\
418 id TEXT, integration_id TEXT, value TEXT, time_updated INTEGER);",
419 )
420 .unwrap();
421 let insert = |integration: &str, key: &str, at: i64| {
422 connection
423 .execute(
424 "INSERT INTO credential VALUES (?, ?, ?, ?)",
425 (
426 format!("cred_{at}"),
427 integration,
428 format!(r#"{{"type":"key","key":"{key}"}}"#),
429 at,
430 ),
431 )
432 .unwrap();
433 };
434 insert("opencode", "zen", 9_000);
435 insert("opencode-go", "go-old", 1_000);
436 insert("opencode-go", "go-new", 2_000);
437 let path = path.to_string_lossy().into_owned();
438 assert_eq!(credential_key_at(&path).as_deref(), Some("go-new"));
441
442 connection
444 .execute(
445 "INSERT INTO credential VALUES ('cred_empty', 'opencode-go', '{\"type\":\"key\",\"key\":\"\"}', 3_000)",
446 [],
447 )
448 .unwrap();
449 assert_eq!(credential_key_at(&path).as_deref(), Some("go-new"));
450
451 let legacy = dir.path().join("legacy.db");
453 Connection::open(&legacy).unwrap();
454 assert!(credential_key_at(&legacy.to_string_lossy()).is_none());
455 }
456
457 fn v2_fixture() -> (tempfile::TempDir, String) {
459 let dir = tempfile::tempdir().unwrap();
460 let path = dir.path().join("opencode.db");
461 let connection = Connection::open(&path).unwrap();
462 connection
463 .execute_batch(
464 "CREATE TABLE session_message (\
465 id TEXT, session_id TEXT, type TEXT, seq INTEGER, \
466 time_created INTEGER, time_updated INTEGER, data TEXT);",
467 )
468 .unwrap();
469 let insert = |kind: &str, data: &str, at: i64| {
470 connection
471 .execute(
472 "INSERT INTO session_message \
473 (id, session_id, type, seq, time_created, time_updated, data) \
474 VALUES (?, 'ses_v2', ?, 0, ?, ?, ?)",
475 (format!("msg_{at}"), kind, at, at, data),
476 )
477 .unwrap();
478 };
479 insert(
480 "assistant",
481 r#"{"model":{"id":"glm-5.3-flash","providerID":"opencode-go"},"cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
482 1_000,
483 );
484 insert(
485 "assistant",
486 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"},"cost":1.25,"tokens":{"input":1,"output":1}}"#,
487 2_000,
488 );
489 insert(
491 "assistant",
492 r#"{"model":{"id":"gpt-5.5","providerID":"opencode"}}"#,
493 3_000,
494 );
495 insert("user", r#"{"text":"hi"}"#, 4_000);
497 insert(
498 "assistant",
499 r#"{"model":{"id":"deepseek","providerID":"command-code-openai"},"cost":9.0,"tokens":{"input":1}}"#,
500 5_000,
501 );
502 (dir, path.to_string_lossy().into_owned())
503 }
504
505 #[test]
506 fn reads_v2_rows_skipping_inflight_and_foreign_providers() {
507 let (_dir, path) = v2_fixture();
508 let rows = read_rows(&path, 0).unwrap();
509 assert_eq!(rows.len(), 2, "{rows:#?}");
510 assert_eq!(rows[0].provider, "opencode-go");
511 assert_eq!(rows[0].model, "glm-5.3-flash");
512 assert_eq!(rows[0].cache_read, 7);
513 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
514 assert_eq!(rows[1].model, "gpt-5.5");
515
516 let later = read_rows(&path, 1_500).unwrap();
517 assert_eq!(later.len(), 1);
518 assert_eq!(later[0].provider, "opencode");
519 }
520
521 #[test]
522 fn missing_database_is_an_error_not_a_panic() {
523 let error = read_rows("/nonexistent/opencode.db", 0).unwrap_err();
524 assert!(error.contains("open"), "{error}");
525 }
526}