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:
@@ -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
|
||||
@@ -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 {},
|
||||
|
||||
Reference in New Issue
Block a user