diff --git a/.changeset/sonnet-5-not-found-fallback.md b/.changeset/sonnet-5-not-found-fallback.md new file mode 100644 index 0000000000..a28e28cf4c --- /dev/null +++ b/.changeset/sonnet-5-not-found-fallback.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Retry configured fallback models when a selected provider model returns a not-found error. +category: fix +dev: Classifies structured provider model 404 payloads, including Anthropic not_found_error, as model-selection failures. diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 3d3936314e..2e244aad03 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -1138,6 +1138,16 @@ describe("isRetryableModelSelectionError", () => { expect(isRetryableModelSelectionError("400 invalid_request_error: missing required field messages")).toBe(false); }); + it("treats provider model-not-found payloads as model-selection retryable", () => { + expect( + isRetryableModelSelectionError( + 'Error: 404 {"type":"error","error":{"type":"not_found_error","message":"Not found"},"request_id":"req_011CcawcZ3Ra9CennJXM8oWC"}', + ), + ).toBe(true); + expect(isRetryableModelSelectionError("model claude-sonnet-5 not found")).toBe(true); + expect(isRetryableModelSelectionError("GET /api/tasks/FN-404 returned 404 Not Found")).toBe(false); + }); + it("treats an unsupported message-role rejection as model-selection retryable so the fallback model is tried (issue #1261)", () => { expect( isRetryableModelSelectionError( diff --git a/packages/engine/src/__tests__/transient-error-detector.test.ts b/packages/engine/src/__tests__/transient-error-detector.test.ts index 80de2ab1d9..16ab313b6c 100644 --- a/packages/engine/src/__tests__/transient-error-detector.test.ts +++ b/packages/engine/src/__tests__/transient-error-detector.test.ts @@ -7,6 +7,7 @@ import { isOperatorActionableAgentError, isStaleWorktreeModuleResolutionError, isModelAuthTierIncompatibilityError, + isProviderModelNotFoundError, isUnsupportedMessageRoleError, isNonContinuableSessionError, TRANSIENT_ERROR_PATTERNS, @@ -361,6 +362,18 @@ describe("Transient Error Detector", () => { }); }); + describe("isProviderModelNotFoundError", () => { + it("matches provider-scoped model 404 payloads without matching generic 404s", () => { + const anthropicSonnet5Error = + 'Error: 404 {"type":"error","error":{"type":"not_found_error","message":"Not found"},"request_id":"req_011CcawcZ3Ra9CennJXM8oWC"}'; + + expect(isProviderModelNotFoundError(anthropicSonnet5Error)).toBe(true); + expect(isProviderModelNotFoundError("model claude-sonnet-5 not found")).toBe(true); + expect(isProviderModelNotFoundError("GET /api/tasks/FN-404 returned 404 Not Found")).toBe(false); + expect(isProviderModelNotFoundError("Task FN-404 not found")).toBe(false); + }); + }); + describe("isOperatorActionableAgentError", () => { it("returns true for credential/model/billing errors", () => { expect(isOperatorActionableAgentError("invalid api key")).toBe(true); @@ -368,6 +381,11 @@ describe("Transient Error Detector", () => { expect(isOperatorActionableAgentError("model gpt-x not found")).toBe(true); expect(isOperatorActionableAgentError("missing OPENAI_API_KEY")).toBe(true); expect(isOperatorActionableAgentError("billing issue: quota exceeded")).toBe(true); + expect( + isOperatorActionableAgentError( + 'Error: 404 {"type":"error","error":{"type":"not_found_error","message":"Not found"},"request_id":"req_011CcawcZ3Ra9CennJXM8oWC"}', + ), + ).toBe(true); }); it("returns true for unsupported message-role errors", () => { diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index e951d47337..a2314b2e07 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -73,7 +73,7 @@ import { resolvePermanentAgentToolDecision } from "./permanent-agent-gating.js"; import type { SystemPromptLayers } from "./prompt-layers.js"; import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js"; import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; -import { isModelAuthTierIncompatibilityError, isUnsupportedMessageRoleError } from "./transient-error-detector.js"; +import { isModelAuthTierIncompatibilityError, isProviderModelNotFoundError, isUnsupportedMessageRoleError } from "./transient-error-detector.js"; import { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp-runtime-support.js"; import { connectMcpSessionTools, type McpClientFactory, type McpSessionToolset } from "./mcp-session-tools.js"; export { isModelAuthTierIncompatibilityError } from "./transient-error-detector.js"; @@ -1106,6 +1106,13 @@ export function isRetryableModelSelectionError(message: string): boolean { if (isUnsupportedMessageRoleError(message)) { return true; } + /* + * FNXC:ModelFallback 2026-07-01-00:30: + * Prompt-time provider 404s for a selected model, including Anthropic's `not_found_error` for Claude Sonnet 5 account/surface gaps, must enter the same single-swap fallback path as auth-tier and role-compatibility failures. Generic 404s remain excluded by the classifier. + */ + if (isProviderModelNotFoundError(message)) { + return true; + } const normalized = message.toLowerCase(); return normalized.includes("rate limit") || normalized.includes("too many requests") diff --git a/packages/engine/src/transient-error-detector.ts b/packages/engine/src/transient-error-detector.ts index 3362fe041a..89f8b867b3 100644 --- a/packages/engine/src/transient-error-detector.ts +++ b/packages/engine/src/transient-error-detector.ts @@ -208,6 +208,12 @@ const MODEL_AUTH_TIER_INCOMPATIBILITY_PATTERNS: RegExp[] = [ /(?:['"`][^'"`]+['"`]\s+)?\bmodel\b\s+(?:is|was)\s+not\s+(?:supported|available)\b/i, ]; +const PROVIDER_MODEL_NOT_FOUND_PATTERNS: RegExp[] = [ + /\bmodel\b[\s\S]{0,160}\bnot\s+found\b/i, + /\bno\s+such\s+model\b/i, + /\bunknown\s+model\b/i, +]; + export function isUnsupportedMessageRoleError(errorMessage: string): boolean { if (!errorMessage || typeof errorMessage !== "string") { return false; @@ -229,6 +235,28 @@ export function isModelAuthTierIncompatibilityError(errorMessage: string): boole return MODEL_AUTH_TIER_INCOMPATIBILITY_PATTERNS.some((pattern) => pattern.test(errorMessage)); } +export function isProviderModelNotFoundError(errorMessage: string): boolean { + if (!errorMessage || typeof errorMessage !== "string") { + return false; + } + + /* + * FNXC:ModelFallback 2026-07-01-00:30: + * Anthropic can reject newly cataloged models such as Claude Sonnet 5 with a structured 404 `not_found_error` when the current account or API surface cannot serve that model. Treat only provider/model-scoped 404s as model-selection failures so configured fallbacks run without reclassifying unrelated application 404s as recoverable model swaps. + */ + const hasStructuredProviderNotFound = + /["']type["']\s*:\s*["']not_found_error["']/i.test(errorMessage) + || /\bnot_found_error\b/i.test(errorMessage); + const hasNotFoundStatus = /\b(?:404|not\s+found)\b/i.test(errorMessage); + const hasProviderErrorEnvelope = /["']type["']\s*:\s*["']error["']/i.test(errorMessage) + || /\bError:\s*404\b/i.test(errorMessage); + if (hasStructuredProviderNotFound && hasNotFoundStatus && hasProviderErrorEnvelope) { + return true; + } + + return PROVIDER_MODEL_NOT_FOUND_PATTERNS.some((pattern) => pattern.test(errorMessage)); +} + export function isNonContinuableSessionError(errorMessage: string): boolean { if (!errorMessage || typeof errorMessage !== "string") { return false; @@ -258,6 +286,7 @@ export function isOperatorActionableAgentError(errorMessage: string): boolean { return ( isUnsupportedMessageRoleError(errorMessage) || isModelAuthTierIncompatibilityError(errorMessage) || + isProviderModelNotFoundError(errorMessage) || OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage)) ); }