Skip to content
Open
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
13 changes: 8 additions & 5 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,8 @@ export const createAntigravityPlugin = (providerId: string) => async (
while (true) {
// Check for abort at the start of each iteration
checkAborted();

const requestedHeaderStyle = getHeaderStyleFromUrl(urlString, family);

const accountCount = accountManager.getAccountCount();

Expand All @@ -1123,7 +1125,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
family,
model,
config.account_selection_strategy,
'antigravity',
requestedHeaderStyle,
config.pid_offset_enabled,
config.soft_quota_threshold_percent,
softQuotaCacheTtlMs,
Expand Down Expand Up @@ -1160,7 +1162,7 @@ export const createAntigravityPlugin = (providerId: string) => async (
continue;
}

const headerStyle = getHeaderStyleFromUrl(urlString, family);
const headerStyle = requestedHeaderStyle;
const explicitQuota = isExplicitQuotaFromUrl(urlString);
// All accounts are rate-limited - wait and retry
const waitMs = accountManager.getMinWaitTimeForFamily(
Expand Down Expand Up @@ -1411,9 +1413,10 @@ export const createAntigravityPlugin = (providerId: string) => async (
let shouldSwitchAccount = false;

// Determine header style from model suffix:
// - Gemini models default to Antigravity
// - Claude models always use Antigravity
let headerStyle = getHeaderStyleFromUrl(urlString, family);
// - Claude models -> always use Antigravity
// - Gemini defaults to Antigravity (including explicit gemini-cli suffix)
// - Gemini CLI is used as fallback when Antigravity is exhausted
let headerStyle = requestedHeaderStyle;
const explicitQuota = isExplicitQuotaFromUrl(urlString);
const cliFirst = getCliFirst(config);
pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`);
Expand Down
77 changes: 77 additions & 0 deletions src/plugin/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,51 @@ describe("AccountManager", () => {
});
});

describe("Issue #400: hybrid strategy respects requested header style", () => {
it("skips account when requested antigravity style is rate-limited", () => {
const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
activeIndexByFamily: { claude: 0, gemini: 0 },
};

const manager = new AccountManager(undefined, stored);
const firstAccount = manager.getCurrentOrNextForFamily("gemini", null, "hybrid", "antigravity");

manager.markRateLimited(firstAccount!, 60000, "gemini", "antigravity");

expect(manager.isRateLimitedForHeaderStyle(firstAccount!, "gemini", "antigravity")).toBe(true);
expect(manager.isRateLimitedForHeaderStyle(firstAccount!, "gemini", "gemini-cli")).toBe(false);

const nextAccount = manager.getCurrentOrNextForFamily("gemini", null, "hybrid", "antigravity");
expect(nextAccount?.index).toBe(1);
});

it("keeps account for gemini-cli when only antigravity style is rate-limited", () => {
const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
activeIndexByFamily: { claude: 0, gemini: 0 },
};

const manager = new AccountManager(undefined, stored);
const firstAccount = manager.getCurrentOrNextForFamily("gemini", null, "hybrid", "gemini-cli");

manager.markRateLimited(firstAccount!, 60000, "gemini", "antigravity");

const nextAccount = manager.getCurrentOrNextForFamily("gemini", null, "hybrid", "gemini-cli");
expect(nextAccount).not.toBeNull();
expect(nextAccount?.parts.refreshToken).toBe("r1");
});
});

describe("Issue #174: saveToDisk throttling", () => {
it("requestSaveToDisk coalesces multiple calls into one write", async () => {
vi.useFakeTimers();
Expand Down Expand Up @@ -1608,6 +1653,38 @@ describe("AccountManager", () => {
expect(account?.parts.refreshToken).toBe("r1");
});

it("does not apply soft quota threshold to gemini-cli in sticky mode", () => {
const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
manager.updateQuotaCache(0, { "gemini-pro": { remainingFraction: 0.01, modelCount: 1 } });

const account = manager.getCurrentOrNextForFamily("gemini", "gemini-2.5-pro", "sticky", "gemini-cli", false, 90);
expect(account?.parts.refreshToken).toBe("r1");
});

it("does not apply soft quota threshold to gemini-cli in hybrid mode", () => {
const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
manager.updateQuotaCache(0, { "gemini-pro": { remainingFraction: 0.01, modelCount: 1 } });

const account = manager.getCurrentOrNextForFamily("gemini", "gemini-2.5-pro", "hybrid", "gemini-cli", false, 90);
expect(account?.parts.refreshToken).toBe("r1");
});

it("returns null when all accounts over threshold", () => {
const stored: AccountStorageV3 = {
version: 3,
Expand Down
37 changes: 21 additions & 16 deletions src/plugin/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,20 +516,22 @@ export class AccountManager {
if (strategy === 'hybrid') {
const healthTracker = getHealthTracker();
const tokenTracker = getTokenTracker();

const accountsWithMetrics: AccountWithMetrics[] = this.accounts
.filter(acc => acc.enabled !== false)
.map(acc => {
clearExpiredRateLimits(acc);
return {
index: acc.index,
lastUsed: acc.lastUsed,
healthScore: healthTracker.getScore(acc.index),
isRateLimited: isRateLimitedForFamily(acc, family, model) ||
isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model),
isCoolingDown: this.isAccountCoolingDown(acc),
};
});

const shouldApplySoftQuota = headerStyle !== "gemini-cli";
const accountsWithMetrics: AccountWithMetrics[] = this.accounts.map(acc => {
clearExpiredRateLimits(acc);
const isOverThreshold = shouldApplySoftQuota &&
isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model);
return {
index: acc.index,
lastUsed: acc.lastUsed,
healthScore: healthTracker.getScore(acc.index),
isRateLimited: acc.enabled === false ||
isRateLimitedForHeaderStyle(acc, family, headerStyle, model) ||
isOverThreshold,
isCoolingDown: this.isAccountCoolingDown(acc),
};
});

// Get current account index for stickiness
const currentIndex = this.currentAccountIndexByFamily[family] ?? null;
Expand Down Expand Up @@ -564,7 +566,8 @@ export class AccountManager {
if (current) {
clearExpiredRateLimits(current);
const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, model);
const isOverThreshold = isOverSoftQuotaThreshold(current, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model);
const isOverThreshold = headerStyle !== "gemini-cli" &&
isOverSoftQuotaThreshold(current, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model);
if (!isLimitedForRequestedStyle && !isOverThreshold && !this.isAccountCoolingDown(current)) {
this.markTouchedForQuota(current, quotaKey);
return current;
Expand All @@ -582,9 +585,11 @@ export class AccountManager {
getNextForFamily(family: ModelFamily, model?: string | null, headerStyle: HeaderStyle = "antigravity", softQuotaThresholdPercent: number = 100, softQuotaCacheTtlMs: number = 10 * 60 * 1000): ManagedAccount | null {
const available = this.accounts.filter((a) => {
clearExpiredRateLimits(a);
const isOverThreshold = headerStyle !== "gemini-cli" &&
isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model);
return a.enabled !== false &&
!isRateLimitedForHeaderStyle(a, family, headerStyle, model) &&
!isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model) &&
!isOverThreshold &&
!this.isAccountCoolingDown(a);
});

Expand Down