fix: integrate Codex & Gemini CLI into provider routes and rate-limit handling (#2922) (#3246)

Extend PROVIDER_ROUTES so doctor/routing recognizes google-gemini-cli
as an alternative for google and openai-codex as an alternative for
openai. Cap rate-limit backoff at 30s for CLI-style providers to avoid
leaving users stuck in long backoff windows.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:23:26 -04:00 committed by GitHub
parent 06d56e5f03
commit c31b03d57c
4 changed files with 175 additions and 1 deletions

View file

@ -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 ---

View file

@ -181,7 +181,8 @@ function resolveKey(providerId: string): KeyLookup {
*/
const PROVIDER_ROUTES: Record<string, string[]> = {
anthropic: ["github-copilot"],
openai: ["github-copilot"],
openai: ["github-copilot", "openai-codex"],
google: ["google-gemini-cli"],
};
function checkLlmProviders(): ProviderCheckResult[] {

View file

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

View file

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