Merge pull request #413 from NoeFabris/fix/supersede-pr-404-cli-fallback

fix: simplify Gemini routing and ignore legacy quota_fallback
This commit is contained in:
Noè
2026-02-09 20:37:22 +00:00
committed by GitHub
7 changed files with 148 additions and 74 deletions

View File

@@ -136,7 +136,7 @@ opencode run "Hello" --model=google/antigravity-claude-sonnet-4-5-thinking --var
> **Routing Behavior:**
> - **Antigravity-first (default):** Gemini models use Antigravity quota across accounts.
> - **CLI-first (`cli_first: true`):** Gemini models use Gemini CLI quota first.
> - With `quota_fallback` enabled, the plugin can spill to the other quota when all accounts are exhausted.
> - When a Gemini quota pool is exhausted, the plugin automatically falls back to the other pool.
> - Claude and image models always use Antigravity.
> Model names are automatically transformed for the target API (e.g., `antigravity-gemini-3-flash` → `gemini-3-flash-preview` for CLI).

View File

@@ -129,7 +129,8 @@
},
"quota_fallback": {
"default": false,
"type": "boolean"
"type": "boolean",
"description": "Deprecated: accepted for backward compatibility but ignored at runtime. Gemini fallback between Antigravity and Gemini CLI is always enabled."
},
"cli_first": {
"default": false,

View File

@@ -62,7 +62,7 @@ Settings for managing multiple Google accounts.
| `account_selection_strategy` | `"hybrid"` | How to select accounts |
| `switch_on_first_rate_limit` | `true` | Switch account immediately on first 429 |
| `pid_offset_enabled` | `false` | Distribute sessions across accounts (for parallel agents) |
| `quota_fallback` | `false` | **Gemini only.** When Antigravity exhausted on ALL accounts, fall back to Gemini CLI quota |
| `quota_fallback` | `false` | Deprecated (ignored). Kept for backward compatibility; Gemini fallback is automatic |
### Strategy Guide

View File

@@ -25,9 +25,9 @@ For Gemini models, the plugin accesses **two independent quota pools** per accou
| Quota Pool | When Used |
|------------|-----------|
| **Antigravity** | Default for all requests |
| **Gemini CLI** | Automatic fallback when Antigravity exhausted on ALL accounts |
| **Gemini CLI** | Automatic fallback between Antigravity and Gemini CLI in both directions |
This effectively **doubles your Gemini quota** when you have `quota_fallback` enabled.
This effectively **doubles your Gemini quota** through automatic fallback between Antigravity and Gemini CLI pools.
### How Quota Fallback Works
@@ -37,13 +37,7 @@ This effectively **doubles your Gemini quota** when you have `quota_fallback` en
4. If no (all accounts exhausted) → fall back to Gemini CLI quota on current account
5. Model names are automatically transformed (e.g., `gemini-3-flash``gemini-3-flash-preview`)
To enable automatic fallback between pools, set in `antigravity.json`:
```json
{
"quota_fallback": true
}
```
Automatic fallback between pools is always enabled for Gemini requests.
---

View File

@@ -1135,10 +1135,13 @@ export const createAntigravityPlugin = (providerId: string) => async (
checkAborted();
const accountCount = accountManager.getAccountCount();
const cliFirst = getCliFirst(config);
const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, cliFirst);
const explicitQuota = isExplicitQuotaFromUrl(urlString);
const allowQuotaFallback = config.quota_fallback && !explicitQuota && family === "gemini";
const routingDecision = resolveHeaderRoutingDecision(urlString, family, config);
const {
cliFirst,
preferredHeaderStyle,
explicitQuota,
allowQuotaFallback,
} = routingDecision;
if (accountCount === 0) {
throw new Error("No Antigravity accounts available. Run `opencode auth login`.");
@@ -1209,7 +1212,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
continue;
}
const strictWait = explicitQuota || !allowQuotaFallback;
const strictWait = !allowQuotaFallback;
// All accounts are rate-limited - wait and retry
const waitMs = accountManager.getMinWaitTimeForFamily(
family,
@@ -1472,7 +1475,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" && !cliFirst) {
if (allowQuotaFallback && family === "gemini" && headerStyle === "antigravity") {
// Check if ANY other account has antigravity available
if (accountManager.hasOtherAccountWithAntigravityAvailable(account.index, family, model)) {
// Switch to another account with antigravity (preserve antigravity priority)
@@ -1482,9 +1485,6 @@ export const createAntigravityPlugin = (providerId: string) => async (
// All accounts exhausted antigravity - fall back to gemini-cli on this account
const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
const fallbackStyle = resolveQuotaFallbackHeaderStyle({
quotaFallback: config.quota_fallback,
cliFirst,
explicitQuota,
family,
headerStyle,
alternateStyle,
@@ -1500,13 +1500,10 @@ export const createAntigravityPlugin = (providerId: string) => async (
shouldSwitchAccount = true;
}
}
} else if (config.quota_fallback && !explicitQuota && family === "gemini") {
} else if (allowQuotaFallback && family === "gemini") {
// gemini-cli rate-limited - try alternate style (antigravity) on same account
const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
const fallbackStyle = resolveQuotaFallbackHeaderStyle({
quotaFallback: config.quota_fallback,
cliFirst,
explicitQuota,
family,
headerStyle,
alternateStyle,
@@ -1742,7 +1739,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
// For Gemini, preserve preferred quota across accounts before fallback
if (family === "gemini") {
if (headerStyle === "antigravity" && !cliFirst) {
if (headerStyle === "antigravity") {
// 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.`);
@@ -1754,12 +1751,9 @@ export const createAntigravityPlugin = (providerId: string) => async (
// All accounts exhausted for Antigravity on THIS model.
// Before falling back to gemini-cli, check if it's the last option (automatic fallback)
if (config.quota_fallback && !explicitQuota) {
if (allowQuotaFallback) {
const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
const fallbackStyle = resolveQuotaFallbackHeaderStyle({
quotaFallback: config.quota_fallback,
cliFirst,
explicitQuota,
family,
headerStyle,
alternateStyle,
@@ -1775,13 +1769,10 @@ export const createAntigravityPlugin = (providerId: string) => async (
continue;
}
}
} else if (headerStyle === "gemini-cli" && cliFirst) {
if (config.quota_fallback && !explicitQuota) {
} else if (headerStyle === "gemini-cli") {
if (allowQuotaFallback) {
const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model);
const fallbackStyle = resolveQuotaFallbackHeaderStyle({
quotaFallback: config.quota_fallback,
cliFirst,
explicitQuota,
family,
headerStyle,
alternateStyle,
@@ -2808,25 +2799,42 @@ function getModelFamilyFromUrl(urlString: string): ModelFamily {
}
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") {
if (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;
}
type HeaderRoutingDecision = {
cliFirst: boolean;
preferredHeaderStyle: HeaderStyle;
explicitQuota: boolean;
allowQuotaFallback: boolean;
};
function resolveHeaderRoutingDecision(
urlString: string,
family: ModelFamily,
config: AntigravityConfig,
): HeaderRoutingDecision {
const cliFirst = getCliFirst(config);
const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, cliFirst);
const explicitQuota = isExplicitQuotaFromUrl(urlString);
return {
cliFirst,
preferredHeaderStyle,
explicitQuota,
allowQuotaFallback: family === "gemini",
};
}
function getCliFirst(config: AntigravityConfig): boolean {
return (config as AntigravityConfig & { cli_first?: boolean }).cli_first ?? false;
}
@@ -2858,5 +2866,6 @@ function isExplicitQuotaFromUrl(urlString: string): boolean {
export const __testExports = {
getHeaderStyleFromUrl,
resolveHeaderRoutingDecision,
resolveQuotaFallbackHeaderStyle,
};

View File

@@ -254,14 +254,10 @@ export const AntigravityConfigSchema = z.object({
max_rate_limit_wait_seconds: z.number().min(0).max(3600).default(300),
/**
* Enable quota fallback for Gemini models.
* When the preferred quota (gemini-cli or antigravity) is exhausted,
* try the alternate quota on the same account before switching accounts.
*
* Only applies when model is requested without explicit quota suffix.
* Explicit suffixes like `:antigravity` or `:gemini-cli` always use
* that specific quota and switch accounts if exhausted.
*
* @deprecated Kept only for backward compatibility.
* This flag is ignored at runtime.
* Gemini requests always fall back between Antigravity and Gemini CLI quotas.
*
* @default false
*/
quota_fallback: z.boolean().default(false),

View File

@@ -2,9 +2,6 @@ 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;
@@ -16,8 +13,20 @@ type GetHeaderStyleFromUrl = (
cliFirst?: boolean,
) => HeaderStyle;
type ResolveHeaderRoutingDecision = (
urlString: string,
family: ModelFamily,
config: unknown,
) => {
cliFirst: boolean;
preferredHeaderStyle: HeaderStyle;
explicitQuota: boolean;
allowQuotaFallback: boolean;
};
let resolveQuotaFallbackHeaderStyle: ResolveQuotaFallbackHeaderStyle | undefined;
let getHeaderStyleFromUrl: GetHeaderStyleFromUrl | undefined;
let resolveHeaderRoutingDecision: ResolveHeaderRoutingDecision | undefined;
beforeAll(async () => {
vi.mock("@opencode-ai/plugin", () => ({
@@ -31,14 +40,14 @@ beforeAll(async () => {
getHeaderStyleFromUrl = (__testExports as {
getHeaderStyleFromUrl?: GetHeaderStyleFromUrl;
}).getHeaderStyleFromUrl;
resolveHeaderRoutingDecision = (__testExports as {
resolveHeaderRoutingDecision?: ResolveHeaderRoutingDecision;
}).resolveHeaderRoutingDecision;
});
describe("quota fallback direction", () => {
it("falls back from gemini-cli to antigravity when cli_first is enabled", () => {
it("falls back from gemini-cli to antigravity when alternate quota is available", () => {
const result = resolveQuotaFallbackHeaderStyle?.({
quotaFallback: true,
cliFirst: true,
explicitQuota: false,
family: "gemini",
headerStyle: "gemini-cli",
alternateStyle: "antigravity",
@@ -47,24 +56,8 @@ describe("quota fallback direction", () => {
expect(result).toBe("antigravity");
});
it("does not fall back from antigravity when cli_first is enabled", () => {
it("falls back from antigravity to gemini-cli when alternate quota is available", () => {
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",
@@ -72,6 +65,16 @@ describe("quota fallback direction", () => {
expect(result).toBe("gemini-cli");
});
it("returns null when no alternate quota is available", () => {
const result = resolveQuotaFallbackHeaderStyle?.({
family: "gemini",
headerStyle: "antigravity",
alternateStyle: null,
});
expect(result).toBeNull();
});
});
describe("header style resolution", () => {
@@ -115,3 +118,74 @@ describe("header style resolution", () => {
expect(headerStyle).toBe("antigravity");
});
});
describe("header routing decision", () => {
it("defaults to antigravity-first for unsuffixed Gemini when cli_first is disabled", () => {
const decision = resolveHeaderRoutingDecision?.(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent",
"gemini",
{
cli_first: false,
},
);
expect(decision).toMatchObject({
cliFirst: false,
preferredHeaderStyle: "antigravity",
explicitQuota: false,
allowQuotaFallback: true,
});
});
it("uses gemini-cli-first for unsuffixed Gemini when cli_first is enabled", () => {
const decision = resolveHeaderRoutingDecision?.(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent",
"gemini",
{
cli_first: true,
},
);
expect(decision).toMatchObject({
cliFirst: true,
preferredHeaderStyle: "gemini-cli",
explicitQuota: false,
allowQuotaFallback: true,
});
});
it("keeps explicit antigravity prefix as primary route while fallback remains available", () => {
const decision = resolveHeaderRoutingDecision?.(
"https://generativelanguage.googleapis.com/v1beta/models/antigravity-gemini-3-flash:streamGenerateContent",
"gemini",
{
cli_first: true,
},
);
expect(decision).toMatchObject({
cliFirst: true,
preferredHeaderStyle: "antigravity",
explicitQuota: true,
allowQuotaFallback: true,
});
});
it("ignores legacy quota_fallback when deciding Gemini fallback availability", () => {
const decision = resolveHeaderRoutingDecision?.(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash:streamGenerateContent",
"gemini",
{
cli_first: false,
quota_fallback: false,
},
);
expect(decision).toMatchObject({
cliFirst: false,
preferredHeaderStyle: "antigravity",
explicitQuota: false,
allowQuotaFallback: true,
});
});
});