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:
parent
06d56e5f03
commit
c31b03d57c
4 changed files with 175 additions and 1 deletions
|
|
@ -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 ---
|
||||
|
|
|
|||
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
);
|
||||
});
|
||||
|
|
@ -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)',
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue