[verified] fix: scope model changes to the active profile

This commit is contained in:
2026-09-09 18:03:32 +00:00
parent 1d85d7af6b
commit aa705f7dd0
3 changed files with 184 additions and 12 deletions
+6
View File
@@ -29,6 +29,7 @@ from gateway.platforms.base import (
BasePlatformAdapter,
SendResult,
)
from hermes_constants import get_hermes_home
from . import protocol as proto
from . import hermes_bridge
@@ -59,6 +60,11 @@ class PhebyAdapter(BasePlatformAdapter):
platform._value_ = "pheby"
platform._name_ = "PHEBY"
super().__init__(config=config, platform=platform)
# WebSocket control requests run outside normal gateway turns. Capture
# the profile root while the adapter is constructed under its routed
# profile context so model changes can install the matching credential
# scope later instead of reading another profile's environment.
self.hermes_home = get_hermes_home()
self._pcfg: PhebyConfig = load_config(config.extra or {})
self._server: Any = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
+63 -10
View File
@@ -14,6 +14,7 @@ import logging
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from . import protocol as proto
@@ -682,7 +683,7 @@ async def models_snapshot(conversation_id: Optional[str] = None) -> Dict[str, An
"""Providers + models Hermes currently exposes (credential-aware)."""
def _collect() -> Dict[str, Any]:
from hermes_cli.config import get_compatible_custom_providers
from hermes_cli.model_switch import list_picker_providers
from hermes_cli.model_switch_providers import list_picker_providers
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
current_model = str(model_cfg.get("default", "") or "")
@@ -751,9 +752,31 @@ async def set_model(model: str, provider: Optional[str],
if not model:
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": "model is required"}
home_token = None
secret_token = None
try:
from agent.secret_scope import (
build_profile_secret_scope, reset_secret_scope, set_secret_scope,
)
from hermes_cli.config import get_compatible_custom_providers
from hermes_cli.model_switch import switch_model
from hermes_constants import (
get_hermes_home, reset_hermes_home_override,
set_hermes_home_override,
)
adapter = _current_adapter()
profile_home = Path(
getattr(adapter, "hermes_home", None) or get_hermes_home())
# model.set is a WebSocket control request, not a gateway message turn,
# so the runner has not installed this profile's ContextVars for us.
# Bind them explicitly before load_config()/switch_model(); to_thread
# copies the current context into its worker.
home_token = set_hermes_home_override(str(profile_home))
secrets = await asyncio.to_thread(
build_profile_secret_scope, profile_home)
secret_token = set_secret_scope(secrets)
cfg = _load_cfg()
model_cfg = (cfg.get("model") or {}) if isinstance(cfg, dict) else {}
result = await asyncio.to_thread(
@@ -762,7 +785,7 @@ async def set_model(model: str, provider: Optional[str],
str(model_cfg.get("provider", "openrouter") or "openrouter"),
str(model_cfg.get("default", "") or ""),
str(model_cfg.get("base_url", "") or ""),
"", # current_api_key — runtime resolution handles credentials
"", # current_api_key — scoped runtime resolution handles credentials
False, # is_global → session-scoped when conversation given
provider or "",
cfg.get("providers") if isinstance(cfg, dict) else None,
@@ -772,15 +795,23 @@ async def set_model(model: str, provider: Optional[str],
logger.error("[pheby] switch_model failed", exc_info=True)
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": proto.safe_str(exc, 200)}
finally:
if secret_token is not None:
reset_secret_scope(secret_token)
if home_token is not None:
reset_hermes_home_override(home_token)
ok = bool(getattr(result, "success", False))
if not ok:
error = (getattr(result, "error_message", "") or
getattr(result, "error", ""))
return {"ok": False, "code": proto.ERR_BAD_REQUEST,
"message": proto.safe_str(getattr(result, "error", ""),
300)}
"message": proto.safe_str(error, 300)}
resolved_model = getattr(result, "model", model)
resolved_provider = getattr(result, "provider", provider or "")
resolved_model = (getattr(result, "new_model", "") or
getattr(result, "model", "") or model)
resolved_provider = (getattr(result, "target_provider", "") or
getattr(result, "provider", "") or provider or "")
override = {"model": resolved_model}
if resolved_provider:
override["provider"] = resolved_provider
@@ -788,14 +819,36 @@ async def set_model(model: str, provider: Optional[str],
store = _session_store()
if conversation_id and store is not None:
try:
session_key = _session_key_for(conversation_id)
if await asyncio.to_thread(
store.peek_session_id,
_session_key_for(conversation_id)) is None:
store.peek_session_id, session_key) is None:
return {"ok": False, "code": proto.ERR_CONVERSATION_NOT_FOUND,
"message": "Conversation not found"}
await asyncio.to_thread(store.set_model_override,
_session_key_for(conversation_id),
override)
session_key, override)
# Match Hermes's native /model commit path: the persisted override
# survives restarts, while the richer in-memory override gives the
# very next turn its resolved endpoint/key/capabilities. Evict any
# cached agent so it cannot answer once more with the old model.
runner = _runner()
runtime_overrides = getattr(
runner, "_session_model_overrides", None)
if isinstance(runtime_overrides, dict):
runtime_overrides[session_key] = {
"model": resolved_model,
"provider": resolved_provider,
"api_key": getattr(result, "api_key", "") or "",
"base_url": getattr(result, "base_url", "") or "",
"api_mode": getattr(result, "api_mode", "") or "",
"request_overrides": dict(
getattr(result, "request_overrides", None) or {}),
"capabilities": dict(
getattr(result, "runtime_capabilities", None) or {}),
}
evict = getattr(runner, "_evict_cached_agent", None)
if callable(evict):
evict(session_key)
return {"ok": True, "model": resolved_model,
"provider": resolved_provider, "scope": "conversation"}
except Exception:
+115 -2
View File
@@ -16,6 +16,7 @@ import json
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
import pytest
@@ -703,7 +704,7 @@ class TestBridge:
from pheby import hermes_bridge as hb
import hermes_cli.config as hermes_config
import hermes_cli.model_switch as model_switch
import hermes_cli.model_switch_providers as model_switch_providers
monkeypatch.setattr(hb, "_load_cfg", lambda: {
"model": {"default": "m1", "provider": "test"},
"providers": {"test": {"base_url": "http://example"}},
@@ -713,7 +714,7 @@ class TestBridge:
hermes_config, "get_compatible_custom_providers",
lambda _cfg: [{"name": "test", "base_url": "http://example"}])
monkeypatch.setattr(
model_switch, "list_picker_providers", fake_list_picker_providers)
model_switch_providers, "list_picker_providers", fake_list_picker_providers)
snapshot = await hb.models_snapshot()
assert snapshot["providers"][0]["slug"] == "test"
@@ -721,6 +722,118 @@ class TestBridge:
assert captured["excluded_providers"] == ["hidden"]
assert captured["custom_providers"][0]["name"] == "test"
@pytest.mark.asyncio
async def test_model_set_uses_profile_secret_scope_and_current_result_fields(
self, tmp_path, monkeypatch):
from agent.secret_scope import (
get_secret, reset_secret_scope, set_multiplex_active,
set_secret_scope,
)
from pheby import hermes_bridge as hb
import hermes_cli.config as hermes_config
import hermes_cli.model_switch as model_switch
(tmp_path / ".env").write_text(
"OPENROUTER_API_KEY=scoped-openrouter-key\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(hb, "_load_cfg", lambda: {
"model": {"default": "gpt-old", "provider": "openai-codex"},
})
monkeypatch.setattr(
hermes_config, "get_compatible_custom_providers", lambda _cfg: [])
def fake_switch_model(*_args, **_kwargs):
assert get_secret("OPENROUTER_API_KEY") == "scoped-openrouter-key"
return SimpleNamespace(
success=True,
new_model="z-ai/glm-5.3-flash",
target_provider="openrouter",
api_key="scoped-openrouter-key",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
request_overrides={},
runtime_capabilities={"native_compaction": False},
)
monkeypatch.setattr(model_switch, "switch_model", fake_switch_model)
class Store:
saved = None
def peek_session_id(self, _session_key):
return "session-1"
def set_model_override(self, _session_key, override):
self.saved = override
store = Store()
class Runner:
def __init__(self):
self._session_model_overrides = {}
self.evicted = []
def _evict_cached_agent(self, session_key):
self.evicted.append(session_key)
runner = Runner()
monkeypatch.setattr(hb, "_session_store", lambda: store)
monkeypatch.setattr(hb, "_runner", lambda: runner)
outer_token = set_secret_scope(None)
set_multiplex_active(True)
try:
result = await hb.set_model("glm-flash", "openrouter", "conv")
finally:
set_multiplex_active(False)
reset_secret_scope(outer_token)
assert result == {
"ok": True,
"model": "z-ai/glm-5.3-flash",
"provider": "openrouter",
"scope": "conversation",
}
assert store.saved == {
"model": "z-ai/glm-5.3-flash",
"provider": "openrouter",
}
assert runner._session_model_overrides[
"agent:main:pheby:dm:conv"] == {
"model": "z-ai/glm-5.3-flash",
"provider": "openrouter",
"api_key": "scoped-openrouter-key",
"base_url": "https://openrouter.ai/api/v1",
"api_mode": "chat_completions",
"request_overrides": {},
"capabilities": {"native_compaction": False},
}
assert runner.evicted == ["agent:main:pheby:dm:conv"]
@pytest.mark.asyncio
async def test_model_set_surfaces_current_hermes_error_message(
self, monkeypatch):
from pheby import hermes_bridge as hb
import hermes_cli.config as hermes_config
import hermes_cli.model_switch as model_switch
monkeypatch.setattr(hb, "_load_cfg", lambda: {"model": {}})
monkeypatch.setattr(
hermes_config, "get_compatible_custom_providers", lambda _cfg: [])
monkeypatch.setattr(
model_switch,
"switch_model",
lambda *_args, **_kwargs: SimpleNamespace(
success=False,
error_message="OpenRouter credentials unavailable",
),
)
result = await hb.set_model("z-ai/glm-5.3-flash", "openrouter", None)
assert result["ok"] is False
assert result["code"] == proto.ERR_BAD_REQUEST
assert result["message"] == "OpenRouter credentials unavailable"
@pytest.mark.asyncio
async def test_cancel_run_interrupts_agent(self, tmp_path):
server = make_server(tmp_path)