Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 2 additions & 8 deletions docs/MULTI-ACCOUNT.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ For Gemini models, the plugin accesses **two independent quota pools** per accou
| **Antigravity** | Default for all requests |
| **Gemini CLI** | Automatic fallback when Antigravity exhausted on ALL accounts |
Copy link
Contributor

Choose a reason for hiding this comment

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

description implies unidirectional fallback (only Antigravity → CLI) but fallback is now bidirectional. Consider clarifying: "Automatic fallback between Antigravity and Gemini CLI in both directions"

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/MULTI-ACCOUNT.md
Line: 28:28

Comment:
description implies unidirectional fallback (only Antigravity → CLI) but fallback is now bidirectional. Consider clarifying: "Automatic fallback between Antigravity and Gemini CLI in both directions"

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

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


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,
});
});
});