From c07ecc10287c666fa9bc2c2ea293bde82931758c Mon Sep 17 00:00:00 2001 From: Jeremy Date: Wed, 8 Apr 2026 10:47:35 -0500 Subject: [PATCH] fix(providers): match 'out of extra usage' error and respect claude-code provider in model resolution (#3772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs prevented subscription users from routing through Claude Code CLI: 1. Retry handler regex only matched "third-party" errors but actual error is "You're out of extra usage" — fallback never triggered 2. auto-model-selection actively rerouted bare model IDs back to anthropic even after startup migration set claude-code as the session provider --- .../src/core/retry-handler.test.ts | 22 +++++++++++++++ .../pi-coding-agent/src/core/retry-handler.ts | 11 ++++---- .../extensions/gsd/auto-model-selection.ts | 13 +++++++-- .../gsd/tests/auto-model-selection.test.ts | 28 ++++++++++++++----- 4 files changed, 60 insertions(+), 14 deletions(-) diff --git a/packages/pi-coding-agent/src/core/retry-handler.test.ts b/packages/pi-coding-agent/src/core/retry-handler.test.ts index 74a21b662..5cd324401 100644 --- a/packages/pi-coding-agent/src/core/retry-handler.test.ts +++ b/packages/pi-coding-agent/src/core/retry-handler.test.ts @@ -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({ diff --git a/packages/pi-coding-agent/src/core/retry-handler.ts b/packages/pi-coding-agent/src/core/retry-handler.ts index c65bd9390..78d12c8ba 100644 --- a/packages/pi-coding-agent/src/core/retry-handler.ts +++ b/packages/pi-coding-agent/src/core/retry-handler.ts @@ -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); } /** diff --git a/src/resources/extensions/gsd/auto-model-selection.ts b/src/resources/extensions/gsd/auto-model-selection.ts index c48afef37..7dc1593f8 100644 --- a/src/resources/extensions/gsd/auto-model-selection.ts +++ b/src/resources/extensions/gsd/auto-model-selection.ts @@ -329,8 +329,17 @@ export function resolveModelId( 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 diff --git a/src/resources/extensions/gsd/tests/auto-model-selection.test.ts b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts index 9cec5afac..1551888d4 100644 --- a/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +++ b/src/resources/extensions/gsd/tests/auto-model-selection.test.ts @@ -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"); });