fix(desktop): preserve local provider identity across new chats

Report a routable provider in session.info instead of the resolved custom
billing class. The desktop carries that identity into new chats without
an endpoint, so losing llamacpp could send a local model to a cloud API.

Recover managed identity from the ownership-checked endpoint through the
existing custom-provider lookup. Bind metadata recovery to the session's
profile, preserving named endpoint precedence, pending selections and
remote compute metadata.

Cover new-chat and resume routing under a cloud default, conflicting
profile mappings, and negative endpoint-ownership cases.
This commit is contained in:
emozilla
2026-09-11 02:49:36 -04:00
parent 8d79c2ff57
commit 28388947f8
3 changed files with 122 additions and 10 deletions
+14 -9
View File
@@ -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:<name>`` 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()
@@ -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
+7 -1
View File
@@ -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 {},