fix: continue fallback after OpenRouter no-endpoints 404 (#61472) (thanks @MonkeyLeeT)

* Fix OpenRouter no-endpoints fallback classification

* Restore bare model-not-found matcher coverage

* Preserve model does-not-exist fallback classification

* Narrow does-not-exist model-not-found matching

* Keep runtime model-not-found matcher strict

* style(agents): drop model matcher comment

* fix: continue fallback after OpenRouter no-endpoints 404 (#61472) (thanks @MonkeyLeeT)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Ted Li
2026-04-10 01:16:14 -07:00
committed by GitHub
parent b53d6ebc21
commit d78d91f8c2
7 changed files with 97 additions and 65 deletions

View File

@@ -78,6 +78,7 @@ Docs: https://docs.openclaw.ai
- Agents/sessions: preserve announce `threadId` when `sessions.list` fallback rehydrates agent-to-agent announce targets so final announce messages stay in the originating thread/topic. (#63506) Thanks @SnowSky1.
- iMessage/self-chat: remember ambiguous `sender === chat_identifier` outbound rows with missing `destination_caller_id` in self-chat dedupe state so the later reflected inbound copy still drops instead of re-entering inbound handling when the echo cache misses. Thanks @neeravmakwana.
- Claude CLI: stop marking spawned Claude Code runs as host-managed so they keep using normal CLI subscription behavior. (#64023) Thanks @Alex-Alaniz.
- Agents/failover: classify OpenRouter `404 No endpoints found for <model>` responses as `model_not_found` so fallback chains continue past retired OpenRouter candidates. (#61472) Thanks @MonkeyLeeT.
## 2026.4.9

View File

@@ -181,6 +181,44 @@ describe("failover-error", () => {
).toBe("overloaded");
});
it("classifies OpenRouter no-endpoints 404s as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
status: 404,
message: "No endpoints found for deepseek/deepseek-r1:free.",
}),
).toBe("model_not_found");
expect(
resolveFailoverReasonFromError({
message: "404 No endpoints found for deepseek/deepseek-r1:free.",
}),
).toBe("model_not_found");
});
it("classifies generic model-does-not-exist messages as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
message: "The model gpt-foo does not exist.",
}),
).toBe("model_not_found");
});
it("does not classify generic access errors as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
message: "The deployment does not exist or you do not have access.",
}),
).toBeNull();
});
it("does not classify generic deprecation transition messages as model_not_found", () => {
expect(
resolveFailoverReasonFromError({
message: "The endpoint has been deprecated. Transition to v2 API for continued access.",
}),
).toBeNull();
});
it("keeps status-only 503s conservative unless the payload is clearly overloaded", () => {
expect(
resolveFailoverReasonFromError({

View File

@@ -6,8 +6,14 @@ import {
describe("live model error helpers", () => {
it("detects generic model-not-found messages", () => {
expect(isModelNotFoundErrorMessage("Model not found: openai/gpt-6")).toBe(true);
expect(isModelNotFoundErrorMessage("model_not_found")).toBe(true);
expect(isModelNotFoundErrorMessage("The model gpt-foo does not exist.")).toBe(true);
expect(isModelNotFoundErrorMessage('{"code":404,"message":"model not found"}')).toBe(true);
expect(isModelNotFoundErrorMessage("model: MiniMax-M2.7-highspeed not found")).toBe(true);
expect(
isModelNotFoundErrorMessage("404 No endpoints found for deepseek/deepseek-r1:free."),
).toBe(true);
expect(
isModelNotFoundErrorMessage(
"HTTP 400 not_found_error: model: claude-3-5-haiku-20241022 (request_id: req_123)",
@@ -15,9 +21,12 @@ describe("live model error helpers", () => {
).toBe(true);
expect(
isModelNotFoundErrorMessage(
"404 The free model has been deprecated. Transition to qwen/qwen3.6-plus for continued paid access.",
"The endpoint has been deprecated. Transition to v2 API for continued access.",
),
).toBe(true);
).toBe(false);
expect(
isModelNotFoundErrorMessage("The deployment does not exist or you do not have access."),
).toBe(false);
expect(isModelNotFoundErrorMessage("request ended without sending any chunks")).toBe(false);
});

View File

@@ -3,19 +3,28 @@ export function isModelNotFoundErrorMessage(raw: string): boolean {
if (!msg) {
return false;
}
if (/no endpoints found for/i.test(msg)) {
return true;
}
if (/unknown model/i.test(msg)) {
return true;
}
if (/model(?:[_\-\s])?not(?:[_\-\s])?found/i.test(msg)) {
return true;
}
if (/\b404\b/.test(msg) && /not(?:[_\-\s])?found/i.test(msg)) {
return true;
}
if (/not_found_error/i.test(msg)) {
return true;
}
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not(?:[_\-\s])?found/i.test(msg)) {
if (/model:\s*[a-z0-9._/-]+/i.test(msg) && /not(?:[_\-\s])?found/i.test(msg)) {
return true;
}
if (/does not exist or you do not have access/i.test(msg)) {
if (/models\/[^\s]+ is not found/i.test(msg)) {
return true;
}
if (/deprecated/i.test(msg) && /(upgrade|transition) to/i.test(msg)) {
if (/model/i.test(msg) && /does not exist/i.test(msg)) {
return true;
}
if (/stealth model/i.test(msg) && /find it here/i.test(msg)) {
@@ -24,6 +33,9 @@ export function isModelNotFoundErrorMessage(raw: string): boolean {
if (/is not a valid model id/i.test(msg)) {
return true;
}
if (/invalid model/i.test(msg) && !/invalid model reference/i.test(msg)) {
return true;
}
return false;
}

View File

@@ -99,6 +99,7 @@ beforeEach(() => {
const OVERLOADED_ERROR_PAYLOAD =
'{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}';
const RATE_LIMIT_ERROR_MESSAGE = "rate limit exceeded";
const NO_ENDPOINTS_FOUND_ERROR_MESSAGE = "404 No endpoints found for deepseek/deepseek-r1:free.";
function makeConfig(): OpenClawConfig {
const apiKeyField = ["api", "Key"].join("");
@@ -388,7 +389,28 @@ function mockAllProvidersOverloaded() {
});
}
describe("runWithModelFallback + runEmbeddedPiAgent overload policy", () => {
describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => {
it("falls back on OpenRouter-style no-endpoints assistant errors", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);
mockPrimaryErrorThenFallbackSuccess(NO_ENDPOINTS_FOUND_ERROR_MESSAGE);
const result = await runEmbeddedFallback({
agentDir,
workspaceDir,
sessionKey: "agent:test:model-not-found-no-endpoints",
runId: "run:model-not-found-no-endpoints",
});
expect(result.provider).toBe("groq");
expect(result.model).toBe("mock-2");
expect(result.attempts[0]?.reason).toBe("model_not_found");
expect(result.result.payloads?.[0]?.text ?? "").toContain("fallback ok");
expectOpenAiThenGroqAttemptOrder();
});
});
it("falls back across providers after overloaded primary failure and persists transient cooldown", async () => {
await withAgentWorkspace(async ({ agentDir, workspaceDir }) => {
await writeAuthStore(agentDir);

View File

@@ -9,6 +9,7 @@ import {
isAnthropicBillingError,
isAnthropicRateLimitError,
} from "./live-auth-keys.js";
import { isModelNotFoundErrorMessage } from "./live-model-errors.js";
import {
isHighSignalLiveModelRef,
resolveHighSignalLiveModelLimit,
@@ -135,35 +136,6 @@ function isGoogleModelNotFoundError(err: unknown): boolean {
return false;
}
function isModelNotFoundErrorMessage(raw: string): boolean {
const msg = raw.trim();
if (!msg) {
return false;
}
if (/\b404\b/.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) {
return true;
}
if (/not_found_error/i.test(msg)) {
return true;
}
if (/model:\s*[a-z0-9._-]+/i.test(msg) && /not(?:[\s_-]+)?found/i.test(msg)) {
return true;
}
if (/does not exist or you do not have access/i.test(msg)) {
return true;
}
if (/deprecated/i.test(msg) && /(upgrade|transition) to/i.test(msg)) {
return true;
}
if (/stealth model/i.test(msg) && /find it here/i.test(msg)) {
return true;
}
if (/is not a valid model id/i.test(msg)) {
return true;
}
return false;
}
describe("isModelNotFoundErrorMessage", () => {
it("matches whitespace-separated not found errors", () => {
expect(isModelNotFoundErrorMessage("404 model not found")).toBe(true);
@@ -182,6 +154,12 @@ describe("isModelNotFoundErrorMessage", () => {
),
).toBe(true);
});
it("matches OpenRouter no-endpoints wording", () => {
expect(
isModelNotFoundErrorMessage("404 No endpoints found for deepseek/deepseek-r1:free."),
).toBe(true);
});
});
function isChatGPTUsageLimitErrorMessage(raw: string): boolean {

View File

@@ -20,6 +20,7 @@ export {
} from "../../shared/assistant-error-format.js";
import { formatExecDeniedUserMessage } from "../exec-approval-result.js";
import { stripInternalRuntimeContext } from "../internal-runtime-context.js";
import { isModelNotFoundErrorMessage } from "../live-model-errors.js";
import { formatSandboxToolPolicyBlockedMessage } from "../sandbox/runtime-status.js";
import { stableStringify } from "../stable-stringify.js";
import {
@@ -1240,36 +1241,7 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
return isAuthErrorMessage(msg.errorMessage ?? "");
}
export function isModelNotFoundErrorMessage(raw: string): boolean {
if (!raw) {
return false;
}
const lower = normalizeLowercaseStringOrEmpty(raw);
// Direct pattern matches from OpenClaw internals and common providers.
if (
lower.includes("unknown model") ||
lower.includes("model not found") ||
lower.includes("model_not_found") ||
lower.includes("not_found_error") ||
(lower.includes("does not exist") && lower.includes("model")) ||
(lower.includes("invalid model") && !lower.includes("invalid model reference"))
) {
return true;
}
// Google Gemini: "models/X is not found for api version"
if (/models\/[^\s]+ is not found/i.test(raw)) {
return true;
}
// JSON error payloads: {"status": "NOT_FOUND"} or {"code": 404} combined with not-found text.
if (/\b404\b/.test(raw) && /not[-_ ]?found/i.test(raw)) {
return true;
}
return false;
}
export { isModelNotFoundErrorMessage };
function isCliSessionExpiredErrorMessage(raw: string): boolean {
if (!raw) {