Skip to content
Draft
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 117 additions & 0 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,50 @@ async function extractRetryInfoFromBody(response: Response): Promise<RateLimitBo
}
}

function getErrorMetadata(parsed: unknown): Record<string, string> | undefined {
if (!parsed || typeof parsed !== "object") return undefined;
const error = (parsed as { error?: any }).error;
const details = error?.details;
if (Array.isArray(details)) {
for (const detail of details) {
if (detail && typeof detail === "object") {
const type = detail["@type"];
if (typeof type === "string" && type.includes("google.rpc.ErrorInfo")) {
return detail.metadata;
}
}
}
}
return undefined;
}

function extractVerificationUrl(parsed: unknown, rawText: string): string | undefined {
const metadata = getErrorMetadata(parsed);
if (metadata?.verificationUrl) return metadata.verificationUrl;

const urlMatch = rawText.match(/https:\/\/accounts[^\s\\"']*/);
return urlMatch ? urlMatch[0].replace(/\\u0026/g, "&") : undefined;
}

function isVerificationRequiredError(
parsedBody: unknown,
rawText: string
): { required: boolean; url?: string } {
const { reason } = extractRateLimitBodyInfo(parsedBody);
if (reason === "VALIDATION_REQUIRED") {
const url = extractVerificationUrl(parsedBody, rawText);
return { required: true, url };
}

if (rawText.includes("verify your account") ||
rawText.includes("validation_error_message")) {
const url = extractVerificationUrl(parsedBody, rawText);
return { required: true, url };
}

return { required: false };
}

function formatWaitTime(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.ceil(ms / 1000);
Expand All @@ -617,6 +661,23 @@ function formatWaitTime(ms: number): string {
return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`;
}

function buildVerificationErrorMessage(
email: string,
url: string | undefined,
cooldown: string
): string {
const urlSection = url
? `\n\nPlease visit this URL to verify your account:\n${url}`
: `\n\nPlease check your email or Google account for verification instructions.`;

return `[Antigravity] Account verification required for ${email}.` +
urlSection +
`\n\nAfter verification, either:` +
`\n• Use "opencode auth" > select account > "Clear verification block"` +
`\n• Wait for cooldown to expire (${cooldown})` +
`\n\nCooldown: ${cooldown}`;
}

// Progressive rate limit retry delays
const FIRST_RETRY_DELAY_MS = 1000; // 1s - first 429 quick retry on same account
const SWITCH_ACCOUNT_DELAY_MS = 5000; // 5s - delay before switching to another account
Expand Down Expand Up @@ -1793,6 +1854,8 @@ export const createAntigravityPlugin = (providerId: string) => async (
getHealthTracker().recordSuccess(account.index);
accountManager.markAccountUsed(account.index);

accountManager.clearVerificationStateOnSuccess(account);

void triggerAsyncQuotaRefreshForAccount(
accountManager,
account.index,
Expand Down Expand Up @@ -1820,6 +1883,60 @@ export const createAntigravityPlugin = (providerId: string) => async (
return createSyntheticErrorResponse(errorMessage, prepared.requestedModel);
}
}

if (response.status === 403) {
const cloned403 = response.clone();
const body403 = await cloned403.text();

let parsedBody: unknown;
try {
parsedBody = JSON.parse(body403);
} catch {
parsedBody = null;
}

const verificationCheck = isVerificationRequiredError(parsedBody, body403);

if (verificationCheck.required) {
const accountEmail = account.email || "unknown account";
const verifyUrl = verificationCheck.url;

const cooldownMs = accountManager.markVerificationRequired(account, verifyUrl);
const cooldownFormatted = formatWaitTime(cooldownMs);

await showToast(
`⚠️ Verification required for ${accountEmail} (cooldown: ${cooldownFormatted})`,
"error"
);

log.warn("Account verification required", {
email: accountEmail,
url: verifyUrl,
cooldownMs
});

const nextAccount = accountManager.getCurrentOrNextForFamily(family);
if (nextAccount) {
shouldSwitchAccount = true;
break;
}

const errorMessage = buildVerificationErrorMessage(accountEmail, verifyUrl, cooldownFormatted);

return new Response(JSON.stringify({
error: {
type: "verification_required",
message: errorMessage,
verification_url: verifyUrl,
account_email: accountEmail,
cooldown_seconds: Math.ceil(cooldownMs / 1000),
}
}), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
}
}

// Empty response retry logic (ported from LLM-API-Key-Proxy)
Expand Down
122 changes: 122 additions & 0 deletions src/plugin/accounts-verification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { AccountManager, type ManagedAccount } from "./accounts";
import type { AccountStorageV3 } from "./storage";

describe("AccountManager - Verification Progressive Cooldown", () => {
let manager: AccountManager;
let account: ManagedAccount;

beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(1000000)); // Fixed start time

const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

manager = new AccountManager(undefined, stored);
const accounts = manager.getAccounts();
if (!accounts[0]) throw new Error("No account found");
account = accounts[0];
});

afterEach(() => {
vi.useRealTimers();
});

it("first attempt uses 10-minute cooldown (plus jitter)", () => {
const cooldownMs = manager.markVerificationRequired(account, "https://verify.me");

expect(account.verificationAttemptCount).toBe(1);
expect(account.verificationUrl).toBe("https://verify.me");
expect(account.verificationUrlCapturedAt).toBe(1000000);
expect(account.cooldownReason).toBe("verification-required");

// 10 mins = 600,000ms. With jitter (max 60s), range is 600,000 - 660,000
expect(cooldownMs).toBeGreaterThanOrEqual(600000);
expect(cooldownMs).toBeLessThan(600000 + 60000 + 1);
expect(account.coolingDownUntil).toBe(1000000 + cooldownMs);
});

it("repeat during cooldown escalates to next tier", () => {
// First attempt (10m)
manager.markVerificationRequired(account);
expect(account.verificationAttemptCount).toBe(1);

// Second attempt (immediate repeat) - should be 1h
const cooldownMs = manager.markVerificationRequired(account);

expect(account.verificationAttemptCount).toBe(2);
// 1h = 3,600,000ms
expect(cooldownMs).toBeGreaterThanOrEqual(3600000);
expect(cooldownMs).toBeLessThan(3600000 + 60000 + 1);
});

it("repeat after cooldown expires resets to tier 1", () => {
// First attempt
manager.markVerificationRequired(account);
expect(account.verificationAttemptCount).toBe(1);

// Advance time past cooldown (approx 24h to be safe, though 10m is enough for tier 1)
vi.advanceTimersByTime(24 * 60 * 60 * 1000);

// Should clear cooldown state naturally when checking or when re-marking
// However, markVerificationRequired calls isAccountCoolingDown which clears expired cooldowns

// Next attempt should be fresh
const cooldownMs = manager.markVerificationRequired(account);

expect(account.verificationAttemptCount).toBe(1);
expect(cooldownMs).toBeGreaterThanOrEqual(600000); // Back to 10m
});

it("caps at 24 hours", () => {
// Tier 1: 10m
manager.markVerificationRequired(account);
// Tier 2: 1h
manager.markVerificationRequired(account);
// Tier 3: 6h
manager.markVerificationRequired(account);
// Tier 4: 24h
const cooldownMs4 = manager.markVerificationRequired(account);
expect(cooldownMs4).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000);

// Tier 5: 24h (capped)
const cooldownMs5 = manager.markVerificationRequired(account);
expect(cooldownMs5).toBeGreaterThanOrEqual(24 * 60 * 60 * 1000);
expect(cooldownMs5).toBeLessThan(24 * 60 * 60 * 1000 + 60000 + 1);
});

it("clearVerificationCooldown resets counter and clears URL", () => {
manager.markVerificationRequired(account, "https://verify.me");
expect(account.verificationAttemptCount).toBe(1);
expect(account.verificationUrl).toBeDefined();

const result = manager.clearVerificationCooldown(0);
expect(result).toBe(true);

expect(account.verificationAttemptCount).toBe(0);
expect(account.verificationUrl).toBeUndefined();
expect(account.coolingDownUntil).toBeUndefined();
});

it("clearVerificationStateOnSuccess resets counter on success", () => {
manager.markVerificationRequired(account, "https://verify.me");

manager.clearVerificationStateOnSuccess(account);

expect(account.verificationAttemptCount).toBe(0);
expect(account.verificationUrl).toBeUndefined();
// Should NOT clear active cooldown
expect(account.coolingDownUntil).toBeDefined();
});

it("getVerificationUrl returns stored URL", () => {
manager.markVerificationRequired(account, "https://verify.me");
expect(manager.getVerificationUrl(0)).toBe("https://verify.me");
});
});
109 changes: 109 additions & 0 deletions src/plugin/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,115 @@ describe("AccountManager", () => {
expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "antigravity")).toBe(false);
expect(manager.isRateLimitedForHeaderStyle(account!, "gemini", "gemini-cli")).toBe(false);
});

it("supports verification-required cooldown reason for account verification errors", () => {
const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
const account = manager.getCurrentOrNextForFamily("claude");

manager.markAccountCoolingDown(account!, 24 * 60 * 60 * 1000, "verification-required");

expect(manager.isAccountCoolingDown(account!)).toBe(true);
expect(manager.getAccountCooldownReason(account!)).toBe("verification-required");

const nextAccount = manager.getCurrentOrNextForFamily("claude");
expect(nextAccount?.parts.refreshToken).toBe("r2");
});
});

describe("getMinWaitTimeForFamily with cooldowns", () => {
it("returns cooldown wait time when account is cooling down but not rate-limited", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));

const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
const account = manager.getAccounts()[0]!;

manager.markAccountCoolingDown(account, 600_000, "verification-required");

const waitTime = manager.getMinWaitTimeForFamily("claude");
expect(waitTime).toBe(600_000);

vi.useRealTimers();
});

it("returns max of rate-limit and cooldown when both set", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(0));

const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
const account = manager.getAccounts()[0]!;

manager.markAccountCoolingDown(account, 300_000, "auth-failure");
manager.markRateLimited(account, 600_000, "claude", "antigravity");

const waitTime = manager.getMinWaitTimeForFamily("claude", null, "antigravity");
expect(waitTime).toBe(600_000);

vi.useRealTimers();
});

it("returns 0 when at least one account is available", () => {
const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
{ refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
const [acc1, acc2] = manager.getAccounts();

manager.markAccountCoolingDown(acc1!, 600_000, "verification-required");
// acc2 is available

const waitTime = manager.getMinWaitTimeForFamily("claude");
expect(waitTime).toBe(0);
});

it("excludes cooling-down accounts from available set", () => {
const stored: AccountStorageV3 = {
version: 3,
accounts: [
{ refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 },
],
activeIndex: 0,
};

const manager = new AccountManager(undefined, stored);
const account = manager.getAccounts()[0]!;

manager.markAccountCoolingDown(account, 600_000, "verification-required");

const next = manager.getCurrentOrNextForFamily("claude");
expect(next).toBeNull();
});
});

describe("account selection strategies", () => {
Expand Down
Loading