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 a provider, model, `cost` and
4// `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// Two layouts: v1 `message` keeps them flat (`role` / `modelID` / `providerID`),
9// v2 `session_message` puts the role in the row's `type` and nests the model
10// (`model.id` / `model.providerID`). v2 stopped appending to `message` at the
11// migration, so reading only the legacy table reports zero for every recent
12// session; `read_rows` picks by table and keeps `message` as the fallback.
13//
14// Read-only by construction: `OpenFlags::SQLITE_OPEN_READ_ONLY`, so a running
15// opencode is unaffected.
16use rusqlite::{Connection, OpenFlags};
17use serde::Deserialize;
18
19#[derive(Debug, Clone, Default)]
20pub struct Row {
21    /// Which local client ran the call — `opencode`, `dsh` or `pi`. The report
22    /// names every client it counted, since only opencode's own store was visible
23    /// before and a total that silently grew would look like a drift.
24    pub client: &'static str,
25    pub at_ms: i64,
26    pub session: String,
27    pub provider: String,
28    pub model: String,
29    pub cost_usd: f64,
30    pub input: u64,
31    pub output: u64,
32    pub reasoning: u64,
33    pub cache_read: u64,
34    pub cache_write: u64,
35}
36
37#[derive(Deserialize)]
38struct AssistantData {
39    role: Option<String>,
40    #[serde(rename = "providerID")]
41    provider: Option<String>,
42    #[serde(rename = "modelID")]
43    model: Option<String>,
44    cost: Option<f64>,
45    tokens: Option<Tokens>,
46}
47
48/// v2 keeps the role in `session_message.type` (the row selector, so it is not
49/// in the payload) and nests the model ref: `model.id` / `model.providerID`.
50#[derive(Deserialize, Default)]
51struct V2AssistantData {
52    model: Option<V2Model>,
53    cost: Option<f64>,
54    tokens: Option<Tokens>,
55}
56
57#[derive(Deserialize, Default)]
58struct V2Model {
59    id: Option<String>,
60    #[serde(rename = "providerID")]
61    provider: Option<String>,
62}
63
64#[derive(Deserialize)]
65struct Tokens {
66    input: Option<u64>,
67    output: Option<u64>,
68    /// Billed at the output rate by opencode itself, so pricing must count it.
69    reasoning: Option<u64>,
70    cache: Option<Cache>,
71}
72
73#[derive(Deserialize)]
74struct Cache {
75    read: Option<u64>,
76    write: Option<u64>,
77}
78
79/// Providers that belong to this tool. `opencode-go` is the Go subscription,
80/// `opencode` the Zen pay-as-you-go gateway.
81pub const PROVIDERS: [&str; 2] = ["opencode-go", "opencode"];
82
83/// opencode's own directory: `$XDG_DATA_HOME/opencode`, else
84/// `~/.local/share/opencode`. Holds the store — which carries the Go credential
85/// in v2's `credential` table — and `auth.json`, the legacy v1 file.
86pub fn opencode_home() -> String {
87    let base = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
88        let home = std::env::var("HOME").unwrap_or_default();
89        format!("{home}/.local/share")
90    });
91    format!("{base}/opencode")
92}
93
94/// Default store location: `$OPENCODE_DB`, else the XDG data dir.
95pub fn default_path() -> String {
96    if let Ok(path) = std::env::var("OPENCODE_DB") {
97        return path;
98    }
99    format!("{}/opencode.db", opencode_home())
100}
101
102/// The Go credential opencode itself is using.
103///
104/// v2 keeps provider credentials in the store's `credential` table; `auth.json`
105/// is the legacy v1 file. Reading the file alone produced a dashboard that said
106/// `api_key_expired` while Go inference worked: `/connect` had written the
107/// replacement key to the store, and the file still held the superseded one.
108/// `opencode-go` is the Go subscription and `opencode` is Zen — the same
109/// account, and the endpoint answers an entitlement refusal for a Zen key
110/// rather than silence, which is more useful than no key at all.
111pub fn credential_key() -> Option<String> {
112    credential_key_at(&default_path())
113}
114
115fn credential_key_at(path: &str) -> Option<String> {
116    let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
117    let mut statement = connection
118        .prepare(
119            // The boolean ordering puts `opencode-go` ahead of `opencode`, then
120            // newest first: a re-connected key supersedes the row it replaced.
121            "SELECT value FROM credential WHERE integration_id IN ('opencode-go', 'opencode') \
122             ORDER BY (integration_id = 'opencode-go') DESC, time_updated DESC",
123        )
124        .ok()?;
125    let values: Vec<String> = statement
126        .query_map([], |row| row.get::<_, String>(0))
127        .ok()?
128        .flatten()
129        .collect();
130    drop(statement);
131    values
132        .iter()
133        .filter_map(|value| serde_json::from_str::<Credential>(value).ok())
134        .filter_map(|credential| credential.key)
135        .find(|key| !key.is_empty())
136}
137
138/// `credential.value` is the connector's own JSON — `{"type":"key","key":"…"}`
139/// for a pasted API key. Only the key is read; the rest is its business.
140#[derive(Deserialize)]
141struct Credential {
142    key: Option<String>,
143}
144
145/// Every OpenCode-owned assistant row at or after `since_ms`, oldest first.
146///
147/// Reads v2's `session_message` when the store has it, and the legacy `message`
148/// table otherwise, so an upgraded opencode does not read as "no usage" — v2
149/// stopped appending to `message` at the migration, and every report was zero
150/// until this branch existed. `time_created >= ?` keeps either scan bounded;
151/// rows are filtered by provider in Rust because the JSON payload is not
152/// indexed.
153pub fn read_rows(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
154    let connection = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
155        .map_err(|e| format!("open {path}: {e}"))?;
156    if has_v2(&connection) {
157        read_v2(&connection, since_ms)
158    } else {
159        read_legacy(&connection, since_ms)
160    }
161}
162
163/// opencode's store plus the other local clients spending the same allowance.
164///
165/// `pi` and `dsh` keep their own session logs (see [`crate::stores`]), and both
166/// run on `opencode-go`, so their calls are the same plan's usage. Only rows on
167/// [`PROVIDERS`] are added: a client pointed at some other gateway is not this
168/// dashboard's traffic.
169pub fn read_all(path: &str, since_ms: i64) -> Result<Vec<Row>, String> {
170    let mut rows = read_rows(path, since_ms)?;
171    rows.extend(external(&crate::stores::Dirs::from_env(), since_ms));
172    Ok(rows)
173}
174
175/// Map the extra clients' turns onto this module's rows, dropping any model call
176/// that did not run on a Go/Zen provider.
177fn external(dirs: &crate::stores::Dirs, since_ms: i64) -> Vec<Row> {
178    let catalog = crate::ocuse::zen::catalog();
179    crate::stores::read(dirs, since_ms)
180        .into_iter()
181        .filter(|turn| PROVIDERS.contains(&turn.provider.as_str()))
182        .map(|turn| {
183            // dsh records no cost of its own, and the daily/hourly/session/model
184            // reports sum `cost_usd` rather than pricing — so price it here on the
185            // documented schedule, the same fallback `window::build` applies.
186            let priced = catalog
187                .price(
188                    &turn.model,
189                    turn.at_ms,
190                    turn.input,
191                    turn.output,
192                    turn.reasoning,
193                    turn.cache_read,
194                    turn.cache_write,
195                )
196                .unwrap_or(turn.cost_usd);
197            Row {
198                client: turn.client,
199                at_ms: turn.at_ms,
200                session: turn.session,
201                provider: turn.provider,
202                model: turn.model,
203                cost_usd: priced,
204                input: turn.input,
205                output: turn.output,
206                reasoning: turn.reasoning,
207                cache_read: turn.cache_read,
208                cache_write: turn.cache_write,
209            }
210        })
211        .collect()
212}
213
214/// Does this store use opencode v2's `session_message` table?
215fn has_v2(connection: &Connection) -> bool {
216    connection
217        .query_row(
218            "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'session_message'",
219            [],
220            |_| Ok(()),
221        )
222        .is_ok()
223}
224
225/// v2 rows: the row's `type` names it (no `$.role`), the model ref is nested,
226/// and only a completed turn carries `tokens` — an in-flight one is skipped
227/// rather than counted as a zero-token request.
228fn read_v2(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
229    let mut statement = connection
230        .prepare(
231            "SELECT data, time_created, session_id FROM session_message
232             WHERE type = 'assistant' AND time_created >= ?",
233        )
234        .map_err(|e| format!("prepare: {e}"))?;
235    let mut rows = statement
236        .query([since_ms])
237        .map_err(|e| format!("query: {e}"))?;
238
239    let mut out = Vec::new();
240    while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
241        let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
242        let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
243        let session: String = row.get(2).unwrap_or_default();
244        let Ok(parsed) = serde_json::from_str::<V2AssistantData>(&data) else {
245            continue;
246        };
247        let Some(tokens) = parsed.tokens else {
248            continue; // still streaming: not billed yet
249        };
250        let model = parsed.model.unwrap_or_default();
251        let provider = model.provider.unwrap_or_default();
252        if !PROVIDERS.contains(&provider.as_str()) {
253            continue;
254        }
255        out.push(Row {
256            client: crate::stores::OPENCODE,
257            at_ms,
258            session,
259            provider,
260            model: model.id.unwrap_or_default(),
261            cost_usd: parsed.cost.unwrap_or(0.0),
262            input: tokens.input.unwrap_or(0),
263            output: tokens.output.unwrap_or(0),
264            reasoning: tokens.reasoning.unwrap_or(0),
265            cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
266            cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
267        });
268    }
269    Ok(out)
270}
271
272/// Pre-v2 rows: `role` in the payload, flat `modelID` / `providerID`.
273fn read_legacy(connection: &Connection, since_ms: i64) -> Result<Vec<Row>, String> {
274    let mut statement = connection
275        .prepare(
276            "SELECT data, time_created, session_id FROM message
277             WHERE data LIKE '%\"tokens\"%' AND time_created >= ?",
278        )
279        .map_err(|e| format!("prepare: {e}"))?;
280    let mut rows = statement
281        .query([since_ms])
282        .map_err(|e| format!("query: {e}"))?;
283
284    let mut out = Vec::new();
285    while let Some(row) = rows.next().map_err(|e| format!("row: {e}"))? {
286        let data: String = row.get(0).map_err(|e| format!("data: {e}"))?;
287        let at_ms: i64 = row.get(1).map_err(|e| format!("time: {e}"))?;
288        let session: String = row.get(2).unwrap_or_default();
289        let Ok(parsed) = serde_json::from_str::<AssistantData>(&data) else {
290            continue;
291        };
292        if parsed.role.as_deref() != Some("assistant") {
293            continue;
294        }
295        let provider = parsed.provider.unwrap_or_default();
296        if !PROVIDERS.contains(&provider.as_str()) {
297            continue;
298        }
299        let tokens = parsed.tokens.unwrap_or(Tokens {
300            input: None,
301            output: None,
302            reasoning: None,
303            cache: None,
304        });
305        out.push(Row {
306            client: crate::stores::OPENCODE,
307            at_ms,
308            session,
309            provider,
310            model: parsed.model.unwrap_or_default(),
311            cost_usd: parsed.cost.unwrap_or(0.0),
312            input: tokens.input.unwrap_or(0),
313            output: tokens.output.unwrap_or(0),
314            reasoning: tokens.reasoning.unwrap_or(0),
315            cache_read: tokens.cache.as_ref().and_then(|c| c.read).unwrap_or(0),
316            cache_write: tokens.cache.and_then(|c| c.write).unwrap_or(0),
317        });
318    }
319    Ok(out)
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    /// A miniature store, shaped like the real one.
327    fn fixture() -> (tempfile::TempDir, String) {
328        let dir = tempfile::tempdir().unwrap();
329        let path = dir.path().join("opencode.db");
330        let connection = Connection::open(&path).unwrap();
331        connection
332            .execute_batch(
333                "CREATE TABLE message (session_id TEXT, data TEXT, time_created INTEGER);",
334            )
335            .unwrap();
336        let insert = |data: &str, at: i64| {
337            connection
338                .execute(
339                    "INSERT INTO message VALUES ('ses_fixture', ?, ?)",
340                    (data, at),
341                )
342                .unwrap();
343        };
344        insert(
345            r#"{"role":"assistant","providerID":"opencode-go","modelID":"glm-5.3-flash","cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
346            1_000,
347        );
348        insert(
349            r#"{"role":"assistant","providerID":"opencode","modelID":"gpt-5.5","cost":1.25,"tokens":{"input":1,"output":1}}"#,
350            2_000,
351        );
352        insert(
353            r#"{"role":"user","providerID":"opencode-go","tokens":{"input":1}}"#,
354            3_000,
355        );
356        insert(
357            r#"{"role":"assistant","providerID":"command-code-openai","modelID":"deepseek-v4.1-flash","cost":9.0,"tokens":{"input":1}}"#,
358            4_000,
359        );
360        (dir, path.to_string_lossy().into_owned())
361    }
362
363    #[test]
364    fn reads_only_opencode_assistant_rows_in_window() {
365        let (_dir, path) = fixture();
366        let rows = read_rows(&path, 0).unwrap();
367        assert_eq!(rows.len(), 2, "{rows:#?}");
368        assert_eq!(rows[0].provider, "opencode-go");
369        assert_eq!(rows[0].cache_read, 7);
370        assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
371        assert_eq!(rows[1].model, "gpt-5.5");
372        assert_eq!(rows[0].client, crate::stores::OPENCODE);
373
374        let later = read_rows(&path, 1_500).unwrap();
375        assert_eq!(later.len(), 1);
376        assert_eq!(later[0].provider, "opencode");
377    }
378
379    /// The extra clients are the same allowance only where the provider says so:
380    /// a `pi` session pointed at another gateway is not Go usage.
381    #[test]
382    fn other_clients_keep_only_go_and_zen_rows() {
383        let dir = tempfile::tempdir().unwrap();
384        let pi = dir.path().join("pi/--p--");
385        std::fs::create_dir_all(&pi).unwrap();
386        std::fs::write(
387            pi.join("s.jsonl"),
388            concat!(
389                r#"{"type":"message","message":{"role":"assistant","provider":"opencode-go","model":"glm-5.2","timestamp":1000,"usage":{"input":10,"output":1}}}"#,
390                "\n",
391                r#"{"type":"message","message":{"role":"assistant","provider":"openrouter","model":"glm-5.2","timestamp":1000,"usage":{"input":10,"output":1}}}"#,
392                "\n",
393            ),
394        )
395        .unwrap();
396        let dirs = crate::stores::Dirs {
397            dsh: dir.path().join("dsh"),
398            pi: dir.path().join("pi"),
399        };
400        let rows = external(&dirs, 0);
401        assert_eq!(rows.len(), 1, "{rows:#?}");
402        assert_eq!(rows[0].client, crate::stores::PI);
403        assert_eq!(rows[0].provider, "opencode-go");
404        assert_eq!(rows[0].input, 10);
405    }
406
407    /// v2 holds the live credential; the legacy file is only where a v1 install
408    /// kept one — and reading that alone is what reported `api_key_expired` while
409    /// Go inference worked.
410    #[test]
411    fn the_live_credential_comes_from_the_store() {
412        let dir = tempfile::tempdir().unwrap();
413        let path = dir.path().join("opencode.db");
414        let connection = Connection::open(&path).unwrap();
415        connection
416            .execute_batch(
417                "CREATE TABLE credential (\
418                 id TEXT, integration_id TEXT, value TEXT, time_updated INTEGER);",
419            )
420            .unwrap();
421        let insert = |integration: &str, key: &str, at: i64| {
422            connection
423                .execute(
424                    "INSERT INTO credential VALUES (?, ?, ?, ?)",
425                    (
426                        format!("cred_{at}"),
427                        integration,
428                        format!(r#"{{"type":"key","key":"{key}"}}"#),
429                        at,
430                    ),
431                )
432                .unwrap();
433        };
434        insert("opencode", "zen", 9_000);
435        insert("opencode-go", "go-old", 1_000);
436        insert("opencode-go", "go-new", 2_000);
437        let path = path.to_string_lossy().into_owned();
438        // Go beats Zen and the newest Go row beats the one it replaced, whatever
439        // the timestamps say (Zen's is the newest here).
440        assert_eq!(credential_key_at(&path).as_deref(), Some("go-new"));
441
442        // An empty key is not a key: nothing to send.
443        connection
444            .execute(
445                "INSERT INTO credential VALUES ('cred_empty', 'opencode-go', '{\"type\":\"key\",\"key\":\"\"}', 3_000)",
446                [],
447            )
448            .unwrap();
449        assert_eq!(credential_key_at(&path).as_deref(), Some("go-new"));
450
451        // A store with no such table (v1) reads as "ask the legacy file".
452        let legacy = dir.path().join("legacy.db");
453        Connection::open(&legacy).unwrap();
454        assert!(credential_key_at(&legacy.to_string_lossy()).is_none());
455    }
456
457    /// A v2 store: `session_message`, role in `type`, model ref nested.
458    fn v2_fixture() -> (tempfile::TempDir, String) {
459        let dir = tempfile::tempdir().unwrap();
460        let path = dir.path().join("opencode.db");
461        let connection = Connection::open(&path).unwrap();
462        connection
463            .execute_batch(
464                "CREATE TABLE session_message (\
465                 id TEXT, session_id TEXT, type TEXT, seq INTEGER, \
466                 time_created INTEGER, time_updated INTEGER, data TEXT);",
467            )
468            .unwrap();
469        let insert = |kind: &str, data: &str, at: i64| {
470            connection
471                .execute(
472                    "INSERT INTO session_message \
473                     (id, session_id, type, seq, time_created, time_updated, data) \
474                     VALUES (?, 'ses_v2', ?, 0, ?, ?, ?)",
475                    (format!("msg_{at}"), kind, at, at, data),
476                )
477                .unwrap();
478        };
479        insert(
480            "assistant",
481            r#"{"model":{"id":"glm-5.3-flash","providerID":"opencode-go"},"cost":0.5,"tokens":{"input":10,"output":2,"cache":{"read":7,"write":0}}}"#,
482            1_000,
483        );
484        insert(
485            "assistant",
486            r#"{"model":{"id":"gpt-5.5","providerID":"opencode"},"cost":1.25,"tokens":{"input":1,"output":1}}"#,
487            2_000,
488        );
489        // In-flight: a model ref but no tokens yet.
490        insert(
491            "assistant",
492            r#"{"model":{"id":"gpt-5.5","providerID":"opencode"}}"#,
493            3_000,
494        );
495        // The role lives in `type`, so this user row must not count.
496        insert("user", r#"{"text":"hi"}"#, 4_000);
497        insert(
498            "assistant",
499            r#"{"model":{"id":"deepseek","providerID":"command-code-openai"},"cost":9.0,"tokens":{"input":1}}"#,
500            5_000,
501        );
502        (dir, path.to_string_lossy().into_owned())
503    }
504
505    #[test]
506    fn reads_v2_rows_skipping_inflight_and_foreign_providers() {
507        let (_dir, path) = v2_fixture();
508        let rows = read_rows(&path, 0).unwrap();
509        assert_eq!(rows.len(), 2, "{rows:#?}");
510        assert_eq!(rows[0].provider, "opencode-go");
511        assert_eq!(rows[0].model, "glm-5.3-flash");
512        assert_eq!(rows[0].cache_read, 7);
513        assert!((rows[0].cost_usd - 0.5).abs() < f64::EPSILON);
514        assert_eq!(rows[1].model, "gpt-5.5");
515
516        let later = read_rows(&path, 1_500).unwrap();
517        assert_eq!(later.len(), 1);
518        assert_eq!(later[0].provider, "opencode");
519    }
520
521    #[test]
522    fn missing_database_is_an_error_not_a_panic() {
523        let error = read_rows("/nonexistent/opencode.db", 0).unwrap_err();
524        assert!(error.contains("open"), "{error}");
525    }
526}