fix(chat): an explicitly requested router model fails loudly, not silently

↗ view on GitHub · Amal · 2026-08-18 · 8fdeac67

Adversarial review of this stack, pre-push: the allowlist choke point
protected the operator's wallet but lied to the user.

WHY THIS MATTERS
When a request body names `openrouter/pricy/frontier` and that model is
not in the user's saved selection, the previous behaviour was to answer
anyway - on gemini-3-flash-preview - with nothing in the response saying
so. The user asked one model a question and got another model's answer,
attributed to the one they picked. That is worse than an error: it is
undetectable from the client, and it makes model comparison, cost
reasoning, and bug reports meaningless.

WHY A SILENT FALLBACK IS STILL RIGHT ELSEWHERE
There are two different situations behind "model not in selection":
- The user named it in THIS request. They are present, they can act, and
  the wrong answer is expensive. Tell them.
- A stored preference names it (title_model / tabular_model, resolved by
  getUserModelSettings). The user set it long ago, is not watching, and
  the alternative to degrading is bricking every background title
  generation. Degrade and warn the operator.

So `resolveRequestedModel` gains an explicit `onOutsideSelection` mode
rather than one hard-coded policy. runLLMStream - reached from chat,
project chat and Word chat with the request body's model - passes
"throw"; getUserModelSettings keeps its own silent guard for stored
preferences, untouched. Tabular reaches runLLMStream with a preference
getUserModelSettings has ALREADY normalized, so it cannot trip the throw.

WHAT THE USER SEES
"Model openrouter/pricy/frontier is not in your saved OpenRouter models
- add it in Settings → BYOK → Routers." Because R3 moved the resolution
inside the stream's try block, that message arrives as a normal SSE
error event and is persisted with the turn, not as a dead socket.

Tests: streamingModelAllowlist.test.ts's two fallback cases become
rejection cases (and assert the adapter is never called), plus a new
case pinning the message onto the stream's error event;
routerModels.test.ts pins both modes directly. Pre-fix, all four fail -
"promise resolved 'gemini-3-flash-preview' instead of rejecting" for the
unit case and "promise resolved undefined instead of rejecting" for the
stream cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repository open-legal-products/mike
Author Amal <mamalanand3@gmail.com>
Authored
Committed
Parents 3849a329
Stats 4 files changed , +108 , -18
Part of Harden model-router selection and failure handling

Capture this commit into my fork

Download a Markdown prompt that tells Claude how to port this exact commit into your working tree. Run it via claude -p < capture-commit-8fdeac67.md from inside the repo you want the change in.

⬇ Download capture-commit-8fdeac67.md