b57e7262e8
set_session_model and the /model slash command called switch_model synchronously on the loop thread; it does ~10 s of network I/O on a cold cache (models.dev, custom-endpoint probes), stalling every ACP session in the process. Same asyncio.to_thread the gateway uses for the same call.
99 lines
4.0 KiB
Python
99 lines
4.0 KiB
Python
"""ACP ``session/set_model`` and the dashboard main slot validate through ``switch_model``.
|
|
|
|
Both surfaces used to accept any string (``parse_model_input`` + ``detect_provider_for_model``
|
|
for ACP; bare provider/model normalization for ``POST /api/model/set``), so a model no catalog
|
|
knew — or a provider with no credentials — was handed to the session / written to config.yaml
|
|
and only failed at inference time. They now share the CLI/gateway/TUI ``/model`` pipeline: a
|
|
rejection from ``switch_model`` is a rejection on these surfaces too, and an acceptance carries
|
|
the resolved (provider, model) — an explicit ``provider:model`` prefix is honoured as
|
|
``--provider`` (#59089), never re-detected.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.model_switch import ModelSwitchResult
|
|
|
|
|
|
def _acp_agent():
|
|
from acp_adapter.server import HermesACPAgent
|
|
made: dict = {}
|
|
|
|
class _SM:
|
|
def _make_agent(self, **kw):
|
|
made.update(kw)
|
|
return types.SimpleNamespace(provider=kw.get("requested_provider"), model=kw.get("model"))
|
|
|
|
def save_session(self, sid):
|
|
pass
|
|
|
|
return HermesACPAgent(session_manager=_SM()), made
|
|
|
|
|
|
def _state():
|
|
return types.SimpleNamespace(
|
|
session_id="s1", cwd=".", model="claude-sonnet-5",
|
|
agent=types.SimpleNamespace(provider="anthropic", base_url="https://api.anthropic.com", api_key="k"))
|
|
|
|
|
|
def test_acp_and_dashboard_reject_what_switch_model_rejects(monkeypatch):
|
|
rejected = ModelSwitchResult(success=False, error_message="Unknown provider 'notaprovider'.")
|
|
monkeypatch.setattr("hermes_cli.model_switch.switch_model", lambda **_kw: rejected)
|
|
|
|
agent, made = _acp_agent()
|
|
state = _state()
|
|
with pytest.raises(ValueError, match="Unknown provider"):
|
|
agent._switch_model(state, "notaprovider:whatever")
|
|
assert made == {} and state.model == "claude-sonnet-5" # session untouched
|
|
|
|
from fastapi import HTTPException
|
|
from hermes_cli.web_server_config import _apply_model_assignment_sync
|
|
with pytest.raises(HTTPException) as exc:
|
|
_apply_model_assignment_sync("main", "notaprovider", "whatever", "", "")
|
|
assert exc.value.status_code == 400 and "Unknown provider" in exc.value.detail
|
|
|
|
|
|
def test_acp_explicit_provider_prefix_becomes_explicit_provider(monkeypatch):
|
|
seen: dict = {}
|
|
|
|
def _switch(**kw):
|
|
seen.update(kw)
|
|
return ModelSwitchResult(success=True, new_model=kw["raw_input"], target_provider=kw["explicit_provider"])
|
|
|
|
monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch)
|
|
agent, made = _acp_agent()
|
|
old, new_provider, model = agent._switch_model(_state(), "anthropic:claude-sonnet-5", keep_endpoint=True)
|
|
assert (seen["explicit_provider"], seen["raw_input"]) == ("anthropic", "claude-sonnet-5")
|
|
assert (old, new_provider, model) == ("anthropic", "anthropic", "claude-sonnet-5")
|
|
assert made["requested_provider"] == "anthropic" and made["base_url"] == "https://api.anthropic.com"
|
|
|
|
|
|
def test_acp_set_session_model_runs_switch_model_off_the_event_loop(monkeypatch):
|
|
"""``switch_model`` does ~10 s of sync network I/O on a cold cache; ACP must run it on a
|
|
worker thread (like the gateway) or every session in the process stalls."""
|
|
import asyncio
|
|
import threading
|
|
|
|
seen: dict = {}
|
|
|
|
def _switch(**kw):
|
|
seen["thread"] = threading.current_thread()
|
|
return ModelSwitchResult(success=True, new_model=kw["raw_input"], target_provider="anthropic")
|
|
|
|
monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch)
|
|
agent, _made = _acp_agent()
|
|
state = _state()
|
|
agent.session_manager.get_session = lambda sid: state
|
|
|
|
async def _run():
|
|
loop_thread = threading.current_thread()
|
|
resp = await agent.set_session_model("anthropic:claude-sonnet-5", "s1")
|
|
return resp, loop_thread
|
|
|
|
resp, loop_thread = asyncio.run(_run())
|
|
assert resp is not None and state.model == "claude-sonnet-5"
|
|
assert seen["thread"] is not loop_thread
|