mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user