diff --git a/api/routes.py b/api/routes.py index 279b3ca0..abe3f581 100644 --- a/api/routes.py +++ b/api/routes.py @@ -5219,8 +5219,27 @@ def _read_profile_model_config( explicit ``requested_provider`` is already set (profile should not override explicit selections). """ - if _clean_session_model_provider(requested_provider) or not getattr(session, "profile", None): + if not getattr(session, "profile", None): return None, None + if _clean_session_model_provider(requested_provider): + # Session already carries an explicit provider; still read profile default + # so process-wakeup can repair bare model suffixes (#5127). + try: + from api.profiles import get_hermes_home_for_profile + _profile_home = get_hermes_home_for_profile(session.profile) + _profile_cfg_path = os.path.join(str(_profile_home), "config.yaml") + if not os.path.isfile(_profile_cfg_path): + return None, None + import yaml + with open(_profile_cfg_path, encoding="utf-8") as _f: + _pcfg = yaml.safe_load(_f) or {} + if not isinstance(_pcfg, dict): + return None, None + _default = (_pcfg.get("model", {}).get("default") or "").strip() or None + return None, _default + except Exception: + logger.warning("profile provider read failed for %r", getattr(session, "profile", None), exc_info=True) + return None, None try: from api.profiles import get_hermes_home_for_profile _profile_home = get_hermes_home_for_profile(session.profile) @@ -5302,6 +5321,19 @@ def _resolve_compatible_session_model_state( and model_prefix == "openai" ) if not explicit_provider and not stale_codex_openai_slash_id: + _profile_default = str(profile_default_model or "").strip() + if ( + _profile_default + and "/" in _profile_default + and "/" not in model + and _profile_default.rsplit("/", 1)[-1] == model + and requested_provider + and ( + requested_provider == "custom" + or str(requested_provider).startswith("custom:") + ) + ): + return _profile_default, requested_provider, True return model, requested_provider, False # Default (human chat/start) path calls get_available_models() with NO diff --git a/tests/test_issue5127_process_wakeup_bare_model.py b/tests/test_issue5127_process_wakeup_bare_model.py new file mode 100644 index 00000000..f8d8f81b --- /dev/null +++ b/tests/test_issue5127_process_wakeup_bare_model.py @@ -0,0 +1,111 @@ +"""Tests for issue #5127 — process-wakeup bare custom-provider model repair. + +When ``_resolve_compatible_session_model_state`` takes the #1855 fast path +(both model and model_provider set), a bare runtime model that matches the +suffix of a slash-qualified ``profile_default_model`` must repair to the full +qualified ID for named custom providers. +""" +from unittest.mock import patch + + +class TestIssue5127CustomProviderBareSuffixRepair: + def test_fast_path_repairs_bare_suffix_to_profile_default(self): + from api.routes import _resolve_compatible_session_model_state + + with patch("api.routes.get_available_models") as mock_catalog: + result = _resolve_compatible_session_model_state( + "grok-composer-2.5-fast", + "custom:my-proxy", + profile_default_model="x-ai/grok-composer-2.5-fast", + prefer_cached_catalog=True, + ) + + assert mock_catalog.call_count == 0 + assert result == ("x-ai/grok-composer-2.5-fast", "custom:my-proxy", True) + + def test_fast_path_repairs_generic_custom_provider(self): + from api.routes import _resolve_compatible_session_model_state + + with patch("api.routes.get_available_models") as mock_catalog: + result = _resolve_compatible_session_model_state( + "some-model", + "custom", + profile_default_model="vendor/some-model", + prefer_cached_catalog=True, + ) + + assert mock_catalog.call_count == 0 + assert result == ("vendor/some-model", "custom", True) + + def test_fast_path_does_not_rewrite_unrelated_bare_model(self): + from api.routes import _resolve_compatible_session_model_state + + with patch("api.routes.get_available_models") as mock_catalog: + result = _resolve_compatible_session_model_state( + "other-model", + "custom:my-proxy", + profile_default_model="x-ai/grok-composer-2.5-fast", + prefer_cached_catalog=True, + ) + + assert mock_catalog.call_count == 0 + assert result == ("other-model", "custom:my-proxy", False) + + def test_fast_path_keeps_qualified_model_unchanged(self): + from api.routes import _resolve_compatible_session_model_state + + with patch("api.routes.get_available_models") as mock_catalog: + result = _resolve_compatible_session_model_state( + "x-ai/grok-composer-2.5-fast", + "custom:my-proxy", + profile_default_model="x-ai/grok-composer-2.5-fast", + prefer_cached_catalog=True, + ) + + assert mock_catalog.call_count == 0 + assert result == ("x-ai/grok-composer-2.5-fast", "custom:my-proxy", False) + + def test_openai_codex_stale_openai_slash_still_uses_slow_path(self): + """Regression: openai/... under openai-codex must not take bare fast return.""" + from api.routes import _resolve_compatible_session_model_state + + with patch("api.routes.get_available_models") as mock_catalog: + mock_catalog.return_value = { + "active_provider": "openai-codex", + "default_model": "gpt-5.5", + "groups": [], + } + result = _resolve_compatible_session_model_state( + "openai/gpt-5.4-mini", + "openai-codex", + profile_default_model="gpt-5.5", + prefer_cached_catalog=True, + ) + + assert mock_catalog.call_count >= 1 + assert result[0] == "gpt-5.5" + assert result[2] is True + + +class TestReadProfileModelConfigWithExplicitProvider: + def test_returns_profile_default_when_session_has_model_provider(self, tmp_path, monkeypatch): + from api.routes import _read_profile_model_config + + profile_home = tmp_path / "prof" + profile_home.mkdir() + (profile_home / "config.yaml").write_text( + "model:\n provider: custom:my-proxy\n default: x-ai/grok-composer-2.5-fast\n", + encoding="utf-8", + ) + + class _Session: + profile = "testprof" + + monkeypatch.setattr( + "api.profiles.get_hermes_home_for_profile", + lambda _p: str(profile_home), + ) + + provider, default = _read_profile_model_config(_Session(), "custom:my-proxy") + assert provider is None + assert default == "x-ai/grok-composer-2.5-fast" \ No newline at end of file