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