From 9d35ce43aa9f8122a8624746f43ed912d7928083 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 25 Mar 2026 03:26:06 +0100 Subject: [PATCH 1/2] fix(storage): preserve cleared rate limit state on disk Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/plugin/accounts.test.ts | 35 +++++++++++++++++++ src/plugin/accounts.ts | 2 +- src/plugin/storage.test.ts | 67 +++++++++++++++++++++++++++++++++++++ src/plugin/storage.ts | 16 ++++++--- 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index ad0b62b..76ff938 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from "./accounts"; +import { saveAccounts } from "./storage"; import type { AccountStorageV4 } from "./storage"; import type { OAuthAuthDetails } from "./types"; @@ -255,6 +256,40 @@ describe("AccountManager", () => { expect(snapshot[1]?.expires).toBe(123); }); + it("saveToDisk preserves empty rateLimitResetTimes so stale limits can be cleared", async () => { + const stored: AccountStorageV4 = { + version: 4, + accounts: [ + { + refreshToken: "r1", + projectId: "p1", + addedAt: 1, + lastUsed: 0, + rateLimitResetTimes: { claude: 123456 }, + }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + const account = manager.getAccounts()[0]; + if (!account) throw new Error("Account not found"); + + account.rateLimitResetTimes = {}; + await manager.saveToDisk(); + + expect(vi.mocked(saveAccounts)).toHaveBeenCalledWith( + expect.objectContaining({ + accounts: [ + expect.objectContaining({ + refreshToken: "r1", + rateLimitResetTimes: {}, + }), + ], + }), + ); + }); + it("debounces toast display for same account", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(0)); diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index 0a91326..eee4732 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -1003,7 +1003,7 @@ export class AccountManager { lastUsed: a.lastUsed, enabled: a.enabled, lastSwitchReason: a.lastSwitchReason, - rateLimitResetTimes: Object.keys(a.rateLimitResetTimes).length > 0 ? a.rateLimitResetTimes : undefined, + rateLimitResetTimes: { ...a.rateLimitResetTimes }, coolingDownUntil: a.coolingDownUntil, cooldownReason: a.cooldownReason, fingerprint: a.fingerprint, diff --git a/src/plugin/storage.test.ts b/src/plugin/storage.test.ts index e1c6f9e..409e5fe 100644 --- a/src/plugin/storage.test.ts +++ b/src/plugin/storage.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { deduplicateAccountsByEmail, + mergeAccountStorage, migrateV2ToV3, loadAccounts, type AccountMetadata, type AccountStorage, + type AccountStorageV4, } from "./storage"; import { promises as fs } from "node:fs"; import { @@ -209,6 +211,71 @@ describe("deduplicateAccountsByEmail", () => { }); }); +describe("mergeAccountStorage", () => { + it("lets an empty incoming rateLimitResetTimes clear stale stored limits", () => { + const existing: AccountStorageV4 = { + version: 4, + accounts: [ + { + refreshToken: "r1", + projectId: "p1", + addedAt: 1, + lastUsed: 10, + rateLimitResetTimes: { claude: 123456 }, + }, + ], + activeIndex: 0, + }; + const incoming: AccountStorageV4 = { + version: 4, + accounts: [ + { + refreshToken: "r1", + projectId: "p1", + addedAt: 1, + lastUsed: 20, + rateLimitResetTimes: {}, + }, + ], + activeIndex: 0, + }; + + const merged = mergeAccountStorage(existing, incoming); + expect(merged.accounts[0]?.rateLimitResetTimes).toBeUndefined(); + }); + + it("preserves existing limits when incoming storage does not specify them", () => { + const existing: AccountStorageV4 = { + version: 4, + accounts: [ + { + refreshToken: "r1", + projectId: "p1", + addedAt: 1, + lastUsed: 10, + rateLimitResetTimes: { claude: 123456 }, + }, + ], + activeIndex: 0, + }; + const incoming: AccountStorageV4 = { + version: 4, + accounts: [ + { + refreshToken: "r1", + projectId: "p1", + addedAt: 1, + lastUsed: 20, + }, + ], + activeIndex: 0, + }; + + const merged = mergeAccountStorage(existing, incoming); + expect(merged.accounts[0]?.rateLimitResetTimes).toEqual({ claude: 123456 }); + }); +}); + vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); return { diff --git a/src/plugin/storage.ts b/src/plugin/storage.ts index 58a44c3..39c8de4 100644 --- a/src/plugin/storage.ts +++ b/src/plugin/storage.ts @@ -399,7 +399,7 @@ async function withFileLock(path: string, fn: () => Promise): Promise { } } -function mergeAccountStorage( +export function mergeAccountStorage( existing: AccountStorageV4, incoming: AccountStorageV4, ): AccountStorageV4 { @@ -415,16 +415,22 @@ function mergeAccountStorage( if (acc.refreshToken) { const existingAcc = accountMap.get(acc.refreshToken); if (existingAcc) { + const incomingRateLimitResetTimes = acc.rateLimitResetTimes; + const mergedRateLimitResetTimes = incomingRateLimitResetTimes === undefined + ? existingAcc.rateLimitResetTimes + : Object.keys(incomingRateLimitResetTimes).length === 0 + ? undefined + : { + ...existingAcc.rateLimitResetTimes, + ...incomingRateLimitResetTimes, + }; accountMap.set(acc.refreshToken, { ...existingAcc, ...acc, // Preserve manually configured projectId/managedProjectId if not in incoming projectId: acc.projectId ?? existingAcc.projectId, managedProjectId: acc.managedProjectId ?? existingAcc.managedProjectId, - rateLimitResetTimes: { - ...existingAcc.rateLimitResetTimes, - ...acc.rateLimitResetTimes, - }, + rateLimitResetTimes: mergedRateLimitResetTimes, lastUsed: Math.max(existingAcc.lastUsed || 0, acc.lastUsed || 0), }); } else { From 66f0227977080d0b2584b6e24b557ed4059b863e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 25 Mar 2026 03:26:18 +0100 Subject: [PATCH 2/2] fix(plugin): reload stale provider state before hard fail Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/plugin.ts | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index ee8b624..8156121 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1410,7 +1410,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Note: AccountManager now ensures the current auth is always included in accounts - const accountManager = await AccountManager.loadFromDisk(auth); + let accountManager = await AccountManager.loadFromDisk(auth); activeAccountManager = accountManager; if (accountManager.getAccountCount() > 0) { accountManager.requestSaveToDisk(); @@ -1461,9 +1461,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( return fetch(input, init); } - if (accountManager.getAccountCount() === 0) { - throw new Error("No Antigravity accounts configured. Run `opencode auth login`."); - } + accountManager = await AccountManager.loadFromDisk(latestAuth); + activeAccountManager = accountManager; const urlString = toUrlString(input); const family = getModelFamilyFromUrl(urlString); @@ -1474,6 +1473,26 @@ export const createAntigravityPlugin = (providerId: string) => async ( debugLines.push(line); }; pushDebug(`request=${urlString}`); + let reloadedProviderState = false; + const reloadProviderState = async (reason: string): Promise => { + if (reloadedProviderState) { + return false; + } + const reloadedAuth = await getAuth(); + if (!isOAuthAuth(reloadedAuth)) { + return false; + } + reloadedProviderState = true; + accountManager = await AccountManager.loadFromDisk(reloadedAuth); + activeAccountManager = accountManager; + rateLimitStateByAccountQuota.clear(); + emptyResponseAttempts.clear(); + accountFailureState.clear(); + rateLimitToastShown = false; + softQuotaToastShown = false; + pushDebug(`reload-provider-state reason=${reason} accounts=${accountManager.getAccountCount()}`); + return true; + }; type FailureContext = { response: Response; @@ -1554,6 +1573,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( } = routingDecision; if (accountCount === 0) { + if (await reloadProviderState("no-accounts")) { + continue; + } throw new Error("No Antigravity accounts available. Run `opencode auth login`."); } @@ -1646,6 +1668,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( // 0 means disabled (wait indefinitely) const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000; if (maxWaitMs > 0 && waitMs > maxWaitMs) { + if (await reloadProviderState(`all-rate-limited:${family}`)) { + continue; + } const waitTimeFormatted = formatWaitTime(waitMs); await showToast( `Rate limited for ${waitTimeFormatted}. Try again later or add another account.`, @@ -1827,6 +1852,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( const warmupUrl = toWarmupStreamUrl(prepared.request); const warmupHeaders = new Headers(prepared.init.headers ?? {}); warmupHeaders.set("accept", "text/event-stream"); + warmupHeaders.delete("x-goog-api-key"); const warmupInit: RequestInit = { ...prepared.init, @@ -1979,6 +2005,13 @@ export const createAntigravityPlugin = (providerId: string) => async ( }, ); + const requestHeaders = new Headers(prepared.init.headers ?? {}); + requestHeaders.delete("x-goog-api-key"); + const requestInit: RequestInit = { + ...prepared.init, + headers: requestHeaders, + }; + const originalUrl = toUrlString(input); const resolvedUrl = toUrlString(prepared.request); pushDebug(`endpoint=${currentEndpoint}`); @@ -1986,9 +2019,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( const debugContext = startAntigravityDebugRequest({ originalUrl, resolvedUrl, - method: prepared.init.method, - headers: prepared.init.headers, - body: prepared.init.body, + method: requestInit.method, + headers: requestInit.headers, + body: requestInit.body, streaming: prepared.streaming, projectId: projectContext.effectiveProjectId, }); @@ -2022,7 +2055,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( tokenConsumed = getTokenTracker().consume(account.index); } - const response = await fetch(prepared.request, prepared.init); + const response = await fetch(prepared.request, requestInit); pushDebug(`status=${response.status} ${response.statusText}`);