The purge dropped both /model offload tests as mechanism pins (they spied asyncio.to_thread). Restored as liveness invariants instead: the blocking call records the thread it ran on and must not be the event-loop thread. Both go red when asyncio.to_thread is made to run inline. - test_model_command_async_offload.py::test_picker_path_runs_provider_listing_off_the_event_loop guards: list_picker_providers (can block on HTTP) never runs on the gateway loop (#41289/#41304) - test_model_command_custom_providers.py::test_direct_model_switch_runs_off_the_event_loop guards: `/model <name>` switch_model() never runs on the gateway loop (#20525)
123 lines
5.1 KiB
Python
123 lines
5.1 KiB
Python
"""Gateway ``/model`` picker listing is a read path (#41289, #74003): it must ask for
|
|
cache-only catalogs and live-probe only the currently selected custom endpoint, so a
|
|
stale provider cache cannot freeze the gateway on blocking HTTP fetches.
|
|
"""
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform
|
|
from gateway.platforms.event import MessageEvent, MessageType
|
|
from gateway.run import GatewayRunner
|
|
from gateway.session import SessionSource
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Harness
|
|
# --------------------------------------------------------------------------- #
|
|
def _make_runner():
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.adapters = {}
|
|
runner._voice_mode = {}
|
|
runner._session_model_overrides = {}
|
|
runner._running_agents = {}
|
|
return runner
|
|
|
|
|
|
def _make_event():
|
|
"""A bare ``/model`` (no args) — triggers the listing branch."""
|
|
return MessageEvent(
|
|
text="/model",
|
|
message_type=MessageType.TEXT,
|
|
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def _isolated_config(tmp_path, monkeypatch):
|
|
"""Point the handler at an empty isolated home so config loading is cheap
|
|
and deterministic (no real provider creds / network)."""
|
|
import gateway.run as gateway_run
|
|
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "config.yaml").write_text("model:\n default: gpt-x\n provider: openrouter\nproviders: {}\n", encoding="utf-8")
|
|
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
|
|
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
|
return hermes_home
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Text-fallback path -> list_authenticated_providers
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Picker path -> list_picker_providers
|
|
# --------------------------------------------------------------------------- #
|
|
class _FakePickerResult:
|
|
success = True
|
|
|
|
|
|
class _FakePickerAdapter:
|
|
"""Adapter whose *type* exposes ``send_model_picker`` (the gate the handler
|
|
checks via ``getattr(type(adapter), 'send_model_picker', None)``)."""
|
|
|
|
async def send_model_picker(self, **kwargs):
|
|
return _FakePickerResult()
|
|
|
|
def _thread_metadata(self, *a, **k): # pragma: no cover - not exercised
|
|
return None
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_picker_path_runs_provider_listing_off_the_event_loop(_isolated_config, monkeypatch):
|
|
"""#41289/#41304: ``list_picker_providers`` can fall through to a blocking HTTP fetch, so the
|
|
picker branch must run it on a worker thread — never on the gateway's event-loop thread."""
|
|
listing_threads: list[int] = []
|
|
|
|
def _fake_list_picker_providers(**kwargs):
|
|
listing_threads.append(threading.get_ident())
|
|
return [{"slug": "openrouter", "name": "OpenRouter", "is_current": True,
|
|
"models": ["gpt-x"], "total_models": 1}]
|
|
|
|
monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", _fake_list_picker_providers)
|
|
runner = _make_runner()
|
|
runner.adapters = {Platform.TELEGRAM: _FakePickerAdapter()}
|
|
monkeypatch.setattr(runner, "_thread_metadata_for_source", lambda *a, **k: None, raising=False)
|
|
monkeypatch.setattr(runner, "_reply_anchor_for_event", lambda *a, **k: None, raising=False)
|
|
|
|
# Picker "sent" => handler returns None, proving it got past the listing call.
|
|
assert await runner._handle_model_command(_make_event()) is None
|
|
assert listing_threads, "listing never ran"
|
|
assert threading.get_ident() not in listing_threads, (
|
|
"list_picker_providers ran inline on the event-loop thread"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_picker_path_lists_cache_only_and_probes_only_the_current_custom_endpoint(_isolated_config, monkeypatch):
|
|
"""#74003: the chat ``/model`` reply is a read path. The listing must ask for cache-only catalogs
|
|
and must not live-probe every saved custom endpoint (only the selected one), matching the GUI."""
|
|
seen: list[dict] = []
|
|
|
|
def _fake_list_picker_providers(**kwargs):
|
|
seen.append(kwargs)
|
|
return [{"slug": "openrouter", "name": "OpenRouter", "is_current": True,
|
|
"models": ["gpt-x"], "total_models": 1}]
|
|
|
|
monkeypatch.setattr("hermes_cli.model_switch_providers.list_picker_providers", _fake_list_picker_providers)
|
|
runner = _make_runner()
|
|
runner.adapters = {Platform.TELEGRAM: _FakePickerAdapter()}
|
|
monkeypatch.setattr(runner, "_thread_metadata_for_source", lambda *a, **k: None, raising=False)
|
|
monkeypatch.setattr(runner, "_reply_anchor_for_event", lambda *a, **k: None, raising=False)
|
|
|
|
assert await runner._handle_model_command(_make_event()) is None
|
|
assert seen, "listing never ran"
|
|
flags = {k: seen[0].get(k) for k in ("non_blocking_catalogs", "probe_custom_providers", "probe_current_custom_provider")}
|
|
assert flags == {"non_blocking_catalogs": True, "probe_custom_providers": False,
|
|
"probe_current_custom_provider": True}, flags
|