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:
7
.changeset/sonnet-5-not-found-fallback.md
Normal file
7
.changeset/sonnet-5-not-found-fallback.md
Normal 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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user