From 7fbd31818b315100fb410ea78ca6f3eee7926f40 Mon Sep 17 00:00:00 2001 From: xiwuqi <64734786+xiwuqi@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:32:29 -0500 Subject: [PATCH] fix: classify invalid-model fallback errors (#50028) Merged via squash. Prepared head SHA: 04b13e09e1f1e43b7069879426c2e67e82edd6b0 Co-authored-by: xiwuqi <64734786+xiwuqi@users.noreply.github.com> Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com> Reviewed-by: @altaywtf --- CHANGELOG.md | 1 + src/agents/failover-error.test.ts | 20 +++++++++++++++++++ src/agents/model-fallback.test.ts | 27 ++++++++++++++++++++++++++ src/commands/models/list.probe.test.ts | 3 ++- src/commands/models/list.probe.ts | 3 +++ 5 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b4a6630352..b22af205d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Gateway/auth: resolve the active gateway bearer per-request on the HTTP server and the HTTP upgrade handler via `getResolvedAuth()`, mirroring the WebSocket path, so a secret rotated through `secrets.reload` or config hot-reload stops authenticating on `/v1/*`, `/tools/invoke`, plugin HTTP routes, and the canvas upgrade path immediately instead of remaining valid on HTTP until gateway restart. (#66651) Thanks @mmaps. - Agents/compaction: cap the compaction reserve-token floor to the model context window so small-context local models (e.g. Ollama with 16K tokens) no longer trigger context-overflow errors or infinite compaction loops on every prompt. (#65671) Thanks @openperf. - Agents/OpenAI Responses: classify the exact `Unknown error (no error details in response)` transport failure as failover reason `unknown` so assistant/model fallback still runs for that no-details failure path. (#65254) Thanks @OpenCodeEngineer. +- Models/probe: surface invalid-model probe failures as `format` instead of `unknown` in `models list --probe`, and lock the invalid-model fallback path in with regression coverage. (#50028) Thanks @xiwuqi. ## 2026.4.14 diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index e5f2b6633f4..a4db5e56b10 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -334,6 +334,26 @@ describe("failover-error", () => { ).toEqual({ kind: "context_overflow" }); }); + it("treats invalid-model HTTP 400 payloads as model_not_found instead of format", () => { + expect( + resolveFailoverReasonFromError({ + message: "openrouter/__invalid_test_model__ is not a valid model ID", + }), + ).toBe("model_not_found"); + expect( + resolveFailoverReasonFromError({ + status: 400, + message: "HTTP 400: openrouter/__invalid_test_model__ is not a valid model ID", + }), + ).toBe("model_not_found"); + expect( + resolveFailoverReasonFromError({ + status: 422, + message: "invalid model: openrouter/__invalid_test_model__", + }), + ).toBe("model_not_found"); + }); + it("treats HTTP 422 as format error", () => { expect( resolveFailoverReasonFromError({ diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index e2141e518c9..7420423d489 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -591,6 +591,33 @@ describe("runWithModelFallback", () => { expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini"); }); + it("records invalid-model HTTP 400 responses as model_not_found during fallback", async () => { + const cfg = makeCfg(); + const run = vi + .fn() + .mockRejectedValueOnce( + Object.assign( + new Error("HTTP 400: openrouter/__invalid_test_model__ is not a valid model ID"), + { status: 400 }, + ), + ) + .mockResolvedValueOnce("ok"); + + const result = await runWithModelFallback({ + cfg, + provider: "openrouter", + model: "__invalid_test_model__", + run, + }); + + expect(result.result).toBe("ok"); + expect(run).toHaveBeenCalledTimes(2); + expect(result.attempts).toHaveLength(1); + expect(result.attempts[0]?.reason).toBe("model_not_found"); + expect(run.mock.calls[1]?.[0]).toBe("openai"); + expect(run.mock.calls[1]?.[1]).toBe("gpt-4.1-mini"); + }); + it("warns when falling back due to model_not_found", async () => { setLoggerOverride({ level: "silent", consoleLevel: "warn" }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/src/commands/models/list.probe.test.ts b/src/commands/models/list.probe.test.ts index a57df70c4ad..932908572dc 100644 --- a/src/commands/models/list.probe.test.ts +++ b/src/commands/models/list.probe.test.ts @@ -27,12 +27,13 @@ describe("mapFailoverReasonToProbeStatus", () => { expect(mapFailoverReasonToProbeStatus("overloaded")).toBe("rate_limit"); expect(mapFailoverReasonToProbeStatus("billing")).toBe("billing"); expect(mapFailoverReasonToProbeStatus("timeout")).toBe("timeout"); + expect(mapFailoverReasonToProbeStatus("model_not_found")).toBe("format"); expect(mapFailoverReasonToProbeStatus("format")).toBe("format"); }); it("falls back to unknown for unrecognized values", () => { expect(mapFailoverReasonToProbeStatus(undefined)).toBe("unknown"); expect(mapFailoverReasonToProbeStatus(null)).toBe("unknown"); - expect(mapFailoverReasonToProbeStatus("model_not_found")).toBe("unknown"); + expect(mapFailoverReasonToProbeStatus("something_else")).toBe("unknown"); }); }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 18df0a0a673..9020e0e4556 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -121,6 +121,9 @@ export function mapFailoverReasonToProbeStatus(reason?: string | null): AuthProb if (reason === "timeout") { return "timeout"; } + if (reason === "model_not_found") { + return "format"; + } if (reason === "format") { return "format"; }