diff --git a/src/plugin.ts b/src/plugin.ts index ff67d93..c681b7d 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1421,6 +1421,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // - Claude models always use Antigravity let headerStyle = getHeaderStyleFromUrl(urlString, family); const explicitQuota = isExplicitQuotaFromUrl(urlString); + const cliFirst = getCliFirst(config); pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`); if (account.fingerprint) { pushDebug(`fingerprint: quotaUser=${account.fingerprint.quotaUser} deviceId=${account.fingerprint.deviceId.slice(0, 8)}...`); @@ -1429,7 +1430,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Check if this header style is rate-limited for this account if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) { // Antigravity-first fallback: exhaust antigravity across ALL accounts before gemini-cli - if (config.quota_fallback && !explicitQuota && family === "gemini" && headerStyle === "antigravity") { + if (config.quota_fallback && !explicitQuota && family === "gemini" && headerStyle === "antigravity" && !cliFirst) { // Check if ANY other account has antigravity available if (accountManager.hasOtherAccountWithAntigravityAvailable(account.index, family, model)) { // Switch to another account with antigravity (preserve antigravity priority) @@ -1438,12 +1439,20 @@ export const createAntigravityPlugin = (providerId: string) => async ( } else { // All accounts exhausted antigravity - fall back to gemini-cli on this account const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - if (alternateStyle && alternateStyle !== headerStyle) { + const fallbackStyle = resolveQuotaFallbackHeaderStyle({ + quotaFallback: config.quota_fallback, + cliFirst, + explicitQuota, + family, + headerStyle, + alternateStyle, + }); + if (fallbackStyle) { await showToast( `Antigravity quota exhausted on all accounts. Using Gemini CLI quota.`, "warning" ); - headerStyle = alternateStyle; + headerStyle = fallbackStyle; pushDebug(`all-accounts antigravity exhausted, quota fallback: ${headerStyle}`); } else { shouldSwitchAccount = true; @@ -1452,14 +1461,22 @@ export const createAntigravityPlugin = (providerId: string) => async ( } else if (config.quota_fallback && !explicitQuota && family === "gemini") { // gemini-cli rate-limited - try alternate style (antigravity) on same account const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - if (alternateStyle && alternateStyle !== headerStyle) { + const fallbackStyle = resolveQuotaFallbackHeaderStyle({ + quotaFallback: config.quota_fallback, + cliFirst, + explicitQuota, + family, + headerStyle, + alternateStyle, + }); + if (fallbackStyle) { const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; - const altQuotaName = alternateStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; + const altQuotaName = fallbackStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; await showToast( `${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning" ); - headerStyle = alternateStyle; + headerStyle = fallbackStyle; pushDebug(`quota fallback: ${headerStyle}`); } else { shouldSwitchAccount = true; @@ -1681,9 +1698,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( accountManager.requestSaveToDisk(); - // For Gemini, try prioritized Antigravity across ALL accounts first + // For Gemini, preserve preferred quota across accounts before fallback if (family === "gemini") { - if (headerStyle === "antigravity") { + if (headerStyle === "antigravity" && !cliFirst) { // Check if any other account has Antigravity quota for this model if (hasOtherAccountWithAntigravity(account)) { pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`); @@ -1697,13 +1714,43 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Before falling back to gemini-cli, check if it's the last option (automatic fallback) if (config.quota_fallback && !explicitQuota) { const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - if (alternateStyle && alternateStyle !== headerStyle) { + const fallbackStyle = resolveQuotaFallbackHeaderStyle({ + quotaFallback: config.quota_fallback, + cliFirst, + explicitQuota, + family, + headerStyle, + alternateStyle, + }); + if (fallbackStyle) { const safeModelName = model || "this model"; await showToast( `Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`, "warning" ); - headerStyle = alternateStyle; + headerStyle = fallbackStyle; + pushDebug(`quota fallback: ${headerStyle}`); + continue; + } + } + } else if (headerStyle === "gemini-cli" && cliFirst) { + if (config.quota_fallback && !explicitQuota) { + const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); + const fallbackStyle = resolveQuotaFallbackHeaderStyle({ + quotaFallback: config.quota_fallback, + cliFirst, + explicitQuota, + family, + headerStyle, + alternateStyle, + }); + if (fallbackStyle) { + const safeModelName = model || "this model"; + await showToast( + `Gemini CLI quota exhausted for ${safeModelName}. Switching to Antigravity quota...`, + "warning" + ); + headerStyle = fallbackStyle; pushDebug(`quota fallback: ${headerStyle}`); continue; } @@ -2680,6 +2727,30 @@ function getModelFamilyFromUrl(urlString: string): ModelFamily { return family; } +function resolveQuotaFallbackHeaderStyle(input: { + quotaFallback: boolean; + cliFirst: boolean; + explicitQuota: boolean; + family: ModelFamily; + headerStyle: HeaderStyle; + alternateStyle: HeaderStyle | null; +}): HeaderStyle | null { + if (!input.quotaFallback || input.explicitQuota || input.family !== "gemini") { + return null; + } + if (!input.alternateStyle || input.alternateStyle === input.headerStyle) { + return null; + } + if (input.cliFirst && input.headerStyle !== "gemini-cli") { + return null; + } + return input.alternateStyle; +} + +function getCliFirst(config: AntigravityConfig): boolean { + return (config as AntigravityConfig & { cli_first?: boolean }).cli_first ?? false; +} + function getHeaderStyleFromUrl(urlString: string, family: ModelFamily): HeaderStyle { if (family === "claude") { return "antigravity"; @@ -2703,4 +2774,5 @@ function isExplicitQuotaFromUrl(urlString: string): boolean { export const __testExports = { getHeaderStyleFromUrl, + resolveQuotaFallbackHeaderStyle, }; diff --git a/src/plugin/quota-fallback.test.ts b/src/plugin/quota-fallback.test.ts new file mode 100644 index 0000000..4139048 --- /dev/null +++ b/src/plugin/quota-fallback.test.ts @@ -0,0 +1,65 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import type { HeaderStyle, ModelFamily } from "./accounts"; + +type ResolveQuotaFallbackHeaderStyle = (input: { + quotaFallback: boolean; + cliFirst: boolean; + explicitQuota: boolean; + family: ModelFamily; + headerStyle: HeaderStyle; + alternateStyle: HeaderStyle | null; +}) => HeaderStyle | null; + +let resolveQuotaFallbackHeaderStyle: ResolveQuotaFallbackHeaderStyle | undefined; + +beforeAll(async () => { + vi.mock("@opencode-ai/plugin", () => ({ + tool: vi.fn(), + })); + + const { __testExports } = await import("../plugin"); + resolveQuotaFallbackHeaderStyle = (__testExports as { + resolveQuotaFallbackHeaderStyle?: ResolveQuotaFallbackHeaderStyle; + }).resolveQuotaFallbackHeaderStyle; +}); + +describe("quota fallback direction", () => { + it("falls back from gemini-cli to antigravity when cli_first is enabled", () => { + const result = resolveQuotaFallbackHeaderStyle?.({ + quotaFallback: true, + cliFirst: true, + explicitQuota: false, + family: "gemini", + headerStyle: "gemini-cli", + alternateStyle: "antigravity", + }); + + expect(result).toBe("antigravity"); + }); + + it("does not fall back from antigravity when cli_first is enabled", () => { + const result = resolveQuotaFallbackHeaderStyle?.({ + quotaFallback: true, + cliFirst: true, + explicitQuota: false, + family: "gemini", + headerStyle: "antigravity", + alternateStyle: "gemini-cli", + }); + + expect(result).toBeNull(); + }); + + it("falls back from antigravity to gemini-cli when cli_first is disabled", () => { + const result = resolveQuotaFallbackHeaderStyle?.({ + quotaFallback: true, + cliFirst: false, + explicitQuota: false, + family: "gemini", + headerStyle: "antigravity", + alternateStyle: "gemini-cli", + }); + + expect(result).toBe("gemini-cli"); + }); +});