diff --git a/gateway/run.py b/gateway/run.py index 61fcd96eb709..838b5b09d6f9 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10840,7 +10840,13 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if image_paths: # Decide routing: native (attach pixels) vs text (vision_analyze # pre-run + prepend description). See agent/image_routing.py. - _img_mode = self._decide_image_input_mode( + # Offload to a worker thread: the decision does blocking network + # I/O — a models.dev fetch on cache miss, and the Ollama + # ``/api/show`` capability probe for local servers — whose + # request timeout would otherwise stall the whole gateway event + # loop (every session) while a single image is routed. + _img_mode = await asyncio.to_thread( + self._decide_image_input_mode, source=source, session_key=session_key, ) diff --git a/tests/gateway/test_image_input_routing_runtime.py b/tests/gateway/test_image_input_routing_runtime.py index bc5093580723..40bb65260cd3 100644 --- a/tests/gateway/test_image_input_routing_runtime.py +++ b/tests/gateway/test_image_input_routing_runtime.py @@ -143,3 +143,48 @@ async def test_prepare_image_routing_falls_back_to_text_for_text_only_session_ov session_key = runner._session_key_for_source(source) assert result == "[vision summary]\n\nlook" assert runner._pending_native_image_paths_by_session.get(session_key) is None + + +@pytest.mark.asyncio +async def test_prepare_image_routing_runs_off_the_event_loop(monkeypatch): + """The image-routing decision does blocking network I/O — a models.dev fetch + on cache miss, and the Ollama ``/api/show`` capability probe for local + servers — so it must run on a worker thread. Run inline on the gateway + event loop it would freeze *every* session for up to the request timeout + while a single image is routed. + """ + import threading + + runner = _make_runner() + source = _source() + event = _image_event() + cfg = _auto_config() + + monkeypatch.setattr("gateway.run._load_gateway_config", lambda: cfg) + monkeypatch.setattr("hermes_cli.config.load_config", lambda: cfg) + monkeypatch.setattr("agent.auxiliary_client._read_main_provider", lambda: "xiaomi") + monkeypatch.setattr("agent.auxiliary_client._read_main_model", lambda: "mimo-v2.5-pro") + monkeypatch.setattr( + runner, + "_resolve_session_agent_runtime", + lambda **_: ("gpt-5.5", {"provider": "openai-codex"}), + ) + + main_thread = threading.current_thread() + seen: dict = {} + + def recording_supports(provider, model, config): + # Stands in for the real, blocking capability lookup and records the + # thread it executes on. + seen["thread"] = threading.current_thread() + return True # vision-capable → native routing (skips _enrich_message_with_vision) + + monkeypatch.setattr("agent.image_routing._lookup_supports_vision", recording_supports) + + await runner._prepare_inbound_message_text(event=event, source=source, history=[]) + + assert seen.get("thread") is not None, "capability lookup was never reached" + assert seen["thread"] is not main_thread, ( + "the blocking image-routing decision must be offloaded off the gateway " + "event loop, not run inline on it" + )