Skip to main content

cmd_usage/ocuse/
db.rs

1// opencode's own message store, read-only.
2//
3// Every assistant row opencode writes carries `providerID`, `modelID`, `cost`
4// and `tokens`, plus a timestamp — a complete, backfilled per-request record for
5// everything the agent ran on this machine. Unlike CommandCode (subscription,
6// cost 0), opencode-go rows carry real cost, so this is also the spend figure.
7//
8// Read-only by construction: `OpenFlags::SQLITE_OPEN_READ_ONLY`, so a running
9// opencode is unaffected.
10use 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
50/// Providers that belong to this tool. `opencode-go` is the Go subscription,
51/// `opencode` the Zen pay-as-you-go gateway.
52pub const PROVIDERS: [&str; 2] = ["opencode-go", "opencode"];
53
54/// Default store location: `$OPENCODE_DB`, else the XDG data dir.
55pub 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
66/// Every OpenCode-owned assistant row at or after `since_ms`, oldest first.
67///
68/// `time_created >= ?` keeps the scan bounded; rows are filtered by provider in
69/// Rust because the JSON payload is not indexed.
70pub 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    /// A miniature store, shaped like the real one.
123    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}