Skip to content

Commit 544fb37

Browse files
authored
Resolve /model <provider-alias> to the alias's pinned model (#825) (#826)
/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.
1 parent ccfef8e commit 544fb37

3 files changed

Lines changed: 218 additions & 2 deletions

File tree

src/provider/resolve.rs

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ pub(super) enum ModelSwitch {
175175
Keep,
176176
/// Rebuild the client against this configured provider alias, then rename.
177177
Switch(String),
178+
/// GH #825: the id named a configured provider ALIAS that pins a `model`.
179+
/// Unlike [`ModelSwitch::Switch`], the model the session must land on is
180+
/// NOT the id the user typed — the alias name itself is a string no
181+
/// endpoint serves — so this carries the alias's pinned model alongside
182+
/// the alias, sparing the route layer from re-deriving the pin.
183+
SwitchAlias { alias: String, model: String },
178184
/// The id's family maps to a provider kind with NO configured provider.
179185
/// Renaming on the active client would send it to the wrong endpoint, so
180186
/// the caller should warn instead. Carries the human family name.
@@ -188,7 +194,10 @@ pub(super) enum ModelSwitch {
188194
/// 1. A model explicitly pinned on the active provider, or the active
189195
/// provider kind's built-in default, stays on the active client.
190196
/// 2. An exact pin on another provider's `model` switches to it.
191-
/// 3. Otherwise infer the id's family ([`model_family`]):
197+
/// 3. An id naming a configured provider alias that pins a `model` switches
198+
/// to that alias's pinned model (GH #825). Ranked below the exact-pin
199+
/// rules so an alias named like a real model id can't shadow the id.
200+
/// 4. Otherwise infer the id's family ([`model_family`]):
192201
/// - unclassifiable, or same kind as the active provider → `Keep` (the
193202
/// active client already speaks this family; just rename).
194203
/// - a different kind with a configured provider of that kind → switch to
@@ -238,6 +247,31 @@ pub(super) fn resolve_model_switch(
238247
return ModelSwitch::Keep;
239248
}
240249

250+
// GH #825: `/model <provider-alias>` — the id names a configured provider
251+
// alias rather than a model. This used to fall through to the family
252+
// inference below, come back `Keep`, and rename the session model to the
253+
// alias string itself — an id no endpoint serves, so the next turn 400'd.
254+
// Resolve it to the alias's pinned model instead. Only an alias that PINS
255+
// a model is resolvable this way; one relying on its provider default
256+
// falls through unchanged. Placed after the exact-pin and gateway rules
257+
// so an alias whose name collides with a real model id never shadows the
258+
// id's own pin, and deliberately NOT a whitelist: a non-alias id keeps
259+
// today's permissive `Keep`, which is what lets a brand-new model id work
260+
// before dirge knows it.
261+
if let Some((alias, entry)) = providers
262+
.get_key_value(model)
263+
.or_else(|| providers.get_key_value(&model.to_ascii_lowercase()))
264+
&& let Some(pinned) = entry.model.as_deref()
265+
{
266+
// The active provider's own alias also lands here: the route layer's
267+
// already-live guard makes that a rename onto the pinned model with
268+
// no client rebuild and no spurious switch note.
269+
return ModelSwitch::SwitchAlias {
270+
alias: alias.clone(),
271+
model: pinned.to_string(),
272+
};
273+
}
274+
241275
let Some(family) = model_family(model) else {
242276
return ModelSwitch::Keep;
243277
};
@@ -1311,6 +1345,133 @@ mod resolve_model_switch_tests {
13111345
ModelSwitch::Keep,
13121346
);
13131347
}
1348+
1349+
/// GH #825's shape: tier-style aliases, each pinning a model.
1350+
fn tiered_providers() -> HashMap<String, ProviderEntry> {
1351+
HashMap::from([
1352+
(
1353+
"low".to_string(),
1354+
typed_entry("anthropic", Some("claude-haiku-4-5")),
1355+
),
1356+
(
1357+
"high".to_string(),
1358+
typed_entry("anthropic", Some("claude-opus-5")),
1359+
),
1360+
])
1361+
}
1362+
1363+
/// GH #825: `/model low` used to come back `Keep` and rename the session
1364+
/// model to the alias string itself — an id no endpoint serves. An alias
1365+
/// that pins a model now resolves to that alias and its pinned model.
1366+
#[test]
1367+
fn alias_resolves_to_its_pinned_model() {
1368+
assert_eq!(
1369+
resolve_model_switch(&tiered_providers(), "high", "low"),
1370+
ModelSwitch::SwitchAlias {
1371+
alias: "low".to_string(),
1372+
model: "claude-haiku-4-5".to_string(),
1373+
},
1374+
);
1375+
}
1376+
1377+
/// Alias lookup follows the same case convention as the rest of this file
1378+
/// (`get` then `get` on the lowercased name).
1379+
#[test]
1380+
fn alias_lookup_is_case_insensitive() {
1381+
assert_eq!(
1382+
resolve_model_switch(&tiered_providers(), "high", "LOW"),
1383+
ModelSwitch::SwitchAlias {
1384+
alias: "low".to_string(),
1385+
model: "claude-haiku-4-5".to_string(),
1386+
},
1387+
);
1388+
}
1389+
1390+
/// The active provider's own alias resolves to its own pinned model; the
1391+
/// route layer's already-live guard then makes applying it a no-op (no
1392+
/// client rebuild, no spurious switch note) — pinned at the route layer.
1393+
#[test]
1394+
fn own_alias_resolves_to_the_active_pin() {
1395+
assert_eq!(
1396+
resolve_model_switch(&tiered_providers(), "high", "high"),
1397+
ModelSwitch::SwitchAlias {
1398+
alias: "high".to_string(),
1399+
model: "claude-opus-5".to_string(),
1400+
},
1401+
);
1402+
}
1403+
1404+
/// An alias with NO pinned model has nothing to resolve to — inventing a
1405+
/// model string would be guessing. It falls through to the unchanged
1406+
/// pre-#825 behavior (here: unclassifiable name → keep).
1407+
#[test]
1408+
fn alias_without_a_pinned_model_falls_through() {
1409+
let providers = HashMap::from([
1410+
("local".to_string(), typed_entry("ollama", None)),
1411+
(
1412+
"high".to_string(),
1413+
typed_entry("anthropic", Some("claude-opus-5")),
1414+
),
1415+
]);
1416+
assert_eq!(
1417+
resolve_model_switch(&providers, "high", "local"),
1418+
ModelSwitch::Keep,
1419+
);
1420+
}
1421+
1422+
/// GH #825 must not narrow the deliberate permissiveness: an id that is
1423+
/// neither an alias nor classifiable still keeps the active client, which
1424+
/// is what lets a brand-new model id work before dirge knows it.
1425+
#[test]
1426+
fn unknown_non_alias_id_still_keeps_the_active_client() {
1427+
assert_eq!(
1428+
resolve_model_switch(&tiered_providers(), "high", "banana"),
1429+
ModelSwitch::Keep,
1430+
);
1431+
}
1432+
1433+
/// An alias whose NAME collides with a real model id must not shadow the
1434+
/// id's own exact pin — the exact-pin rules stay ahead of the alias rule.
1435+
#[test]
1436+
fn exact_pin_wins_over_a_same_named_alias() {
1437+
let providers = HashMap::from([
1438+
// An alias unluckily named like a model id, pinning something else.
1439+
("gpt-5.5".to_string(), typed_entry("glm", Some("glm-5.2"))),
1440+
// The provider that actually pins the id.
1441+
(
1442+
"azure-gpt".to_string(),
1443+
typed_entry("openai", Some("gpt-5.5")),
1444+
),
1445+
(
1446+
"high".to_string(),
1447+
typed_entry("anthropic", Some("claude-opus-5")),
1448+
),
1449+
]);
1450+
assert_eq!(
1451+
resolve_model_switch(&providers, "high", "gpt-5.5"),
1452+
ModelSwitch::Switch("azure-gpt".to_string()),
1453+
);
1454+
}
1455+
1456+
/// The active provider's own pinned model stays rule 1 even when an alias
1457+
/// shares its name — `Keep` wins before the alias rule is reached.
1458+
#[test]
1459+
fn active_pin_wins_over_a_same_named_alias() {
1460+
let providers = HashMap::from([
1461+
(
1462+
"claude-opus-5".to_string(),
1463+
typed_entry("openai", Some("gpt-5.5")),
1464+
),
1465+
(
1466+
"high".to_string(),
1467+
typed_entry("anthropic", Some("claude-opus-5")),
1468+
),
1469+
]);
1470+
assert_eq!(
1471+
resolve_model_switch(&providers, "high", "claude-opus-5"),
1472+
ModelSwitch::Keep,
1473+
);
1474+
}
13141475
}
13151476

13161477
#[cfg(test)]

src/provider/route.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ pub fn resolve_model_route(cfg: &Config, active_provider: &str, model: &str) ->
109109
match super::resolve_model_switch(&cfg.providers_map(), active_provider, &model) {
110110
ModelSwitch::Keep => ModelRoute::Active(model),
111111
ModelSwitch::Switch(alias) => ModelRoute::Provider { alias, model },
112+
// GH #825: the id named a provider alias — the route must carry the
113+
// alias's pinned model, NOT the alias string the user typed, or the
114+
// session would land on a "model" no endpoint serves.
115+
ModelSwitch::SwitchAlias { alias, model } => ModelRoute::Provider { alias, model },
112116
ModelSwitch::NoProviderForFamily(family) => ModelRoute::Unroutable { model, family },
113117
}
114118
}
@@ -466,4 +470,51 @@ mod tests {
466470
"the window must follow the model, not stay stale",
467471
);
468472
}
473+
474+
/// GH #825: `/model <provider-alias>` lands the session on the alias's
475+
/// PINNED model, not on the alias string — the failure mode where the
476+
/// client switches but the session keeps the alias as its "model" would
477+
/// 400 on the first turn just like the original bug.
478+
#[test]
479+
fn applying_an_alias_route_lands_on_the_pinned_model() {
480+
let cfg = cfg();
481+
let mut client = client(&cfg, "gpt-sol");
482+
let mut session = session("gpt-sol", "gpt-5.5");
483+
484+
let switched = apply_model_route(
485+
&cfg,
486+
&mut client,
487+
&mut session,
488+
resolve_model_route(&cfg, "gpt-sol", "glm"),
489+
)
490+
.expect("a configured alias must be routable");
491+
492+
assert_eq!(switched.as_deref(), Some("glm"));
493+
assert!(matches!(client, AnyClient::Glm(_)), "client must move");
494+
assert_eq!(session.model, "glm-5.2", "the PINNED model, not `glm`");
495+
assert_eq!(session.provider, "glm");
496+
}
497+
498+
/// GH #825: naming the ACTIVE provider's own alias is a no-op — no client
499+
/// rebuild, no spurious switch note, and the session model stays the
500+
/// alias's pinned model rather than becoming the alias string.
501+
#[test]
502+
fn applying_the_active_alias_route_is_a_no_op() {
503+
let cfg = cfg();
504+
let mut client = client(&cfg, "gpt-sol");
505+
let mut session = session("gpt-sol", "gpt-5.5");
506+
507+
let switched = apply_model_route(
508+
&cfg,
509+
&mut client,
510+
&mut session,
511+
resolve_model_route(&cfg, "gpt-sol", "gpt-sol"),
512+
)
513+
.unwrap();
514+
515+
assert_eq!(switched, None, "no client swap, so no switch note");
516+
assert!(matches!(client, AnyClient::OpenAI(_)), "client untouched");
517+
assert_eq!(session.model, "gpt-5.5");
518+
assert_eq!(session.provider, "gpt-sol");
519+
}
469520
}

src/ui/slash/cmd/model.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,12 @@ pub(crate) async fn cmd_model(ctx: &mut SlashCtx<'_>, parts: &[&str]) -> anyhow:
8989
.as_deref()
9090
.map(|a| format!(" · {a}"))
9191
.unwrap_or_default();
92+
// GH #825: report the model the session actually landed on, not the
93+
// raw argument — `/model <provider-alias>` resolves to the alias's
94+
// pinned model, so echoing the argument would print the alias string
95+
// as if it were a model id. Identical on every non-alias path.
9296
ctx.renderer.write_line(
93-
&format!("switched to model: {new_model}{provider_note}"),
97+
&format!("switched to model: {}{provider_note}", ctx.session.model),
9498
c_agent(),
9599
)?;
96100
let reserve = ctx.cfg.resolve_reserve_tokens();

0 commit comments

Comments
 (0)