Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
3 changes: 2 additions & 1 deletion assets/antigravity.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 3 additions & 9 deletions docs/MULTI-ACCOUNT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

---

Expand Down
69 changes: 39 additions & 30 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.`);
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowQuotaFallback ignores explicitQuota, breaking documented behavior. Previously, explicit quota suffixes (:antigravity or :gemini-cli) would "always use that specific quota and switch accounts if exhausted" without falling back. Now they fallback to the alternate quota.

Suggested change
allowQuotaFallback: family === "gemini",
allowQuotaFallback: !explicitQuota && family === "gemini",
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugin.ts
Line: 2834:2834

Comment:
`allowQuotaFallback` ignores `explicitQuota`, breaking documented behavior. Previously, explicit quota suffixes (`:antigravity` or `:gemini-cli`) would "always use that specific quota and switch accounts if exhausted" without falling back. Now they fallback to the alternate quota.

```suggestion
    allowQuotaFallback: !explicitQuota && family === "gemini",
```

How can I resolve this? If you propose a fix, please make it concise.

};
}

function getCliFirst(config: AntigravityConfig): boolean {
return (config as AntigravityConfig & { cli_first?: boolean }).cli_first ?? false;
}
Expand Down Expand Up @@ -2858,5 +2866,6 @@ function isExplicitQuotaFromUrl(urlString: string): boolean {

export const __testExports = {
getHeaderStyleFromUrl,
resolveHeaderRoutingDecision,
resolveQuotaFallbackHeaderStyle,
};
12 changes: 4 additions & 8 deletions src/plugin/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
110 changes: 92 additions & 18 deletions src/plugin/quota-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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", () => ({
Expand All @@ -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",
Expand All @@ -47,30 +56,24 @@ 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();
expect(result).toBe("gemini-cli");
});

it("falls back from antigravity to gemini-cli when cli_first is disabled", () => {
it("returns null when no alternate quota is available", () => {
const result = resolveQuotaFallbackHeaderStyle?.({
quotaFallback: true,
cliFirst: false,
explicitQuota: false,
family: "gemini",
headerStyle: "antigravity",
alternateStyle: "gemini-cli",
alternateStyle: null,
});

expect(result).toBe("gemini-cli");
expect(result).toBeNull();
});
});

Expand Down Expand Up @@ -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,
});
});
});