1use rusqlite::{Connection, OpenFlags};
11use serde::Deserialize;
12
13#[derive(Debug, Clone, Default)]
14pub struct Row {
15 pub at_ms: i64,
16 pub session: String,
17 pub provider: String,
18 pub model: String,
19 pub cost_usd: f64,
20 pub input: u64,
21 pub output: u64,
22 pub cache_read: u64,
23 pub cache_write: u64,
24}
25
26#[derive(Deserialize)]
27struct AssistantData {
28 role: Option<String>,
29 #[serde(rename = "providerID")]
30 provider: Option<String>,
31 #[serde(rename = "modelID")]
32 model: Option<String>,
33 cost: Option<f64>,
34 tokens: Option<Tokens>,
35}
36
37#[derive(Deserialize)]
38struct Tokens {
39 input: Option<u64>,
40 output: Option<u64>,
41 cache: Option<Cache>,
42}
43
44#[derive(Deserialize)]
45struct Cache {
46 read: Option<u64>,
47 write: Option<u64>,
48}
49
50pub const PROVIDERS: [&str; 2] = ["opencode-go", "opencode"];
53
54pub fn default_path() -> String {
56 if let Ok(path) = std::env::var("OPENCODE_DB") {
57 return path;
58 }
59 let base = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
60 let home = std::env::var("HOME").unwrap_or_default();
61 format!("{home}/.local/share")
62 });
63 format!("{base}/opencode/opencode.db")
64}
65
66pub fn read_rows(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
71 let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
72 .map_err(|e| format!("open {path}: {e}"))?;
73 let mut statement = connection
74 .prepare(
75 "SELECT data, time_created, session_id FROM message
76 WHERE data LIKE '%\"tokens\"%' AND time_created >= ?",
77 )
78 .map_err(|e| format!("prepare: {e}"))?;
79 let mut rows = statement
80 .query([since_ms])
81 .map_err(|e| format!("query: {e}"))?;
82
83 let mut out = Vec::new();
84 while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
85 let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
86 let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
87 let session: String = row.get(2).unwrap_or_default();
88 let Ok(parsed) = serde_json::from_str::<AssistantData>(&data) else {
89 continue;
90 };
91 if parsed.role.as_deref() != Some("assistant") {
92 continue;
93 }
94 let provider = parsed.provider.unwrap_or_default();
95 if !PROVIDERS.contains(&provider.as_str()) {
96 continue;
97 }
98 let tokens = parsed.tokens.unwrap_or(Tokens {
99 input: None,
100 output: None,
101 cache: None,
102 });
103 out.push(Row {
104 at_ms,
105 session,
106 provider,
107 model: parsed.model.unwrap_or_default(),
108 cost_usd: parsed.cost.unwrap_or(0.0),
109 input: tokens.input.unwrap_or(0),
110 output: tokens.output.unwrap_or(0),
111 cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
112 cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
113 });
114 }
115 Ok(out)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn fixture() -> (tempfile::TempDir, String) {
124 let dir = tempfile::tempdir().unwrap();
125 let path = dir.path().join("opencode.db");
126 let connection = Connection::open(&path).unwrap();
127 connection
128 .execute_batch(
129 "CREATE TABLE message (session_id TEXT, data TEXT, time_created INTEGER);",
130 )
131 .unwrap();
132 let insert = |data: &str, at: i64| {
133 connection
134 .execute(
135 "INSERT INTO message VALUES ('ses_fixture', ?, ?)",
136 (data, at),
137 )
138 .unwrap();
139 };
140 insert(
141 r#"{"role":"assistant","providerID":"opencode-go","modelID":"glm-5.3-flash","cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
142 1_000,
143 );
144 insert(
145 r#"{"role":"assistant","providerID":"opencode","modelID":"gpt-5.5","cost":1.25,"tokens":{"input":1,"output":1}}"#,
146 2_000,
147 );
148 insert(
149 r#"{"role":"user","providerID":"opencode-go","tokens":{"input":1}}"#,
150 3_000,
151 );
152 insert(
153 r#"{"role":"assistant","providerID":"command-code-openai","modelID":"deepseek-v4.1-flash","cost":9.0,"tokens":{"input":1}}"#,
154 4_000,
155 );
156 (dir, path.to_string_lossy().into_owned())
157 }
158
159 #[test]
160 fn reads_only_opencode_assistant_rows_in_window() {
161 let (_dir, path) = fixture();
162 let rows = read_rows(&path, 0).unwrap();
163 assert_eq!(rows.len(), 2, "{rows:#?}");
164 assert_eq!(rows[0].provider, "opencode-go");
165 assert_eq!(rows[0].cache_read, 7);
166 assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
167 assert_eq!(rows[1].model, "gpt-5.5");
168
169 let later = read_rows(&path, 1_500).unwrap();
170 assert_eq!(later.len(), 1);
171 assert_eq!(later[0].provider, "opencode");
172 }
173
174 #[test]
175 fn missing_database_is_an_error_not_a_panic() {
176 let error = read_rows("/nonexistent/opencode.db", 0).unwrap_err();
177 assert!(error.contains("open"), "{error}");
178 }
179}