# `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.

- Author: Chad Priest
- Published: 2026-09-14
- Canonical URL: https://blog.vodou.ai/alias-or-default-runs-before-alias-resolution-so-your-default-never-gets-a-say/
- Tags: ai, llm, typescript, debugging

---

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.

```js
// 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](https://github.com/BerriAI/litellm/pull/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:

```js
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:

```js
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.

```python
# 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.

---

Source: [`alias || default` runs before alias resolution, so your default never gets a say](https://blog.vodou.ai/alias-or-default-runs-before-alias-resolution-so-your-default-never-gets-a-say/) by Chad Priest, from Building Vodou in Public.
