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" } },