diff --git a/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts b/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts index 22dd56075..09a66e6e1 100644 --- a/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +++ b/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts @@ -79,6 +79,15 @@ export async function handleAgentEnd( // ── 1. Classify ────────────────────────────────────────────────────── const cls = classifyError(errorMsg, explicitRetryAfterMs); + // Cap rate-limit backoff for CLI-style providers (openai-codex, google-gemini-cli) + // which use per-user quotas with shorter windows (#2922). + if (cls.kind === "rate-limit") { + const currentProvider = ctx.model?.provider; + if (currentProvider === "openai-codex" || currentProvider === "google-gemini-cli") { + cls.retryAfterMs = Math.min(cls.retryAfterMs, 30_000); + } + } + // ── 2. Decide & Act ────────────────────────────────────────────────── // --- Network errors: same-model retry with backoff --- diff --git a/src/resources/extensions/gsd/doctor-providers.ts b/src/resources/extensions/gsd/doctor-providers.ts index 99c8c4ede..e0f35341b 100644 --- a/src/resources/extensions/gsd/doctor-providers.ts +++ b/src/resources/extensions/gsd/doctor-providers.ts @@ -181,7 +181,8 @@ function resolveKey(providerId: string): KeyLookup { */ const PROVIDER_ROUTES: Record = { anthropic: ["github-copilot"], - openai: ["github-copilot"], + openai: ["github-copilot", "openai-codex"], + google: ["google-gemini-cli"], }; function checkLlmProviders(): ProviderCheckResult[] { diff --git a/src/resources/extensions/gsd/tests/cli-provider-rate-limit.test.ts b/src/resources/extensions/gsd/tests/cli-provider-rate-limit.test.ts new file mode 100644 index 000000000..cd79cf9a2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/cli-provider-rate-limit.test.ts @@ -0,0 +1,47 @@ +/** + * cli-provider-rate-limit.test.ts — Verify rate-limit backoff capping + * for CLI-style providers (openai-codex, google-gemini-cli). (#2922) + * + * These providers use per-user quotas with shorter windows, so the + * default 60s backoff should be capped at 30s to avoid leaving users + * stuck in an apparent permanent "rate limit" state. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const RECOVERY_PATH = join(__dirname, "..", "bootstrap", "agent-end-recovery.ts"); + +function getRecoverySource(): string { + return readFileSync(RECOVERY_PATH, "utf-8"); +} + +test("agent-end-recovery references openai-codex for rate-limit handling (#2922)", () => { + const src = getRecoverySource(); + assert.ok( + src.includes("openai-codex"), + 'agent-end-recovery.ts must reference "openai-codex" for CLI provider rate-limit handling (#2922)', + ); +}); + +test("agent-end-recovery references google-gemini-cli for rate-limit handling (#2922)", () => { + const src = getRecoverySource(); + assert.ok( + src.includes("google-gemini-cli"), + 'agent-end-recovery.ts must reference "google-gemini-cli" for CLI provider rate-limit handling (#2922)', + ); +}); + +test("agent-end-recovery caps rate-limit backoff for CLI providers (#2922)", () => { + const src = getRecoverySource(); + // Must have a Math.min capping pattern for CLI provider rate-limit backoff + const cappingRe = /Math\.min\s*\(/; + assert.ok( + cappingRe.test(src), + 'agent-end-recovery.ts must cap rate-limit backoff with Math.min for CLI providers (#2922)', + ); +}); diff --git a/src/resources/extensions/gsd/tests/doctor-providers.test.ts b/src/resources/extensions/gsd/tests/doctor-providers.test.ts index 96f6abd3e..8df31fc10 100644 --- a/src/resources/extensions/gsd/tests/doctor-providers.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-providers.test.ts @@ -484,3 +484,120 @@ test("runProviderChecks uses object provider field for anthropic-vertex models", rmSync(repo, { recursive: true, force: true }); rmSync(tmpHome, { recursive: true, force: true }); }); + +// ─── Cross-provider routing: Codex & Gemini CLI (#2922) ──────────────────── + +test("runProviderChecks reports ok for Google via google-gemini-cli auth.json (#2922)", () => { + const repo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-gemini-cli-repo-"))); + mkdirSync(join(repo, ".gsd"), { recursive: true }); + writeFileSync( + join(repo, ".gsd", "PREFERENCES.md"), + [ + "---", + "models:", + " execution: gemini-2.5-pro", + "---", + "", + ].join("\n"), + ); + + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-gemini-cli-home-"))); + const agentDir = join(tmpHome, ".gsd", "agent"); + mkdirSync(agentDir, { recursive: true }); + + // google-gemini-cli OAuth in auth.json (no google API key) + const authData = { + "google-gemini-cli": { type: "oauth", apiKey: "ya29.gemini-cli-token", expires: Date.now() + 3_600_000 }, + }; + writeFileSync(join(agentDir, "auth.json"), JSON.stringify(authData)); + + withEnv({ + HOME: tmpHome, + GEMINI_API_KEY: undefined, + GOOGLE_API_KEY: undefined, + }, () => { + withCwd(repo, () => { + const results = runProviderChecks(); + const google = results.find(r => r.name === "google"); + assert.ok(google, "google result should exist"); + assert.equal(google!.status, "ok", "should be ok when google-gemini-cli auth is available (#2922)"); + assert.ok(google!.message.includes("Google Gemini CLI"), "should mention Gemini CLI as the source (#2922)"); + }); + }); + + rmSync(repo, { recursive: true, force: true }); + rmSync(tmpHome, { recursive: true, force: true }); +}); + +test("runProviderChecks reports ok for OpenAI via openai-codex auth.json (#2922)", () => { + const repo = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-codex-repo-"))); + mkdirSync(join(repo, ".gsd"), { recursive: true }); + writeFileSync( + join(repo, ".gsd", "PREFERENCES.md"), + [ + "---", + "models:", + " execution: gpt-4o", + "---", + "", + ].join("\n"), + ); + + const tmpHome = realpathSync(mkdtempSync(join(tmpdir(), "gsd-providers-codex-home-"))); + const agentDir = join(tmpHome, ".gsd", "agent"); + mkdirSync(agentDir, { recursive: true }); + + // openai-codex OAuth in auth.json (no openai API key) + const authData = { + "openai-codex": { type: "oauth", apiKey: "codex-token", expires: Date.now() + 3_600_000 }, + }; + writeFileSync(join(agentDir, "auth.json"), JSON.stringify(authData)); + + withEnv({ + HOME: tmpHome, + OPENAI_API_KEY: undefined, + // Clear Copilot env vars so it doesn't route through Copilot + COPILOT_GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + }, () => { + withCwd(repo, () => { + const results = runProviderChecks(); + const openai = results.find(r => r.name === "openai"); + assert.ok(openai, "openai result should exist"); + assert.equal(openai!.status, "ok", "should be ok when openai-codex auth is available (#2922)"); + assert.ok(openai!.message.includes("Codex"), "should mention Codex as the source (#2922)"); + }); + }); + + rmSync(repo, { recursive: true, force: true }); + rmSync(tmpHome, { recursive: true, force: true }); +}); + +test("PROVIDER_ROUTES includes google-gemini-cli as route for google (#2922)", async () => { + const { readFileSync: readFS } = await import("node:fs"); + const { dirname: dirn, join: joinPath } = await import("node:path"); + const { fileURLToPath: fileUrl } = await import("node:url"); + const __dir = dirn(fileUrl(import.meta.url)); + const src = readFS(joinPath(__dir, "..", "doctor-providers.ts"), "utf-8"); + + // PROVIDER_ROUTES must map google -> [..., "google-gemini-cli"] + assert.ok( + src.includes('"google-gemini-cli"'), + 'PROVIDER_ROUTES must include "google-gemini-cli" as a route (#2922)', + ); +}); + +test("PROVIDER_ROUTES includes openai-codex as route for openai (#2922)", async () => { + const { readFileSync: readFS } = await import("node:fs"); + const { dirname: dirn, join: joinPath } = await import("node:path"); + const { fileURLToPath: fileUrl } = await import("node:url"); + const __dir = dirn(fileUrl(import.meta.url)); + const src = readFS(joinPath(__dir, "..", "doctor-providers.ts"), "utf-8"); + + // PROVIDER_ROUTES must map openai -> [..., "openai-codex"] + assert.ok( + src.includes('"openai-codex"'), + 'PROVIDER_ROUTES must include "openai-codex" as a route (#2922)', + ); +});