diff --git a/.husky/pre-commit b/.husky/pre-commit index 1a4e69c..e35db6b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -15,10 +15,9 @@ if ! command -v npx >/dev/null 2>&1; then fi # Zero-tolerance gate on exactly what this change can affect: the staged -# files plus the files that import them (see scripts/staged-lint.ts). Runs -# BEFORE formatting so the linter sees the file as written. The full-tree -# lint (including tests) runs in CI and on demand via `npm run lint`. +# files plus the files that import them (see scripts/staged-lint.ts). All +# linters run in CHECK mode only — nothing is ever rewritten or renamed at +# commit time; a violation blocks the commit with an error and the user +# fixes it (or runs `npm run format` / `npm run lint:fix`) themselves. npx tsx scripts/staged-lint.ts - -# Format changed files (code + non-code) after linting passes. npx lint-staged diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a7565..e667827 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Added -- _Nothing yet._ +- **`[Usage]` Server-accurate Go meters via the official `/zen/go/v1/usage` endpoint (#130).** The status bar, tooltip, quick-pick and usage webview previously showed locally estimated Session/Weekly/Monthly percentages that drifted from opencode.ai (issue #23) because they missed CLI, cross-device and pre-install usage. The tracker now pulls the official endpoint (upstream anomalyco/opencode#16513, verified live) with the existing Go key on startup and after each request (60s TTL cache): rolling/weekly/monthly percent + reset times are server-computed and account-wide, `spent` is derived from the authoritative percent, and Today/Yesterday + per-session spend stay device-local. Failures (401/403/404/network) fall back to the existing SQLite → tracked estimates. The key is only ever sent as the Authorization header and never logged or persisted. New pure module `src/goUsageSync.ts` with unit tests. ## [0.5.2] — 2026-08-11 diff --git a/README.md b/README.md index beaf71b..d8b9ecb 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,14 @@ Per-model reasoning configuration, dynamically enhanced with `reasoning_options` - **Go Usage Tracker** — real-time burn-rate of OpenCode Go subscription: - Tracks **5-hour rolling** ($12), **weekly** ($30), **monthly** ($60) tiers. - - Client-side cost calc: token usage × per-model pricing (input/output/cache_read). + - **Server-synced meters** — on startup and after each request the tracker + pulls the official `/zen/go/v1/usage` endpoint with your Go key, so the + Session/Weekly/Monthly percentages and "resets in" timers are + account-wide and server-accurate (includes CLI, other devices, anything + before the extension was installed). Falls back to local estimates when + the endpoint is unreachable or the key/subscription is invalid. + - Today / Yesterday and per-session spend stay **device-local** (the API + does not return them). - Status bar: `Go: 27%·62%·75%` — ⚠ warning when any tier exceeds 80%. - Persisted in VS Code `globalState` — survives restarts. - **Response usage bar** — latest prompt/output/total/cache summary after each response. diff --git a/package.json b/package.json index 2c0b517..c1f3a96 100644 --- a/package.json +++ b/package.json @@ -381,9 +381,9 @@ "prepare": "husky" }, "lint-staged": { - "*": "prettier --write --ignore-unknown", - "*.{js,cjs,ts}": "eslint --fix --max-warnings 0", - "*.md": "markdownlint-cli2 --config .markdownlint-cli2.json --fix", + "*": "prettier --check --ignore-unknown", + "*.{js,cjs,ts}": "eslint --max-warnings 0", + "*.md": "markdownlint-cli2 --config .markdownlint-cli2.json", ".husky/*": "shellcheck" }, "devDependencies": { diff --git a/src/extension.ts b/src/extension.ts index 6bb7e44..c097eb4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -114,6 +114,8 @@ let goUsageStatusBarItem: vscode.StatusBarItem | undefined; let goUsageTracker: GoUsageTracker | undefined; /** Per-profile trackers indexed by key fingerprint. */ const goUsageTrackers = new Map(); +/** API key per profile fingerprint — lets refreshes sync the active profile's own key. */ +const profileApiKeys = new Map(); let usageWebviewPanel: vscode.WebviewPanel | undefined; let profilesCache: UsageProfile[] = []; @@ -192,6 +194,10 @@ function ensureProfileSync(apiKey: string): void { */ function ensureProfileForApiKey(apiKey: string): GoUsageTracker { ensureProfileSync(apiKey); + // Remember which API key owns each profile, so status-bar refreshes can + // sync the ACTIVE profile's meters with its own key instead of the + // extension secret (which may belong to another account). + profileApiKeys.set(keyFingerprint(apiKey), apiKey); return getOrCreateTracker(keyFingerprint(apiKey)); } @@ -330,18 +336,19 @@ function isTransientFetchError(error: unknown): boolean { * * Used to back off between model-list fetch retries without leaking * CancellationToken subscriptions. + * + * A single subscription suffices for already-cancelled tokens: VS Code's + * cancellation tokens invoke listeners registered after cancellation + * (shortcutEvent), and Promises ignore double settlement. */ function sleep(ms: number, token?: vscode.CancellationToken): Promise { if (token?.isCancellationRequested) { return Promise.reject(new DOMException("Aborted", "AbortError")); } return new Promise((resolve, reject) => { - let settled = false; - const state: { subscription?: vscode.Disposable } = {}; + const state: { timer?: ReturnType; subscription?: vscode.Disposable } = {}; const finish = (cancelled: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timer); + if (state.timer) clearTimeout(state.timer); state.subscription?.dispose(); if (cancelled) { reject(new DOMException("Aborted", "AbortError")); @@ -349,17 +356,13 @@ function sleep(ms: number, token?: vscode.CancellationToken): Promise { resolve(); } }; - const timer = setTimeout(() => { + state.timer = setTimeout(() => { finish(false); }, ms); if (token) { - if (token.isCancellationRequested) { + state.subscription = token.onCancellationRequested(() => { finish(true); - } else { - state.subscription = token.onCancellationRequested(() => { - finish(true); - }); - } + }); } }); } @@ -748,6 +751,12 @@ export function activate(context: vscode.ExtensionContext) { ensureUsageStatusBar(context); ensureGoUsageStatusBar(context); + // Pull the server-accurate account meters once at startup (TTL-guarded). + void (async () => { + const apiKey = await context.secrets.get(SECRET_KEY); + if (!apiKey) return; + await syncTrackerUsage(getOrCreateTracker(keyFingerprint(apiKey)), apiKey); + })(); // Read from the root configuration with the FULL setting key: section-scoped // reads (getConfiguration("opencodego")) resolve keys relative to the // section, which would misread the Zen flag as opencodego.opencodezen.enabled. @@ -794,7 +803,7 @@ export function activate(context: vscode.ExtensionContext) { const tracker = activeGoUsageTracker(); if (!tracker) return; const summary = tracker.getSummary(); - const items = buildUsageQuickPickItems(summary); + const items = buildUsageQuickPickItems(summary, tracker.hasServerUsage); const sessionCost = tracker.getCurrentSessionCost(); if (sessionCost && sessionCost.cost > 0) { @@ -856,6 +865,19 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand("opencodego.setUsageTargets"); } else if (action === "showUsageDetails") { vscode.commands.executeCommand("opencodego.showUsageDetails"); + } else if (action === "openConsole") { + void vscode.env.openExternal(vscode.Uri.parse("https://opencode.ai")); + } else if (action === "resetTracked") { + const confirm = await vscode.window.showWarningMessage( + "Reset all locally tracked usage data (Today, Yesterday, session spend)? Server-synced meters are unaffected.", + { modal: true }, + "Reset", + ); + if (confirm !== "Reset") return; + tracker.clear(); + refreshGoUsageStatusBar(); + updateWebviewContent(); + vscode.window.showInformationMessage("Locally tracked usage data cleared."); } else if (action === "switchProfile" && "_fp" in picked) { void setActiveProfile((picked as { _fp: string })._fp); } @@ -1273,6 +1295,26 @@ function refreshGoUsageStatusBar(): void { goUsageStatusBarItem.tooltip = buildUsageTooltip(s, tracker.getCurrentSessionCost()); goUsageStatusBarItem.show(); updateWebviewContent(); + + // Refresh the server-accurate meters in the background (TTL-guarded); when + // a new snapshot lands, rebuild the status bar with it. Use the active + // profile's own key when known, falling back to the extension secret. + void (async () => { + const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(SECRET_KEY)); + if (!apiKey) return; + const changed = await tracker.syncServerUsage(apiKey); + if (changed) refreshGoUsageStatusBar(); + })(); +} + +/** + * Fetch server-accurate usage for a key and repaint the status bar when a new + * snapshot arrived. Uses the tracker owning that key (creating its profile on + * first use), so multi-account setups keep per-key meters. + */ +async function syncTrackerUsage(tracker: GoUsageTracker, apiKey: string): Promise { + const changed = await tracker.syncServerUsage(apiKey); + if (changed) refreshGoUsageStatusBar(); } function showUsageWebview(context: vscode.ExtensionContext): void { @@ -1305,7 +1347,6 @@ function updateWebviewContent(): void { return; } const s = tracker.getSummary(); - const sc = tracker.getCurrentSessionCost(); const activeProfile = findProfile(profilesCache, activeProfileFingerprint); const profileLabel = activeProfile?.label ?? "OpenCode Go"; @@ -1317,44 +1358,138 @@ function updateWebviewContent(): void { OpenCode Usage Summary — ${escapeSvg(profileLabel)} -
- ${buildUsageTooltipSvg(s, sc)} +
+
${escapeSvg(profileLabel)} - Usage
+ + ${usageCardSectionHtml("Session (5h rolling)", s.session)} + ${usageCardSectionHtml("Weekly", s.weekly)} + ${usageCardSectionHtml("Monthly", s.monthly)} + +
+ + ${usageCardStatsHtml("Today", s.today)} + ${usageCardStatsHtml("Yesterday", s.yesterday)} + +
`; } +/** One meter section: label + resets, bar, used + percent. */ +function usageCardSectionHtml(label: string, p: _UsageSummary["session"]): string { + const pct = p.percent.toFixed(1); + const width = Math.min(Math.max(p.percent, 0), 100); + return [ + '
', + '
', + `${escapeSvg(label)}`, + `Resets in ${escapeSvg(rel(p.resetsAt))}`, + "
", + `
`, + '
', + `${escapeSvg(`${usd(p.spent)} / ${usd(p.limit)} used`)}`, + `${pct}%`, + "
", + "
", + ].join(""); +} + +/** One stats row: three label-over-value columns. */ +function usageCardStatsHtml(label: string, day: _UsageSummary["today"]): string { + return [ + '
', + `
${escapeSvg(label)}
${escapeSvg(usd(day.cost))}
`, + `
Requests
${day.requests}
`, + `
Tokens
${escapeSvg(tokens(day.tokens))}
`, + "
", + ].join(""); +} + function buildUsageTooltip( s: ReturnType, sessionCost?: { cost: number; requests: number; promptTokens: number; completionTokens: number }, @@ -1365,17 +1500,10 @@ function buildUsageTooltip( const activeProfile = findProfile(profilesCache, activeProfileFingerprint); const profileLabel = activeProfile?.label ?? "OpenCode Go"; - const commands = ["opencodego.setUsageTargets"]; - if (nonLegacyCount(profilesCache) > 0) { - commands.push("opencodego.renameActiveProfile"); - } - (md as vscode.MarkdownString & { supportedCommands?: string[] }).supportedCommands = commands; - - md.appendMarkdown(`Go usage summary`); - md.appendMarkdown("\n\n[$(pencil) Set spent targets](command:opencodego.setUsageTargets)"); - if (nonLegacyCount(profilesCache) > 0) { - md.appendMarkdown(" \u00B7 [$(pencil) Rename](command:opencodego.renameActiveProfile)"); - } + // The hover shows the summary card only; Set spent targets / Rename are + // available from the Command Palette (opencodego.setUsageTargets, + // opencodego.renameActiveProfile). + md.appendMarkdown(`Go usage summary`); return md; } @@ -1500,13 +1628,11 @@ function buildUsageTooltipSvg( sc?: { cost: number; requests: number; promptTokens: number; completionTokens: number }, profileLabel?: string, ): string { - const hasSession = sc && sc.cost > 0; - // Session label is longer ("Session (est):") so widen the card and shift - // the cost column right when session data is present. - const cx = hasSession ? 120 : 80; // cost value column - const width = hasSession ? 440 : 420; - const height = s.hasData ? (hasSession ? 310 : 286) : 78; - const bg = "#1e1e1e"; + // Stable geometry: fixed card width and fixed columns, so the layout never + // shifts when session data appears or a day has no usage yet. + const width = 440; + const padX = 14; + const right = width - padX; const fg = "#d4d4d4"; const muted = "#a6a6a6"; const track = "#3c3c3c"; @@ -1529,61 +1655,67 @@ function buildUsageTooltipSvg( ].join(""); }; + // Meter block with a uniform 14px gutter between blocks: label row with the + // reset time right-aligned at the card's right padding, then the bar and + // the spent/limit line below it. const period = (label: string, p: _UsageSummary["session"], y: number): string => [ - text(label, 14, y, 14, 700), - text(`Resets in ${rel(p.resetsAt)}`, 410, y, 12, 400, muted, "end"), - bar(p.percent, 14, y + 12, 340), - text(`${p.percent.toFixed(1)}%`, 410, y + 19, 14, 700, fg, "end"), - text(`${usd(p.spent)} / ${usd(p.limit)} used`, 14, y + 34, 13, 400, fg), + text(label, padX, y, 14, 700), + text(`Resets in ${rel(p.resetsAt)}`, right, y, 12, 400, muted, "end"), + bar(p.percent, padX, y + 14, 340), + text(`${p.percent.toFixed(1)}%`, right, y + 21, 14, 700, fg, "end"), + text(`${usd(p.spent)} / ${usd(p.limit)} used`, padX, y + 36, 13, 400, fg), + ].join(""); + + // Device-local rows share one fixed column grid: label, cost, requests, + // tokens. Always rendered (zeros included) so the card height is stable. + const deviceRow = (label: string, cost: number, requests: number, tokenCount: number, y: number): string => + [ + text(label, padX, y, 13, 400, muted), + text(usd(cost), 120, y, 13, 700), + text("Requests:", 190, y, 13, 400, muted), + text(String(requests), 262, y, 13, 700), + text("Tokens:", 305, y, 13, 400, muted), + text(tokens(tokenCount), 385, y, 13, 700), ].join(""); if (!s.hasData) { - return ` - -${text(svgTitle, 14, 26, 16, 700)} -${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", 14, 50, 12, 400, muted)} + return `${text(svgTitle, padX, 28, 16, 700)} +${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 52, 12, 400, muted)} `; } - return ` - -${text(svgTitle, 14, 26, 16, 700)} -${period("Session (5h rolling)", s.session, 54)} -${period("Weekly", s.weekly, 116)} -${period("Monthly", s.monthly, 178)} - -${ - hasSession - ? [ - text("Session (est):", 14, 250, 13, 400, muted), - text(`$${sc.cost.toFixed(4)}`, cx, 250, 13, 700), - text("Requests:", 200, 250, 13, 400, muted), - text(String(sc.requests), 280, 250, 13, 700), - text("Tokens:", 320, 250, 13, 400, muted), - text(tokens(sc.promptTokens + sc.completionTokens), 400, 250, 13, 700), - ].join("") - : "" -} -${text("Today:", 14, hasSession ? 274 : 256, 13, 400, muted)} -${text(usd(s.today.cost), cx, hasSession ? 274 : 256, 13, 700)} -${text("Requests:", 200, hasSession ? 274 : 256, 13, 400, muted)} -${text(String(s.today.requests), 280, hasSession ? 274 : 256, 13, 700)} -${text("Tokens:", 320, hasSession ? 274 : 256, 13, 400, muted)} -${text(tokens(s.today.tokens), 400, hasSession ? 274 : 256, 13, 700)} -${ - s.yesterday.requests > 0 - ? [ - text("Yesterday:", 14, hasSession ? 298 : 278, 13, 400, muted), - text(usd(s.yesterday.cost), cx, hasSession ? 298 : 278, 13, 700), - text("Requests:", 200, hasSession ? 298 : 278, 13, 400, muted), - text(String(s.yesterday.requests), 280, hasSession ? 298 : 278, 13, 700), - text("Tokens:", 320, hasSession ? 298 : 278, 13, 400, muted), - text(tokens(s.yesterday.tokens), 400, hasSession ? 298 : 278, 13, 700), - ].join("") - : "" -} -`; + // Title starts at the same 14px gutter as the sides. Meter rows, the + // divider and the device rows keep a consistent 14px rhythm. + const meterRows = [ + ["Session (5h rolling)", s.session, 56], + ["Weekly", s.weekly, 116], + ["Monthly", s.monthly, 176], + ] as const; + const dividerY = 226; + const firstRowY = 248; + const rowGap = 24; + // All three rows are always rendered (zeros included) so the card is + // stable regardless of whether a session is currently active. + const sessionCost = sc && sc.cost > 0 ? sc : { cost: 0, requests: 0, promptTokens: 0, completionTokens: 0 }; + const deviceRows: Array<[string, number, number, number, number]> = []; + deviceRows.push([ + "Session (est):", + sessionCost.cost, + sessionCost.requests, + sessionCost.promptTokens + sessionCost.completionTokens, + firstRowY, + ]); + deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + rowGap]); + deviceRows.push(["Yesterday:", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, firstRowY + 2 * rowGap]); + + const height = firstRowY + 2 * rowGap + 14; + + return ` +${text(svgTitle, padX, 28, 16, 700)} +${meterRows.map(([label, periodValue, y]) => period(label, periodValue, y)).join("")} + +${deviceRows.map(([label, cost, requests, tokenCount, y]) => deviceRow(label, cost, requests, tokenCount, y)).join("")}`; } function escapeSvg(value: string): string { @@ -2400,11 +2532,14 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { controller.abort(); }); @@ -2779,7 +2916,7 @@ async function refreshOpenCodeModelMetadata( modelMetadataSnapshot = snapshot; await context.globalState.update(MODEL_METADATA_CACHE_KEY, snapshot); output?.appendLine( - `[metadata] refreshed models.dev cache go=${String(Object.keys(snapshot.providers[GO_VENDOR] ?? {}).length)} zen=${String(Object.keys(snapshot.providers[ZEN_VENDOR] ?? {}).length)}`, + `[metadata] refreshed models.dev cache go=${Object.keys(snapshot.providers[GO_VENDOR] ?? {}).length} zen=${Object.keys(snapshot.providers[ZEN_VENDOR] ?? {}).length}`, ); return snapshot; })() diff --git a/src/goUsageSync.ts b/src/goUsageSync.ts new file mode 100644 index 0000000..645eacc --- /dev/null +++ b/src/goUsageSync.ts @@ -0,0 +1,129 @@ +import type { UsageSummary } from "./goUsageTracker"; + +/** + * Official OpenCode Go usage endpoint (upstream anomalyco/opencode#16513, + * live since 2026-08-11). Authenticates with the same `opencode-go` API key + * the extension already stores for models/chat; returns server-accurate, + * account-wide rolling/weekly/monthly percentages and reset times instead of + * the per-machine estimates the extension computes locally. + * + * Verified against the upstream route source (packages/console/app/src/routes/ + * zen/go/v1/usage.ts): 401 = missing/invalid key, 403 = no Go subscription, + * 200 = `{ usage: { rolling, weekly, monthly: { status, percent, resetsAt } } }` + * where `percent` is an integer 0–100 computed server-side and `resetsAt` is + * an ISO timestamp. + */ +export const GO_USAGE_API_URL = "https://opencode.ai/zen/go/v1/usage"; +/** How long a successful server-usage snapshot is reused before refetching. */ +export const GO_USAGE_SYNC_TTL_MS = 60_000; +/** Hard timeout for a single usage fetch. */ +export const GO_USAGE_FETCH_TIMEOUT_MS = 10_000; + +export type GoUsagePeriodStatus = "ok" | "rate-limited"; + +export interface GoUsagePeriod { + status: GoUsagePeriodStatus; + percent: number; + resetsAt: string; +} + +export interface GoUsageApiResponse { + usage: { + rolling: GoUsagePeriod; + weekly: GoUsagePeriod; + monthly: GoUsagePeriod; + }; +} + +export type GoUsageSyncFailureReason = "no-key" | "unauthorized" | "no-subscription" | "not-found" | "network" | "invalid"; + +export type GoUsageSyncResult = { ok: true; data: GoUsageApiResponse } | { ok: false; reason: GoUsageSyncFailureReason }; + +/** Minimal structural guard for the endpoint payload. */ +function isGoUsageApiResponse(value: unknown): value is GoUsageApiResponse { + if (typeof value !== "object" || value === null) return false; + const usage = (value as { usage?: unknown }).usage; + if (typeof usage !== "object" || usage === null) return false; + const periods: unknown[] = ["rolling", "weekly", "monthly"].map((key) => (usage as Record)[key]); + return periods.every((period) => { + if (typeof period !== "object" || period === null) return false; + const p = period as Record; + return (p.status === "ok" || p.status === "rate-limited") && typeof p.percent === "number" && typeof p.resetsAt === "string"; + }); +} + +/** + * Fetch server-accurate Go usage for an API key. + * + * CONTRACT: + * - Never logs or persists the key; it is only sent as the Authorization + * header of this request. + * - Failures are classified so callers can fall back to local estimates: + * 401 → unauthorized, 403 → no subscription, 404 → endpoint not deployed, + * network/timeout errors → network, malformed payloads → invalid. + */ +export async function fetchGoUsage( + apiKey: string, + fetcher: typeof fetch = fetch, + timeoutMs: number = GO_USAGE_FETCH_TIMEOUT_MS, +): Promise { + if (!apiKey) { + return { ok: false, reason: "no-key" }; + } + let response: Response; + try { + response = await fetcher(GO_USAGE_API_URL, { + method: "GET", + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + } catch { + return { ok: false, reason: "network" }; + } + + if (response.status === 401) return { ok: false, reason: "unauthorized" }; + if (response.status === 403) return { ok: false, reason: "no-subscription" }; + if (response.status === 404) return { ok: false, reason: "not-found" }; + if (!response.ok) return { ok: false, reason: "network" }; + + try { + const payload: unknown = await response.json(); + if (!isGoUsageApiResponse(payload)) { + return { ok: false, reason: "invalid" }; + } + return { ok: true, data: payload }; + } catch { + return { ok: false, reason: "invalid" }; + } +} + +/** + * Merge server-accurate meters onto a locally-computed summary. + * + * The endpoint reports percent + resetsAt but not raw spend, so `spent` is + * derived as `limit × percent / 100` to keep the status bar and tooltip + * internally consistent (the percent itself is authoritative). Today / + * Yesterday / per-session spend stay local — the API does not return them. + */ +export function mergeServerUsage( + summary: UsageSummary, + api: GoUsageApiResponse, + limits: { session: number; weekly: number; monthly: number }, +): UsageSummary { + const period = (server: GoUsagePeriod, limit: number): UsageSummary["session"] => ({ + spent: Math.round(limit * (server.percent / 100) * 100) / 100, + limit, + percent: server.percent, + resetsAt: new Date(server.resetsAt), + }); + + return { + ...summary, + session: period(api.usage.rolling, limits.session), + weekly: period(api.usage.weekly, limits.weekly), + monthly: period(api.usage.monthly, limits.monthly), + // Server meters are real account-wide data — never report "no data" + // when a snapshot exists (e.g. a fresh install with CLI usage). + hasData: true, + }; +} diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index b776b28..944898d 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -6,6 +6,7 @@ import { execFileSync } from "child_process"; import { GO_VENDOR } from "./providerTypes"; import type { ModelCost } from "./metadata"; import type { TransportRequestSummary } from "./streaming"; +import { fetchGoUsage, mergeServerUsage, GO_USAGE_SYNC_TTL_MS, type GoUsageApiResponse } from "./goUsageSync"; /** Callback to resolve live model cost from the models.dev metadata cache. */ export type CostResolver = (modelId: string) => ModelCost | undefined; @@ -14,6 +15,7 @@ export type CostResolver = (modelId: string) => ModelCost | undefined; const STORAGE_KEY = "opencodego.usageLog.v1"; const BASELINE_STORAGE_KEY = "opencodego.usageBaseline.v1"; +const EVER_TRACKED_KEY = "opencodego.everTracked.v1"; const SESSION_COSTS_KEY = "opencodego.sessionCosts.v1"; const MAX_LOG_ENTRIES = 2000; @@ -291,11 +293,23 @@ function readOpenCodeHistory(): HistoryRow[] | null { export class GoUsageTracker { private entries: UsageLogEntry[] = []; + /** + * Whether this profile has ever recorded (or had cleared) local usage. + * Kept true after a reset so the usage card shows zeroed local values + * instead of collapsing into the first-run "no data" state. + */ + private everTracked = false; private baseline: UsageBaseline = {}; private readonly log?: (msg: string) => void; private costResolver?: CostResolver; /** Per-chat-session cost accumulator. Key = sessionId. */ private sessionCosts = new Map(); + /** Latest server-accurate usage snapshot (account-wide meters). */ + private serverUsage: GoUsageApiResponse | undefined; + /** Unix ms of the last successful {@link syncServerUsage} fetch. */ + private serverUsageFetchedAt = 0; + /** In-flight sync promise per key — prevents duplicate concurrent fetches. */ + private syncInFlight: { apiKey: string; promise: Promise } | undefined; private static readonly SESSION_IDLE_MS = 2 * 60 * 60 * 1000; // 2h private static readonly MAX_SESSIONS = 50; @@ -402,6 +416,7 @@ export class GoUsageTracker { sessionId: summary.sessionId, copilotCredits, }); + this.markEverTracked(); // Accumulate per-session cost if (summary.sessionId) { @@ -436,15 +451,67 @@ export class GoUsageTracker { // When namespaced (per-profile), skip the shared SQLite — it has no // key column, so reading it would mix quota from all accounts. const isPerProfile = this.storageKeySuffix.length > 0; + let summary: UsageSummary; if (!isPerProfile) { const sqliteRows = readOpenCodeHistory(); if (sqliteRows) { - return this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp); + summary = this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp); + } else { + // Fall back to extension-tracked data (works without CLI). + summary = this.buildSummaryFromTracked(nowMs, clamp); + } + } else { + summary = this.buildSummaryFromTracked(nowMs, clamp); + } + + // Overlay the server-accurate meters when a recent snapshot exists + // (fetched via syncServerUsage). Today / Yesterday / per-session spend + // remain local. + return this.serverUsage ? mergeServerUsage(summary, this.serverUsage, GO_LIMITS) : summary; + } + + /** + * Fetch server-accurate account-wide usage for this profile's key and + * cache it for {@link GO_USAGE_SYNC_TTL_MS}. Safe to call on every + * request/status-bar refresh: the TTL guard makes it a no-op while a + * fresh snapshot exists. Failures keep the previous snapshot (stale + * beats nothing) and the local estimates remain the fallback. + * + * @returns true when a new snapshot was fetched. + */ + async syncServerUsage(apiKey: string): Promise { + // Dedupe concurrent calls for the same key (startup + status-bar refresh + // can fire at the same moment) — a single in-flight fetch is enough. + if (this.syncInFlight && this.syncInFlight.apiKey === apiKey) { + return this.syncInFlight.promise; + } + const promise = this.performServerUsageSync(apiKey); + this.syncInFlight = { apiKey, promise }; + try { + return await promise; + } finally { + if (this.syncInFlight.promise === promise) { + this.syncInFlight = undefined; } } + } - // Fall back to extension-tracked data (works without CLI). - return this.buildSummaryFromTracked(nowMs, clamp); + private async performServerUsageSync(apiKey: string): Promise { + const now = Date.now(); + if (this.serverUsageFetchedAt > 0 && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { + return false; + } + const result = await fetchGoUsage(apiKey); + // Pace retries after failures too — an invalid key or unreachable + // endpoint must not hammer the API on every request. + this.serverUsageFetchedAt = Date.now(); + if (!result.ok) { + this.log?.(`[go-usage] Server usage sync skipped (${result.reason}); keeping local estimates.`); + return false; + } + this.serverUsage = result.data; + this.log?.("[go-usage] Server usage synced from /zen/go/v1/usage."); + return true; } /** Build summary from SQLite, enriched with token/request counts from tracked entries. */ @@ -657,7 +724,7 @@ export class GoUsageTracker { requests: yestReq, tokens: yestTokens, }, - hasData: this.entries.length > 0, + hasData: this.entries.length > 0 || this.everTracked, sqliteAvailable: false, }; } @@ -772,8 +839,24 @@ export class GoUsageTracker { clear(): void { this.entries = []; this.baseline = {}; + this.sessionCosts.clear(); this.persist(); this.persistBaseline(); + // Keep the usage card alive with zeroed values instead of falling back + // to the first-run "no data" state. + this.markEverTracked(); + } + + /** Mark (and persist) that this profile has local usage history. */ + private markEverTracked(): void { + if (this.everTracked) return; + this.everTracked = true; + void this.context.globalState.update(this.storageKey(EVER_TRACKED_KEY), true); + } + + /** Whether a server-accurate usage snapshot is currently in effect. */ + get hasServerUsage(): boolean { + return this.serverUsage !== undefined; } private prune(): void { @@ -842,6 +925,8 @@ export class GoUsageTracker { this.entries = stored.filter((e) => typeof e.timestamp === "number" && typeof e.cost === "number"); } + this.everTracked = this.context.globalState.get(this.storageKey(EVER_TRACKED_KEY), this.entries.length > 0); + const baseline = this.context.globalState.get(this.storageKey(BASELINE_STORAGE_KEY), {}); if (typeof baseline === "object") { this.baseline = baseline; @@ -985,7 +1070,7 @@ export function formatGoUsageLanguageStatusDetail(summary: UsageSummary): string } /** Build Quick Pick items for the usage panel */ -export function buildUsageQuickPickItems(summary: UsageSummary): vscode.QuickPickItem[] { +export function buildUsageQuickPickItems(summary: UsageSummary, syncedFromServer = false): vscode.QuickPickItem[] { const now = new Date(); const isEmpty = !summary.hasData; @@ -1012,6 +1097,14 @@ export function buildUsageQuickPickItems(summary: UsageSummary): vscode.QuickPic }); } + if (syncedFromServer) { + items.push({ + label: "$(cloud) Synced from opencode.ai", + detail: "Session/Weekly/Monthly meters are account-wide and server-accurate.", + alwaysShow: true, + }); + } + // ── Period bars ────────────────────────────────────────────────────────── items.push({ label: "Subscription Limits", kind: vscode.QuickPickItemKind.Separator }); @@ -1056,13 +1149,15 @@ export function buildUsageQuickPickItems(summary: UsageSummary): vscode.QuickPic label: "$(link-external) Open OpenCode console", description: "View usage at opencode.ai", alwaysShow: true, - }); + _action: "openConsole", + } as vscode.QuickPickItem & { _action: string }); items.push({ label: "$(trash) Reset tracked usage data", description: "Clears all locally tracked data", alwaysShow: true, - }); + _action: "resetTracked", + } as vscode.QuickPickItem & { _action: string }); return items; } diff --git a/src/test/goUsageSync.test.ts b/src/test/goUsageSync.test.ts new file mode 100644 index 0000000..6409b60 --- /dev/null +++ b/src/test/goUsageSync.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { fetchGoUsage, mergeServerUsage, GO_USAGE_API_URL, type GoUsageApiResponse } from "../goUsageSync"; +import type { UsageSummary } from "../goUsageTracker"; + +/** Mirrors GO_LIMITS — kept literal so the test never loads goUsageTracker (vscode). */ +const LIMITS = { session: 12, weekly: 30, monthly: 60 }; + +/** Minimal locally-computed summary used as the merge input. */ +function localSummary(): UsageSummary { + return { + session: { spent: 1, limit: LIMITS.session, percent: 8.3, resetsAt: new Date("2026-08-11T12:00:00Z") }, + weekly: { spent: 5, limit: LIMITS.weekly, percent: 16.7, resetsAt: new Date("2026-08-17T00:00:00Z") }, + monthly: { spent: 9, limit: LIMITS.monthly, percent: 15, resetsAt: new Date("2026-08-31T00:00:00Z") }, + today: { cost: 0.4, requests: 3, tokens: 4200 }, + yesterday: { cost: 1.1, requests: 8, tokens: 9800 }, + hasData: true, + sqliteAvailable: false, + }; +} + +function apiResponse(): GoUsageApiResponse { + return { + usage: { + rolling: { status: "ok", percent: 27, resetsAt: "2026-08-11T14:32:10.000Z" }, + weekly: { status: "ok", percent: 62, resetsAt: "2026-08-17T00:00:00.000Z" }, + monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-08-31T00:00:00.000Z" }, + }, + }; +} + +function stubFetch(status: number, body: unknown): typeof fetch { + const response = new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + return () => Promise.resolve(response); +} + +test("fetchGoUsage — sends the key as Bearer to the official endpoint", async () => { + let requestedUrl = ""; + let authHeader = ""; + const fetcher: typeof fetch = (input, init) => { + // The production call always passes a string URL; capture it as-is. + requestedUrl = typeof input === "string" ? input : ""; + const headers = init?.headers as Record | undefined; + authHeader = headers?.Authorization ?? ""; + return Promise.resolve(new Response(JSON.stringify(apiResponse()), { status: 200 })); + }; + + const result = await fetchGoUsage("sk-test", fetcher); + assert.equal(requestedUrl, GO_USAGE_API_URL); + assert.equal(authHeader, "Bearer sk-test"); + assert.equal(result.ok, true); +}); + +test("fetchGoUsage — parses a 200 payload", async () => { + const result = await fetchGoUsage("sk-test", stubFetch(200, apiResponse())); + assert.ok(result.ok); + assert.equal(result.data.usage.rolling.percent, 27); + assert.equal(result.data.usage.monthly.status, "rate-limited"); +}); + +test("fetchGoUsage — classifies failures so callers can fall back", async () => { + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(401, {})), { ok: false, reason: "unauthorized" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(403, {})), { ok: false, reason: "no-subscription" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(404, {})), { ok: false, reason: "not-found" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(500, {})), { ok: false, reason: "network" }); +}); + +test("fetchGoUsage — missing key is refused without any request", async () => { + let called = false; + const fetcher: typeof fetch = () => { + called = true; + return Promise.resolve(new Response("", { status: 200 })); + }; + assert.deepEqual(await fetchGoUsage("", fetcher), { ok: false, reason: "no-key" }); + assert.equal(called, false); +}); + +test("fetchGoUsage — malformed payloads and network errors are classified", async () => { + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(200, { nope: true })), { ok: false, reason: "invalid" }); + assert.deepEqual(await fetchGoUsage("sk-test", stubFetch(200, "not json")), { ok: false, reason: "invalid" }); + const throwing: typeof fetch = () => { + throw new TypeError("fetch failed"); + }; + assert.deepEqual(await fetchGoUsage("sk-test", throwing), { ok: false, reason: "network" }); +}); + +test("mergeServerUsage — overlays server percent and resetsAt per period", () => { + const merged = mergeServerUsage(localSummary(), apiResponse(), LIMITS); + assert.equal(merged.session.percent, 27); + assert.equal(merged.session.resetsAt.toISOString(), "2026-08-11T14:32:10.000Z"); + assert.equal(merged.weekly.percent, 62); + assert.equal(merged.monthly.percent, 100); + assert.equal(merged.monthly.resetsAt.toISOString(), "2026-08-31T00:00:00.000Z"); +}); + +test("mergeServerUsage — derives spent from the authoritative percent", () => { + const merged = mergeServerUsage(localSummary(), apiResponse(), LIMITS); + assert.equal(merged.session.spent, Math.round(LIMITS.session * 0.27 * 100) / 100); + assert.equal(merged.weekly.spent, Math.round(LIMITS.weekly * 0.62 * 100) / 100); + // rate-limited → 100% → full limit + assert.equal(merged.monthly.spent, LIMITS.monthly); +}); + +test("mergeServerUsage — keeps local today/yesterday and metadata", () => { + const merged = mergeServerUsage(localSummary(), apiResponse(), LIMITS); + assert.deepEqual(merged.today, localSummary().today); + assert.deepEqual(merged.yesterday, localSummary().yesterday); + assert.equal(merged.hasData, true); + assert.equal(merged.sqliteAvailable, false); +}); + +test("mergeServerUsage — server meters imply hasData (fresh install with CLI usage)", () => { + const empty: UsageSummary = { ...localSummary(), hasData: false }; + const merged = mergeServerUsage(empty, apiResponse(), LIMITS); + assert.equal(merged.hasData, true, "status bar must not say 'no data' when server meters exist"); + assert.equal(merged.monthly.percent, 100); +});