From 9eff02d36cedf6e90778c8d10b5ad4792e651324 Mon Sep 17 00:00:00 2001 From: Will Bogusz <24197106+will-bogusz@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:26:01 +0200 Subject: [PATCH 1/3] fix(usage): use authoritative OpenCode Go quotas --- packages/ai/CHANGELOG.md | 8 + packages/ai/src/auth-storage.ts | 73 +----- .../ai/src/auth/sqlite-credential-store.ts | 56 ----- packages/ai/src/usage.ts | 20 -- packages/ai/src/usage/opencode-go.ts | 211 +++++++++++++----- .../auth-storage-check-credentials.test.ts | 8 +- .../test/auth-storage-usage-history.test.ts | 82 ++++--- packages/ai/test/opencode-go-usage.test.ts | 180 +++++++++++++++ .../ai/test/usage-report-notes-schema.test.ts | 22 +- packages/coding-agent/CHANGELOG.md | 4 + .../modes/components/status-line/component.ts | 13 +- .../modes/components/status-line/segments.ts | 5 +- .../coding-agent/src/session/agent-session.ts | 7 - .../test/status-line-usage.test.ts | 45 +++- packages/coding-agent/test/usage-cli.test.ts | 18 +- .../test/usage-report-tui-notes.test.ts | 12 +- 16 files changed, 475 insertions(+), 289 deletions(-) create mode 100644 packages/ai/test/opencode-go-usage.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 326853dd8..8d44233f4 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Changed + +- OpenCode Go usage now comes from the official `GET /zen/go/v1/usage` endpoint (rolling 5h / weekly / monthly percent windows with server-computed resets) instead of synthesizing dollar estimates from OMP-observed request costs, so `/usage` reflects spend made outside OMP and the hardcoded $12/$30/$60 caps are gone. The usage probe now validates credentials (401 invalid key, 403 lapsed Go subscription), and a new ranking strategy routes multi-key pools by rolling/weekly headroom while keeping the monthly window display-only (an exhausted monthly can still serve requests via the console "Use balance" fallback). + +### Removed + +- Removed the observed-request-cost machinery that existed only to power the OpenCode Go estimate: `AuthStorage.recordUsageCost`, the store `recordUsageCosts`/`listUsageCosts` hooks, `UsageFetchContext.listUsageCosts`, the `UsageCostHistoryEntry`/`UsageCostHistoryQuery` types, and the `usage_cost_history` schema and statements. Existing unused tables are left intact rather than deleting local data during startup. + ## [17.2.15] - 2026-08-12 ### Fixed diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 2340a73cf..ce23f083f 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -36,8 +36,6 @@ import type { CredentialRankingContext, CredentialRankingStrategy, ObservedUsageEntry, - UsageCostHistoryEntry, - UsageCostHistoryQuery, UsageCredential, UsageFetchContext, UsageFetchParams, @@ -66,7 +64,7 @@ import { listCodexResetCredits, pickSoonestExpiringCredit, } from "./usage/openai-codex-reset"; -import { opencodeGoUsageProvider } from "./usage/opencode-go"; +import { opencodeGoRankingStrategy, opencodeGoUsageProvider } from "./usage/opencode-go"; import { syntheticUsageProvider } from "./usage/synthetic"; import { umansUsageProvider } from "./usage/umans"; import { xaiOauthUsageProvider } from "./usage/xai-oauth"; @@ -454,10 +452,6 @@ export interface AuthCredentialStore { * skipped — the broker host records into its own database instead. */ recordUsageSnapshots?(entries: UsageHistoryEntry[]): void; - /** Append observed request costs for providers without upstream usage APIs. */ - recordUsageCosts?(entries: UsageCostHistoryEntry[]): void; - /** Read observed request costs, oldest first. */ - listUsageCosts?(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[]; /** Read recorded usage-limit snapshots, oldest first. */ listUsageHistory?(query?: UsageHistoryQuery): UsageHistoryEntry[]; /** @@ -698,6 +692,10 @@ const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000; const USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES: Partial> = { "google-antigravity": 2, zai: 2, + // v2: retires cached reports from the OMP-observed spend estimator (dollar + // units) now that limits come from the upstream percent-based `/usage` + // endpoint; the 24h last-good retention would otherwise keep serving them. + "opencode-go": 2, // v2: cache identity gained an `org:` component so two subscriptions on one // account email stop sharing a slot. v3 retires parsed reports created before // Anthropic extra-usage rows existed; header ingestion can otherwise keep @@ -1072,6 +1070,7 @@ const DEFAULT_RANKING_STRATEGIES = new Map( ["anthropic", claudeRankingStrategy], ["google-antigravity", antigravityRankingStrategy], ["zai", zaiRankingStrategy], + ["opencode-go", opencodeGoRankingStrategy], ]); function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined { @@ -3175,7 +3174,6 @@ export class AuthStorage { const report = await providerImpl.fetchUsage(params, { fetch: this.#usageFetch, logger: this.#usageLogger, - listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [], }); // Attribute the report to the credential's organization. The orgId and // orgName fallbacks apply independently: Claude's usage endpoint stamps @@ -3297,42 +3295,6 @@ export class AuthStorage { return this.#store.listUsageHistory?.(query) ?? []; } - /** Record one observed provider request cost for later local usage aggregation. */ - recordUsageCost( - provider: Provider, - costUsd: number, - options?: { sessionId?: string; recordedAt?: number; baseUrl?: string }, - ): boolean { - if (!Number.isFinite(costUsd) || costUsd <= 0) return false; - const record = this.#store.recordUsageCosts; - if (!record) return false; - const credential = this.#resolveObservedUsageCredential(provider, options?.sessionId); - if (!credential) return false; - const entry: UsageCostHistoryEntry = { - recordedAt: options?.recordedAt ?? Date.now(), - provider, - accountKey: this.#buildUsageCacheIdentity(credential), - costUsd, - }; - try { - record.call(this.#store, [entry]); - const cacheKey = this.#buildUsageReportCacheKey({ - provider, - credential, - baseUrl: options?.baseUrl, - }); - const existing = this.#usageCache.getStale(cacheKey); - this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: Date.now() - 1 }); - return true; - } catch (error) { - this.#usageLogger?.debug("usage cost record failed", { - provider, - error: String(error), - }); - return false; - } - } - /** * Forward one completed request's usage to the store's observer hook. * Broker-backed stores batch these into per-install reports so the broker @@ -3383,28 +3345,6 @@ export class AuthStorage { return this.#store.getClientUsageSummary?.(sinceMs) ?? { clients: [] }; } - #resolveObservedUsageCredential(provider: Provider, sessionId?: string): UsageCredential | undefined { - const entries = this.#getStoredCredentials(provider); - const sessionCredential = this.#getSessionCredential(provider, sessionId); - if (sessionCredential) { - const credential = entries[sessionCredential.index]?.credential; - if (credential) { - return credential.type === "api_key" - ? { type: "api_key", apiKey: credential.key } - : this.#buildUsageCredential(credential); - } - } - if (entries.length === 1) { - const credential = entries[0]!.credential; - return credential.type === "api_key" - ? { type: "api_key", apiKey: credential.key } - : this.#buildUsageCredential(credential); - } - const envKey = getEnvApiKey(provider); - if (envKey) return { type: "api_key", apiKey: envKey }; - return undefined; - } - ingestUsageHeaders( provider: Provider, headers: Record, @@ -4101,7 +4041,6 @@ export class AuthStorage { const ctx: UsageFetchContext = { fetch: this.#usageFetch, logger: this.#usageLogger, - listUsageCosts: query => this.#store.listUsageCosts?.(query) ?? [], }; const results: CredentialHealthResult[] = []; diff --git a/packages/ai/src/auth/sqlite-credential-store.ts b/packages/ai/src/auth/sqlite-credential-store.ts index 4380a2312..346ef0408 100644 --- a/packages/ai/src/auth/sqlite-credential-store.ts +++ b/packages/ai/src/auth/sqlite-credential-store.ts @@ -25,8 +25,6 @@ import type { ClientProviderUsage, ClientUsageReport, ClientUsageSummary, - UsageCostHistoryEntry, - UsageCostHistoryQuery, UsageHistoryEntry, UsageHistoryQuery, } from "../usage"; @@ -387,8 +385,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { #releaseCredentialRefreshLeaseStmt: Statement; #credentialBlockReconcileAfter: Map = new Map(); #insertUsageHistoryStmt: Statement; - #insertUsageCostStmt: Statement; - #listUsageCostsStmt: Statement; #lastUsageHistoryStmt: Statement; #listUsageHistoryStmt: Statement; #updateUsageHistoryStmt: Statement; @@ -516,12 +512,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { this.#listUsageHistoryStmt = this.#db.prepare( "SELECT recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at FROM usage_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) ORDER BY recorded_at ASC", ); - this.#insertUsageCostStmt = this.#db.prepare( - "INSERT INTO usage_cost_history (recorded_at, provider, account_key, cost_usd) VALUES (?, ?, ?, ?)", - ); - this.#listUsageCostsStmt = this.#db.prepare( - "SELECT recorded_at, provider, account_key, cost_usd FROM usage_cost_history WHERE recorded_at >= ? AND (? IS NULL OR provider = ?) AND (? IS NULL OR account_key = ?) ORDER BY recorded_at ASC", - ); } static async open(dbPath: string = getAgentDbPath()): Promise { @@ -634,14 +624,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { resets_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_usage_history_series ON usage_history(provider, account_key, limit_id, recorded_at); - CREATE TABLE IF NOT EXISTS usage_cost_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - recorded_at INTEGER NOT NULL, - provider TEXT NOT NULL, - account_key TEXT NOT NULL, - cost_usd REAL NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_usage_cost_history_lookup ON usage_cost_history(provider, account_key, recorded_at); CREATE INDEX IF NOT EXISTS idx_usage_history_recorded ON usage_history(recorded_at); CREATE TABLE IF NOT EXISTS clients ( install_id TEXT PRIMARY KEY, @@ -1769,42 +1751,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { return []; } } - recordUsageCosts(entries: UsageCostHistoryEntry[]): void { - try { - for (const entry of entries) { - this.#insertUsageCostStmt.run(entry.recordedAt, entry.provider, entry.accountKey, entry.costUsd); - } - } catch { - // Cost history is best-effort; never break request persistence. - } - } - - listUsageCosts(query?: UsageCostHistoryQuery): UsageCostHistoryEntry[] { - try { - const provider = query?.provider ?? null; - const accountKey = query?.accountKey ?? null; - const rows = this.#listUsageCostsStmt.all( - query?.sinceMs ?? 0, - provider, - provider, - accountKey, - accountKey, - ) as Array<{ - recorded_at: number; - provider: string; - account_key: string; - cost_usd: number; - }>; - return rows.map(row => ({ - recordedAt: row.recorded_at, - provider: row.provider as Provider, - accountKey: row.account_key, - costUsd: row.cost_usd, - })); - } catch { - return []; - } - } recordClientUsage(report: ClientUsageReport): void { const now = Date.now(); @@ -2051,8 +1997,6 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore { this.#lastUsageHistoryStmt.finalize(); this.#listUsageHistoryStmt.finalize(); this.#updateUsageHistoryStmt.finalize(); - this.#insertUsageCostStmt.finalize(); - this.#listUsageCostsStmt.finalize(); this.#updateIfMatchesStmt.finalize(); this.#updateIfMatchesWithLeaseStmt.finalize(); this.#deleteIfMatchesWithLeaseStmt.finalize(); diff --git a/packages/ai/src/usage.ts b/packages/ai/src/usage.ts index 3770de78e..2cd7b0f50 100644 --- a/packages/ai/src/usage.ts +++ b/packages/ai/src/usage.ts @@ -166,24 +166,6 @@ export interface UsageHistoryQuery { /** Inclusive lower bound on {@link UsageHistoryEntry.recordedAt} (epoch ms). */ sinceMs?: number; } -/** One observed provider request cost, attributed to the credential that made it. */ -export interface UsageCostHistoryEntry { - /** Epoch ms the request completed. */ - recordedAt: number; - provider: Provider; - /** Stable credential identity key (account/email/project/secret derived). */ - accountKey: string; - /** Estimated request cost in USD. */ - costUsd: number; -} - -/** Filter for reading observed request costs. */ -export interface UsageCostHistoryQuery { - provider?: string; - accountKey?: string; - /** Inclusive lower bound on {@link UsageCostHistoryEntry.recordedAt} (epoch ms). */ - sinceMs?: number; -} /** * Aggregated request usage a client observed for one (provider, model) pair. @@ -346,8 +328,6 @@ export interface UsageFetchContext { fetch: FetchImpl; logger?: UsageLogger; retryWait?: (delayMs: number, signal?: AbortSignal) => Promise; - /** Observed request-cost history for providers without upstream usage APIs. */ - listUsageCosts?: (query?: UsageCostHistoryQuery) => UsageCostHistoryEntry[]; } /** Provider implementation for fetching usage information. */ diff --git a/packages/ai/src/usage/opencode-go.ts b/packages/ai/src/usage/opencode-go.ts index 8e4a117ba..981fa3abd 100644 --- a/packages/ai/src/usage/opencode-go.ts +++ b/packages/ai/src/usage/opencode-go.ts @@ -1,88 +1,181 @@ -import type { UsageCostHistoryEntry, UsageLimit, UsageProvider, UsageWindow } from "../usage"; +import { ProviderHttpError } from "../error"; +import type { + CredentialRankingStrategy, + UsageFetchContext, + UsageFetchParams, + UsageLimit, + UsageProvider, + UsageReport, + UsageStatus, + UsageWindow, +} from "../usage"; +import { isRecord } from "../utils"; import { DAY_MS, HOUR_MS } from "./shared"; const OPENCODE_GO_PROVIDER = "opencode-go"; -const OPENCODE_GO_LIMITS = [ - { id: "rolling-5h", label: "5 Hour", durationMs: 5 * HOUR_MS, limitUsd: 12 }, - { id: "weekly", label: "Weekly", durationMs: 7 * DAY_MS, limitUsd: 30 }, - { id: "monthly", label: "Monthly", durationMs: 30 * DAY_MS, limitUsd: 60 }, +const DEFAULT_ENDPOINT = "https://opencode.ai/zen/go"; +const USAGE_PATH = "/v1/usage"; + +/** + * `GET /zen/go/v1/usage` response windows. The route is first-party but + * undocumented (`anomalyco/opencode` `packages/console/app/src/routes/zen/go/v1/usage.ts`) + * and its shape changed once on merge day, so each window is decoded + * defensively and malformed windows are skipped rather than failing the report. + * + * Per window: `status` is `"ok" | "rate-limited"`, `percent` is a floored, + * clamped integer 0-100, and `resetsAt` is an ISO timestamp computed server + * side. The monthly window anchors on the subscription anniversary — not a + * 30-day rolling span — so it deliberately carries no `durationMs`. + */ +const OPENCODE_GO_WINDOWS = [ + { key: "rolling", limitId: "rolling-5h", windowId: "5h", label: "5 Hour", durationMs: 5 * HOUR_MS }, + { key: "weekly", limitId: "weekly", windowId: "7d", label: "Weekly", durationMs: 7 * DAY_MS }, + { key: "monthly", limitId: "monthly", windowId: "monthly", label: "Monthly", durationMs: undefined }, ] as const; -function sumWindowCosts(entries: UsageCostHistoryEntry[], sinceMs: number): { used: number; resetsAt?: number } { - let used = 0; - let firstRecordedAt: number | undefined; - for (const entry of entries) { - if (entry.recordedAt < sinceMs) continue; - used += entry.costUsd; - if (firstRecordedAt === undefined || entry.recordedAt < firstRecordedAt) { - firstRecordedAt = entry.recordedAt; - } - } - return { used, resetsAt: firstRecordedAt }; +function normalizeBaseUrl(baseUrl?: string): string { + if (!baseUrl?.trim()) return DEFAULT_ENDPOINT; + // Strip a trailing `/v1` (models.json carries both `zen/go` and + // `zen/go/v1` base URLs) so the usage path doesn't double it, while + // preserving any path-mounted gateway prefix. + const withoutTrailingSlash = baseUrl.trim().replace(/\/+$/, ""); + return withoutTrailingSlash.replace(/\/v1$/i, "") || DEFAULT_ENDPOINT; } -function resolveStatus(usedFraction: number): UsageLimit["status"] { +function resolveStatus(windowStatus: unknown, usedFraction: number): UsageStatus { + if (windowStatus === "rate-limited") return "exhausted"; if (usedFraction >= 1) return "exhausted"; if (usedFraction >= 0.8) return "warning"; return "ok"; } -function buildWindowLimit( - limit: (typeof OPENCODE_GO_LIMITS)[number], - entries: UsageCostHistoryEntry[], - nowMs: number, -): UsageLimit { - const sinceMs = nowMs - limit.durationMs; - const windowCost = sumWindowCosts(entries, sinceMs); - const used = Number(windowCost.used.toFixed(6)); - const usedFraction = used / limit.limitUsd; - const window: UsageWindow = { - id: limit.id, - label: limit.label, - durationMs: limit.durationMs, - }; - if (windowCost.resetsAt !== undefined) { - window.resetsAt = windowCost.resetsAt + limit.durationMs; - } +function buildWindowLimit(descriptor: (typeof OPENCODE_GO_WINDOWS)[number], payload: unknown): UsageLimit | undefined { + if (!isRecord(payload)) return undefined; + const percent = payload.percent; + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) return undefined; + const usedFraction = percent / 100; + const window: UsageWindow = { id: descriptor.windowId, label: descriptor.label }; + if (descriptor.durationMs !== undefined) window.durationMs = descriptor.durationMs; + const resetsAtMs = typeof payload.resetsAt === "string" ? Date.parse(payload.resetsAt) : Number.NaN; + if (Number.isFinite(resetsAtMs)) window.resetsAt = resetsAtMs; return { - id: limit.id, - label: `${limit.label} limit`, + id: descriptor.limitId, + label: `${descriptor.label} limit`, scope: { provider: OPENCODE_GO_PROVIDER, - windowId: limit.id, + windowId: descriptor.windowId, + shared: true, }, window, amount: { - used, - limit: limit.limitUsd, - remaining: Math.max(0, limit.limitUsd - used), + used: percent, usedFraction, remainingFraction: Math.max(0, 1 - usedFraction), - unit: "usd", + unit: "percent", }, - status: resolveStatus(usedFraction), + status: resolveStatus(payload.status, usedFraction), + }; +} + +async function readUpstreamErrorMessage(response: Response): Promise { + try { + const payload = (await response.json()) as unknown; + if (!isRecord(payload) || !isRecord(payload.error)) return undefined; + return typeof payload.error.message === "string" ? payload.error.message : undefined; + } catch { + return undefined; + } +} + +async function fetchOpenCodeGoUsage(params: UsageFetchParams, ctx: UsageFetchContext): Promise { + if (params.provider !== OPENCODE_GO_PROVIDER) return null; + const credential = params.credential; + if (credential.type !== "api_key" || !credential.apiKey) return null; + + const url = `${normalizeBaseUrl(params.baseUrl)}${USAGE_PATH}`; + let payload: unknown; + try { + const response = await ctx.fetch(url, { + headers: { + accept: "application/json", + authorization: `Bearer ${credential.apiKey}`, + }, + signal: params.signal, + }); + if (!response.ok) { + // 401 (missing/invalid key) and 403 (no Go subscription) must throw + // so checkCredentials flags the credential as ok:false rather than + // ok:null (unknown). Other non-ok statuses are transient — return + // null so the cached last-good report serves through them. + if (response.status === 401 || response.status === 403) { + const detail = await readUpstreamErrorMessage(response); + throw new ProviderHttpError( + `OpenCode Go usage endpoint returned ${response.status}${detail ? `: ${detail}` : ""}`, + response.status, + ); + } + ctx.logger?.warn("OpenCode Go usage fetch failed", { + status: response.status, + statusText: response.statusText, + }); + return null; + } + payload = (await response.json()) as unknown; + } catch (error) { + if (error instanceof ProviderHttpError) throw error; + ctx.logger?.warn("OpenCode Go usage fetch error", { error: String(error) }); + return null; + } + + if (!isRecord(payload) || !isRecord(payload.usage)) { + ctx.logger?.warn("OpenCode Go usage response had no usage object"); + return null; + } + const usage = payload.usage; + const limits: UsageLimit[] = []; + for (const descriptor of OPENCODE_GO_WINDOWS) { + const limit = buildWindowLimit(descriptor, usage[descriptor.key]); + if (limit) limits.push(limit); + } + if (limits.length === 0) return null; + + return { + provider: OPENCODE_GO_PROVIDER, + fetchedAt: Date.now(), + limits, + metadata: { + planType: "OpenCode Go", + endpoint: url, + }, + raw: payload, }; } export const opencodeGoUsageProvider: UsageProvider = { id: OPENCODE_GO_PROVIDER, + fetchUsage: fetchOpenCodeGoUsage, supports: params => params.provider === OPENCODE_GO_PROVIDER && params.credential.type === "api_key", - validatesCredentials: false, - async fetchUsage(params, ctx) { - if (params.provider !== OPENCODE_GO_PROVIDER || params.credential.type !== "api_key") return null; - const nowMs = Date.now(); - const sinceMs = nowMs - OPENCODE_GO_LIMITS[OPENCODE_GO_LIMITS.length - 1]!.durationMs; - const entries = - ctx.listUsageCosts?.({ provider: OPENCODE_GO_PROVIDER, accountKey: params.accountKey, sinceMs }) ?? []; - return { - provider: OPENCODE_GO_PROVIDER, - fetchedAt: nowMs, - limits: OPENCODE_GO_LIMITS.map(limit => buildWindowLimit(limit, entries, nowMs)), - notes: ["OMP-observed spend only; OpenCode usage outside OMP is not included."], - metadata: { - planType: "OpenCode Go", - source: "omp-observed-request-costs", - }, - }; + validatesCredentials: true, +}; + +/** + * Multi-key pools rank by real headroom on the rolling and weekly windows. + * + * The monthly window is deliberately display-only: an exhausted monthly can + * still serve requests when the account's console "Use balance" fallback is + * enabled, and the usage endpoint does not report that flag — blocking on it + * would bench a working key until the subscription anniversary. Hard monthly + * failures still rotate credentials via the `401 Insufficient balance` + * usage-limit classification ([#3169](https://github.com/can1357/oh-my-pi/issues/3169)). + */ +export const opencodeGoRankingStrategy: CredentialRankingStrategy = { + findWindowLimits: report => ({ + primary: report.limits.find(limit => limit.id === "rolling-5h"), + secondary: report.limits.find(limit => limit.id === "weekly"), + }), + scopeLimits: report => report.limits.filter(limit => limit.id !== "monthly"), + windowDefaults: { + primaryMs: 5 * HOUR_MS, + secondaryMs: 7 * DAY_MS, }, }; diff --git a/packages/ai/test/auth-storage-check-credentials.test.ts b/packages/ai/test/auth-storage-check-credentials.test.ts index 6a73a5146..678db52cf 100644 --- a/packages/ai/test/auth-storage-check-credentials.test.ts +++ b/packages/ai/test/auth-storage-check-credentials.test.ts @@ -34,7 +34,7 @@ import { } from "@oh-my-pi/pi-ai/auth-storage"; import type { UsageProvider } from "@oh-my-pi/pi-ai/usage"; import * as claudeUsage from "@oh-my-pi/pi-ai/usage/claude"; -import { opencodeGoUsageProvider } from "@oh-my-pi/pi-ai/usage/opencode-go"; +import { ollamaCloudUsageProvider } from "@oh-my-pi/pi-ai/usage/ollama"; function oauthRow(id: number, email: string, opts?: { expired?: boolean }): StoredAuthCredential { const credential: AuthCredential = { @@ -402,13 +402,13 @@ describe("AuthStorage.checkCredentials", () => { it("does not mark local-only usage providers healthy without upstream validation", async () => { const apiKeyRow: StoredAuthCredential = { id: 12, - provider: "opencode-go", - credential: { type: "api_key", key: "sk-opencode-go" }, + provider: "ollama-cloud", + credential: { type: "api_key", key: "sk-ollama-cloud" }, disabledCause: null, }; const store = makeStore([apiKeyRow]); const storage = new AuthStorage(store, { - usageProviderResolver: provider => (provider === "opencode-go" ? opencodeGoUsageProvider : undefined), + usageProviderResolver: provider => (provider === "ollama-cloud" ? ollamaCloudUsageProvider : undefined), }); await storage.reload(); diff --git a/packages/ai/test/auth-storage-usage-history.test.ts b/packages/ai/test/auth-storage-usage-history.test.ts index 28659bc63..9d9894a09 100644 --- a/packages/ai/test/auth-storage-usage-history.test.ts +++ b/packages/ai/test/auth-storage-usage-history.test.ts @@ -188,15 +188,33 @@ describe("AuthStorage usage history recording", () => { }); }); -describe("OpenCode Go usage from observed request costs", () => { +describe("OpenCode Go usage via the upstream endpoint", () => { let store: SqliteAuthCredentialStore; let storage: AuthStorage; + let fetchCalls: Array<{ url: string; headers: Record }>; beforeEach(async () => { + fetchCalls = []; store = new SqliteAuthCredentialStore(new Database(":memory:")); storage = new AuthStorage(store, { usageProviderResolver: provider => provider === "opencode-go" ? opencodeGoUsage.opencodeGoUsageProvider : undefined, + usageFetch: (async (input: string | URL | Request, init?: RequestInit) => { + fetchCalls.push({ + url: String(input), + headers: (init?.headers as Record) ?? {}, + }); + return new Response( + JSON.stringify({ + usage: { + rolling: { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" }, + weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" }, + monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-08-19T00:31:53.847Z" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch, }); await storage.reload(); await storage.set("opencode-go", { type: "api_key", key: "opencode-go-key" }); @@ -208,50 +226,30 @@ describe("OpenCode Go usage from observed request costs", () => { vi.restoreAllMocks(); }); - it("returns zero-dollar OpenCode Go limits for a fresh key", async () => { + it("fetches percent-based limits for a stored API key and records history rows", async () => { const reports = await storage.fetchUsageReports(); + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.url).toBe("https://opencode.ai/zen/go/v1/usage"); + expect(fetchCalls[0]?.headers.authorization).toBe("Bearer opencode-go-key"); + const report = reports?.find(candidate => candidate.provider === "opencode-go"); - expect(report?.limits.map(limit => [limit.id, limit.amount.used, limit.amount.limit])).toEqual([ - ["rolling-5h", 0, 12], - ["weekly", 0, 30], - ["monthly", 0, 60], + expect(report?.limits.map(limit => [limit.id, limit.amount.used, limit.status])).toEqual([ + ["rolling-5h", 12, "ok"], + ["weekly", 8, "ok"], + ["monthly", 100, "exhausted"], + ]); + expect(report?.limits.map(limit => limit.scope.windowId)).toEqual(["5h", "7d", "monthly"]); + expect(report?.limits.find(limit => limit.id === "monthly")?.window?.resetsAt).toBe( + Date.parse("2026-08-19T00:31:53.847Z"), + ); + + // Fresh reports append durable usage-history rows per limit window. + const rows = store.listUsageHistory({ provider: "opencode-go" }); + expect(rows.map(row => [row.limitId, row.usedFraction])).toEqual([ + ["rolling-5h", 0.12], + ["weekly", 0.08], + ["monthly", 1], ]); }); - - it("refreshes cached OpenCode Go limits after recording new observed spend", async () => { - const nowMs = Date.parse("2026-06-18T12:00:00Z"); - setSystemTime(new Date(nowMs)); - - const initialReports = await storage.fetchUsageReports(); - const initial = initialReports?.find(candidate => candidate.provider === "opencode-go"); - expect(initial?.limits.find(limit => limit.id === "rolling-5h")?.amount.used).toBe(0); - - storage.recordUsageCost("opencode-go", 3, { recordedAt: nowMs }); - - const refreshedReports = await storage.fetchUsageReports(); - const refreshed = refreshedReports?.find(candidate => candidate.provider === "opencode-go"); - expect(refreshed?.limits.find(limit => limit.id === "rolling-5h")?.amount.used).toBe(3); - }); - - it("aggregates one key's observed spend into OpenCode Go cap windows", async () => { - const nowMs = Date.parse("2026-06-18T12:00:00Z"); - setSystemTime(new Date(nowMs)); - storage.recordUsageCost("opencode-go", 4, { recordedAt: nowMs - HOUR }); - storage.recordUsageCost("opencode-go", 7, { recordedAt: nowMs - 6 * HOUR }); - storage.recordUsageCost("opencode-go", 11, { recordedAt: nowMs - 10 * 24 * HOUR }); - storage.recordUsageCost("opencode-go", 13, { recordedAt: nowMs - 31 * 24 * HOUR }); - - const reports = await storage.fetchUsageReports(); - const report = reports?.find(candidate => candidate.provider === "opencode-go"); - if (!report) throw new Error("expected opencode-go usage report"); - - const usedByLimit = new Map(report.limits.map(limit => [limit.id, limit.amount.used])); - expect(usedByLimit.get("rolling-5h")).toBe(4); - expect(usedByLimit.get("weekly")).toBe(11); - expect(usedByLimit.get("monthly")).toBe(22); - - const fiveHour = report.limits.find(limit => limit.id === "rolling-5h"); - expect(fiveHour?.window?.resetsAt).toBe(nowMs - HOUR + 5 * HOUR); - }); }); diff --git a/packages/ai/test/opencode-go-usage.test.ts b/packages/ai/test/opencode-go-usage.test.ts new file mode 100644 index 000000000..b19866faf --- /dev/null +++ b/packages/ai/test/opencode-go-usage.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "bun:test"; +import type { FetchImpl } from "@oh-my-pi/pi-ai/types"; +import { opencodeGoRankingStrategy, opencodeGoUsageProvider } from "../src/usage/opencode-go"; + +const DEFAULT_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; + +/** Live capture from `GET /zen/go/v1/usage`, 2026-08-12. */ +function usagePayload(overrides: Record = {}): Record { + return { + usage: { + rolling: { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" }, + weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" }, + monthly: { status: "rate-limited", percent: 100, resetsAt: "2026-08-19T00:31:53.847Z" }, + ...overrides, + }, + }; +} + +function fakeFetch(payload: unknown, status = 200): FetchImpl { + const fn = async () => + new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); + return fn as unknown as typeof fetch; +} + +function fetchRecorder( + calls: Array<{ url: string; headers: Record }>, + payload: unknown, + status = 200, +): FetchImpl { + const fn = async (input: string | URL | Request, init?: RequestInit) => { + calls.push({ + url: String(input), + headers: (init?.headers as Record) ?? {}, + }); + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); + }; + return fn as unknown as typeof fetch; +} + +describe("opencode-go usage provider", () => { + it("parses the three windows into percent limits with canonical window ids", async () => { + const report = await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { fetch: fakeFetch(usagePayload()) }, + ); + expect(report).not.toBeNull(); + expect(report?.limits.map(limit => [limit.id, limit.scope.windowId, limit.amount.used])).toEqual([ + ["rolling-5h", "5h", 12], + ["weekly", "7d", 8], + ["monthly", "monthly", 100], + ]); + const rolling = report?.limits.find(limit => limit.id === "rolling-5h"); + expect(rolling?.amount.usedFraction).toBeCloseTo(0.12, 5); + expect(rolling?.amount.unit).toBe("percent"); + expect(rolling?.window?.durationMs).toBe(5 * 3_600_000); + expect(rolling?.window?.resetsAt).toBe(Date.parse("2026-08-12T15:09:04.847Z")); + // Monthly anchors on the subscription anniversary, not a 30d span. + const monthly = report?.limits.find(limit => limit.id === "monthly"); + expect(monthly?.window?.durationMs).toBeUndefined(); + expect(monthly?.window?.resetsAt).toBe(Date.parse("2026-08-19T00:31:53.847Z")); + }); + + it("maps rate-limited windows to exhausted and high usage to warning", async () => { + const report = await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { + fetch: fakeFetch( + usagePayload({ + rolling: { status: "ok", percent: 85, resetsAt: "2026-08-12T15:09:04.847Z" }, + }), + ), + }, + ); + expect(report?.limits.find(limit => limit.id === "rolling-5h")?.status).toBe("warning"); + expect(report?.limits.find(limit => limit.id === "weekly")?.status).toBe("ok"); + expect(report?.limits.find(limit => limit.id === "monthly")?.status).toBe("exhausted"); + }); + + it("sends Authorization: Bearer to the fixed usage route", async () => { + const calls: Array<{ url: string; headers: Record }> = []; + await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { fetch: fetchRecorder(calls, usagePayload()) }, + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe(DEFAULT_USAGE_URL); + expect(calls[0]?.headers.authorization).toBe("Bearer sk-test"); + }); + + it("normalizes both catalog baseUrl forms onto the usage route", async () => { + for (const baseUrl of ["https://opencode.ai/zen/go", "https://opencode.ai/zen/go/v1"]) { + const calls: Array<{ url: string; headers: Record }> = []; + await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" }, baseUrl }, + { fetch: fetchRecorder(calls, usagePayload()) }, + ); + expect(calls[0]?.url).toBe(DEFAULT_USAGE_URL); + } + }); + + it("throws on 401 with the upstream error message so checkCredentials flags the key", async () => { + await expect( + opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { + fetch: fakeFetch({ type: "error", error: { type: "AuthError", message: "Unauthorized" } }, 401), + }, + ), + ).rejects.toThrow(/401.*Unauthorized/); + }); + + it("throws on 403 so lapsed Go subscriptions surface in credential health", async () => { + await expect( + opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { + fetch: fakeFetch( + { type: "error", error: { type: "EntitlementError", message: "OpenCode Go subscription required." } }, + 403, + ), + }, + ), + ).rejects.toThrow(/403.*subscription required/); + }); + + it("returns null on a transient non-auth HTTP failure (500)", async () => { + const report = await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { fetch: fakeFetch({ message: "internal server error" }, 500) }, + ); + expect(report).toBeNull(); + }); + + it("skips malformed windows and returns null when no window parses", async () => { + const partial = await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { + fetch: fakeFetch(usagePayload({ rolling: { status: "ok", percent: "abc" }, weekly: null })), + }, + ); + expect(partial?.limits.map(limit => limit.id)).toEqual(["monthly"]); + + const empty = await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { fetch: fakeFetch({ usage: {} }) }, + ); + expect(empty).toBeNull(); + + const noUsage = await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { fetch: fakeFetch({}) }, + ); + expect(noUsage).toBeNull(); + }); +}); + +describe("opencode-go ranking strategy", () => { + it("ranks on rolling/weekly and keeps the monthly window display-only", async () => { + const report = await opencodeGoUsageProvider.fetchUsage( + { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, + { fetch: fakeFetch(usagePayload()) }, + ); + if (!report) throw new Error("expected report"); + + const windows = opencodeGoRankingStrategy.findWindowLimits(report); + expect(windows.primary?.id).toBe("rolling-5h"); + expect(windows.secondary?.id).toBe("weekly"); + + // Exhausted monthly (recoverable via the console "Use balance" + // fallback) must not enter credential-wide exhaustion checks. + const scoped = opencodeGoRankingStrategy.scopeLimits?.(report); + expect(scoped?.map(limit => limit.id)).toEqual(["rolling-5h", "weekly"]); + }); +}); diff --git a/packages/ai/test/usage-report-notes-schema.test.ts b/packages/ai/test/usage-report-notes-schema.test.ts index 7a6dce3d9..1b9c7f283 100644 --- a/packages/ai/test/usage-report-notes-schema.test.ts +++ b/packages/ai/test/usage-report-notes-schema.test.ts @@ -14,24 +14,24 @@ import { type } from "@oh-my-pi/omptype"; import { usageReportSchema } from "@oh-my-pi/pi-ai"; import { usageResponseSchema } from "@oh-my-pi/pi-ai/auth-broker/wire-schemas"; -const DISCLAIMER = "OMP-observed spend only; OpenCode usage outside OMP is not included."; +const PROVIDER_NOTE = "Usage data can be delayed by up to five minutes."; function reportWithNotes() { return { - provider: "opencode-go", + provider: "anthropic", fetchedAt: Date.now(), limits: [ { - id: "rolling-5h", - label: "5 Hour limit", - scope: { provider: "opencode-go", windowId: "rolling-5h" }, - window: { id: "rolling-5h", label: "5 Hour", durationMs: 5 * 3_600_000 }, - amount: { used: 3, limit: 12, remaining: 9, usedFraction: 0.25, remainingFraction: 0.75, unit: "usd" }, + id: "anthropic:5h", + label: "5 Hour", + scope: { provider: "anthropic", windowId: "5h" }, + window: { id: "5h", label: "5 Hour", durationMs: 5 * 3_600_000 }, + amount: { usedFraction: 0.25, remainingFraction: 0.75, unit: "percent" }, status: "ok", }, ], - notes: [DISCLAIMER], - metadata: { planType: "OpenCode Go" }, + notes: [PROVIDER_NOTE], + metadata: { planType: "Pro" }, }; } @@ -39,7 +39,7 @@ describe("usage report notes wire schema", () => { it("usageReportSchema accepts report-level notes and preserves them", () => { const validated = usageReportSchema(reportWithNotes()); expect(validated).not.toBeInstanceOf(type.errors); - expect(validated).toHaveProperty("notes", [DISCLAIMER]); + expect(validated).toHaveProperty("notes", [PROVIDER_NOTE]); }); it("usageResponseSchema preserves report-level notes through the broker reject gate", () => { @@ -52,6 +52,6 @@ describe("usage report notes wire schema", () => { expect(validated).toHaveProperty("reports"); if (validated instanceof type.errors) throw new Error("expected valid response"); const reports = validated.reports; - expect(reports[0]).toHaveProperty("notes", [DISCLAIMER]); + expect(reports[0]).toHaveProperty("notes", [PROVIDER_NOTE]); }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b387e76fd..9f90cdf95 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- `/usage`, `omp usage`, and the status line now show authoritative OpenCode Go quota from the official `GET /zen/go/v1/usage` endpoint — including usage made outside OMP — instead of dollar estimates summed from OMP-observed request costs. The status line renders all three windows (`5h` / `7d` / `mo`), and the per-turn cost recording special case for `opencode-go` sessions is gone along with the "OMP-observed spend only" disclaimer. + ## [17.2.15] - 2026-08-12 ### Added diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 0eb84ab24..ab566360f 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -1446,10 +1446,15 @@ export class StatusLineComponent implements Component { }; sevenDayTier = tier || undefined; } - // Conservatively gate monthly status-line rendering to Cursor for now — - // Copilot/OpenCode also emit monthly windows, but their multi-bucket - // shape needs a dedicated selector before we surface `mo N%` for them. - if (activeProvider === "cursor" && (windowId === "monthly" || windowId === "30d")) { + // Monthly rendering is gated to providers with a single monthly + // bucket (Cursor's priority selector picks its personal rail; + // OpenCode Go emits exactly one). Copilot also emits monthly + // windows, but its multi-bucket shape needs a dedicated selector + // before we surface `mo N%` for it. + if ( + (activeProvider === "cursor" || activeProvider === "opencode-go") && + (windowId === "monthly" || windowId === "30d") + ) { const priority = cursorMonthlyPriority(l.id); const shouldReplace = !monthly || diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 66dcb3a2c..aec3f3298 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -667,8 +667,9 @@ const usageSegment: StatusLineSegment = { } if (u.monthly) { const pct = u.monthly.percent; - // Cursor-only today (normalize gates monthly to provider === "cursor"). - // Cursor's web dashboard floors included-usage percents (1.88 → "1% used"). + // Cursor and OpenCode Go (normalize gates monthly to those providers). + // Both floor used percents upstream (Cursor's dashboard shows 1.88 → + // "1% used"; OpenCode's endpoint already emits floored integers). const pctText = theme.fg(pickUsageColor(pct), `${Math.floor(pct)}%`); const reset = u.monthly.resetHours !== undefined diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 5b0475f3c..daca681b0 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2590,13 +2590,6 @@ export class AgentSession { this.#maintenance.skipPostTurnMaintenanceAssistantTimestamp = assistantMsg.timestamp; } await this.#recovery.onAssistantSettledSuccessfully(assistantMsg); - if (assistantMsg.provider === "opencode-go") { - this.#modelRegistry.authStorage.recordUsageCost(assistantMsg.provider, assistantMsg.usage.cost.total, { - sessionId: this.#activeProviderSessionId(), - recordedAt: assistantMsg.timestamp, - baseUrl: this.#modelRegistry.getProviderBaseUrl?.(assistantMsg.provider), - }); - } // Broker deployments: report this request's burn so the broker can // attribute token usage per install. No-op with a local auth store. this.#modelRegistry.authStorage.recordObservedUsage({ diff --git a/packages/coding-agent/test/status-line-usage.test.ts b/packages/coding-agent/test/status-line-usage.test.ts index e0a3cc00e..ef7eb531c 100644 --- a/packages/coding-agent/test/status-line-usage.test.ts +++ b/packages/coding-agent/test/status-line-usage.test.ts @@ -400,13 +400,31 @@ describe("usage status-line segment", () => { expect(content).not.toContain("90%"); }); - it("does not render monthly usage for non-Cursor providers", async () => { + it("renders all three OpenCode Go windows including monthly", async () => { + const now = Date.now(); const component = makeComponent( [ { provider: "opencode-go", limits: [ - { id: "opencode-go:usd:monthly", scope: { windowId: "monthly" }, amount: { usedFraction: 0.42 } }, + { + id: "rolling-5h", + scope: { windowId: "5h" }, + window: { id: "5h", durationMs: 5 * 3_600_000, resetsAt: now + 90 * 60_000 }, + amount: { used: 12, usedFraction: 0.12, unit: "percent" }, + }, + { + id: "weekly", + scope: { windowId: "7d" }, + window: { id: "7d", durationMs: 7 * 86_400_000, resetsAt: now + 100 * 3_600_000 }, + amount: { used: 8, usedFraction: 0.08, unit: "percent" }, + }, + { + id: "monthly", + scope: { windowId: "monthly" }, + window: { id: "monthly", resetsAt: now + 160 * 3_600_000 }, + amount: { used: 42, usedFraction: 0.42, unit: "percent" }, + }, ], }, ], @@ -417,6 +435,29 @@ describe("usage status-line segment", () => { await flushUsageRefresh(); const content = stripVTControlCharacters(component.getTopBorder(200).content); + expect(content).toContain("5h"); + expect(content).toContain("12%"); + expect(content).toContain("7d"); + expect(content).toContain("8%"); + expect(content).toContain("mo"); + expect(content).toContain("42%"); + }); + + it("does not render monthly usage for providers outside the single-bucket gate", async () => { + const component = makeComponent( + [ + { + provider: "github-copilot", + limits: [{ id: "copilot:premium", scope: { windowId: "monthly" }, amount: { usedFraction: 0.42 } }], + }, + ], + { provider: "github-copilot" }, + ); + + component.refreshUsageInBackground(); + await flushUsageRefresh(); + const content = stripVTControlCharacters(component.getTopBorder(200).content); + expect(content).not.toContain("mo"); expect(content).not.toContain("42%"); }); diff --git a/packages/coding-agent/test/usage-cli.test.ts b/packages/coding-agent/test/usage-cli.test.ts index b6f58ec16..def312951 100644 --- a/packages/coding-agent/test/usage-cli.test.ts +++ b/packages/coding-agent/test/usage-cli.test.ts @@ -422,29 +422,29 @@ describe("formatUsageBreakdown", () => { }); it("renders provider-level notes once per provider, not duplicated per account or limit", () => { - const disclaimer = "OMP-observed spend only; OpenCode usage outside OMP is not included."; + const providerNote = "Usage data can be delayed by up to five minutes."; const multiAccount = [ makeReport( - "opencode-go", + "anthropic", "acct-a@example.test", [makeLimit({ id: "5 Hour", usedFraction: 0.3, durationMs: FIVE_HOURS, windowId: "5h" })], - [disclaimer], + [providerNote], ), makeReport( - "opencode-go", + "anthropic", "acct-b@example.test", [makeLimit({ id: "5 Hour", usedFraction: 0.6, durationMs: FIVE_HOURS, windowId: "5h" })], - [disclaimer], + [providerNote], ), ]; const text = stripVTControlCharacters(formatUsageBreakdown(multiAccount, [], Date.now())); - // The disclaimer appears exactly once, not once per account or limit. - const occurrences = text.split(disclaimer).length - 1; + // The provider note appears exactly once, not once per account or limit. + const occurrences = text.split(providerNote).length - 1; expect(occurrences).toBe(1); // It appears above the per-account rows, not inline with a limit line. - const disclaimerIdx = text.indexOf(disclaimer); + const noteIdx = text.indexOf(providerNote); const firstLimitIdx = text.indexOf("5 Hour"); - expect(disclaimerIdx).toBeLessThan(firstLimitIdx); + expect(noteIdx).toBeLessThan(firstLimitIdx); }); it("renders Antigravity weekly windows in the usage breakdown", () => { diff --git a/packages/coding-agent/test/usage-report-tui-notes.test.ts b/packages/coding-agent/test/usage-report-tui-notes.test.ts index d45928432..21b9639a3 100644 --- a/packages/coding-agent/test/usage-report-tui-notes.test.ts +++ b/packages/coding-agent/test/usage-report-tui-notes.test.ts @@ -49,23 +49,23 @@ function report(provider: string, email: string, limits: UsageReport["limits"], describe("renderUsageReports (#3268 TUI aggregate)", () => { it("renders provider-wide UsageReport.notes exactly once for multiple accounts", () => { - const disclaimer = "OMP-observed spend only; OpenCode usage outside OMP is not included."; + const providerNote = "Usage data can be delayed by up to five minutes."; const reports: UsageReport[] = [ report( - "opencode-go", + "github-copilot", "acct-a@example.test", [limit("5 Hour limit", "rolling-5h", 5 * HOUR, 0.3)], - [disclaimer], + [providerNote], ), report( - "opencode-go", + "github-copilot", "acct-b@example.test", [limit("5 Hour limit", "rolling-5h", 5 * HOUR, 0.6)], - [disclaimer], + [providerNote], ), ]; const text = stripVTControlCharacters(renderUsageReports(reports, theme, Date.now(), 120)); - const occurrences = text.split(disclaimer).length - 1; + const occurrences = text.split(providerNote).length - 1; expect(occurrences).toBe(1); }); From a120c96784ce48d531fe697f27c771bcfad8ee31 Mon Sep 17 00:00:00 2001 From: Will Bogusz <24197106+will-bogusz@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:48:09 +0200 Subject: [PATCH 2/3] fix(usage): resolve reference-stored API keys before usage probes --- packages/ai/CHANGELOG.md | 8 +++- packages/ai/src/auth-storage.ts | 42 ++++++++++++++----- .../auth-storage-check-credentials.test.ts | 34 +++++++++++++++ .../test/auth-storage-usage-history.test.ts | 34 +++++++++++++++ packages/coding-agent/CHANGELOG.md | 2 +- 5 files changed, 106 insertions(+), 14 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 8d44233f4..78bbe97ee 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -4,11 +4,15 @@ ### Changed -- OpenCode Go usage now comes from the official `GET /zen/go/v1/usage` endpoint (rolling 5h / weekly / monthly percent windows with server-computed resets) instead of synthesizing dollar estimates from OMP-observed request costs, so `/usage` reflects spend made outside OMP and the hardcoded $12/$30/$60 caps are gone. The usage probe now validates credentials (401 invalid key, 403 lapsed Go subscription), and a new ranking strategy routes multi-key pools by rolling/weekly headroom while keeping the monthly window display-only (an exhausted monthly can still serve requests via the console "Use balance" fallback). +- OpenCode Go usage now comes from the official `GET /zen/go/v1/usage` endpoint (rolling 5h / weekly / monthly percent windows with server-computed resets) instead of synthesizing dollar estimates from OMP-observed request costs, so `/usage` reflects spend made outside OMP and the hardcoded $12/$30/$60 caps are gone. The usage probe now validates credentials (401 invalid key, 403 lapsed Go subscription), and a new ranking strategy routes multi-key pools by rolling/weekly headroom while keeping the monthly window display-only (an exhausted monthly can still serve requests via the console "Use balance" fallback) ([#8337](https://github.com/can1357/oh-my-pi/pull/8337) by [@will-bogusz](https://github.com/will-bogusz)). + +### Fixed + +- Fixed aggregate usage fetches and credential-health probes sending reference-stored API keys (env var name, `!command`) as the literal reference string instead of the resolved secret, which would 401 and flag working credentials as bad for providers whose usage probe validates credentials ([#8337](https://github.com/can1357/oh-my-pi/pull/8337) by [@will-bogusz](https://github.com/will-bogusz)). ### Removed -- Removed the observed-request-cost machinery that existed only to power the OpenCode Go estimate: `AuthStorage.recordUsageCost`, the store `recordUsageCosts`/`listUsageCosts` hooks, `UsageFetchContext.listUsageCosts`, the `UsageCostHistoryEntry`/`UsageCostHistoryQuery` types, and the `usage_cost_history` schema and statements. Existing unused tables are left intact rather than deleting local data during startup. +- Removed the observed-request-cost machinery that existed only to power the OpenCode Go estimate: `AuthStorage.recordUsageCost`, the store `recordUsageCosts`/`listUsageCosts` hooks, `UsageFetchContext.listUsageCosts`, the `UsageCostHistoryEntry`/`UsageCostHistoryQuery` types, and the `usage_cost_history` schema and statements. Existing unused tables are left intact rather than deleting local data during startup ([#8337](https://github.com/can1357/oh-my-pi/pull/8337) by [@will-bogusz](https://github.com/will-bogusz)). ## [17.2.15] - 2026-08-12 diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index ce23f083f..705684afe 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3428,9 +3428,9 @@ export class AuthStorage { return true; } - #collectUsageRequests(options?: { + async #collectUsageRequests(options?: { baseUrlResolver?: (provider: Provider) => string | undefined; - }): UsageRequestDescriptor[] { + }): Promise { const resolver = this.#usageProviderResolver; if (!resolver) return []; @@ -3490,10 +3490,19 @@ export class AuthStorage { for (const entry of entries) { const credential = entry.credential; - const request = - credential.type === "api_key" - ? this.#buildUsageRequest(provider, { type: "api_key", apiKey: credential.key }, baseUrl) - : this.#buildUsageRequestForOauth(provider, credential, baseUrl); + let request: UsageRequestDescriptor; + if (credential.type === "api_key") { + // Stored keys may be references (env var name, "!command") — + // resolve to the actual secret before it reaches a provider + // fetcher's Authorization header. Unresolvable references are + // skipped: probing with the literal reference string would + // 401 and flag a working credential as bad. + const apiKey = await this.#configValueResolver(credential.key); + if (!apiKey) continue; + request = this.#buildUsageRequest(provider, { type: "api_key", apiKey }, baseUrl); + } else { + request = this.#buildUsageRequestForOauth(provider, credential, baseUrl); + } if (providerImpl.supports && !providerImpl.supports(request)) continue; requests.push(request); } @@ -3945,7 +3954,7 @@ export class AuthStorage { } if (!this.#usageProviderResolver) return null; - const requests = this.#collectUsageRequests(options); + const requests = await this.#collectUsageRequests(options); if (requests.length === 0) return []; this.#usageLogger?.debug("Usage fetch requested", { @@ -4062,10 +4071,21 @@ export class AuthStorage { const baseUrl = options?.baseUrlResolver?.(row.provider as Provider); const cred = row.credential; - const initialRequest: UsageRequestDescriptor = - cred.type === "api_key" - ? this.#buildUsageRequest(row.provider as Provider, { type: "api_key", apiKey: cred.key }, baseUrl) - : this.#buildUsageRequestForOauth(row.provider as Provider, cred, baseUrl); + let initialRequest: UsageRequestDescriptor; + if (cred.type === "api_key") { + // Stored keys may be references (env var name, "!command") — probe + // with the resolved secret, not the reference string, so both the + // usage probe and the completion probe exercise the real bytes. + const apiKey = await this.#configValueResolver(cred.key); + if (!apiKey) { + base.reason = "api key reference could not be resolved"; + results.push(base); + continue; + } + initialRequest = this.#buildUsageRequest(row.provider as Provider, { type: "api_key", apiKey }, baseUrl); + } else { + initialRequest = this.#buildUsageRequestForOauth(row.provider as Provider, cred, baseUrl); + } const timeoutSignal = AbortSignal.timeout(timeoutMs); const probeSignal = options?.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal; diff --git a/packages/ai/test/auth-storage-check-credentials.test.ts b/packages/ai/test/auth-storage-check-credentials.test.ts index 678db52cf..7d350be01 100644 --- a/packages/ai/test/auth-storage-check-credentials.test.ts +++ b/packages/ai/test/auth-storage-check-credentials.test.ts @@ -522,4 +522,38 @@ describe("AuthStorage.checkCredentials", () => { storage.close(); } }); + + it("probes reference-stored API keys with the resolved secret", async () => { + // Keys stored as references (env var name, "!command") must reach the + // usage probe as the resolved secret — probing with the literal + // reference string would 401 and flag a working credential as bad. + const apiKeyRow: StoredAuthCredential = { + id: 21, + provider: "opencode-go", + credential: { type: "api_key", key: "ref:opencode" }, + disabledCause: null, + }; + const seenKeys: Array = []; + const probeProvider: UsageProvider = { + id: "opencode-go", + validatesCredentials: true, + async fetchUsage(params) { + seenKeys.push(params.credential.type === "api_key" ? params.credential.apiKey : undefined); + return { provider: "opencode-go", fetchedAt: Date.now(), limits: [] }; + }, + }; + const storage = new AuthStorage(makeStore([apiKeyRow]), { + usageProviderResolver: provider => (provider === "opencode-go" ? probeProvider : undefined), + configValueResolver: async config => (config === "ref:opencode" ? "sk-resolved-secret" : config), + }); + await storage.reload(); + + try { + const [result] = await storage.checkCredentials(); + expect(seenKeys).toEqual(["sk-resolved-secret"]); + expect(result.ok).toBe(true); + } finally { + storage.close(); + } + }); }); diff --git a/packages/ai/test/auth-storage-usage-history.test.ts b/packages/ai/test/auth-storage-usage-history.test.ts index 9d9894a09..4e96bd561 100644 --- a/packages/ai/test/auth-storage-usage-history.test.ts +++ b/packages/ai/test/auth-storage-usage-history.test.ts @@ -252,4 +252,38 @@ describe("OpenCode Go usage via the upstream endpoint", () => { ["monthly", 1], ]); }); + + it("resolves reference-stored API keys before the Authorization header", async () => { + // Keys stored as references (env var name, "!command") must reach the + // endpoint as the resolved secret, not the reference string (#8337 review). + const referenceStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")), { + usageProviderResolver: provider => + provider === "opencode-go" ? opencodeGoUsage.opencodeGoUsageProvider : undefined, + configValueResolver: async config => (config === "ref:opencode" ? "sk-resolved-secret" : config), + usageFetch: (async (input: string | URL | Request, init?: RequestInit) => { + fetchCalls.push({ + url: String(input), + headers: (init?.headers as Record) ?? {}, + }); + return new Response( + JSON.stringify({ + usage: { rolling: { status: "ok", percent: 5, resetsAt: "2026-08-12T15:09:04.847Z" } }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as unknown as typeof fetch, + }); + try { + await referenceStorage.reload(); + await referenceStorage.set("opencode-go", { type: "api_key", key: "ref:opencode" }); + + const reports = await referenceStorage.fetchUsageReports(); + + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0]?.headers.authorization).toBe("Bearer sk-resolved-secret"); + expect(reports?.some(candidate => candidate.provider === "opencode-go")).toBe(true); + } finally { + referenceStorage.close(); + } + }); }); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9f90cdf95..ba47eb95c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Changed -- `/usage`, `omp usage`, and the status line now show authoritative OpenCode Go quota from the official `GET /zen/go/v1/usage` endpoint — including usage made outside OMP — instead of dollar estimates summed from OMP-observed request costs. The status line renders all three windows (`5h` / `7d` / `mo`), and the per-turn cost recording special case for `opencode-go` sessions is gone along with the "OMP-observed spend only" disclaimer. +- `/usage`, `omp usage`, and the status line now show authoritative OpenCode Go quota from the official `GET /zen/go/v1/usage` endpoint — including usage made outside OMP — instead of dollar estimates summed from OMP-observed request costs. The status line renders all three windows (`5h` / `7d` / `mo`), and the per-turn cost recording special case for `opencode-go` sessions is gone along with the "OMP-observed spend only" disclaimer ([#8337](https://github.com/can1357/oh-my-pi/pull/8337) by [@will-bogusz](https://github.com/will-bogusz)). ## [17.2.15] - 2026-08-12 From 990984f19bac779f2bef2976c6b0c0068ebcd4dd Mon Sep 17 00:00:00 2001 From: Will Bogusz <24197106+will-bogusz@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:52:49 +0200 Subject: [PATCH 3/3] fix(usage): purge stale quota on auth failure, reject partial payloads --- packages/ai/src/auth-storage.ts | 7 ++ packages/ai/src/usage/opencode-go.ts | 11 ++- .../test/auth-storage-usage-history.test.ts | 94 ++++++++++++++++++- packages/ai/test/opencode-go-usage.test.ts | 6 +- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/auth-storage.ts b/packages/ai/src/auth-storage.ts index 705684afe..66bf5e9ec 100644 --- a/packages/ai/src/auth-storage.ts +++ b/packages/ai/src/auth-storage.ts @@ -3195,6 +3195,13 @@ export class AuthStorage { } return report; } catch (error) { + if (error instanceof AIError.ProviderHttpError && (error.status === 401 || error.status === 403)) { + // Definitive auth failure (revoked key, lapsed subscription): purge + // the last-good report so #fetchUsageCached's failure branch can't + // keep rendering and ranking from stale quota the way it does for + // transient failures. Mirrors the definitive-OAuth-refresh path. + this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 }); + } logger.debug("AuthStorage usage fetch failed", { provider: request.provider, error: String(error), diff --git a/packages/ai/src/usage/opencode-go.ts b/packages/ai/src/usage/opencode-go.ts index 981fa3abd..dcc66872f 100644 --- a/packages/ai/src/usage/opencode-go.ts +++ b/packages/ai/src/usage/opencode-go.ts @@ -137,7 +137,16 @@ async function fetchOpenCodeGoUsage(params: UsageFetchParams, ctx: UsageFetchCon const limit = buildWindowLimit(descriptor, usage[descriptor.key]); if (limit) limits.push(limit); } - if (limits.length === 0) return null; + // All-or-nothing: a partial report would overwrite the complete last-good + // report in the usage cache, silently dropping the windows used for + // ranking and display. Treat any malformed/missing window like a + // transient failure so the cached report keeps serving instead. + if (limits.length !== OPENCODE_GO_WINDOWS.length) { + ctx.logger?.warn("OpenCode Go usage response missing or malformed windows", { + decoded: limits.map(limit => limit.id), + }); + return null; + } return { provider: OPENCODE_GO_PROVIDER, diff --git a/packages/ai/test/auth-storage-usage-history.test.ts b/packages/ai/test/auth-storage-usage-history.test.ts index 4e96bd561..e9697316a 100644 --- a/packages/ai/test/auth-storage-usage-history.test.ts +++ b/packages/ai/test/auth-storage-usage-history.test.ts @@ -267,7 +267,11 @@ describe("OpenCode Go usage via the upstream endpoint", () => { }); return new Response( JSON.stringify({ - usage: { rolling: { status: "ok", percent: 5, resetsAt: "2026-08-12T15:09:04.847Z" } }, + usage: { + rolling: { status: "ok", percent: 5, resetsAt: "2026-08-12T15:09:04.847Z" }, + weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" }, + monthly: { status: "ok", percent: 10, resetsAt: "2026-08-19T00:31:53.847Z" }, + }, }), { status: 200, headers: { "content-type": "application/json" } }, ); @@ -286,4 +290,92 @@ describe("OpenCode Go usage via the upstream endpoint", () => { referenceStorage.close(); } }); + + it("drops the last-good report when the key turns definitively unauthorized", async () => { + // Transient failures serve the cached report; a 401/403 must not — a + // revoked key or lapsed subscription would otherwise keep rendering and + // ranking from stale quota until the process restarts. + let respondWith: "success" | "unauthorized" = "success"; + const transitionStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")), { + usageProviderResolver: provider => + provider === "opencode-go" ? opencodeGoUsage.opencodeGoUsageProvider : undefined, + usageFetch: (async () => + respondWith === "success" + ? new Response( + JSON.stringify({ + usage: { + rolling: { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" }, + weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" }, + monthly: { status: "ok", percent: 10, resetsAt: "2026-08-19T00:31:53.847Z" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + : new Response( + JSON.stringify({ type: "error", error: { type: "AuthError", message: "Unauthorized" } }), + { + status: 401, + headers: { "content-type": "application/json" }, + }, + )) as unknown as typeof fetch, + }); + try { + await transitionStorage.reload(); + await transitionStorage.set("opencode-go", { type: "api_key", key: "opencode-go-key" }); + + const nowMs = Date.now(); + setSystemTime(new Date(nowMs)); + const fresh = await transitionStorage.fetchUsageReports(); + expect(fresh?.some(candidate => candidate.provider === "opencode-go")).toBe(true); + + // Past the report TTL the next poll re-hits the endpoint and gets 401. + respondWith = "unauthorized"; + setSystemTime(new Date(nowMs + 10 * 60_000)); + const afterRevocation = await transitionStorage.fetchUsageReports(); + expect(afterRevocation?.some(candidate => candidate.provider === "opencode-go")).toBe(false); + } finally { + transitionStorage.close(); + } + }); + + it("retains the last-good report through a partial payload", async () => { + // One malformed window fails the whole decode, which must fall back to + // the cached complete report instead of replacing it with fewer windows. + let respondWith: "success" | "partial" = "success"; + const partialStorage = new AuthStorage(new SqliteAuthCredentialStore(new Database(":memory:")), { + usageProviderResolver: provider => + provider === "opencode-go" ? opencodeGoUsage.opencodeGoUsageProvider : undefined, + usageFetch: (async () => + new Response( + JSON.stringify({ + usage: { + rolling: + respondWith === "success" + ? { status: "ok", percent: 12, resetsAt: "2026-08-12T15:09:04.847Z" } + : { status: "ok", percent: "abc" }, + weekly: { status: "ok", percent: 8, resetsAt: "2026-08-17T00:00:00.847Z" }, + monthly: { status: "ok", percent: 10, resetsAt: "2026-08-19T00:31:53.847Z" }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + )) as unknown as typeof fetch, + }); + try { + await partialStorage.reload(); + await partialStorage.set("opencode-go", { type: "api_key", key: "opencode-go-key" }); + + const nowMs = Date.now(); + setSystemTime(new Date(nowMs)); + const fresh = await partialStorage.fetchUsageReports(); + expect(fresh?.find(candidate => candidate.provider === "opencode-go")?.limits).toHaveLength(3); + + respondWith = "partial"; + setSystemTime(new Date(nowMs + 10 * 60_000)); + const afterPartial = await partialStorage.fetchUsageReports(); + const retained = afterPartial?.find(candidate => candidate.provider === "opencode-go"); + expect(retained?.limits.map(limit => limit.id)).toEqual(["rolling-5h", "weekly", "monthly"]); + } finally { + partialStorage.close(); + } + }); }); diff --git a/packages/ai/test/opencode-go-usage.test.ts b/packages/ai/test/opencode-go-usage.test.ts index b19866faf..f121b63f6 100644 --- a/packages/ai/test/opencode-go-usage.test.ts +++ b/packages/ai/test/opencode-go-usage.test.ts @@ -137,14 +137,16 @@ describe("opencode-go usage provider", () => { expect(report).toBeNull(); }); - it("skips malformed windows and returns null when no window parses", async () => { + it("rejects the whole payload unless all three windows decode", async () => { + // A partial report would overwrite the complete last-good report in the + // usage cache, so one malformed window must fail the entire payload. const partial = await opencodeGoUsageProvider.fetchUsage( { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } }, { fetch: fakeFetch(usagePayload({ rolling: { status: "ok", percent: "abc" }, weekly: null })), }, ); - expect(partial?.limits.map(limit => limit.id)).toEqual(["monthly"]); + expect(partial).toBeNull(); const empty = await opencodeGoUsageProvider.fetchUsage( { provider: "opencode-go", credential: { type: "api_key", apiKey: "sk-test" } },