Skip to content

Commit 94de974

Browse files
authored
Error-class-bound fallthrough for OpenRouter models[] fallback (#334)
## Error-class-bound fallthrough for the OpenRouter `models[]` fallback engine Follow-up to #328. That PR shipped `models[]` failover (an error-fixture candidate falls through to the next model, unless `provider.allow_fallbacks:false`). But real OpenRouter fails over **inconsistently by error class** — the loudest authentic user complaint (openclaw#60191, #45834, #19249): a `403 budget-exceeded` or generic "provider error" does **not** advance to the next model (it retries the same primary), while 429/503 do. Users can't rehearse that. ### What this adds An optional **`fallthrough?: boolean`** on the error fixture response: - **absent (default)** → current behavior: the error candidate falls through to the next `models[]` candidate (fully backward-compatible). - **`fallthrough: false`** → the error is **terminal**: the loop stops and serves that error, no failover — reproducing "this error class doesn't fail over." It composes with the request-level `provider.allow_fallbacks:false` (either signal makes an error terminal). A dev models an error *class* by setting `fallthrough:false` on that class's fixture (e.g. the 403) and leaving 429/503 fixtures to default — so they can reproduce the exact failure that burned them and assert their handling, *and* the positive "fallback saved me" path. ### Correctness - **Load-time validation**: `validateFixtures` rejects a non-boolean `fallthrough` (`"false"`, `0`, `null`) — without this, a JSON author's `"false"` string would silently invert TERMINAL→fall-through (fail-closed quietly failing open). - A winning non-error slug is echoed as `response.model`; a terminal error is served as the OpenRouter error envelope (`{error:{code,message,metadata?}}`, no `model` field). - Default path is byte-identical to #328's fall-through. ### Testing / review - Red→green against the real HTTP surface; new tests for `fallthrough:false` terminal, default fall-through, composition, and the non-boolean load-time rejection. - Full suite green (`pnpm test`); tsc/lint/build/exports clean. No version bump (release is separate). - Reviewed via a multi-round CR (converged; silent-failure clean, gate/threading/validation confirmed correct). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018yy1y2Cwc31DaB4UNqvT8K
2 parents d43701c + a93f8fc commit 94de974

6 files changed

Lines changed: 259 additions & 11 deletions

File tree

docs/openrouter-chat/index.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,37 @@ <h2 id="fallback">models[] Fallback Simulation</h2>
344344
primary candidate is tried, and its error (or a miss) is terminal instead of walking the
345345
rest of the list.
346346
</p>
347+
<p>
348+
Real OpenRouter fails over <em>inconsistently</em> by error class: a
349+
<code>403 budget-exceeded</code> or a generic "provider returned error" is served as
350+
terminal (it does <em>not</em> advance to the next candidate), while 429/503 usually do
351+
fail over. Reproduce that per error class with <code>fallthrough: false</code> on the
352+
error fixture &mdash; the loop stops and serves that error as terminal, no failover, even
353+
when a good candidate follows. Absent (or <code>true</code>) keeps the default
354+
fall-through, so existing fixtures are unchanged. This composes with the request-level
355+
gate: fall-through happens only when both allow it (if either
356+
<code>allow_fallbacks: false</code> or <code>fallthrough: false</code> says stop, the
357+
error is terminal). Model an error class a dev must handle themselves &mdash; the exact
358+
failure that burned them &mdash; by setting <code>fallthrough: false</code> on that
359+
class's fixture (e.g. the 403) and leaving 429/503 fixtures to default.
360+
</p>
361+
<div class="code-block">
362+
<div class="code-block-header">
363+
terminal-error.test.ts <span class="lang-tag">ts</span>
364+
</div>
365+
<pre><code><span class="cm">// a 403 that does NOT fail over — the app must handle it, no fallback</span>
366+
<span class="op">mock</span>.<span class="fn">on</span>(
367+
{ <span class="prop">model</span>: <span class="str">"openai/gpt-4o"</span>, <span class="prop">userMessage</span>: <span class="str">"route"</span> },
368+
{ <span class="prop">error</span>: { <span class="prop">message</span>: <span class="str">"budget exceeded"</span> }, <span class="prop">status</span>: <span class="num">403</span>, <span class="prop">fallthrough</span>: <span class="kw">false</span> }
369+
);
370+
<span class="op">mock</span>.<span class="fn">on</span>(
371+
{ <span class="prop">model</span>: <span class="str">"anthropic/claude-3.5-sonnet"</span>, <span class="prop">userMessage</span>: <span class="str">"route"</span> },
372+
{ <span class="prop">content</span>: <span class="str">"never reached"</span> }
373+
);
374+
375+
<span class="cm">// models[] lists a good fallback, but the 403 is terminal — no failover</span>
376+
<span class="fn">expect</span>(<span class="op">res</span>.<span class="prop">status</span>).<span class="fn">toBe</span>(<span class="num">403</span>);</code></pre>
377+
</div>
347378
<div class="info-box">
348379
<p>
349380
<strong>Deliberate non-goal.</strong> Real OpenRouter rejects an unknown/invalid model

skills/write-fixtures/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ A request whose path starts with `/api/v1/` (point the OpenAI SDK at a `baseURL`
485485

486486
- **Scriptable cost / provider / finish reason**: set `provider`, `nativeFinishReason`, and `usage.cost` on the response (see the overrides table). `cost`/`cost_details` are emitted only when a fixture supplies `cost` — aimock never fabricates a cost.
487487
- **`models[]` fallback (router failover)**: when the request body carries `models: [m1, m2, ...]`, aimock walks `[model, ...models]` in order and serves the first fixture that returns a NON-error response. A `429`/`503` error fixture on a candidate simulates a RUNTIME provider failure and falls through to the next candidate; the winning slug is echoed back as the top-level `model` (assert failover via `response.model`). Model the primary's "failure" as a 429/503 — an unknown/invalid model is just a fixture miss (aimock does not replicate OpenRouter's up-front invalid-model 400).
488+
- **Terminal (non-failover) error class — `fallthrough: false`**: real OpenRouter fails over inconsistently by error class (a `403 budget-exceeded` / generic "provider returned error" is served as terminal and does NOT advance to the next candidate, while 429/503 usually do). Set `fallthrough: false` on an error fixture to make it terminal: the fallback loop stops and serves that error even when a good candidate follows. Absent / `true` keeps the default fall-through. Composes with `provider.allow_fallbacks`: fall-through happens only when both allow it (if either says stop, the error is terminal). Use it to reproduce the exact provider error a dev's app must handle itself. `{ error: { message: "budget exceeded" }, status: 403, fallthrough: false }`
488489
- **Keepalive**: set the fixture option `openRouterProcessing: true` to emit one `: OPENROUTER PROCESSING` SSE comment before the first data frame (opt-in, default off).
489490
- **Error envelope**: OpenRouter errors are `{ error: { message, code } }` (numeric `code` == HTTP status), with optional free-form `metadata`.
490491

src/__tests__/openrouter-chat.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,183 @@ describe("OpenRouter chat: fail-closed + fallback correctness (round-1 fixes)",
605605
});
606606
});
607607

608+
// ---------------------------------------------------------------------------
609+
// v1.1 — error-class-bound fallthrough (`fallthrough: false` = terminal error)
610+
// ---------------------------------------------------------------------------
611+
612+
describe("OpenRouter chat: error-class-bound fallthrough (v1.1)", () => {
613+
let tmpDir: string | null = null;
614+
afterEach(() => {
615+
if (tmpDir) {
616+
rmSync(tmpDir, { recursive: true, force: true });
617+
tmpDir = null;
618+
}
619+
});
620+
621+
// A 403 whose error CLASS does not fail over on real OpenRouter (the
622+
// openclaw #60191 pattern): the primary error is marked `fallthrough: false`,
623+
// so it must be served as terminal even though a good fallback follows.
624+
const terminalChain = (fallthrough?: boolean): Fixture[] => [
625+
{
626+
match: { model: "primary/bad", userMessage: "route" },
627+
response: {
628+
error: { message: "budget exceeded (non-failover class)" },
629+
status: 403,
630+
...(fallthrough !== undefined && { fallthrough }),
631+
},
632+
},
633+
{
634+
match: { model: "fallback/good", userMessage: "route" },
635+
response: { content: "served by fallback", usage: { cost: 0.5 } },
636+
},
637+
];
638+
639+
it("fallthrough:false serves the primary error as terminal — NO failover", async () => {
640+
instance = await createServer(terminalChain(false));
641+
const res = await httpPost(`${instance.url}${OR}`, {
642+
model: "primary/bad",
643+
models: ["primary/bad", "fallback/good"],
644+
messages: [{ role: "user", content: "route" }],
645+
});
646+
const json = JSON.parse(res.body);
647+
// Terminal: the primary's 403 is served, the good fallback is never reached.
648+
expect(res.status).toBe(403);
649+
expect(json.error.code).toBe(403);
650+
expect(json.error.message).toBe("budget exceeded (non-failover class)");
651+
});
652+
653+
it("fallthrough absent (default) still falls through to the good fallback", async () => {
654+
instance = await createServer(terminalChain(undefined));
655+
const res = await httpPost(`${instance.url}${OR}`, {
656+
model: "primary/bad",
657+
models: ["primary/bad", "fallback/good"],
658+
messages: [{ role: "user", content: "route" }],
659+
});
660+
const json = JSON.parse(res.body);
661+
expect(res.status).toBe(200);
662+
expect(json.model).toBe("fallback/good");
663+
expect(json.choices[0].message.content).toBe("served by fallback");
664+
});
665+
666+
it("fallthrough:true (explicit) still falls through to the good fallback", async () => {
667+
instance = await createServer(terminalChain(true));
668+
const res = await httpPost(`${instance.url}${OR}`, {
669+
model: "primary/bad",
670+
models: ["primary/bad", "fallback/good"],
671+
messages: [{ role: "user", content: "route" }],
672+
});
673+
const json = JSON.parse(res.body);
674+
expect(res.status).toBe(200);
675+
expect(json.model).toBe("fallback/good");
676+
});
677+
678+
it("fallthrough:false composes with allow_fallbacks:true — still terminal", async () => {
679+
// The per-error gate is independent of the request-level gate: if EITHER
680+
// says don't fall through, don't. allow_fallbacks:true does not override
681+
// fallthrough:false.
682+
instance = await createServer(terminalChain(false));
683+
const res = await httpPost(`${instance.url}${OR}`, {
684+
model: "primary/bad",
685+
models: ["primary/bad", "fallback/good"],
686+
provider: { allow_fallbacks: true },
687+
messages: [{ role: "user", content: "route" }],
688+
});
689+
expect(res.status).toBe(403);
690+
expect(JSON.parse(res.body).error.message).toBe("budget exceeded (non-failover class)");
691+
});
692+
693+
it("fallthrough:false on an error does not affect a normal (non-error) fallback chain", async () => {
694+
// Shipped behavior intact: a SUCCESS primary is served regardless of any
695+
// fallthrough flag on a sibling error fixture; the terminal-error flag only
696+
// gates the error candidate it lives on.
697+
const fixtures: Fixture[] = [
698+
{
699+
match: { model: "primary/good", userMessage: "route" },
700+
response: { content: "served by primary" },
701+
},
702+
{
703+
match: { model: "other/bad", userMessage: "route" },
704+
response: { error: { message: "non-failover" }, status: 403, fallthrough: false },
705+
},
706+
];
707+
instance = await createServer(fixtures);
708+
const res = await httpPost(`${instance.url}${OR}`, {
709+
model: "primary/good",
710+
models: ["primary/good", "other/bad"],
711+
messages: [{ role: "user", content: "route" }],
712+
});
713+
const json = JSON.parse(res.body);
714+
expect(res.status).toBe(200);
715+
expect(json.model).toBe("primary/good");
716+
expect(json.choices[0].message.content).toBe("served by primary");
717+
});
718+
719+
it("threads fallthrough:false through the real JSON fixture-loader path", async () => {
720+
tmpDir = mkdtempSync(join(tmpdir(), "or-fallthrough-fixture-"));
721+
const fixtureFile = join(tmpDir, "fallthrough.json");
722+
writeFileSync(
723+
fixtureFile,
724+
JSON.stringify({
725+
fixtures: [
726+
{
727+
match: { model: "primary/bad", userMessage: "route" },
728+
response: {
729+
error: { message: "budget exceeded (non-failover class)" },
730+
status: 403,
731+
fallthrough: false,
732+
},
733+
},
734+
{
735+
match: { model: "fallback/good", userMessage: "route" },
736+
response: { content: "served by fallback" },
737+
},
738+
],
739+
}),
740+
"utf-8",
741+
);
742+
const loaded = loadFixtureFile(fixtureFile);
743+
const errors = validateFixtures(loaded).filter((r) => r.severity === "error");
744+
expect(errors).toEqual([]);
745+
746+
instance = await createServer(loaded);
747+
const res = await httpPost(`${instance.url}${OR}`, {
748+
model: "primary/bad",
749+
models: ["primary/bad", "fallback/good"],
750+
messages: [{ role: "user", content: "route" }],
751+
});
752+
expect(res.status).toBe(403);
753+
expect(JSON.parse(res.body).error.message).toBe("budget exceeded (non-failover class)");
754+
});
755+
756+
it("rejects a non-boolean fallthrough at load (fail-closed cannot fail open)", async () => {
757+
// A JSON/YAML author writing "false" (string), 0, or null would otherwise
758+
// be silently treated as fall-through (`!== false` is true), the OPPOSITE
759+
// of the intended terminal behavior. The loader must reject it up front.
760+
const bad: Fixture[] = [
761+
{
762+
match: { model: "primary/bad", userMessage: "route" },
763+
// Intentionally wrong type — mimics a JSON author's "false" string.
764+
response: {
765+
error: { message: "x" },
766+
status: 403,
767+
fallthrough: "false",
768+
} as unknown as Fixture["response"],
769+
},
770+
];
771+
const errors = validateFixtures(bad).filter((r) => r.severity === "error");
772+
expect(errors.some((e) => /fallthrough must be a boolean/.test(e.message))).toBe(true);
773+
774+
// A proper boolean is accepted (no error).
775+
const good: Fixture[] = [
776+
{
777+
match: { model: "primary/bad", userMessage: "route" },
778+
response: { error: { message: "x" }, status: 403, fallthrough: false },
779+
},
780+
];
781+
expect(validateFixtures(good).filter((r) => r.severity === "error")).toEqual([]);
782+
});
783+
});
784+
608785
describe("OpenRouter request extensions", () => {
609786
it("accepts and journals extension fields + attribution headers without rejecting", async () => {
610787
const fixtures: Fixture[] = [{ match: { userMessage: "hi" }, response: { content: "ok" } }];

src/fixture-loader.ts

705 Bytes
Binary file not shown.

src/server.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,13 @@ import {
5252
handleOpenRouterKey,
5353
handleOpenRouterCredits,
5454
} from "./openrouter-chat.js";
55-
import type { FixtureResponse, ChatCompletion, SSEChunk, ResponseOverrides } from "./types.js";
55+
import type {
56+
FixtureResponse,
57+
ErrorResponse,
58+
ChatCompletion,
59+
SSEChunk,
60+
ResponseOverrides,
61+
} from "./types.js";
5662
import { handleResponses } from "./responses.js";
5763
import { handleMessages } from "./messages.js";
5864
import { handleGemini } from "./gemini.js";
@@ -701,13 +707,23 @@ async function handleCompletions(
701707
// request see the increment (see the `let matchCounts` note above).
702708
matchCounts = journal.getFixtureMatchCountsForTest(testId);
703709
responseModel = candidate;
704-
if (outcome.isError && allowFallbacks) {
705-
// Runtime provider failure — remember it and fail over to the next.
706-
lastErrorFixture = outcome.fixture;
707-
lastErrorResponse = outcome.response;
708-
continue;
710+
if (outcome.isError) {
711+
// Per-error-fixture failover gate. An ERROR fixture may set
712+
// `fallthrough: false` to mark its error CLASS as non-failover-eligible
713+
// — reproducing OpenRouter serving a 403/generic provider error as
714+
// terminal instead of advancing to the next `models[]` candidate
715+
// (openclaw #60191). Absent / `true` keeps the default fall-through.
716+
// Composes with the request-level `allowFallbacks` gate: fail over only
717+
// when BOTH allow it (if EITHER says don't, this candidate is terminal).
718+
const errorFallsThrough = (outcome.response as ErrorResponse).fallthrough !== false;
719+
if (allowFallbacks && errorFallsThrough) {
720+
// Runtime provider failure — remember it and fail over to the next.
721+
lastErrorFixture = outcome.fixture;
722+
lastErrorResponse = outcome.response;
723+
continue;
724+
}
709725
}
710-
// Success, or a primary error under allow_fallbacks:false — terminal.
726+
// Success, or a terminal error (allow_fallbacks:false or fallthrough:false).
711727
fixture = outcome.fixture;
712728
preResolvedResponse = outcome.response;
713729
break;

src/types.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,17 @@ export interface ChatCompletionRequest {
4646
/**
4747
* OpenRouter fallback model list. When present (and the request arrived on
4848
* the OpenRouter `/api/v1/...` base), the candidate list `[model, ...models]`
49-
* is attempted in order and the first NON-error fixture match wins — the
50-
* winning slug is echoed back as the response `model`. Purely additive: a
51-
* request without `models` behaves as a single-model match. Every other
52-
* OpenRouter request extension (`provider`, `route`, `reasoning`, `plugins`,
49+
* is attempted in order. A NON-error fixture match wins; an ERROR fixture
50+
* candidate falls through to the next candidate by default — UNLESS it is
51+
* terminal, in which case that error is served with no failover. An error is
52+
* terminal when its fixture sets `fallthrough: false` (per-error-class gate)
53+
* or when the request sets `provider.allow_fallbacks: false` (request-level
54+
* gate). A winning (non-error) slug is echoed back as the response `model`;
55+
* when the chain terminates on an error, the response is the OpenRouter error
56+
* envelope (`{ error: { code, message, metadata? } }`), which carries no
57+
* `model` field. Purely additive: a request without `models` behaves as a
58+
* single-model match. Every other
59+
* OpenRouter request extension (`route`, `reasoning`, `plugins`,
5360
* `prediction`, `usage`, and any unknown key) is accepted and journaled via
5461
* the index signature below — never required, never rejected. aimock does not
5562
* model those sub-shapes; unknown body keys pass straight through.
@@ -347,6 +354,22 @@ export interface ErrorResponse {
347354
status?: number;
348355
/** Override the Retry-After header value on 429 responses. Default: 1. */
349356
retryAfter?: number;
357+
/**
358+
* OpenRouter `models[]` failover eligibility for THIS error class. Real
359+
* OpenRouter fails over INCONSISTENTLY by error class — a `403 budget-exceeded`
360+
* or a generic "provider returned error" is served as terminal (it does NOT
361+
* advance to the next `models[]` candidate), while 429/503 usually do fail
362+
* over (openclaw #60191). This flag lets a fixture reproduce that:
363+
* - absent / `true` (default) — the error candidate FALLS THROUGH to the next
364+
* `models[]` candidate (backward-compatible; existing fixtures unchanged).
365+
* - `false` — the error is TERMINAL: the fallback loop stops and serves this
366+
* error with no failover, even if a later candidate would match.
367+
*
368+
* Composes with the request-level `provider.allow_fallbacks`: fall-through
369+
* happens only when BOTH allow it (if EITHER says don't, don't). Only
370+
* consulted for OpenRouter `models[]` fallback; ignored elsewhere.
371+
*/
372+
fallthrough?: boolean;
350373
}
351374

352375
export interface EmbeddingResponse {

0 commit comments

Comments
 (0)