diff --git a/api/config.py b/api/config.py index 29abbf95e..cac79548f 100644 --- a/api/config.py +++ b/api/config.py @@ -4601,17 +4601,46 @@ def _load_models_cache_from_disk() -> dict | None: if not _is_loadable_disk_cache(cache): return None # Strip the disk-only metadata before returning, so the in-memory - # cache shape stays exactly what the rest of the code expects. + # cache shape stays exactly what the rest of the code expects. The + # disk save path does not persist `aliases`, so reconstruct them from + # current config to keep the /api/models.aliases contract intact (a + # disk-cache hit must not silently drop `/model ` resolution). return { "active_provider": cache["active_provider"], "default_model": cache["default_model"], "configured_model_badges": cache["configured_model_badges"], "groups": cache["groups"], + "aliases": ( + cache["aliases"] + if isinstance(cache.get("aliases"), dict) + else _model_aliases_from_config() + ), } except Exception: return None +def _model_aliases_from_config() -> dict[str, str]: + """Build the normalized model-alias map from current config. + + Mirrors the alias construction used by the live and static catalog paths so + the `/api/models.aliases` contract is consistent across every catalog source + (live, static, and the stale-disk fallback, which can't read aliases from a + disk cache that never persisted them). + """ + try: + raw_aliases = cfg.get("model", {}).get("aliases", {}) + if isinstance(raw_aliases, dict): + return { + str(k).strip(): str(v).strip() + for k, v in raw_aliases.items() + if k and v + } + except Exception: + pass + return {} + + def _load_stale_models_cache_from_disk() -> dict | None: """Load a shape-valid stale /api/models disk cache for timeout fallback only. @@ -4636,12 +4665,20 @@ def _load_stale_models_cache_from_disk() -> dict | None: if cache.get("_schema_version") != _MODELS_CACHE_SCHEMA_VERSION: return None aliases = cache.get("aliases") + if not isinstance(aliases, dict): + # The disk cache save path does not persist `aliases`, so a cache + # read back from disk lacks them. Defaulting to {} would silently + # break `/model ` slash-command resolution (static/commands.js + # resolves slash aliases only from /api/models.aliases) for the + # duration of the over-budget stale fallback. Reconstruct from + # current config, mirroring the live/static catalog alias build. + aliases = _model_aliases_from_config() return { "active_provider": cache["active_provider"], "default_model": cache["default_model"], "configured_model_badges": cache["configured_model_badges"], "groups": cache["groups"], - "aliases": aliases if isinstance(aliases, dict) else {}, + "aliases": aliases, } except Exception: return None diff --git a/tests/test_issue3928_models_budget_fallback.py b/tests/test_issue3928_models_budget_fallback.py index 1a382d180..28f030e55 100644 --- a/tests/test_issue3928_models_budget_fallback.py +++ b/tests/test_issue3928_models_budget_fallback.py @@ -401,6 +401,27 @@ def test_load_stale_models_cache_from_disk_rejects_cross_schema( assert cfg._load_stale_models_cache_from_disk() is None +def test_load_stale_models_cache_reconstructs_aliases_from_config( + monkeypatch, + isolate_models_catalog_state, +): + """When the disk cache lacks aliases (the save path never persisted them), + the stale loader must reconstruct them from current config so /model + slash-command resolution keeps working during the over-budget fallback.""" + payload = _build_stale_disk_cache_payload() + payload.pop("aliases", None) + models_cache_path = isolate_models_catalog_state["models_cache_path"] + monkeypatch.setattr(cfg, "_get_models_cache_path", lambda: models_cache_path) + models_cache_path.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr( + cfg, "cfg", {"model": {"aliases": {"fast": "ollama-cloud/chat-1"}}}, raising=False + ) + + stale = cfg._load_stale_models_cache_from_disk() + assert stale is not None + assert stale["aliases"] == {"fast": "ollama-cloud/chat-1"} + + def test_default_group_survives_only_as_emergency_last_resort( monkeypatch, isolate_models_catalog_state,