Skip to content
Draft
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
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.

136 changes: 136 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,67 @@ 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"
);

console.error(`\n⚠️ Account verification required for ${accountEmail}`);
if (verifyUrl) {
console.error(` Verify at: ${verifyUrl}`);
}
console.error(` Cooldown: ${cooldownFormatted}`);
console.error(` After verifying, run: opencode auth login > select account > "Clear verification block"\n`);

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 Expand Up @@ -2200,6 +2324,18 @@ export const createAntigravityPlugin = (providerId: string) => async (
console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`);
}
}
if (menuResult.clearVerificationAccountIndex !== undefined) {
const acc = existingStorage.accounts[menuResult.clearVerificationAccountIndex];
if (acc && acc.cooldownReason === "verification-required") {
delete acc.coolingDownUntil;
delete acc.cooldownReason;
acc.verificationAttemptCount = 0;
delete acc.verificationUrl;
delete acc.verificationUrlCapturedAt;
await saveAccounts(existingStorage);
console.log(`\nVerification block cleared for ${acc.email || menuResult.clearVerificationAccountIndex + 1}.\n`);
}
}
continue;
}

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");
});
});
Loading