Merge pull request #3794 from jeremymcs/fix/claude-code-routing-bugs

fix(providers): match subscription block errors and respect claude-code routing
This commit is contained in:
Jeremy McSpadden 2026-04-08 11:15:26 -05:00 committed by GitHub
commit d29d086f6a
4 changed files with 60 additions and 14 deletions

View file

@ -316,6 +316,28 @@ describe("RetryHandler — long-context entitlement 429 (#2803)", () => {
assert.ok(switchEvent!.to.startsWith("claude-code/"), "Should switch to claude-code provider");
});
it("switches to claude-code on 'out of extra usage' error (#3772)", async () => {
const ccModel = createMockModel("claude-code", "claude-opus-4-6");
const { deps, emittedEvents } = createMockDeps({
model: createMockModel("anthropic", "claude-opus-4-6"),
findModelResult: (provider: string, modelId: string) => {
if (provider === "claude-code" && modelId === "claude-opus-4-6") return ccModel;
return undefined;
},
});
deps.isClaudeCodeReady = () => true;
const handler = new RetryHandler(deps);
const msg = errorMessage("You're out of extra usage. Add more at claude.ai/settings/usage and keep going.");
const result = await handler.handleRetryableError(msg);
assert.equal(result, true, "should retry via claude-code fallback");
const switchEvent = emittedEvents.find((e) => e.type === "fallback_provider_switch");
assert.ok(switchEvent, "Expected fallback_provider_switch event");
assert.ok(switchEvent!.to.startsWith("claude-code/"), "Should switch to claude-code provider");
});
it("does NOT switch to claude-code when current provider is not anthropic", async () => {
const ccModel = createMockModel("claude-code", "gpt-4o");
const { deps, emittedEvents } = createMockDeps({

View file

@ -116,7 +116,7 @@ export class RetryHandler {
// generated error from getApiKey() when credentials are in a backoff window.
// Re-entering the retry handler for that message creates a cascade of empty
// error entries in the session file, breaking resume (#3429).
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated|retry delay|network.?(?:is\s+)?unavailable|credentials.*expired|extra usage is required|third.party.*draw from extra|third.party.*not.*available/i.test(
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|terminated|retry delay|network.?(?:is\s+)?unavailable|credentials.*expired|extra usage is required|(?:out of|no) extra usage|third.party.*draw from extra|third.party.*not.*available/i.test(
err,
);
}
@ -458,12 +458,13 @@ export class RetryHandler {
}
/**
* Detect the Anthropic third-party subscription block error (#3772).
* This is a hard policy block, not a transient rate limit credential
* rotation will not help.
* Detect Anthropic subscription block errors (#3772).
* These are hard policy blocks, not transient rate limits credential
* rotation will not help. Matches both the explicit "third-party" message
* and the "out of extra usage" variant that subscription users receive.
*/
private _isThirdPartyBlock(errorMessage: string): boolean {
return /third[- .]party.*(?:draw from extra|not.*available|plan limits|not permitted|cannot be used|not supported)/i.test(errorMessage);
return /third[- .]party.*(?:draw from extra|not.*available|plan limits|not permitted|cannot be used|not supported)|(?:out of|no) extra usage/i.test(errorMessage);
}
/**

View file

@ -329,8 +329,17 @@ export function resolveModelId<T extends { id: string; provider: string }>(
if (candidates.length === 0) return undefined;
if (candidates.length === 1) return candidates[0];
// Extension / CLI-wrapper providers that should never win bare-ID resolution
// when a first-class API provider also offers the same model.
// When the user's current provider is claude-code (set by startup migration
// or explicit selection), honour it for bare IDs. Routing back to anthropic
// would undo the migration and hit the third-party subscription block (#3772).
if (currentProvider === "claude-code") {
const ccMatch = candidates.find(m => m.provider === "claude-code");
if (ccMatch) return ccMatch;
}
// Extension / CLI-wrapper providers that should not win bare-ID resolution
// when a first-class API provider also offers the same model AND the user
// has not explicitly chosen the extension provider.
const EXTENSION_PROVIDERS = new Set(["claude-code"]);
// Prefer currentProvider only when it is a first-class API provider

View file

@ -143,17 +143,17 @@ test("resolvePreferredModelConfig keeps explicit phase models as the ceiling", (
// ─── resolveModelId tests ─────────────────────────────────────────────────
test("resolveModelId: bare ID resolves to anthropic over claude-code when session is claude-code (#2905)", () => {
test("resolveModelId: bare ID resolves to claude-code when session is claude-code (#3772)", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "anthropic" },
{ id: "claude-sonnet-4-6", provider: "claude-code" },
];
// Bug: when currentProvider is "claude-code", bare ID "claude-sonnet-4-6"
// resolves to claude-code/claude-sonnet-4-6 instead of anthropic/claude-sonnet-4-6
// When currentProvider is "claude-code" (set by startup migration for subscription
// users), bare IDs must resolve to claude-code to avoid the third-party block (#3772).
const result = resolveModelId("claude-sonnet-4-6", availableModels, "claude-code");
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "anthropic", "bare ID must resolve to anthropic, not claude-code");
assert.equal(result.provider, "claude-code", "bare ID must resolve to claude-code when session provider is claude-code");
});
test("resolveModelId: bare ID still prefers current provider when it is a first-class API provider", () => {
@ -227,14 +227,28 @@ test("model change notify in selectAndApplyModel is gated behind verbose flag",
);
});
test("resolveModelId: anthropic wins over claude-code regardless of list order", () => {
test("resolveModelId: anthropic wins over claude-code when session provider is not claude-code", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "claude-code" },
{ id: "claude-sonnet-4-6", provider: "anthropic" },
];
// Even when claude-code appears first in the list, anthropic should win
// When the session is NOT on claude-code, bare IDs should resolve to
// the canonical anthropic provider (original #2905 behavior preserved).
const result = resolveModelId("claude-sonnet-4-6", availableModels, undefined);
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "anthropic", "anthropic must win when session is not claude-code");
});
test("resolveModelId: claude-code wins when session is claude-code regardless of list order", () => {
const availableModels = [
{ id: "claude-sonnet-4-6", provider: "claude-code" },
{ id: "claude-sonnet-4-6", provider: "anthropic" },
];
// When session provider is claude-code (subscription user migration), it must
// win regardless of candidate ordering to avoid the third-party block (#3772).
const result = resolveModelId("claude-sonnet-4-6", availableModels, "claude-code");
assert.ok(result, "should resolve a model");
assert.equal(result.provider, "anthropic", "anthropic must win over claude-code regardless of list order");
assert.equal(result.provider, "claude-code", "claude-code must win when it is the session provider");
});