Skip to content

Resolve /model <provider-alias> to the alias's pinned model (#825) - #826

Merged
yogthos merged 1 commit into
dirge-code:mainfrom
chrismurrph:fix/825-model-alias
Aug 30, 2026
Merged

Resolve /model <provider-alias> to the alias's pinned model (#825)#826
yogthos merged 1 commit into
dirge-code:mainfrom
chrismurrph:fix/825-model-alias

Conversation

@chrismurrph

Copy link
Copy Markdown
Contributor

Fixes #825.

Problem

/model <provider-alias> reported success and left the session on a model string no provider can serve — no * in the listing, and the next message 400s. Any unrecognised id did the same (/model banana included).

Cause

resolve_model_switch falls through to Keep when the id can't be classified:

let Some(family) = model_family(model) else {
    return ModelSwitch::Keep;
};

Keep becomes ModelRoute::Active(model) and apply_model_route renames the session model on the current client with no validation.

That permissiveness is deliberate and is kept: it is what lets /model claude-opus-6 work the day it ships, before dirge knows the id. This is not a whitelist — a non-alias id still returns Keep exactly as before.

Fix

A new rule in resolve_model_switch: if the id names a configured providers alias that pins a model, resolve to that alias's pinned model.

Placed after the exact-pin rules and the #711 vendor-prefixed gateway guard, and before family inference — so an alias whose name collides with a real model id never shadows that id's own pin.

Switch(alias) was not sufficient. It maps to ModelRoute::Provider { alias, model: <raw arg> }, so the session would have switched provider while keeping low as the model — the same bug wearing a different hat. Added an accretive variant carrying the resolved model:

SwitchAlias { alias: String, model: String }

Switch is untouched and no existing match arm changed.

Active provider's own alias flows through the route layer's existing already-live guard: no client rebuild, no · alias note, provider untouched. It does restore the pinned model if the session had drifted off it, which seems the right reading of /model <own-alias>.

Alias lookup is case-insensitive, matching active_provider_kind's existing convention, and uses get_key_value so the stored key is what reaches the note and the swap guard.

An alias with no pinned model (relying on its provider default) falls through unchanged rather than inventing a model string.

One UX change that falls out

cmd_model's success line now prints ctx.session.model rather than the raw argument:

switched to model: claude-haiku-4-5  ·  low     (was: switched to model: low)

Identical on every non-alias path, since there session.model is the argument.

Tests

9 new — 7 in resolve_model_switch_tests, 2 at the route layer:

  • alias resolves to its pinned model; alias lookup is case-insensitive
  • exact pin wins over a same-named alias; active pin wins over a same-named alias
  • alias without a pinned model falls through
  • unknown non-alias id still keeps the active client (banana)
  • route layer: applying an alias route lands on the pinned model (session.model == "glm-5.2", not "glm"), client swapped, provider updated
  • route layer: applying the active alias route is a no-op

The four pre-existing vendor-prefix/gateway tests pin constraint 5 unchanged.

Full suite 5432 passed, 0 failed, 1 ignored (baseline on upstream/main was 5423/0/1 — all 9 new). clippy --all-targets -- -D warnings clean, cargo fmt --check clean.

Note

/model is TUI-only, so there is no headless end-to-end for this; the two route-layer tests exercise resolve_model_route + apply_model_route against real clients, which is the layer where a "switched provider but kept the alias as the model" regression would hide.

Built and tested with --no-default-features --features no-plugin (no janet toolchain on this host — #712); CI covers the plugin feature.

…de#825)

/model low reported success and renamed the session model to the alias
string itself — an id no endpoint serves — because resolve_model_switch
fell through to Keep for any unclassifiable id. Aliases that pin a model
now resolve to that alias and its pinned model, via a new
ModelSwitch::SwitchAlias variant that carries the model (Switch alone
maps to a route whose model is the raw argument, which would have
switched the client but kept the alias string as the model).

Ranked below the exact-pin and gateway rules so a same-named model pin
still wins, and only for aliases that PIN a model — everything else,
including unknown non-alias ids, keeps today's permissive behaviour.
The active provider's own alias flows through the route layer's
already-live guard: no rebuild, no spurious switch note. cmd_model now
echoes the model the session landed on rather than the raw argument.
@yogthos
yogthos merged commit 544fb37 into dirge-code:main Aug 30, 2026
15 checks passed
yogthos pushed a commit that referenced this pull request Sep 2, 2026
`resolve_model_switch` ends in a deliberate permissive fallthrough: an id
matching no configured alias and no known model family returns `Keep`,
which is what lets `/model claude-opus-6` work on release day. #826
narrowed the trap to genuinely unknown strings rather than closing it, on
purpose, and that stays.

But `Keep` is also what `/model off` gets, and reaching for `off` is a
natural slip — it is a valid argument to both `/effort off` and `/agent
off`, and `/model` has no "go back". It reported success and left
`session.model = "off"`, with nothing said until the next request 400s.

Keep applying it, and say so. The unrecognised case now returns its own
`ModelSwitch::KeepUnrecognized`, carried through to `ModelRoute::Active {
model, recognized }`, and `/model` appends one clause:

    switched to model: off  (unrecognised — your provider may not serve it)

The clause never fires on a configured alias, an exact pin, the
gateway-dialect rule, or an id whose family `model_family` knows, so in
practice it fires on typos and on genuinely new models. It asserts
RECOGNITION, not validity — only the provider knows whether an id is
servable, and the two come apart for a new-but-valid id — so the
consequence stays conditional. Same clause on the ACP `/model` path.
yogthos added a commit that referenced this pull request Sep 2, 2026
`resolve_model_switch` ends in a deliberate permissive fallthrough: an id
matching no configured alias and no known model family returns `Keep`,
which is what lets `/model claude-opus-6` work on release day. #826
narrowed the trap to genuinely unknown strings rather than closing it, on
purpose, and that stays.

But `Keep` is also what `/model off` gets, and reaching for `off` is a
natural slip — it is a valid argument to both `/effort off` and `/agent
off`, and `/model` has no "go back". It reported success and left
`session.model = "off"`, with nothing said until the next request 400s.

Keep applying it, and say so. The unrecognised case now returns its own
`ModelSwitch::KeepUnrecognized`, carried through to `ModelRoute::Active {
model, recognized }`, and `/model` appends one clause:

    switched to model: off  (unrecognised — your provider may not serve it)

The clause never fires on a configured alias, an exact pin, the
gateway-dialect rule, or an id whose family `model_family` knows, so in
practice it fires on typos and on genuinely new models. It asserts
RECOGNITION, not validity — only the provider knows whether an id is
servable, and the two come apart for a new-but-valid id — so the
consequence stays conditional. Same clause on the ACP `/model` path.

Co-authored-by: Yogthos <yogthos@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/model <provider-alias> silently sets an unservable model id (reports success, next request 400s)

2 participants