Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions docs/providers/opencode-go.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
60 changes: 54 additions & 6 deletions plugins/opencode-go/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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) {
Expand All @@ -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) };
}
Expand Down
65 changes: 56 additions & 9 deletions plugins/opencode-go/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading