@@ -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) ]
0 commit comments