Skip to main content

seher/opencode_go/
local.rs

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
10// Documented OpenCode Go plan caps: $12 / 5h, $30 / 7d, $60 / 30d (rolling).
11const FIVE_HOUR_LIMIT_USD: f64 = 12.0;
12const WEEKLY_LIMIT_USD: f64 = 30.0;
13const MONTHLY_LIMIT_USD: f64 = 60.0;
14
15// Env overrides for the per-window USD caps. OpenCode Go is tracked by summing
16// the per-message `cost` recorded in the local OpenCode DB over rolling windows
17// and comparing against the plan caps above — local-device tracking, not the
18// hosted console. These overrides let the user adjust the caps if the plan
19// changes, or set a window to `0` to disable it entirely.
20const 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
52/// Resolve the effective USD limit for a window: the value of `env_var` if it
53/// parses as a finite `f64`, otherwise `default`. A parsed value of `0` (or
54/// negative) disables the window (never limited).
55fn 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    /// # Errors
122    ///
123    /// Returns an error when the local `SQLite` history cannot be copied, read,
124    /// or parsed.
125    pub fn fetch_usage() -> Result<OpencodeGoUsageSnapshot, OpencodeGoUsageError> {
126        Self::fetch_usage_with_paths_at(None, None, Utc::now())
127    }
128
129    /// # Errors
130    ///
131    /// Returns an error when the local `SQLite` history cannot be copied, read,
132    /// or parsed.
133    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    /// # Errors
141    ///
142    /// Returns an error when the local `SQLite` history cannot be copied, read,
143    /// or parsed.
144    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        // A non-positive limit means the window is disabled (never limited).
282        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        // limit_usd <= 0 means the window is disabled, even if spend is huge.
404        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        // Use a unique var name unlikely to be set in the environment.
423        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}