1use super::auth::OpencodeGoAuth;
2use super::types::{OpencodeGoUsageSnapshot, OpencodeGoUsageSource, OpencodeGoUsageWindow};
3use chrono::{DateTime, Duration, Utc};
4use rusqlite::Connection;
5use serde::Deserialize;
6use std::path::{Path, PathBuf};
7use tempfile::TempDir;
8use thiserror::Error;
9
10const FIVE_HOUR_LIMIT_USD: f64 = 12.0;
12const WEEKLY_LIMIT_USD: f64 = 30.0;
13const MONTHLY_LIMIT_USD: f64 = 60.0;
14
15const ENV_FIVE_HOUR_LIMIT: &str = "SEHER_OPENCODE_5H_LIMIT_USD";
21const ENV_WEEKLY_LIMIT: &str = "SEHER_OPENCODE_WEEKLY_LIMIT_USD";
22const ENV_MONTHLY_LIMIT: &str = "SEHER_OPENCODE_MONTHLY_LIMIT_USD";
23
24struct WindowDef {
25 entry_type: &'static str,
26 window_seconds: i64,
27 default_limit_usd: f64,
28 env_var: &'static str,
29}
30
31const WINDOW_DEFS: [WindowDef; 3] = [
32 WindowDef {
33 entry_type: "five_hour_spend",
34 window_seconds: 5 * 60 * 60,
35 default_limit_usd: FIVE_HOUR_LIMIT_USD,
36 env_var: ENV_FIVE_HOUR_LIMIT,
37 },
38 WindowDef {
39 entry_type: "weekly_spend",
40 window_seconds: 7 * 24 * 60 * 60,
41 default_limit_usd: WEEKLY_LIMIT_USD,
42 env_var: ENV_WEEKLY_LIMIT,
43 },
44 WindowDef {
45 entry_type: "monthly_spend",
46 window_seconds: 30 * 24 * 60 * 60,
47 default_limit_usd: MONTHLY_LIMIT_USD,
48 env_var: ENV_MONTHLY_LIMIT,
49 },
50];
51
52fn resolve_limit(env_var: &str, default: f64) -> f64 {
56 std::env::var(env_var)
57 .ok()
58 .and_then(|v| v.trim().parse::<f64>().ok())
59 .filter(|v| v.is_finite())
60 .unwrap_or(default)
61}
62
63fn window_specs() -> Vec<WindowSpec> {
64 WINDOW_DEFS
65 .iter()
66 .map(|d| WindowSpec {
67 entry_type: d.entry_type,
68 window_seconds: d.window_seconds,
69 limit_usd: resolve_limit(d.env_var, d.default_limit_usd),
70 })
71 .collect()
72}
73
74const LIMIT_EPSILON: f64 = 1e-9;
75
76#[derive(Debug, Error)]
77pub enum OpencodeGoUsageError {
78 #[error("could not determine home directory for opencode.db")]
79 HomeDirNotFound,
80
81 #[error("failed to read OpenCode usage database: {0}")]
82 Io(#[from] std::io::Error),
83
84 #[error("failed to query OpenCode usage database: {0}")]
85 Sql(#[from] rusqlite::Error),
86
87 #[error("failed to parse OpenCode message row: {0}")]
88 Parse(#[from] serde_json::Error),
89}
90
91#[derive(Debug, Clone, PartialEq)]
92struct UsageRecord {
93 completed_at: DateTime<Utc>,
94 cost_usd: f64,
95}
96
97#[derive(Debug, Clone, Copy)]
98struct WindowSpec {
99 entry_type: &'static str,
100 window_seconds: i64,
101 limit_usd: f64,
102}
103
104#[derive(Debug, Deserialize)]
105struct MessageRow {
106 role: String,
107 #[serde(rename = "providerID")]
108 provider_id: Option<String>,
109 cost: Option<f64>,
110 time: Option<MessageTime>,
111}
112
113#[derive(Debug, Deserialize)]
114struct MessageTime {
115 completed: Option<i64>,
116}
117
118pub struct OpencodeGoUsageStore;
119
120impl OpencodeGoUsageStore {
121 pub fn fetch_usage() -> Result<OpencodeGoUsageSnapshot, OpencodeGoUsageError> {
126 Self::fetch_usage_with_paths_at(None, None, Utc::now())
127 }
128
129 pub fn fetch_usage_from_path_at(
134 db_path: &Path,
135 now: DateTime<Utc>,
136 ) -> Result<OpencodeGoUsageSnapshot, OpencodeGoUsageError> {
137 Self::fetch_usage_with_paths_at(Some(db_path), None, now)
138 }
139
140 pub fn fetch_usage_with_paths_at(
145 db_path: Option<&Path>,
146 auth_path: Option<&Path>,
147 now: DateTime<Utc>,
148 ) -> Result<OpencodeGoUsageSnapshot, OpencodeGoUsageError> {
149 let credentials_available = auth_path
150 .map_or_else(
151 OpencodeGoAuth::read_api_key,
152 OpencodeGoAuth::read_api_key_from,
153 )
154 .is_ok();
155 let db_path = match db_path {
156 Some(path) => path.to_path_buf(),
157 None => Self::default_db_path()?,
158 };
159 let records = if db_path.exists() {
160 Self::load_records(&db_path)?
161 } else {
162 Vec::new()
163 };
164
165 Ok(Self::snapshot_from_records(
166 now,
167 &records,
168 credentials_available,
169 ))
170 }
171
172 fn default_db_path() -> Result<PathBuf, OpencodeGoUsageError> {
173 let home = dirs::home_dir().ok_or(OpencodeGoUsageError::HomeDirNotFound)?;
174 Ok(home.join(".local/share/opencode/opencode.db"))
175 }
176
177 fn load_records(db_path: &Path) -> Result<Vec<UsageRecord>, OpencodeGoUsageError> {
178 let (temp_dir, temp_db_path) = Self::copy_sqlite_database(db_path)?;
179 let conn = Connection::open(&temp_db_path)?;
180 let records = Self::query_records(&conn)?;
181 drop(conn);
182 drop(temp_dir);
183 Ok(records)
184 }
185
186 fn copy_sqlite_database(db_path: &Path) -> Result<(TempDir, PathBuf), OpencodeGoUsageError> {
187 let temp_dir = tempfile::tempdir()?;
188 let file_name = db_path
189 .file_name()
190 .ok_or_else(|| std::io::Error::other("opencode.db path has no file name"))?;
191 let temp_db_path = temp_dir.path().join(file_name);
192 std::fs::copy(db_path, &temp_db_path)?;
193
194 for suffix in ["-wal", "-shm"] {
195 let sidecar_name = format!("{}{}", file_name.to_string_lossy(), suffix);
196 let src = db_path.with_file_name(&sidecar_name);
197 if src.exists() {
198 let dst = temp_dir.path().join(sidecar_name);
199 std::fs::copy(src, dst)?;
200 }
201 }
202
203 Ok((temp_dir, temp_db_path))
204 }
205
206 fn query_records(conn: &Connection) -> Result<Vec<UsageRecord>, OpencodeGoUsageError> {
207 let mut stmt = match conn.prepare("SELECT data FROM message") {
208 Ok(stmt) => stmt,
209 Err(rusqlite::Error::SqliteFailure(_, Some(message)))
210 if message.contains("no such table: message") =>
211 {
212 return Ok(Vec::new());
213 }
214 Err(err) => return Err(err.into()),
215 };
216 let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
217 let mut records = Vec::new();
218
219 for row in rows {
220 let row = serde_json::from_str::<MessageRow>(&row?)?;
221 if row.role != "assistant" || row.provider_id.as_deref() != Some("opencode-go") {
222 continue;
223 }
224
225 let Some(cost_usd) = row.cost else {
226 continue;
227 };
228 if cost_usd <= 0.0 {
229 continue;
230 }
231
232 let completed = row.time.and_then(|time| time.completed);
233 let Some(completed_at) = completed.and_then(DateTime::from_timestamp_millis) else {
234 continue;
235 };
236
237 records.push(UsageRecord {
238 completed_at,
239 cost_usd,
240 });
241 }
242
243 records.sort_by_key(|record| record.completed_at);
244 Ok(records)
245 }
246
247 fn snapshot_from_records(
248 now: DateTime<Utc>,
249 records: &[UsageRecord],
250 credentials_available: bool,
251 ) -> OpencodeGoUsageSnapshot {
252 let windows = window_specs()
253 .into_iter()
254 .map(|spec| Self::window_from_records(now, records, spec))
255 .collect();
256
257 OpencodeGoUsageSnapshot {
258 source: OpencodeGoUsageSource::LocalDatabase,
259 credentials_available,
260 total_messages: records.len(),
261 windows,
262 }
263 }
264
265 fn window_from_records(
266 now: DateTime<Utc>,
267 records: &[UsageRecord],
268 spec: WindowSpec,
269 ) -> OpencodeGoUsageWindow {
270 let duration = Duration::seconds(spec.window_seconds);
271 let window_start = now - duration;
272 let active_records: Vec<&UsageRecord> = records
273 .iter()
274 .filter(|record| record.completed_at >= window_start)
275 .collect();
276 let spent_usd = active_records
277 .iter()
278 .map(|record| record.cost_usd)
279 .sum::<f64>();
280
281 let limited = spec.limit_usd > 0.0 && spent_usd + LIMIT_EPSILON >= spec.limit_usd;
283 let resets_at = if limited {
284 let mut remaining = spent_usd;
285 active_records.iter().find_map(|record| {
286 remaining -= record.cost_usd;
287 if remaining + LIMIT_EPSILON < spec.limit_usd {
288 Some(record.completed_at + duration)
289 } else {
290 None
291 }
292 })
293 } else {
294 None
295 };
296
297 OpencodeGoUsageWindow {
298 entry_type: spec.entry_type,
299 spent_usd,
300 limit_usd: spec.limit_usd,
301 resets_at,
302 }
303 }
304}
305
306#[cfg(test)]
307#[expect(clippy::unwrap_used)]
308mod tests {
309 use super::*;
310 use chrono::TimeZone;
311
312 type TestResult = Result<(), Box<dyn std::error::Error>>;
313
314 fn usage_record(ts: i64, cost_usd: f64) -> UsageRecord {
315 UsageRecord {
316 completed_at: Utc.timestamp_millis_opt(ts).single().unwrap(),
317 cost_usd,
318 }
319 }
320
321 #[test]
322 fn snapshot_is_empty_when_no_messages_exist() {
323 let now = Utc.timestamp_millis_opt(1_000_000).single().unwrap();
324 let snapshot = OpencodeGoUsageStore::snapshot_from_records(now, &[], false);
325
326 assert_eq!(snapshot.total_messages, 0);
327 assert_eq!(snapshot.windows.len(), 3);
328 assert!(snapshot.windows.iter().all(|window| !window.is_limited()));
329 assert!(
330 snapshot
331 .windows
332 .iter()
333 .all(|window| window.spent_usd == 0.0)
334 );
335 }
336
337 #[test]
338 fn computes_five_hour_reset_from_oldest_blocking_message() {
339 let now = Utc
340 .timestamp_millis_opt(20 * 60 * 60 * 1000)
341 .single()
342 .unwrap();
343 let records = vec![
344 usage_record(15 * 60 * 60 * 1000, 4.0),
345 usage_record(16 * 60 * 60 * 1000, 5.0),
346 usage_record(19 * 60 * 60 * 1000, 4.0),
347 ];
348
349 let snapshot = OpencodeGoUsageStore::snapshot_from_records(now, &records, true);
350 let five_hour = snapshot
351 .windows
352 .iter()
353 .find(|window| window.entry_type == "five_hour_spend")
354 .unwrap();
355
356 assert!(five_hour.is_limited());
357 assert_eq!(
358 five_hour.resets_at,
359 Some(records[0].completed_at + Duration::hours(5))
360 );
361 assert!((five_hour.spent_usd - 13.0).abs() < LIMIT_EPSILON);
362 }
363
364 #[test]
365 fn computes_longer_windows_independently() {
366 let now = Utc
367 .timestamp_millis_opt(40 * 24 * 60 * 60 * 1000)
368 .single()
369 .unwrap();
370 let records = vec![
371 usage_record(10 * 24 * 60 * 60 * 1000, 31.0),
372 usage_record(34 * 24 * 60 * 60 * 1000, 11.0),
373 usage_record(35 * 24 * 60 * 60 * 1000, 10.0),
374 usage_record(39 * 24 * 60 * 60 * 1000, 10.0),
375 ];
376
377 let snapshot = OpencodeGoUsageStore::snapshot_from_records(now, &records, true);
378 let weekly = snapshot
379 .windows
380 .iter()
381 .find(|window| window.entry_type == "weekly_spend")
382 .unwrap();
383 let monthly = snapshot
384 .windows
385 .iter()
386 .find(|window| window.entry_type == "monthly_spend")
387 .unwrap();
388
389 assert!(weekly.is_limited());
390 assert_eq!(
391 weekly.resets_at,
392 Some(records[1].completed_at + Duration::days(7))
393 );
394 assert!(monthly.is_limited());
395 assert_eq!(
396 monthly.resets_at,
397 Some(records[0].completed_at + Duration::days(30))
398 );
399 }
400
401 #[test]
402 fn disabled_window_is_never_limited() {
403 let now = Utc
405 .timestamp_millis_opt(10 * 60 * 60 * 1000)
406 .single()
407 .unwrap();
408 let records = vec![usage_record(9 * 60 * 60 * 1000, 999.0)];
409 let spec = WindowSpec {
410 entry_type: "five_hour_spend",
411 window_seconds: 5 * 60 * 60,
412 limit_usd: 0.0,
413 };
414 let w = OpencodeGoUsageStore::window_from_records(now, &records, spec);
415 assert!(!w.is_limited());
416 assert_eq!(w.resets_at, None);
417 assert!(w.utilization().abs() < f64::EPSILON);
418 }
419
420 #[test]
421 fn resolve_limit_uses_default_when_env_absent() {
422 assert!(
424 (resolve_limit("SEHER_OPENCODE_TEST_UNSET_LIMIT_XYZ", 42.0) - 42.0).abs()
425 < f64::EPSILON
426 );
427 }
428
429 #[test]
430 fn limited_window_above_threshold() {
431 let now = Utc
432 .timestamp_millis_opt(10 * 60 * 60 * 1000)
433 .single()
434 .unwrap();
435 let records = vec![usage_record(9 * 60 * 60 * 1000, 13.0)];
436 let spec = WindowSpec {
437 entry_type: "five_hour_spend",
438 window_seconds: 5 * 60 * 60,
439 limit_usd: 12.0,
440 };
441 let w = OpencodeGoUsageStore::window_from_records(now, &records, spec);
442 assert!(w.is_limited());
443 assert!(w.resets_at.is_some());
444 }
445
446 #[test]
447 fn reads_only_opencode_go_assistant_messages_from_sqlite() -> TestResult {
448 let tmp = tempfile::NamedTempFile::new()?;
449 let conn = Connection::open(tmp.path())?;
450 conn.execute("CREATE TABLE message (data TEXT NOT NULL)", [])?;
451 conn.execute(
452 "INSERT INTO message (data) VALUES (?1)",
453 [r#"{"role":"assistant","providerID":"opencode-go","cost":1.5,"time":{"completed":3600000}}"#],
454 )?;
455 conn.execute(
456 "INSERT INTO message (data) VALUES (?1)",
457 [r#"{"role":"assistant","providerID":"opencode","cost":9.0,"time":{"completed":3600000}}"#],
458 )?;
459 conn.execute(
460 "INSERT INTO message (data) VALUES (?1)",
461 [r#"{"role":"user","providerID":"opencode-go","cost":9.0,"time":{"completed":3600000}}"#],
462 )?;
463 drop(conn);
464
465 let snapshot = OpencodeGoUsageStore::fetch_usage_from_path_at(
466 tmp.path(),
467 Utc.timestamp_millis_opt(10 * 60 * 60 * 1000)
468 .single()
469 .unwrap(),
470 )?;
471
472 assert_eq!(snapshot.total_messages, 1);
473 let five_hour = snapshot
474 .windows
475 .iter()
476 .find(|window| window.entry_type == "five_hour_spend")
477 .ok_or("missing five_hour window")?;
478 assert!((five_hour.spent_usd - 0.0).abs() < LIMIT_EPSILON);
479 let weekly = snapshot
480 .windows
481 .iter()
482 .find(|window| window.entry_type == "weekly_spend")
483 .ok_or("missing weekly window")?;
484 assert!((weekly.spent_usd - 1.5).abs() < LIMIT_EPSILON);
485 Ok(())
486 }
487}