Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ OpenUsage lives in your menu bar and shows you how much of your AI coding subscr
- [**JetBrains AI Assistant**](docs/providers/jetbrains-ai-assistant.md) / quota, remaining
- [**Kimi Code**](docs/providers/kimi.md) / session, weekly
- [**MiniMax**](docs/providers/minimax.md) / coding plan session
- [**OpenCode Go**](docs/providers/opencode-go.md) / 5h, weekly, monthly spend limits
- [**Windsurf**](docs/providers/windsurf.md) / prompt credits, flex credits
- [**Z.ai**](docs/providers/zai.md) / session, weekly, web searches

Expand Down
62 changes: 62 additions & 0 deletions docs/providers/opencode-go.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# OpenCode Go

> Uses local OpenCode history from SQLite to track observed OpenCode Go spend on this machine.

## Overview

- **Source of truth:** `~/.local/share/opencode/opencode.db`
- **Auth discovery:** `~/.local/share/opencode/auth.json`
- **Provider ID:** `opencode-go`
- **Usage scope:** local observed assistant spend only

## Detection

The plugin enables when either condition is true:

- `~/.local/share/opencode/auth.json` contains an `opencode-go` entry with a non-empty `key`
- local OpenCode history already contains `opencode-go` assistant messages with numeric `cost`

If neither signal exists, the plugin stays hidden.

## Data Source

OpenUsage reads the local OpenCode SQLite database directly:

```sql
SELECT
CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs,
CAST(json_extract(data, '$.cost') AS REAL) AS cost
FROM message
WHERE json_valid(data)
AND json_extract(data, '$.providerID') = 'opencode-go'
AND json_extract(data, '$.role') = 'assistant'
AND json_type(data, '$.cost') IN ('integer', 'real')
```

Only assistant messages with numeric `cost` count. Missing remote or other-device usage is not estimated.

## Limits

OpenUsage uses the current published OpenCode Go plan limits from the official docs:

- `5h`: `$12`
- `Weekly`: `$30`
- `Monthly`: `$60`

Bars show observed local spend as a percentage of those fixed limits and clamp at `100%`.

## Window Rules

- `5h`: rolling last 5 hours from now
- `Weekly`: UTC Monday `00:00` through the next UTC Monday `00:00`
- `Monthly`: inferred subscription-style monthly window using the earliest local OpenCode Go usage timestamp as the anchor

Monthly usage is inferred from local history, not read from OpenCode’s account API. OpenUsage reuses the earliest observed local OpenCode Go usage timestamp as the monthly anchor. If no local history exists yet, it falls back to UTC calendar month boundaries until the first Go usage is recorded.

## Failure Behavior

If auth or prior history already indicates OpenCode Go is in use, but SQLite becomes unreadable or malformed, the provider stays visible and shows a grey `Status: No usage data` badge instead of failing hard.

## Future Compatibility

The public provider identity stays `opencode-go`. If OpenCode later exposes account-truth usage by API key, OpenUsage can swap the backend without changing the provider ID or UI contract.
1 change: 1 addition & 0 deletions plugins/opencode-go/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
287 changes: 287 additions & 0 deletions plugins/opencode-go/plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
(function () {
const PROVIDER_ID = "opencode-go";
const AUTH_PATH = "~/.local/share/opencode/auth.json";
const DB_PATH = "~/.local/share/opencode/opencode.db";
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
const LIMITS = {
session: 12,
weekly: 30,
monthly: 60,
};

const HISTORY_EXISTS_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 json_type(data, '$.cost') IN ('integer', 'real')
LIMIT 1
`;

const HISTORY_ROWS_SQL = `
SELECT
CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs,
CAST(json_extract(data, '$.cost') AS REAL) AS cost
FROM message
WHERE json_valid(data)
AND json_extract(data, '$.providerID') = 'opencode-go'
AND json_extract(data, '$.role') = 'assistant'
AND json_type(data, '$.cost') IN ('integer', 'real')
`;

function readNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}

function readNowMs() {
return Date.now();
}

function clampPercent(used, limit) {
if (!Number.isFinite(used) || !Number.isFinite(limit) || limit <= 0)
return 0;
const percent = (used / limit) * 100;
if (!Number.isFinite(percent)) return 0;
return Math.round(Math.max(0, Math.min(100, percent)) * 10) / 10;
}

function toIso(ms) {
if (!Number.isFinite(ms)) return null;
return new Date(ms).toISOString();
}

function startOfUtcWeek(nowMs) {
const date = new Date(nowMs);
const offset = (date.getUTCDay() + 6) % 7;
date.setUTCDate(date.getUTCDate() - offset);
date.setUTCHours(0, 0, 0, 0);
return date.getTime();
}

function startOfUtcMonth(nowMs) {
const date = new Date(nowMs);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0);
}

function startOfNextUtcMonth(nowMs) {
const date = new Date(nowMs);
return Date.UTC(
date.getUTCFullYear(),
date.getUTCMonth() + 1,
1,
0,
0,
0,
0,
);
}

function shiftMonth(year, month, delta) {
const total = year * 12 + month + delta;
return [Math.floor(total / 12), ((total % 12) + 12) % 12];
}

function anchorMonth(year, month, anchorDate) {
const maxDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
return Date.UTC(
year,
month,
Math.min(anchorDate.getUTCDate(), maxDay),
anchorDate.getUTCHours(),
anchorDate.getUTCMinutes(),
anchorDate.getUTCSeconds(),
anchorDate.getUTCMilliseconds(),
);
}

function anchoredMonthBounds(nowMs, anchorMs) {
if (!Number.isFinite(anchorMs)) {
const startMs = startOfUtcMonth(nowMs);
return { startMs, endMs: startOfNextUtcMonth(nowMs) };
}

const nowDate = new Date(nowMs);
const anchorDate = new Date(anchorMs);
let year = nowDate.getUTCFullYear();
let month = nowDate.getUTCMonth();
let startMs = anchorMonth(year, month, anchorDate);

if (startMs > nowMs) {
const previous = shiftMonth(year, month, -1);
year = previous[0];
month = previous[1];
startMs = anchorMonth(year, month, anchorDate);
}

const next = shiftMonth(year, month, 1);
return {
startMs,
endMs: anchorMonth(next[0], next[1], anchorDate),
};
}

function sumRange(rows, startMs, endMs) {
let total = 0;
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
if (row.createdMs < startMs || row.createdMs >= endMs) continue;
total += row.cost;
}
return Math.round(total * 10000) / 10000;
}

function nextRollingReset(rows, nowMs) {
const startMs = nowMs - FIVE_HOURS_MS;
let oldest = null;
for (let i = 0; i < rows.length; i += 1) {
const row = rows[i];
if (row.createdMs < startMs || row.createdMs >= nowMs) continue;
if (oldest === null || row.createdMs < oldest) oldest = row.createdMs;
}
return toIso((oldest === null ? nowMs : oldest) + FIVE_HOURS_MS);
}

function queryRows(ctx, sql) {
try {
const raw = ctx.host.sqlite.query(DB_PATH, sql);
const rows = Array.isArray(raw) ? raw : ctx.util.tryParseJson(raw);
if (!Array.isArray(rows)) {
ctx.host.log.warn("sqlite query returned non-array result");
return { ok: false, rows: [] };
}
return { ok: true, rows };
} catch (e) {
ctx.host.log.warn("sqlite query failed: " + String(e));
return { ok: false, rows: [] };
}
}

function loadAuthKey(ctx) {
if (!ctx.host.fs.exists(AUTH_PATH)) return null;

try {
const text = ctx.host.fs.readText(AUTH_PATH);
const parsed = ctx.util.tryParseJson(text);
if (!parsed || typeof parsed !== "object") {
ctx.host.log.warn("opencode auth file is not valid json");
return null;
}
const entry = parsed[PROVIDER_ID];
if (!entry || typeof entry !== "object") return null;
const key = typeof entry.key === "string" ? entry.key.trim() : "";
return key || null;
} catch (e) {
ctx.host.log.warn("opencode auth read failed: " + String(e));
return null;
}
}

function hasHistory(ctx) {
const result = queryRows(ctx, HISTORY_EXISTS_SQL);
if (!result.ok) return { ok: false, present: false };
return { ok: true, present: result.rows.length > 0 };
}

function loadHistory(ctx) {
const result = queryRows(ctx, HISTORY_ROWS_SQL);
if (!result.ok) return result;

const rows = [];
for (let i = 0; i < result.rows.length; i += 1) {
const row = result.rows[i];
if (!row || typeof row !== "object") continue;
const createdMs = readNumber(row.createdMs);
const cost = readNumber(row.cost);
if (createdMs === null || createdMs <= 0) continue;
if (cost === null || cost < 0) continue;
rows.push({ createdMs, cost });
}

return { ok: true, rows };
}

function buildProgressLines(ctx, rows, nowMs) {
const sessionStartMs = nowMs - FIVE_HOURS_MS;
const weeklyStartMs = startOfUtcWeek(nowMs);
const weeklyEndMs = weeklyStartMs + WEEK_MS;
let earliestMs = null;
for (let i = 0; i < rows.length; i += 1) {
const createdMs = rows[i].createdMs;
if (!Number.isFinite(createdMs)) continue;
if (earliestMs === null || createdMs < earliestMs) earliestMs = createdMs;
}
const monthBounds = anchoredMonthBounds(nowMs, earliestMs);
const monthlyStartMs = monthBounds.startMs;
const monthlyEndMs = monthBounds.endMs;

const sessionCost = sumRange(rows, sessionStartMs, nowMs);
const weeklyCost = sumRange(rows, weeklyStartMs, weeklyEndMs);
const monthlyCost = sumRange(rows, monthlyStartMs, monthlyEndMs);

return [
ctx.line.progress({
label: "5h",
used: clampPercent(sessionCost, LIMITS.session),
limit: 100,
format: { kind: "percent" },
resetsAt: nextRollingReset(rows, nowMs),
periodDurationMs: FIVE_HOURS_MS,
}),
ctx.line.progress({
label: "Weekly",
used: clampPercent(weeklyCost, LIMITS.weekly),
limit: 100,
format: { kind: "percent" },
resetsAt: toIso(weeklyEndMs),
periodDurationMs: WEEK_MS,
}),
ctx.line.progress({
label: "Monthly",
used: clampPercent(monthlyCost, LIMITS.monthly),
limit: 100,
format: { kind: "percent" },
resetsAt: toIso(monthlyEndMs),
periodDurationMs: monthlyEndMs - monthlyStartMs,
}),
];
}

function buildSoftEmptyLines(ctx) {
return [
ctx.line.badge({
label: "Status",
text: "No usage data",
color: "#a3a3a3",
}),
];
}

function probe(ctx) {
const authKey = loadAuthKey(ctx);
const history = hasHistory(ctx);
const detected = !!authKey || (history.ok && history.present);

if (!detected) {
throw "OpenCode Go not detected. Log in with OpenCode Go or use it locally first.";
}

if (!history.ok) {
return { plan: "Go", lines: buildSoftEmptyLines(ctx) };
}

const rowsResult = loadHistory(ctx);
if (!rowsResult.ok) {
return { plan: "Go", lines: buildSoftEmptyLines(ctx) };
}

return {
plan: "Go",
lines: buildProgressLines(ctx, rowsResult.rows, readNowMs()),
};
}

globalThis.__openusage_plugin = { id: PROVIDER_ID, probe };
})();
Loading