{"repo":"NousResearch/hermes-agent","public_id":"39963","display_label":"Vision override lookup for stripped custom provider keys","summary":"These issues and PRs address a bug where vision capability overrides were not being checked against the stripped 'custom:<name>' provider key form. The fixes ensure that image routing and supports_vision lookups correctly handle named custom provider key variants.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":57649},"resolution":{"state":"resolved","by":"pr-57649"},"freshness":{"state":"verified","last_verified_at":"2026-07-03T10:33:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":36054,"pr_number":57649,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39963,"pr_number":57649,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":52918,"pr_number":57649,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":39963,"title":"Bug: supports_vision override ignores stripped named custom provider key","url":null,"state":"closed"},{"kind":"pr","number":39973,"title":"fix(image_routing): check stripped custom:<name> provider key for vision override","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57649","number":57649},{"kind":"pr","value":"#39963","number":39963},{"kind":"pr","value":"#40056","number":40056},{"kind":"pr","value":"#40089","number":40089}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40053,"title":"fix(agent): honor prefixed custom vision overrides","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39973","number":39973},{"kind":"pr","value":"#39963","number":39963},{"kind":"pr","value":"#40056","number":40056}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":40056,"title":"fix(image_routing): include stripped custom:<name> key in supports_vision lookup","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39973","number":39973},{"kind":"pr","value":"#39963","number":39963},{"kind":"pr","value":"#57649","number":57649}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40089,"title":"fix(routing): try stripped named custom provider key for vision override (#39963)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#39973","number":39973},{"kind":"pr","value":"#39963","number":39963},{"kind":"pr","value":"#57649","number":57649}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57649,"title":"fix(vision): custom-provider routing — stripped custom:<name> override, session model, canonical slug","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/image_routing.py","gateway/run.py","hermes_cli/model_setup_flows.py","tests/agent/test_image_routing.py","tests/gateway/test_image_input_routing_runtime.py","tests/gateway/test_native_image_buffer_isolation.py","tests/gateway/test_queued_native_image_session_key.py","tests/hermes_cli/test_model_provider_persistence.py","tests/hermes_cli/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"agent/image_routing.py","hunks":["@@ -185,7 +185,8 @@ def _supports_vision_override(\n       2. ``providers.<provider>.models.<model>.supports_vision``\n          (named custom providers — ``provider`` may be the runtime-resolved\n          value ``\"custom\"`` and/or the user-declared name under\n-         ``model.provider``; both are tried)\n+         ``model.provider``; both are tried. For ``custom:<name>`` syntax,\n+         the stripped ``<name>`` is also tried as a provider key.)\n \n     Returns None when no override is set, so the caller falls through to\n     models.dev. Returns False explicitly only when the user wrote a","@@ -205,11 +206,16 @@ def _supports_vision_override(\n     # get rewritten to provider=\"custom\" at runtime\n     # (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the\n     # config still holds the user-declared name under model.provider. Try\n-    # both as candidate provider keys.\n+    # both as candidate provider keys, plus the stripped suffix from\n+    # \"custom:<name>\" (where <name> is the key under providers:).\n     config_provider = str(model_cfg.get(\"provider\") or \"\").strip()\n+    # Extract the stripped name from \"custom:<name>\" if present\n+    stripped_suffix = \"\"\n+    if config_provider.startswith(\"custom:\"):\n+        stripped_suffix = config_provider[len(\"custom:\"):]\n     providers_raw = cfg.get(\"providers\")\n     providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}\n-    for p in dict.fromkeys(filter(None, (provider, config_provider))):\n+    for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):\n         entry_raw = providers_cfg.get(p)\n         entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}\n         models_raw = entry.get(\"models\")"]},{"file":"gateway/run.py","hunks":["@@ -10035,7 +10035,10 @@ async def _prepare_inbound_message_text(\n             if image_paths:\n                 # Decide routing: native (attach pixels) vs text (vision_analyze\n                 # pre-run + prepend description).  See agent/image_routing.py.\n-                _img_mode = self._decide_image_input_mode()\n+                _img_mode = self._decide_image_input_mode(\n+                    source=source,\n+                    session_key=session_key,\n+                )\n                 if _img_mode == \"native\":\n                     # Defer attachment to the run_conversation call site.\n                     pending_native = getattr(self, \"_pending_native_image_paths_by_session\", None)","@@ -14381,25 +14384,62 @@ def _shutdown_executor(self) -> None:\n         except TypeError:\n             executor.shutdown(wait=False)\n \n-    def _decide_image_input_mode(self) -> str:\n-        \"\"\"Resolve the image-input routing for the currently active model.\n+    def _decide_image_input_mode(\n+        self,\n+        *,\n+        source: Optional[SessionSource] = None,\n+        session_key: Optional[str] = None,\n+        user_config: Optional[dict] = None,\n+        provider: Optional[str] = None,\n+        model: Optional[str] = None,\n+    ) -> str:\n+        \"\"\"Resolve image-input routing for the effective model this turn.\n \n         Returns ``\"native\"`` (attach pixels on the user turn) or ``\"text\"``\n         (pre-analyze with vision_analyze and prepend the description). See\n         agent/image_routing.py for the full decision table.\n \n-        The active provider/model are read from config.yaml so the decision\n-        tracks ``/model`` switches automatically on the next message.\n+        Gateway sessions can have /model overrides that live outside\n+        config.yaml. Image preprocessing runs before AIAgent sets the\n+        auxiliary_client runtime globals, so resolve the same per-session\n+        runtime bundle the upcoming agent turn will use instead of consulting\n+        only the persisted default model.\n         \"\"\"\n         try:\n             from agent.image_routing import decide_image_input_mode\n             from agent.auxiliary_client import _read_main_model, _read_main_provider\n             from hermes_cli.config import load_config\n \n-            cfg = load_config()\n-            provider = _read_main_provider()\n-            model = _read_main_model()\n-            return decide_image_input_mode(provider, model, cfg)\n+            cfg = user_config if isinstance(user_config, dict) else load_config()\n+            resolved_provider = (provider or \"\").strip()\n+            resolved_model = (model or \"\").strip()\n+\n+            needs_session_runtime = not resolved_provider or not resolved_model\n+            has_session_identity = source is not None or session_key\n+            if needs_session_runtime and has_session_identity:\n+                try:\n+                    turn_model, runtime_kwargs = self._resolve_session_agent_runtime(\n+                        source=source,\n+                        session_key=session_key,\n+                        user_config=cfg,\n+                    )\n+                    if not resolved_model and isinstance(turn_model, str):\n+                        resolved_model = turn_model.strip()\n+                    runtime_provider = runtime_kwargs.get(\"provider\") if isinstance(runtime_kwargs, dict) else None\n+                    if not resolved_provider and isinstance(runtime_provider, str):\n+                        resolved_provider = runtime_provider.strip()\n+                except Exception as exc:\n+                    logger.debug(\n+                        \"image_routing: session runtime resolution failed, falling back to config — %s\",\n+                        exc,\n+                    )\n+\n+            if not resolved_provider:\n+                resolved_provider = _read_main_provider()\n+            if not resolved_model:\n+                resolved_model = _read_main_model()\n+\n+            return decide_image_input_mode(resolved_provider, resolved_model, cfg)\n         except Exception as exc:\n             logger.debug(\"image_routing: decision failed, falling back to text — %s\", exc)\n             return \"text\""]},{"file":"hermes_cli/model_setup_flows.py","hunks":["@@ -1446,7 +1446,7 @@ def _model_flow_named_custom(config, provider_info):\n         model = {\"default\": model} if model else {}\n         cfg[\"model\"] = model\n     if provider_key:\n-        model[\"provider\"] = provider_key\n+        model[\"provider\"] = \"custom:\" + provider_key.strip().lower().replace(\" \", \"-\")\n         model.pop(\"base_url\", None)\n         model.pop(\"api_key\", None)\n     else:"]},{"file":"tests/agent/test_image_routing.py","hunks":["@@ -224,6 +224,37 @@ def test_malformed_sections_are_ignored(self):\n         cfg = {\"model\": \"some-string\", \"providers\": [\"not-a-dict\"]}\n         assert _supports_vision_override(cfg, \"custom\", \"my-llava\") is None\n \n+    def test_custom_colon_name_stripped_suffix_lookup(self):\n+        # model.provider: custom:my-proxy → should resolve stripped key \"my-proxy\"\n+        cfg = {\n+            \"model\": {\"provider\": \"custom:my-proxy\"},\n+            \"providers\": {\n+                \"my-proxy\": {\"models\": {\"gpt-5.5\": {\"supports_vision\": True}}},\n+            },\n+        }\n+        assert _supports_vision_override(cfg, \"custom\", \"gpt-5.5\") is True\n+\n+    def test_custom_colon_name_stripped_suffix_false(self):\n+        # Explicitly disabled vision on the stripped key.\n+        cfg = {\n+            \"model\": {\"provider\": \"custom:my-proxy\"},\n+            \"providers\": {\n+                \"my-proxy\": {\"models\": {\"gpt-5.5\": {\"supports_vision\": False}}},\n+            },\n+        }\n+        assert _supports_vision_override(cfg, \"custom\", \"gpt-5.5\") is False\n+\n+    def test_custom_colon_name_no_stripped_key_falls_through(self):\n+        # custom:my-proxy but providers only has \"custom\" — stripped key\n+        # doesn't match, but \"custom\" does via runtime provider.\n+        cfg = {\n+            \"model\": {\"provider\": \"custom:my-proxy\"},\n+            \"providers\": {\n+                \"custom\": {\"models\": {\"gpt-5.5\": {\"supports_vision\": True}}},\n+            },\n+        }\n+        assert _supports_vision_override(cfg, \"custom\", \"gpt-5.5\") is True\n+\n \n # ─── _lookup_supports_vision (override-aware) ────────────────────────────────\n "]},{"file":"tests/gateway/test_image_input_routing_runtime.py","hunks":["@@ -0,0 +1,140 @@\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.run import GatewayRunner\n+from gateway.session import SessionSource\n+\n+\n+def _make_runner() -> GatewayRunner:\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"fake\")}\n+    )\n+    runner.adapters = {}\n+    runner._pending_native_image_paths_by_session = {}\n+    runner._session_model_overrides = {}\n+    runner._session_reasoning_overrides = {}\n+    return runner\n+\n+\n+def _source() -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"273403055\",\n+        chat_type=\"dm\",\n+        user_id=\"42\",\n+        user_name=\"Maxim\",\n+    )\n+\n+\n+def _image_event(text: str = \"look\") -> MessageEvent:\n+    return MessageEvent(\n+        text=text,\n+        message_type=MessageType.PHOTO,\n+        source=_source(),\n+        media_urls=[\"/tmp/cashback.png\"],\n+        media_types=[\"image/png\"],\n+    )\n+\n+\n+def _auto_config() -> dict:\n+    return {\n+        \"agent\": {\"image_input_mode\": \"auto\"},\n+        \"auxiliary\": {\"vision\": {\"provider\": \"auto\", \"model\": \"\", \"base_url\": \"\"}},\n+        \"model\": {\"provider\": \"xiaomi\", \"default\": \"mimo-v2.5-pro\"},\n+    }\n+\n+\n[redacted:email]\n+async def test_prepare_image_routing_uses_session_vision_model_override(monkeypatch):\n+    \"\"\"Telegram /model overrides must affect native-vs-text image routing.\n+\n+    Regression: _prepare_inbound_message_text used config.yaml's default model\n+    before the per-session model override was installed on auxiliary_client's\n+    runtime globals. A Telegram session switched to a vision model still had\n+    screenshots pre-analyzed as text when config.default was text-only.\n+    \"\"\"\n+    runner = _make_runner()\n+    source = _source()\n+    event = _image_event()\n+    cfg = _auto_config()\n+\n+    monkeypatch.setattr(\"gateway.run._load_gateway_config\", lambda: cfg)\n+    monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: cfg)\n+    monkeypatch.setattr(\"agent.auxiliary_client._read_main_provider\", lambda: \"xiaomi\")\n+    monkeypatch.setattr(\"agent.auxiliary_client._read_main_model\", lambda: \"mimo-v2.5-pro\")\n+    monkeypatch.setattr(\n+        runner,\n+        \"_resolve_session_agent_runtime\",\n+        lambda **_: (\"gpt-5.5\", {\"provider\": \"openai-codex\"}),\n+    )\n+\n+    def fake_supports(provider, model, config):\n+        return provider == \"openai-codex\" and model == \"gpt-5.5\"\n+\n+    monkeypatch.setattr(\"agent.image_routing._lookup_supports_vision\", fake_supports)\n+\n+    async def fail_enrich(*_args, **_kwargs):\n+        pytest.fail(\"vision-capable session override should use native image routing\")\n+\n+    monkeypatch.setattr(runner, \"_enrich_message_with_vision\", fail_enrich)\n+\n+    result = await runner._prepare_inbound_message_text(\n+        event=event,\n+        source=source,\n+        history=[],\n+    )\n+\n+    session_key = runner._session_key_for_source(source)\n+    assert result == \"look\"\n+    assert runner._pending_native_image_paths_by_session[session_key] == [\n+        \"/tmp/cashback.png\"\n+    ]\n+\n+\n[redacted:email]\n+async def test_prepare_image_routing_falls_back_to_text_for_text_only_session_override(monkeypatch):\n+    \"\"\"A text-only session override should get vision_analyze text fallback.\n+\n+    Regression mirror case: if config.default is a vision model but the current\n+    Telegram session is switched to a text-only provider (for example Mimo),\n+    auto routing must not attach pixels natively to the text-only model.\n+    \"\"\"\n+    runner = _make_runner()\n+    source = _source()\n+    event = _image_event()\n+    cfg = _auto_config()\n+    cfg[\"model\"] = {\"provider\": \"openai-codex\", \"default\": \"gpt-5.5\"}\n+\n+    monkeypatch.setattr(\"gateway.run._load_gateway_config\", lambda: cfg)\n+    monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: cfg)\n+    monkeypatch.setattr(\"agent.auxiliary_client._read_main_provider\", lambda: \"openai-codex\")\n+    monkeypatch.setattr(\"agent.auxiliary_client._read_main_model\", lambda: \"gpt-5.5\")\n+    monkeypatch.setattr(\n+        runner,\n+        \"_resolve_session_agent_runtime\",\n+        lambda **_: (\"mimo-v2.5-pro\", {\"provider\": \"xiaomi\"}),\n+    )\n+\n+    def fake_supports(provider, model, config):\n+        return provider == \"openai-codex\" and model == \"gpt-5.5\"\n+\n+    monkeypatch.setattr(\"agent.image_routing._lookup_supports_vision\", fake_supports)\n+\n+    async def fake_enrich(user_text, image_paths):\n+        assert user_text == \"look\"\n+        assert image_paths == [\"/tmp/cashback.png\"]\n+        return \"[vision summary]\\n\\nlook\"\n+\n+    monkeypatch.setattr(runner, \"_enrich_message_with_vision\", fake_enrich)\n+\n+    result = await runner._prepare_inbound_message_text(\n+        event=event,\n+        source=source,\n+        history=[],\n+    )\n+\n+    session_key = runner._session_key_for_source(source)\n+    assert result == \"[vision summary]\\n\\nlook\"\n+    assert runner._pending_native_image_paths_by_session.get(session_key) is None"]},{"file":"tests/gateway/test_native_image_buffer_isolation.py","hunks":["@@ -14,7 +14,7 @@ def _make_runner() -> GatewayRunner:\n     runner.adapters = {}\n     runner._model = \"openai/gpt-4.1-mini\"\n     runner._base_url = None\n-    runner._decide_image_input_mode = lambda: \"native\"\n+    runner._decide_image_input_mode = lambda **_: \"native\"\n     return runner\n \n "]},{"file":"tests/gateway/test_queued_native_image_session_key.py","hunks":["@@ -86,7 +86,7 @@ def _make_runner(adapter):\n     )\n     runner._model = \"openai/gpt-4.1-mini\"\n     runner._base_url = None\n-    runner._decide_image_input_mode = lambda: \"native\"\n+    runner._decide_image_input_mode = lambda **_kw: \"native\"\n     return runner\n \n "]},{"file":"tests/hermes_cli/test_model_provider_persistence.py","hunks":["@@ -207,6 +207,51 @@ def test_named_custom_provider_preserves_explicit_api_mode(self, config_home):\n         assert model.get(\"base_url\") == \"https://packy.example.com/v1\"\n         assert model.get(\"api_mode\") == \"codex_responses\"\n \n+    def test_named_custom_provider_with_builtin_slug_persists_custom_prefix(\n+        self, config_home, monkeypatch\n+    ):\n+        \"\"\"providers.<builtin-slug> must persist as a named custom provider.\"\"\"\n+        import yaml\n+\n+        from hermes_cli.main import _model_flow_named_custom\n+\n+        config_path = config_home / \"config.yaml\"\n+        config_path.write_text(\n+            \"providers:\\n\"\n+            \"  minimax-cn:\\n\"\n+            \"    name: MiniMax CN Proxy\\n\"\n+            \"    api: https://mimimax.cn/v1\\n\"\n+            \"    key_env: MINIMAX_CN_PROXY_KEY\\n\"\n+            \"    transport: chat_completions\\n\"\n+            \"    model: MiniMax-M3\\n\"\n+            \"    default_model: MiniMax-M3\\n\"\n+        )\n+        monkeypatch.setenv(\"MINIMAX_CN_PROXY_KEY\", \"proxy-secret\")\n+\n+        provider_info = {\n+            \"name\": \"MiniMax CN Proxy\",\n+            \"base_url\": \"https://mimimax.cn/v1\",\n+            \"api_key\": \"\",\n+            \"key_env\": \"MINIMAX_CN_PROXY_KEY\",\n+            \"model\": \"MiniMax-M3\",\n+            \"api_mode\": \"chat_completions\",\n+            \"provider_key\": \"minimax-cn\",\n+        }\n+\n+        with patch(\"hermes_cli.auth._save_model_choice\"), \\\n+             patch(\"hermes_cli.auth.deactivate_provider\"), \\\n+             patch(\"hermes_cli.models.fetch_api_models\", return_value=[\"MiniMax-M3\"]), \\\n+             patch(\"hermes_cli.curses_ui.curses_radiolist\", side_effect=OSError(\"no tty in test\")), \\\n+             patch(\"builtins.input\", return_value=\"1\"):\n+            _model_flow_named_custom({}, provider_info)\n+\n+        config = yaml.safe_load(config_path.read_text()) or {}\n+        model = config.get(\"model\")\n+        assert isinstance(model, dict)\n+        assert model.get(\"provider\") == \"custom:minimax-cn\"\n+        assert \"base_url\" not in model\n+        assert \"api_key\" not in model\n+\n     def test_copilot_acp_provider_saved_when_selected(self, config_home):\n         \"\"\"_model_flow_copilot_acp should persist provider/base_url/model together.\"\"\"\n         from hermes_cli.main import _model_flow_copilot_acp","@@ -555,4 +600,3 @@ def fake_choice(choices, *, default=0, title=\"\"):\n             _select_zai_endpoint(custom_url)\n \n         assert captured[\"default\"] == expected_default\n-"]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -1272,6 +1272,33 @@ def test_resolve_requested_provider_precedence(monkeypatch):\n     assert rp.resolve_requested_provider() == \"auto\"\n \n \n+def test_resolve_runtime_provider_named_custom_with_builtin_slug(monkeypatch):\n+    monkeypatch.setenv(\"MINIMAX_CN_PROXY_KEY\", \"proxy-secret\")\n+    monkeypatch.setattr(\n+        rp,\n+        \"load_config\",\n+        lambda: {\n+            \"model\": {\"provider\": \"custom:minimax-cn\"},\n+            \"providers\": {\n+                \"minimax-cn\": {\n+                    \"name\": \"MiniMax CN Proxy\",\n+                    \"api\": \"https://mimimax.cn/v1\",\n+                    \"key_env\": \"MINIMAX_CN_PROXY_KEY\",\n+                    \"transport\": \"chat_completions\",\n+                    \"default_model\": \"MiniMax-M3\",\n+                }\n+            },\n+        },\n+    )\n+\n+    resolved = rp.resolve_runtime_provider()\n+\n+    assert resolved[\"provider\"] == \"custom\"\n+    assert resolved[\"base_url\"] == \"https://mimimax.cn/v1\"\n+    assert resolved[\"api_key\"] == \"proxy-secret\"\n+    assert resolved[\"api_mode\"] == \"chat_completions\"\n+\n+\n # ── api_mode config override tests ──────────────────────────────────────\n \n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}