`alias || default` runs before alias resolution, so your default never gets a say
A per-chat model alias with no mapping for the active provider blocks your configured default, then the SDK default runs. Resolve first, then fall back.
If a per-chat model alias goes through || before it gets resolved, your configured default has already lost. The alias is a non-empty string, so it’s truthy and it wins. Then it resolves against a provider that has no entry for it and comes back undefined. It goes into the request as no model at all. Nothing throws. Whatever sits below you decides which model answers, if any does.
I fixed when the override was read, not the order it was resolved in
On 2026-08-29 I committed a fix to per-turn model overrides in our gateway, log line [SmartRouting] Simple query → haiku included. The old code set module-level model variables and put them back right after dispatch. Two provider paths only read those variables after an await, so on the default provider the swap never happened. I replaced the globals with a per-conversation map, read at the point of use. I thought that closed the silent-mismatch problem. It never touched ordering.
Two weeks later a review session left a note: if (metadata.model || providerDefault) runs before alias resolution, an alias with no target on the active provider skips the default. Here’s that chain next to the fixed one, from a standalone repro. resolveAlias(x, provider) returns the provider’s mapping for x, passes through ids the provider already knows, and returns undefined for anything else. openai has a fast alias and no smart one.
// broken: the default gets picked before anyone knows the alias is useless
function pickBroken(metadata, provider) {
return resolveAlias(metadata.model || DEFAULTS[provider], provider); // 'smart' on openai -> undefined
}
// ...then client.create({ model: undefined, messages })
// fixed: resolve first, fall back second, assert before the call
function pickFixed(metadata, provider) {
const model = (metadata.model && resolveAlias(metadata.model, provider)) || DEFAULTS[provider];
if (!known(model, provider)) throw new Error(`no model id for ${JSON.stringify(metadata.model)} on ${provider}`);
return model;
}
Here’s what I have and what I don’t. I didn’t reproduce the note against our gateway. I have no count of affected turns, no usage rows and no cost. Our gateway has no alias table between the override and the provider call. Its resolver is override?.model || fallback, so the undefined step isn’t in our code. The unchecked half is. Nothing asks whether a skill’s prefer_model means anything to the provider that’s currently active.
What I did reproduce is the pattern itself, with a 30-line script sending to a fake upstream. This is the upstream’s terminal after node repro.mjs send broken, then send fixed:
model on the wire: None
model on the wire: 'gpt-4o'
None isn’t a model called None. JSON.stringify drops keys whose value is undefined, so the request went out with no model field at all. What fills that gap depends on the layer below. An endpoint that requires model rejects the request. A client or CLI with a default baked in uses that default. I haven’t captured which one happens for any particular SDK, so I’m not naming a model. Either way, nobody asked your configured default.
LiteLLM hit the loud version
LiteLLM PR #29378 gets the shape right. The fallback lookup used the alias name, but fallbacks were keyed by the real model group, so the alias has to be resolved first. Their failure was loud: No fallback model group found for original model_group=alias-model. That PR is the only other instance I have, and it raised an error. The variant in the review note doesn’t. The lookup returns undefined, the request goes out without a model, and there’s no error string to search for. I don’t have a second project with the quiet version, so I won’t tell you it’s everywhere. I’ll tell you it’s cheap to rule out.
Checking your own stack: one assertion, one table, one fake upstream
Invariant: the model you hand the SDK is a non-empty id the active provider knows. The SDK never gets to choose.
You can enforce that at one boundary, the line right before the call:
if (!known(model, provider)) throw new Error(`no model id for ${JSON.stringify(alias)} on ${provider}`);
My first draft of this check was a grep for model ||. It misses { model = DEFAULT }, .get(key, default), Python’s or, {...defaults, ...override} merges, and any variable named alias or m. A test doesn’t care how the fallback is spelled. Loop over every provider × every alias in your table, plus no alias and an alias nobody maps. Every row must resolve to a known id or throw:
const aliases = [undefined, ...new Set(Object.values(ALIASES).flatMap(Object.keys)), 'nonsense'];
let failed = 0;
for (const provider of Object.keys(DEFAULTS)) for (const alias of aliases) {
let got;
try { got = pick({ model: alias }, provider); }
catch (e) { console.log(`ok ${provider} × ${alias} -> refused: ${e.message}`); continue; }
if (got) console.log(`ok ${provider} × ${alias} -> ${got}`);
else { failed++; console.log(`FAIL ${provider} × ${alias} -> ${got}`); }
}
process.exit(failed ? 1 : 0);
The broken chain:
$ node repro.mjs table broken
ok anthropic × undefined -> claude-sonnet-4-5
ok anthropic × fast -> claude-haiku-4-5
ok anthropic × smart -> claude-opus-4-1
FAIL anthropic × nonsense -> undefined
ok openai × undefined -> gpt-4o
ok openai × fast -> gpt-4o-mini
FAIL openai × smart -> undefined
FAIL openai × nonsense -> undefined
3 failing
The same broken chain with only the one-line assertion added. The ordering is still wrong, but silent turned into loud:
$ node repro.mjs table guarded
ok anthropic × undefined -> claude-sonnet-4-5
ok anthropic × fast -> claude-haiku-4-5
ok anthropic × smart -> claude-opus-4-1
ok anthropic × nonsense -> refused: no model id for "nonsense" on anthropic
ok openai × undefined -> gpt-4o
ok openai × fast -> gpt-4o-mini
ok openai × smart -> refused: no model id for "smart" on openai
ok openai × nonsense -> refused: no model id for "nonsense" on openai
all pass
The fixed chain resolves first, so those rows fall back instead of refusing:
$ node repro.mjs table fixed
...
ok anthropic × nonsense -> claude-sonnet-4-5
ok openai × smart -> gpt-4o
ok openai × nonsense -> gpt-4o
all pass
Whether an unmapped alias should fall back or refuse is your call. Keep the assertion either way, because it also catches a bad default.
The table tests your function. The fake upstream tests the wire. It prints the model field it receives and answers with a valid 200, so nothing retries. An earlier version returned a 500. Most clients retry or fail over on a 5xx, which can move the turn to another backend and fake exactly the result you’re looking for. A 500 also looks the same as your app refusing the turn.
# fake_upstream.py
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class H(BaseHTTPRequestHandler):
def read_body(self):
if "chunked" in self.headers.get("Transfer-Encoding", "").lower():
data = b""
while (n := int(self.rfile.readline().split(b";")[0], 16)):
data += self.rfile.read(n); self.rfile.readline()
self.rfile.readline()
return data
return self.rfile.read(int(self.headers.get("Content-Length", 0)))
def do_POST(self):
body = json.loads(self.read_body() or b"{}")
print("model on the wire:", repr(body.get("model")), flush=True)
if self.path.endswith("/messages"): # Anthropic Messages API shape
out = {"id": "msg_fake", "type": "message", "role": "assistant", "model": "fake",
"content": [{"type": "text", "text": "ok"}], "stop_reason": "end_turn",
"stop_sequence": None, "usage": {"input_tokens": 1, "output_tokens": 1}}
else: # OpenAI chat completions shape
out = {"id": "chatcmpl-fake", "object": "chat.completion", "created": 0, "model": "fake",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}}
raw = json.dumps(out).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw)))
self.end_headers()
self.wfile.write(raw)
HTTPServer(("127.0.0.1", 8999), H).serve_forever()
It reads chunked request bodies as well as Content-Length ones. I checked with a chunked curl, and it printed 'gpt-4o'. It doesn’t stream responses. Turn streaming off for this check. The line prints before the reply goes out, but a streaming client that gets a plain JSON body may error and retry, and then you’ll see the line twice.
Point one provider’s base URL at http://127.0.0.1:8999 (add /v1 if your client expects it) and set a configured default. Then send a turn with an alias that only a different provider maps.
Passing: model on the wire: 'your-configured-default'. Or your app refuses before sending, with an error naming the alias and the provider, and the upstream prints nothing. Failing: None, or any model id you never configured.