diff --git a/hermes_cli/runtime_provider_custom.py b/hermes_cli/runtime_provider_custom.py index b42d6537a4..b6783924a0 100644 --- a/hermes_cli/runtime_provider_custom.py +++ b/hermes_cli/runtime_provider_custom.py @@ -252,16 +252,21 @@ def find_custom_provider_identity_by_model(model: str) -> Optional[str]: def canonical_custom_identity(*, base_url: Optional[str] = None, config_provider: Optional[str] = None, model: Optional[str] = None) -> Optional[str]: - """Recover a routable ``custom:`` identity for a bare custom provider. Every path that - persists or restores a session's provider override must run the resolved provider through this - so a bare ``"custom"`` is upgraded back to its durable menu key. Sources in priority order: - (1) ``base_url`` reverse lookup — the one fact that always survives the round-trip when a URL - was recorded; (2) ``model`` reverse lookup (``model``/``default_model``/``models`` catalog); - (3) the configured provider (arg, ``model.provider``, ``HERMES_INFERENCE_PROVIDER``) when it - names a real entry.""" + """Recover the durable menu identity for a bare custom provider. Match a configured + endpoint first, then the ownership-checked managed server, then a configured model or + provider. Every session persistence/restore path shares this lookup.""" rp = _rp() - identity = (find_custom_provider_identity(base_url) if base_url else None) or ( - find_custom_provider_identity_by_model(model) if model else None) + if base_url: + identity = find_custom_provider_identity(base_url) + if identity: + return identity + # The managed server has no custom-provider config entry. Recover its menu key + # from the ownership-checked endpoint, never from a model name or a fixed port. + from hermes_cli.local_runtime.endpoint import _state_endpoint + endpoint = _state_endpoint() + if endpoint and _normalize_base_url_for_match(base_url) == _normalize_base_url_for_match(endpoint["base_url"]): + return "llamacpp" + identity = find_custom_provider_identity_by_model(model) if model else None if identity: return identity candidate = str(config_provider or "").strip() diff --git a/tests/tui_gateway/test_local_model_session_identity.py b/tests/tui_gateway/test_local_model_session_identity.py new file mode 100644 index 0000000000..31b07729c3 --- /dev/null +++ b/tests/tui_gateway/test_local_model_session_identity.py @@ -0,0 +1,101 @@ +"""A local selection must survive session.info, a new chat and stored-session resume.""" +from types import SimpleNamespace + +import pytest + +from hermes_cli import runtime_provider as rp +from hermes_cli.local_runtime import endpoint +from tui_gateway import server + + +@pytest.fixture +def local_route(tmp_path, monkeypatch): + cfg = {"model": {"provider": "anthropic", "default": "claude-test"}, + "local_runtime": {"enabled": True}} + monkeypatch.setattr(rp, "load_config", lambda: cfg) + monkeypatch.setattr(rp, "_get_model_config", lambda: cfg["model"]) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg) + monkeypatch.setattr(server, "_load_cfg", lambda: cfg) + route = {"base_url": "http://127.0.0.1:18434/v1", "api_key": "local-test-key"} + monkeypatch.setattr(endpoint, "_state_endpoint", lambda: route) + monkeypatch.setattr(endpoint, "resolve_llamacpp_endpoint", lambda **kw: route) + monkeypatch.setattr(server, "_probe_credentials", lambda agent: None) + monkeypatch.setattr("hermes_cli.banner.get_update_result", lambda **kw: None) + monkeypatch.setattr("hermes_cli.banner.get_available_skills", lambda: {}) + return route, {"cwd": str(tmp_path), "session_key": "local-identity"} + + +def test_live_local_identity_survives_new_chat_and_resume(local_route): + route, session = local_route + model = "Local.Model-Q4_K_M" + runtime = rp.resolve_runtime_provider(requested="llamacpp", target_model=model) + agent = SimpleNamespace(model=model, provider=runtime["provider"], base_url=runtime["base_url"], + api_mode=runtime["api_mode"], reasoning_config=None, service_tier=None, + session_id=session["session_key"]) + # The renderer carries these two fields into the next session.create. + info = server._session_info(agent, session) + assert info["provider"] == "llamacpp" + assert info["model"] == model + next_model, next_runtime = server._resolve_agent_model_runtime( + {"model": info["model"], "provider": info["provider"]}, None) + assert next_model == model and next_runtime["base_url"] == route["base_url"] + assert next_runtime["api_key"] == route["api_key"] + persisted = server._runtime_model_config(agent) + assert persisted["provider"] == "llamacpp" + assert "api_key" not in persisted + # Legacy rows kept the local endpoint but lost the provider slug. + for provider in ("custom", "llamacpp"): + row = {"model": model, "model_config": {**persisted, "provider": provider}} + overrides = server._stored_session_runtime_overrides(row) + restored_model, restored = server._resolve_agent_model_runtime( + overrides["model_override"], overrides.get("provider_override")) + assert restored_model == model + assert restored["base_url"] == route["base_url"] + assert restored["api_key"] == route["api_key"] + # Pending picks and compute-host mirrors still own the reported identity. + session["pending_model_switch"] = {"display_model": "claude-test", "display_provider": "anthropic"} + assert server._session_info(agent, session)["provider"] == "anthropic" + + +def test_session_info_recovers_identity_from_the_owning_profile(tmp_path, monkeypatch): + import json + from pathlib import Path + + from hermes_constants import get_hermes_home + + launch = tmp_path / "launch" + secondary = launch / "profiles" / "secondary" + secondary.mkdir(parents=True) + url = "https://session-endpoint.invalid/v1" + for home, name in ((launch, "launch-route"), (secondary, "secondary-route")): + config = {"model": {"provider": "anthropic", "default": "claude-test"}, + "providers": {name: {"api": url, "models": ["same-model"]}}} + (home / "config.yaml").write_text(json.dumps(config), encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(launch)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr(server, "_hermes_home", launch) + monkeypatch.setattr(server, "_probe_credentials", lambda agent: None) + monkeypatch.setattr("hermes_cli.banner.get_update_result", lambda **kw: None) + monkeypatch.setattr("hermes_cli.banner.get_available_skills", lambda: {}) + agent = SimpleNamespace(model="same-model", provider="custom", base_url=url, + reasoning_config=None, service_tier=None, session_id="profile-identity") + session = {"cwd": str(tmp_path), "session_key": "profile-identity", "profile_home": str(secondary)} + # Broadcast/resume can publish metadata outside the session's profile scope. + assert server._session_info(agent, session)["provider"] == "custom:secondary-route" + assert get_hermes_home() == launch + # A launch-profile session must also ignore an ambient secondary-profile scope. + with server._profile_build_scope(secondary): + assert server._session_info(agent, {**session, "profile_home": None})["provider"] == "custom:launch-route" + assert get_hermes_home() == secondary + assert get_hermes_home() == launch + # Remote compute metadata remains authoritative; never reinterpret it using local profiles. + session["_metadata_mirror"] = {"model": "remote-model", "provider": "custom:remote-route"} + assert server._session_info(agent, session)["provider"] == "custom:remote-route" + + +def test_local_identity_never_claims_an_unrelated_endpoint(local_route): + route, _ = local_route + assert rp.canonical_custom_identity(base_url=route["base_url"]) == "llamacpp" + assert rp.canonical_custom_identity(base_url="http://127.0.0.1:18435/v1") is None + assert rp.canonical_custom_identity(base_url="https://api.anthropic.com") is None + assert rp.canonical_custom_identity(model="Local.Model-Q4_K_M") is None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 064e2784c6..9d7dc79735 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2060,9 +2060,15 @@ def _session_info(agent, session: dict | None = None) -> dict: pending_switch = sess.get("pending_model_switch") or {} pending_model = str(pending_switch.get("display_model") or "").strip() pending_provider = str(pending_switch.get("display_provider") or "").strip() + provider = mirror.get("provider", getattr(agent, "provider", "")) + if provider == "custom" and "provider" not in mirror and agent is not None: + # Clients reuse this identity for new chats without carrying the endpoint or key. + # Broadcast/resume callers need not be bound to this session's profile. + with _profile_build_scope(sess.get("profile_home") or _hermes_home): + provider = _runtime_model_config(agent).get("provider", provider) info: dict = { "model": pending_model or mirror.get("model", getattr(agent, "model", "")), - "provider": pending_provider or mirror.get("provider", getattr(agent, "provider", "")), + "provider": pending_provider or provider, "reasoning_effort": reasoning_effort, "service_tier": service_tier, "fast": service_tier == "priority", "yolo": yolo, "approval_mode": approval_mode, "tools": dict(mirror.get("tools") or {}) if isinstance(mirror.get("tools"), dict) else {},