fix(FN-7291): retry fallback for provider model 404s

Classify structured provider model-not-found payloads as model-selection failures so configured fallback models run when Claude Sonnet 5 is unavailable on an account or API surface.
This commit is contained in:
gsxdsm
2026-07-01 00:33:38 -07:00
parent d96eb3c644
commit 8079722314
5 changed files with 72 additions and 1 deletions

View File

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

View File

@@ -1138,6 +1138,16 @@ describe("isRetryableModelSelectionError", () => {
expect(isRetryableModelSelectionError("400 invalid_request_error: missing required field messages")).toBe(false); 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)", () => { it("treats an unsupported message-role rejection as model-selection retryable so the fallback model is tried (issue #1261)", () => {
expect( expect(
isRetryableModelSelectionError( isRetryableModelSelectionError(

View File

@@ -7,6 +7,7 @@ import {
isOperatorActionableAgentError, isOperatorActionableAgentError,
isStaleWorktreeModuleResolutionError, isStaleWorktreeModuleResolutionError,
isModelAuthTierIncompatibilityError, isModelAuthTierIncompatibilityError,
isProviderModelNotFoundError,
isUnsupportedMessageRoleError, isUnsupportedMessageRoleError,
isNonContinuableSessionError, isNonContinuableSessionError,
TRANSIENT_ERROR_PATTERNS, 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", () => { describe("isOperatorActionableAgentError", () => {
it("returns true for credential/model/billing errors", () => { it("returns true for credential/model/billing errors", () => {
expect(isOperatorActionableAgentError("invalid api key")).toBe(true); 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("model gpt-x not found")).toBe(true);
expect(isOperatorActionableAgentError("missing OPENAI_API_KEY")).toBe(true); expect(isOperatorActionableAgentError("missing OPENAI_API_KEY")).toBe(true);
expect(isOperatorActionableAgentError("billing issue: quota exceeded")).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", () => { it("returns true for unsupported message-role errors", () => {

View File

@@ -73,7 +73,7 @@ import { resolvePermanentAgentToolDecision } from "./permanent-agent-gating.js";
import type { SystemPromptLayers } from "./prompt-layers.js"; import type { SystemPromptLayers } from "./prompt-layers.js";
import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js"; import { READONLY_ALLOWLIST, filterCustomToolsForReadonly, isReadonlyAllowed } from "./workflow-step-tool-policy.js";
import { createStreamingDeltaNormalizer } from "./streaming-delta.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 { logMcpForwardingSkipped, runtimeSupportsMcp } from "./mcp-runtime-support.js";
import { connectMcpSessionTools, type McpClientFactory, type McpSessionToolset } from "./mcp-session-tools.js"; import { connectMcpSessionTools, type McpClientFactory, type McpSessionToolset } from "./mcp-session-tools.js";
export { isModelAuthTierIncompatibilityError } from "./transient-error-detector.js"; export { isModelAuthTierIncompatibilityError } from "./transient-error-detector.js";
@@ -1106,6 +1106,13 @@ export function isRetryableModelSelectionError(message: string): boolean {
if (isUnsupportedMessageRoleError(message)) { if (isUnsupportedMessageRoleError(message)) {
return true; 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(); const normalized = message.toLowerCase();
return normalized.includes("rate limit") return normalized.includes("rate limit")
|| normalized.includes("too many requests") || normalized.includes("too many requests")

View File

@@ -208,6 +208,12 @@ const MODEL_AUTH_TIER_INCOMPATIBILITY_PATTERNS: RegExp[] = [
/(?:['"`][^'"`]+['"`]\s+)?\bmodel\b\s+(?:is|was)\s+not\s+(?:supported|available)\b/i, /(?:['"`][^'"`]+['"`]\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 { export function isUnsupportedMessageRoleError(errorMessage: string): boolean {
if (!errorMessage || typeof errorMessage !== "string") { if (!errorMessage || typeof errorMessage !== "string") {
return false; return false;
@@ -229,6 +235,28 @@ export function isModelAuthTierIncompatibilityError(errorMessage: string): boole
return MODEL_AUTH_TIER_INCOMPATIBILITY_PATTERNS.some((pattern) => pattern.test(errorMessage)); 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 { export function isNonContinuableSessionError(errorMessage: string): boolean {
if (!errorMessage || typeof errorMessage !== "string") { if (!errorMessage || typeof errorMessage !== "string") {
return false; return false;
@@ -258,6 +286,7 @@ export function isOperatorActionableAgentError(errorMessage: string): boolean {
return ( return (
isUnsupportedMessageRoleError(errorMessage) || isUnsupportedMessageRoleError(errorMessage) ||
isModelAuthTierIncompatibilityError(errorMessage) || isModelAuthTierIncompatibilityError(errorMessage) ||
isProviderModelNotFoundError(errorMessage) ||
OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage)) OPERATOR_ACTIONABLE_AGENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage))
); );
} }