diff --git a/docs/providers/opencode-go.md b/docs/providers/opencode-go.md index cc8be20d2..bc494fc58 100644 --- a/docs/providers/opencode-go.md +++ b/docs/providers/opencode-go.md @@ -20,7 +20,9 @@ If neither signal exists, the plugin stays hidden. ## Data Source -OpenUsage reads the local OpenCode SQLite database directly: +OpenUsage reads the local OpenCode SQLite database directly. + +For current OpenCode databases it queries the `session` table: ```sql SELECT @@ -32,7 +34,20 @@ WHERE json_valid(model) AND cost > 0 ``` -Only sessions with a positive cost count. `time_updated` is stored in milliseconds (same unit as `Date.now()`), confirmed from OpenCode's [`packages/core/src/session/sql.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/sql.ts) where `time_created` and `time_updated` use `Date.now()` as their default value. Missing remote or other-device usage is not estimated. +If the `session` table does not yet have the `cost` and `model` columns (older local databases), it falls back to summing assistant messages from the `message` table: + +```sql +SELECT + time_created AS createdMs, + CAST(coalesce(json_extract(data, '$.cost'), 0) AS TEXT) AS cost +FROM message +WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND coalesce(json_extract(data, '$.cost'), 0) > 0 +``` + +Only sessions/messages with a positive cost count. `time_updated`/`time_created` are stored in milliseconds (same unit as `Date.now()`), confirmed from OpenCode's [`packages/core/src/session/sql.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/session/sql.ts) where `time_created` and `time_updated` use `Date.now()` as their default value. Missing remote or other-device usage is not estimated. ## Limits diff --git a/plugins/opencode-go/plugin.js b/plugins/opencode-go/plugin.js index 99bec4931..0c945cf81 100644 --- a/plugins/opencode-go/plugin.js +++ b/plugins/opencode-go/plugin.js @@ -29,6 +29,27 @@ AND cost > 0 `; + const HISTORY_EXISTS_MESSAGE_SQL = ` + SELECT 1 AS present + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND coalesce(json_extract(data, '$.cost'), 0) > 0 + LIMIT 1 + `; + + const HISTORY_ROWS_MESSAGE_SQL = ` + SELECT + time_created AS createdMs, + CAST(coalesce(json_extract(data, '$.cost'), 0) AS TEXT) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND coalesce(json_extract(data, '$.cost'), 0) > 0 + `; + function readNumber(value) { const n = Number(value); return Number.isFinite(n) ? n : null; @@ -177,15 +198,41 @@ } } - function hasHistory(ctx) { - const result = queryRows(ctx, HISTORY_EXISTS_SQL); + function detectSchema(ctx) { + const result = queryRows( + ctx, + `PRAGMA table_info(session)` + ); + if (!result.ok) return { ok: false, hasSessionCostModel: false }; + const rows = Array.isArray(result.rows) ? result.rows : []; + const names = new Set(); + for (let i = 0; i < rows.length; i += 1) { + const row = rows[i]; + if (row && typeof row.name === "string") names.add(row.name); + } + return { + ok: true, + hasSessionCostModel: names.has("cost") && names.has("model"), + }; + } + + function historyExistsSql(hasSessionCostModel) { + return hasSessionCostModel ? HISTORY_EXISTS_SQL : HISTORY_EXISTS_MESSAGE_SQL; + } + + function historyRowsSql(hasSessionCostModel) { + return hasSessionCostModel ? HISTORY_ROWS_SQL : HISTORY_ROWS_MESSAGE_SQL; + } + + function hasHistory(ctx, schema) { + const result = queryRows(ctx, historyExistsSql(schema.hasSessionCostModel)); if (!result.ok) return { ok: false, present: false }; return { ok: true, present: result.rows.length > 0 }; } - function loadHistory(ctx) { + function loadHistory(ctx, schema) { // time_updated is in milliseconds (same unit as Date.now()), confirmed from OpenCode source - const result = queryRows(ctx, HISTORY_ROWS_SQL); + const result = queryRows(ctx, historyRowsSql(schema.hasSessionCostModel)); if (!result.ok) return result; const rows = []; @@ -260,7 +307,8 @@ function probe(ctx) { const authKey = loadAuthKey(ctx); - const history = hasHistory(ctx); + const schema = detectSchema(ctx); + const history = hasHistory(ctx, schema); const detected = !!authKey || (history.ok && history.present); if (!detected) { @@ -271,7 +319,7 @@ return { plan: "Go", lines: buildSoftEmptyLines(ctx) }; } - const rowsResult = loadHistory(ctx); + const rowsResult = loadHistory(ctx, schema); if (!rowsResult.ok) { return { plan: "Go", lines: buildSoftEmptyLines(ctx) }; } diff --git a/plugins/opencode-go/plugin.test.js b/plugins/opencode-go/plugin.test.js index d601becc0..ce1fc97bd 100644 --- a/plugins/opencode-go/plugin.test.js +++ b/plugins/opencode-go/plugin.test.js @@ -20,25 +20,54 @@ function setAuth(ctx, value = "go-key") { function setHistoryQuery(ctx, rows, options = {}) { const list = Array.isArray(rows) ? rows : []; + const hasSessionCostModel = options.hasSessionCostModel !== false; ctx.host.sqlite.query.mockImplementation((dbPath, sql) => { expect(dbPath).toBe("~/.local/share/opencode/opencode.db"); + if (String(sql).includes("PRAGMA table_info")) { + const columns = [ + { name: "id" }, + { name: "project_id" }, + { name: "time_created" }, + { name: "time_updated" }, + ]; + if (hasSessionCostModel) { + columns.push({ name: "cost" }, { name: "model" }); + } + return JSON.stringify(columns); + } + if (String(sql).includes("SELECT 1 AS present")) { if (options.assertFilters !== false) { - expect(String(sql)).toContain( - "json_extract(model, '$.providerID') = 'opencode-go'", - ); - expect(String(sql)).toContain("cost > 0"); + if (hasSessionCostModel) { + expect(String(sql)).toContain( + "json_extract(model, '$.providerID') = 'opencode-go'", + ); + expect(String(sql)).toContain("cost > 0"); + } else { + expect(String(sql)).toContain( + "json_extract(data, '$.providerID') = 'opencode-go'", + ); + expect(String(sql)).toContain("json_extract(data, '$.role') = 'assistant'"); + } } return JSON.stringify(list.length > 0 ? [{ present: 1 }] : []); } if (options.assertFilters !== false) { - expect(String(sql)).toContain( - "json_extract(model, '$.providerID') = 'opencode-go'", - ); - expect(String(sql)).toContain("time_updated"); - expect(String(sql)).toContain("cost > 0"); + if (hasSessionCostModel) { + expect(String(sql)).toContain( + "json_extract(model, '$.providerID') = 'opencode-go'", + ); + expect(String(sql)).toContain("time_updated"); + expect(String(sql)).toContain("cost > 0"); + } else { + expect(String(sql)).toContain( + "json_extract(data, '$.providerID') = 'opencode-go'", + ); + expect(String(sql)).toContain("json_extract(data, '$.role') = 'assistant'"); + expect(String(sql)).toContain("time_created"); + } } return JSON.stringify(list); @@ -251,6 +280,24 @@ describe("opencode-go plugin", () => { expect(result.lines[0].used).toBe(5); }); + it("falls back to message table when session cost/model columns are missing", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-06T12:00:00.000Z")); + + const ctx = makeCtx(); + setHistoryQuery( + ctx, + [{ createdMs: Date.parse("2026-03-06T11:00:00.000Z"), cost: 3 }], + { hasSessionCostModel: false }, + ); + + const plugin = await loadPlugin(); + const result = plugin.probe(ctx); + + expect(result.plan).toBe("Go"); + expect(result.lines[0].used).toBe(25); + }); + it("returns a soft empty state when sqlite returns malformed JSON and auth exists", async () => { const ctx = makeCtx(); setAuth(ctx);