{"repo":"NousResearch/hermes-agent","public_id":"36054","display_label":"Image routing ignores session model overrides","summary":"These issues and fixes address a consistent theme: image and vision routing uses stale or incorrect model information when the active model is changed mid-session, including failures to respect session-level model overrides across gateway, TUI, and custom provider contexts.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":57649},"resolution":{"state":"partially_resolved","by":"pr-63521"},"freshness":{"state":"verified","last_verified_at":"2026-07-17T16:08:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.29066666666666663},"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":43516,"pr_number":63521,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":49151,"pr_number":63521,"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":36054,"title":"Gateway image auto-routing ignores session model overrides","url":null,"state":"closed"},{"kind":"issue","number":43516,"title":"Image routing uses stale model info when switching models mid-session","url":null,"state":"closed"},{"kind":"pr","number":36055,"title":"fix: route gateway images by session model override","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#57649","number":57649}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":43529,"title":"fix(tui): use live agent model for image routing after /model switch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ef9a98315","number":0},{"kind":"pr","value":"#63521","number":63521}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"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"]}]},{"kind":"pr","number":63521,"title":"fix(auxiliary): isolate model-scoped runtime state","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","agent/context_compressor.py","agent/image_routing.py","agent/turn_context.py","gateway/run.py","run_agent.py","scripts/release.py","tests/agent/test_auxiliary_runtime_cache_key.py","tests/agent/test_context_compressor.py","tests/agent/test_turn_context.py","tests/gateway/test_image_input_routing_runtime.py","tests/run_agent/test_compression_feasibility.py","tests/tui_gateway/test_image_routing_stale_model.py","tui_gateway/server.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -41,6 +41,9 @@\n \"\"\"\n \n import contextlib\n+import contextvars\n+import hashlib\n+import inspect\n import json\n import logging\n import os","@@ -2207,7 +2210,7 @@ def _read_main_model() -> str:\n     that gate on \"the active main model\" (e.g. ``vision_analyze``'s native\n     fast path) see the live runtime, not the persisted config default.\n     \"\"\"\n-    override = _RUNTIME_MAIN_MODEL\n+    override = _runtime_main_value(\"model\")\n     if isinstance(override, str) and override.strip():\n         return override.strip()\n     try:","@@ -2234,7 +2237,7 @@ def _read_main_provider() -> str:\n     Runtime override: see ``_read_main_model`` — same mechanism for the\n     provider half of the runtime tuple.\n     \"\"\"\n-    override = _RUNTIME_MAIN_PROVIDER\n+    override = _runtime_main_value(\"provider\")\n     if isinstance(override, str) and override.strip():\n         return override.strip().lower()\n     try:","@@ -2263,7 +2266,7 @@ def _read_main_api_key() -> str:\n     the main model's credentials instead of falling to ``no-key-required``\n     (issue #9318).\n     \"\"\"\n-    override = _RUNTIME_MAIN_API_KEY\n+    override = _runtime_main_value(\"api_key\")\n     if isinstance(override, str) and override.strip():\n         return override.strip()\n     try:","@@ -2284,7 +2287,7 @@ def _read_main_base_url() -> str:\n \n     Same override-then-config pattern as ``_read_main_api_key``.\n     \"\"\"\n-    override = _RUNTIME_MAIN_BASE_URL\n+    override = _runtime_main_value(\"base_url\")\n     if isinstance(override, str) and override.strip():\n         return override.strip()\n     try:","@@ -2320,13 +2323,55 @@ def _read_main_api_key_if_same_host(aux_base_url: str) -> str:\n     return _read_main_api_key()\n \n \n-# Process-local override set by AIAgent at session/turn start. Single-threaded\n-# per turn — no lock needed. Cleared by ``clear_runtime_main()``.\n+# Compatibility mirrors for older readers/tests. The authoritative value is\n+# the ContextVar below: gateway sessions can overlap in one process, so a\n+# process-global tuple is not safe as routing or cache-key input.\n _RUNTIME_MAIN_PROVIDER: str = \"\"\n _RUNTIME_MAIN_MODEL: str = \"\"\n _RUNTIME_MAIN_BASE_URL: str = \"\"\n-_RUNTIME_MAIN_API_KEY: str = \"\"\n+_RUNTIME_MAIN_API_KEY: Any = \"\"\n _RUNTIME_MAIN_API_MODE: str = \"\"\n+_RUNTIME_MAIN_AUTH_MODE: str = \"\"\n+_RUNTIME_MAIN_CONTEXT: contextvars.ContextVar[Optional[Dict[str, Any]]] = (\n+    contextvars.ContextVar(\"auxiliary_runtime_main\", default=None)\n+)\n+_RUNTIME_MAIN_COMPAT_SNAPSHOT: Tuple[Any, ...] = (\"\", \"\", \"\", \"\", \"\", \"\")\n+_RUNTIME_MAIN_COMPAT_LOCK = threading.Lock()\n+\n+\n+def _compat_runtime_main() -> Optional[Dict[str, Any]]:\n+    \"\"\"Expose deliberately patched legacy globals in a single main context.\n+\n+    ``set_runtime_main`` mirrors values into the old module attributes for\n+    introspection, but those mirrors must never become runtime inputs. A direct\n+    patch is recognized only when it differs from the mirrored snapshot and\n+    only on the main thread, keeping concurrent session workers isolated.\n+    \"\"\"\n+    if threading.current_thread() is not threading.main_thread():\n+        return None\n+    values = (\n+        _RUNTIME_MAIN_PROVIDER,\n+        _RUNTIME_MAIN_MODEL,\n+        _RUNTIME_MAIN_BASE_URL,\n+        _RUNTIME_MAIN_API_KEY,\n+        _RUNTIME_MAIN_API_MODE,\n+        _RUNTIME_MAIN_AUTH_MODE,\n+    )\n+    if values == _RUNTIME_MAIN_COMPAT_SNAPSHOT:\n+        return None\n+    return dict(zip(_MAIN_RUNTIME_FIELDS, values))\n+\n+\n+def _runtime_main_value(field: str) -> Any:\n+    \"\"\"Read one runtime field through context-local/controlled legacy state.\"\"\"\n+    runtime = _RUNTIME_MAIN_CONTEXT.get()\n+    if runtime is None:\n+        runtime = _compat_runtime_main()\n+    if isinstance(runtime, dict):\n+        value = runtime.get(field)\n+        if value:\n+            return value\n+    return \"\"\n \n \n def set_runtime_main(","@@ -2334,38 +2379,85 @@ def set_runtime_main(\n     model: str,\n     *,\n     base_url: str = \"\",\n-    api_key: str = \"\",\n+    api_key: Any = \"\",\n     api_mode: str = \"\",\n-) -> None:\n-    \"\"\"Record the live runtime provider/model/credentials for the current AIAgent.\n-\n-    Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or\n-    equivalent setter) at the top of each turn so that\n-    ``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway\n-    overrides instead of the stale config.yaml default.\n+    auth_mode: str = \"\",\n+) -> contextvars.Token:\n+    \"\"\"Record the current context's live main runtime for auxiliary routing.\n \n-    For ``custom:`` providers, ``base_url`` and ``api_key`` must also be\n-    recorded so that ``_resolve_auto`` can construct a valid client in\n-    Step 1 instead of falling through to the aggregator chain.\n+    Context-local state prevents concurrent gateway sessions from overwriting\n+    one another while retaining compatibility mirrors for legacy readers.\n     \"\"\"\n     global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL\n     global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE\n-    _RUNTIME_MAIN_PROVIDER = (provider or \"\").strip().lower()\n-    _RUNTIME_MAIN_MODEL = (model or \"\").strip()\n-    _RUNTIME_MAIN_BASE_URL = (base_url or \"\").strip()\n-    _RUNTIME_MAIN_API_KEY = [redacted:secret-assignment] if isinstance(api_key, str) else \"\"\n-    _RUNTIME_MAIN_API_MODE = (api_mode or \"\").strip()\n+    global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT\n+    runtime = {\n+        \"provider\": (provider or \"\").strip().lower(),\n+        \"model\": (model or \"\").strip(),\n+        \"base_url\": (base_url or \"\").strip(),\n+        \"api_key\": (\n+            api_key.strip()\n+            if isinstance(api_key, str)\n+            else api_key if callable(api_key) else \"\"\n+        ),\n+        \"api_mode\": (api_mode or \"\").strip(),\n+        \"auth_mode\": (auth_mode or \"\").strip().lower(),\n+    }\n+    # Publish authoritative context before updating locked compatibility\n+    # mirrors; concurrent sessions never read those mirrors at runtime.\n+    token = [redacted:secret-assignment]\n+    with _RUNTIME_MAIN_COMPAT_LOCK:\n+        (\n+            _RUNTIME_MAIN_PROVIDER,\n+            _RUNTIME_MAIN_MODEL,\n+            _RUNTIME_MAIN_BASE_URL,\n+            _RUNTIME_MAIN_API_KEY,\n+            _RUNTIME_MAIN_API_MODE,\n+            _RUNTIME_MAIN_AUTH_MODE,\n+        ) = (runtime[field] for field in _MAIN_RUNTIME_FIELDS)\n+        _RUNTIME_MAIN_COMPAT_SNAPSHOT = tuple(\n+            runtime[field] for field in _MAIN_RUNTIME_FIELDS\n+        )\n+    return token\n+\n+\n+def reset_runtime_main(token: [redacted:secret-assignment] -> None:\n+    \"\"\"Restore the runtime binding that preceded one scoped turn.\"\"\"\n+    if token is None:\n+        return\n+    try:\n+        _RUNTIME_MAIN_CONTEXT.reset(token)\n+    except (RuntimeError, ValueError):\n+        # A token cannot be reset from another copied Context. Background\n+        # workers inherit values, not ownership of the parent's token.\n+        pass\n+\n+\n[redacted:email]\n+def scoped_runtime_main(main_runtime: Optional[Dict[str, Any]]):\n+    \"\"\"Temporarily bind an explicit runtime without touching legacy mirrors.\"\"\"\n+    runtime = _normalize_main_runtime(main_runtime)\n+    token = [redacted:secret-assignment] or None)\n+    try:\n+        yield runtime\n+    finally:\n+        _RUNTIME_MAIN_CONTEXT.reset(token)\n \n \n def clear_runtime_main() -> None:\n-    \"\"\"Clear the runtime override (e.g. on session end).\"\"\"\n+    \"\"\"Clear the runtime override in the current context.\"\"\"\n     global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL\n     global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE\n-    _RUNTIME_MAIN_PROVIDER = \"\"\n-    _RUNTIME_MAIN_MODEL = \"\"\n-    _RUNTIME_MAIN_BASE_URL = \"\"\n-    _RUNTIME_MAIN_API_KEY = \"\"\n-    _RUNTIME_MAIN_API_MODE = \"\"\n+    global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT\n+    _RUNTIME_MAIN_CONTEXT.set(None)\n+    with _RUNTIME_MAIN_COMPAT_LOCK:\n+        _RUNTIME_MAIN_PROVIDER = \"\"\n+        _RUNTIME_MAIN_MODEL = \"\"\n+        _RUNTIME_MAIN_BASE_URL = \"\"\n+        _RUNTIME_MAIN_API_KEY = \"\"\n+        _RUNTIME_MAIN_API_MODE = \"\"\n+        _RUNTIME_MAIN_AUTH_MODE = \"\"\n+        _RUNTIME_MAIN_COMPAT_SNAPSHOT = (\"\", \"\", \"\", \"\", \"\", \"\")\n \n \n def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:","@@ -2784,6 +2876,14 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,\n     surface as the main agent. The OpenAI SDK accepts ``Callable[[], str]``\n     for ``api_key`` and calls it before every request.\n     \"\"\"\n+    if main_runtime is None:\n+        # Context-local state is inherited by tool worker wrappers while\n+        # remaining isolated across concurrent gateway sessions. Never fall\n+        # back to compatibility mirrors here: another session may have written\n+        # them most recently, which would leak its endpoint/key into this call.\n+        main_runtime = _RUNTIME_MAIN_CONTEXT.get()\n+        if main_runtime is None:\n+            main_runtime = _compat_runtime_main()\n     if not isinstance(main_runtime, dict):\n         return {}\n     normalized: Dict[str, Any] = {}","@@ -3311,13 +3411,7 @@ def _evict_cached_clients(provider: str) -> None:\n         for key in stale_keys:\n             client = _client_cache.get(key, (None, None, None))[0]\n             if client is not None:\n-                _force_close_async_httpx(client)\n-                try:\n-                    close_fn = getattr(client, \"close\", None)\n-                    if callable(close_fn):\n-                        close_fn()\n-                except Exception:\n-                    pass\n+                _close_cached_client(client)\n             _client_cache.pop(key, None)\n \n ","@@ -4308,17 +4402,6 @@ def _resolve_auto(\n     runtime_api_key = [redacted:secret-assignment]\"api_key\", \"\")\n     runtime_api_mode = str(runtime.get(\"api_mode\") or \"\")\n \n-    # Fall back to process-local globals when main_runtime dict was not\n-    # provided or was incomplete.  ``set_runtime_main()`` now records\n-    # base_url/api_key/api_mode alongside provider/model, so custom:\n-    # providers get the full credential surface in Step 1 of the\n-    # auto-detect chain.\n-    if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:\n-        runtime_base_url = _RUNTIME_MAIN_BASE_URL\n-    if not runtime_api_key and _RUNTIME_MAIN_API_KEY:\n-        runtime_api_key = [redacted:secret-assignment]\n-    if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:\n-        runtime_api_mode = _RUNTIME_MAIN_API_MODE\n \n     # ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named\n     #    provider (not 'custom').  This catches the common \"env poisoning\"","@@ -4632,12 +4715,14 @@ def resolve_provider_client(\n     # Normalise aliases\n     provider = _normalize_aux_provider(provider)\n \n-    # Universal model-resolution fallback chain.  Callers (notably title\n-    # generation, vision, session search, and other auxiliary tasks) can\n-    # reach this function without an explicit model — the user picked their\n-    # main provider, didn't bother configuring a per-task ``auxiliary.<task>.model``,\n-    # and just expects \"use my main model for side tasks too.\"  Resolve in\n-    # this order, stopping at the first non-empty answer:\n+    # Universal model-resolution fallback for concrete providers. ``auto`` is\n+    # intentionally excluded: `_resolve_auto(main_runtime=...)` returns the\n+    # model paired with the provider it actually selected. Pre-filling an auto\n+    # call from `_read_main_model()` can leak a stale process-global runtime\n+    # into a different provider (for example Claude model slug on Codex OAuth)\n+    # and override that correctly resolved model.\n+    #\n+    # Concrete provider resolution order:\n     #\n     #   1. ``model`` argument (caller knew what they wanted)\n     #   2. Provider's catalog default — cheap/fast model the provider","@@ -5482,6 +5567,7 @@ def resolve_vision_provider_client(\n     base_url: Optional[str] = None,\n     api_key: [redacted:secret-assignment] = None,\n     async_mode: bool = False,\n+    main_runtime: Optional[Dict[str, Any]] = None,\n ) -> Tuple[Optional[str], Optional[Any], Optional[str]]:\n     \"\"\"Resolve the client actually used for vision tasks.\n ","@@ -5490,6 +5576,7 @@ def resolve_vision_provider_client(\n     backends, so users can intentionally force experimental providers. Auto mode\n     stays conservative and only tries vision backends known to work today.\n     \"\"\"\n+    runtime = _normalize_main_runtime(main_runtime)\n     requested, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(\n         \"vision\", provider, model, base_url, api_key\n     )","@@ -5515,6 +5602,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[\n             explicit_base_url=resolved_base_url,\n             explicit_api_key=[redacted:secret-assignment]\n             api_mode=resolved_api_mode,\n+            main_runtime=runtime,\n         )\n         if client is None:\n             return provider_for_base_override, None, None","@@ -5538,8 +5626,8 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[\n         #                   live from the catalog — tried when\n         #                   DEEPINFRA_API_KEY is set)\n         #   5. Stop\n-        main_provider = _read_main_provider()\n-        main_model = _read_main_model()\n+        main_provider = str(runtime.get(\"provider\") or _read_main_provider())\n+        main_model = str(runtime.get(\"model\") or _read_main_model())\n         if main_provider and main_provider not in {\"auto\", \"\"}:\n             # A provider-specific vision default wins over the user's chat model:\n             # static overrides (xiaomi/zai) and catalog-backed discovery (the","@@ -5602,10 +5690,15 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[\n                 rpc_api_key = None\n                 rpc_api_mode = resolved_api_mode\n                 if main_provider == \"custom\" or main_provider.startswith(\"custom:\"):\n-                    if _RUNTIME_MAIN_BASE_URL:\n-                        rpc_base_url = _RUNTIME_MAIN_BASE_URL\n-                        rpc_api_key = [redacted:secret-assignment] or None\n-                        rpc_api_mode = resolved_api_mode or _RUNTIME_MAIN_API_MODE or None\n+                    runtime_base_url = runtime.get(\"base_url\")\n+                    if runtime_base_url:\n+                        rpc_base_url = runtime_base_url\n+                        rpc_api_key = [redacted:secret-assignment]\"api_key\") or None\n+                        rpc_api_mode = (\n+                            resolved_api_mode\n+                            or runtime.get(\"api_mode\")\n+                            or None\n+                        )\n                     else:\n                         # No live runtime recorded (non-gateway caller): fall\n                         # back to resolving the configured custom endpoint.","@@ -5619,6 +5712,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[\n                     api_mode=rpc_api_mode,\n                     explicit_base_url=rpc_base_url,\n                     explicit_api_key=[redacted:secret-assignment]\n+                    main_runtime=runtime,\n                     is_vision=True)\n                 if rpc_client is not None:\n                     logger.info(","@@ -5661,6 +5755,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[\n                 base_url=_zai_url,\n                 api_key=[redacted:secret-assignment] or None,\n                 api_mode=\"chat_completions\",\n+                main_runtime=runtime,\n                 is_vision=True,\n             )\n             if client is not None:","@@ -5668,6 +5763,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[\n         # Fallback: try without explicit base_url (old behavior)\n         client, final_model = _get_cached_client(requested, resolved_model, async_mode,\n                                                  api_mode=resolved_api_mode,\n+                                                 main_runtime=runtime,\n                                                  is_vision=True)\n         if client is None:\n             return requested, None, None","@@ -5675,6 +5771,7 @@ def _finalize(resolved_provider: str, sync_client: Any, default_model: Optional[\n \n     client, final_model = _get_cached_client(requested, resolved_model, async_mode,\n                                              api_mode=resolved_api_mode,\n+                                             main_runtime=runtime,\n                                              is_vision=True)\n     if client is None:\n         return requested, None, None","@@ -5742,6 +5839,38 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di\n _CLIENT_CACHE_MAX_SIZE = 64  # safety belt — evict oldest when exceeded\n \n \n+class _CallableCacheDiscriminator:\n+    \"\"\"Hash a credential callback by identity without exposing its state.\"\"\"\n+\n+    __slots__ = (\"_callback\",)\n+\n+    def __init__(self, callback: Any) -> None:\n+        # Retain the callback so its id cannot be reused while cached.\n+        self._callback = callback\n+\n+    def __hash__(self) -> int:\n+        return id(self._callback)\n+\n+    def __eq__(self, other: object) -> bool:\n+        return (\n+            isinstance(other, _CallableCacheDiscriminator)\n+            and self._callback is other._callback\n+        )\n+\n+    def __repr__(self) -> str:\n+        return \"<callable-api-key>\"\n+\n+\n+def _runtime_cache_discriminator(field: str, value: Any) -> Any:\n+    \"\"\"Return a hashable, secret-safe runtime cache-key component.\"\"\"\n+    if field == \"api_key\" and callable(value):\n+        return _CallableCacheDiscriminator(value)\n+    if field == \"api_key\" and isinstance(value, str) and value:\n+        digest = hashlib.blake2b(value.encode(\"utf-8\"), digest_size=16).digest()\n+        return (\"api-key-digest\", digest)\n+    return value\n+\n+\n def _client_cache_key(\n     provider: str,\n     *,","@@ -5755,7 +5884,10 @@ def _client_cache_key(\n     model: Optional[str] = None,\n ) -> tuple:\n     runtime = _normalize_main_runtime(main_runtime)\n-    runtime_key = tuple(runtime.get(field, \"\") for field in _MAIN_RUNTIME_FIELDS) if provider == \"auto\" else ()\n+    runtime_key = tuple(\n+        _runtime_cache_discriminator(field, runtime.get(field, \"\"))\n+        for field in _MAIN_RUNTIME_FIELDS\n+    ) if provider == \"auto\" else ()\n     # `auto` can now resolve through task-specific or main fallback policy,\n     # so the task participates in the cache key. Non-auto providers keep the\n     # old cache shape because the explicit provider/model tuple is sufficient.","@@ -5770,21 +5902,16 @@ def _client_cache_key(\n     # APIConnectionError that fails the sibling advisor (root cause of the run2\n     # double-advisor \"Connection error\" collapse). Keying on model gives each\n     # model its own client, so concurrent fan-out calls never cross-close.\n-    model_key = model or \"\"\n-    return (provider, async_mode, base_url or \"\", api_key or \"\", api_mode or \"\", runtime_key, is_vision, task_key, pool_hint, model_key)\n+    model_key = model or runtime.get(\"model\", \"\")\n+    api_key_key = [redacted:secret-assignment]\"api_key\", api_key or \"\")\n+    return (provider, async_mode, base_url or \"\", api_key_key, api_mode or \"\", runtime_key, is_vision, task_key, pool_hint, model_key)\n \n \n def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:\n     with _client_cache_lock:\n         old_entry = _client_cache.get(cache_key)\n         if old_entry is not None and old_entry[0] is not client:\n-            _force_close_async_httpx(old_entry[0])\n-            try:\n-                close_fn = getattr(old_entry[0], \"close\", None)\n-                if callable(close_fn):\n-                    close_fn()\n-            except Exception:\n-                pass\n+            _close_cached_client(old_entry[0])\n         _client_cache[cache_key] = (client, default_model, bound_loop)\n \n ","@@ -5886,30 +6013,31 @@ def _force_close_async_httpx(client: Any) -> None:\n         pass\n \n \n+def _close_cached_client(client: Any) -> None:\n+    \"\"\"Apply the canonical best-effort close policy to one cached client.\"\"\"\n+    if client is None:\n+        return\n+    _force_close_async_httpx(client)\n+    try:\n+        close_fn = getattr(client, \"close\", None)\n+        if callable(close_fn) and not inspect.iscoroutinefunction(close_fn):\n+            close_fn()\n+    except Exception:\n+        pass\n+\n+\n def shutdown_cached_clients() -> None:\n     \"\"\"Close all cached clients (sync and async) to prevent event-loop errors.\n \n     Call this during CLI shutdown, *before* the event loop is closed, to\n     avoid ``AsyncHttpxClientWrapper.__del__`` raising on a dead loop.\n     \"\"\"\n-    import inspect\n-\n     with _client_cache_lock:\n         for key, entry in list(_client_cache.items()):\n             client = entry[0]\n             if client is None:\n                 continue\n-            # Mark any async httpx transport as closed first (prevents __del__\n-            # from scheduling aclose() on a dead event loop).\n-            _force_close_async_httpx(client)\n-            # Sync clients: close the httpx connection pool cleanly.\n-            # Async clients: skip — we already neutered __del__ above.\n-            try:\n-                close_fn = getattr(client, \"close\", None)\n-                if close_fn and not inspect.iscoroutinefunction(close_fn):\n-                    close_fn()\n-            except Exception:\n-                pass\n+            _close_cached_client(client)\n         _client_cache.clear()\n \n ","@@ -6059,13 +6187,20 @@ def _get_cached_client(\n             if cache_key not in _client_cache:\n                 # Safety belt: if the cache has grown beyond the max, evict\n                 # the oldest entries (FIFO — dict preserves insertion order).\n+                # Do not close an evicted client here: another caller may be\n+                # mid-request with the object it obtained from this cache.\n+                # Dropping the cache reference lets normal refcount/GC cleanup\n+                # happen after in-flight users release it.\n                 while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE:\n-                    evict_key, evict_entry = next(iter(_client_cache.items()))\n-                    _force_close_async_httpx(evict_entry[0])\n+                    evict_key = next(iter(_client_cache))\n                     del _client_cache[evict_key]\n                 _client_cache[cache_key] = (client, default_model, bound_loop)\n             else:\n+                built_client = client\n                 client, default_model, _ = _client_cache[cache_key]\n+                # This concurrently built loser was never exposed to a caller,\n+                # so it is safe to close immediately.\n+                _close_cached_client(built_client)\n     return client, model or default_model\n \n ","@@ -6818,6 +6953,11 @@ def call_llm(\n     Raises:\n         RuntimeError: If no provider is configured.\n     \"\"\"\n+    # Capture one immutable runtime snapshot for keying, resolution, retries,\n+    # and fallbacks. Reading ambient state independently in each phase lets a\n+    # concurrent /model switch produce a key for one runtime and a client for\n+    # another.\n+    main_runtime = _normalize_main_runtime(main_runtime)\n     resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(\n         task, provider, model, base_url, api_key)\n     if api_mode:","@@ -6832,6 +6972,7 @@ def call_llm(\n             base_url=resolved_base_url or base_url,\n             api_key=[redacted:secret-assignment] or api_key,\n             async_mode=False,\n+            main_runtime=main_runtime,\n         )\n         if client is None and resolved_provider != \"auto\" and not resolved_base_url:\n             logger.warning(","@@ -6842,6 +6983,7 @@ def call_llm(\n                 provider=\"auto\",\n                 model=resolved_model,\n                 async_mode=False,\n+                main_runtime=main_runtime,\n             )\n         if client is None:\n             raise RuntimeError(","@@ -7444,6 +7586,9 @@ async def async_call_llm(\n \n     Same as call_llm() but async. See call_llm() for full documentation.\n     \"\"\"\n+    # Keep every async phase on the same runtime identity, even if another\n+    # session switches models while this task is awaiting network I/O.\n+    main_runtime = _normalize_main_runtime(main_runtime)\n     resolved_provider, resolved_model, resolved_base_url, resolved_api_key, resolved_api_mode = _resolve_task_provider_model(\n         task, provider, model, base_url, api_key)\n     effective_extra_body = _get_task_extra_body(task)","@@ -7456,6 +7601,7 @@ async def async_call_llm(\n             base_url=resolved_base_url or base_url,\n             api_key=[redacted:secret-assignment] or api_key,\n             async_mode=True,\n+            main_runtime=main_runtime,\n         )\n         if client is None and resolved_provider != \"auto\" and not resolved_base_url:\n             logger.warning(","@@ -7466,6 +7612,7 @@ async def async_call_llm(\n                 provider=\"auto\",\n                 model=resolved_model,\n                 async_mode=True,\n+                main_runtime=main_runtime,\n             )\n         if client is None:\n             raise RuntimeError(","@@ -7481,6 +7628,7 @@ async def async_call_llm(\n             base_url=resolved_base_url,\n             api_key=[redacted:secret-assignment]\n             api_mode=resolved_api_mode,\n+            main_runtime=main_runtime,\n         )\n         if client is None:\n             _explicit = (resolved_provider or \"\").strip().lower()"]},{"file":"agent/context_compressor.py","hunks":["@@ -1175,6 +1175,9 @@ def update_model(\n         if runtime_changed:\n             self._fallback_compression_streak = 0\n             self._persist_fallback_compression_streak()\n+            # Failure cooldowns are scoped to the model/provider that failed.\n+            # A switch must give the new runtime an immediate summary attempt.\n+            self._clear_compression_failure_cooldown()\n         self._verify_compaction_cleared_threshold = False\n         self._last_compression_made_progress = False\n ","@@ -1260,7 +1263,6 @@ def _compute_threshold_tokens(\n             return max(1, min(int(effective_window * ContextCompressor._MIN_CTX_TRIGGER_RATIO),\n                               effective_window - 1))\n         return floored\n-\n     def __init__(\n         self,\n         model: str,"]},{"file":"agent/image_routing.py","hunks":["@@ -267,10 +267,12 @@ def _resolve_inference_base_url(\n ) -> str:\n     \"\"\"Best-effort base URL for the active inference provider.\"\"\"\n     try:\n-        from agent.auxiliary_client import _RUNTIME_MAIN_BASE_URL\n+        from agent.auxiliary_client import _runtime_main_value\n \n-        runtime = str(_RUNTIME_MAIN_BASE_URL or \"\").strip()\n-        if runtime:\n+        runtime = str(_runtime_main_value(\"base_url\") or \"\").strip()\n+        runtime_provider = str(_runtime_main_value(\"provider\") or \"\").strip().lower()\n+        requested_provider = str(provider or \"\").strip().lower()\n+        if runtime and (not requested_provider or requested_provider == runtime_provider):\n             return runtime\n     except Exception:\n         pass"]},{"file":"agent/turn_context.py","hunks":["@@ -151,7 +151,17 @@ def build_turn_context(\n     # null; rebuilding from scratch\" warning and a needless first-turn prefix\n     # cache miss. (Issue #45499.)\n \n-    # Tell auxiliary_client what the live main provider/model are for this turn.\n+    # Tag log records on this thread with the session ID for ``hermes logs``.\n+    set_session_context(agent.session_id)\n+\n+    # Bind the skill write-origin ContextVar for this thread.\n+    set_current_write_origin(getattr(agent, \"_memory_write_origin\", \"assistant_tool\"))\n+\n+    # Restore the primary runtime if the previous turn activated fallback.\n+    agent._restore_primary_runtime()\n+\n+    # Tell auxiliary_client what the live main provider/model are for this turn\n+    # after primary restoration has settled the runtime.\n     try:\n         from agent.auxiliary_client import set_runtime_main\n         set_runtime_main(","@@ -160,19 +170,11 @@ def build_turn_context(\n             base_url=getattr(agent, \"base_url\", \"\") or \"\",\n             api_key=[redacted:secret-assignment] \"api_key\", \"\") or \"\",\n             api_mode=getattr(agent, \"api_mode\", \"\") or \"\",\n+            auth_mode=getattr(agent, \"auth_mode\", \"\") or \"\",\n         )\n     except Exception:\n         pass\n \n-    # Tag log records on this thread with the session ID for ``hermes logs``.\n-    set_session_context(agent.session_id)\n-\n-    # Bind the skill write-origin ContextVar for this thread.\n-    set_current_write_origin(getattr(agent, \"_memory_write_origin\", \"assistant_tool\"))\n-\n-    # Restore the primary runtime if the previous turn activated fallback.\n-    agent._restore_primary_runtime()\n-\n     # Between-turns MCP refresh: an MCP server that finished connecting since\n     # the previous turn (slow HTTP/OAuth servers routinely take 2-6s on a cold\n     # connect, missing the bounded startup wait) lands in THIS turn's tool"]},{"file":"gateway/run.py","hunks":["@@ -10860,10 +10860,30 @@ async def _prepare_inbound_message_text(\n                         \"Image routing: text (mode=%s). Pre-analyzing %d image(s) via vision_analyze.\",\n                         _img_mode, len(image_paths),\n                     )\n-                    message_text = await self._enrich_message_with_vision(\n-                        message_text,\n-                        image_paths,\n-                    )\n+                    # Vision enrichment runs before AIAgent.run_conversation(),\n+                    # so bind this session's resolved runtime explicitly rather\n+                    # than consulting process-global compatibility mirrors.\n+                    vision_runtime = None\n+                    try:\n+                        turn_model, runtime_kwargs = self._resolve_session_agent_runtime(\n+                            source=source,\n+                            session_key=session_key,\n+                        )\n+                        vision_runtime = dict(runtime_kwargs or {})\n+                        vision_runtime[\"model\"] = turn_model\n+                    except Exception:\n+                        logger.debug(\n+                            \"vision enrichment: session runtime resolution failed\",\n+                            exc_info=True,\n+                        )\n+\n+                    from agent.auxiliary_client import scoped_runtime_main\n+\n+                    with scoped_runtime_main(vision_runtime):\n+                        message_text = await self._enrich_message_with_vision(\n+                            message_text,\n+                            image_paths,\n+                        )\n \n             if audio_paths:\n                 message_text, _successful_transcripts = await self._enrich_message_with_transcription("]},{"file":"run_agent.py","hunks":["@@ -1199,6 +1199,7 @@ def _current_main_runtime(self) -> Dict[str, str]:\n             \"base_url\": getattr(self, \"base_url\", \"\") or \"\",\n             \"api_key\": getattr(self, \"api_key\", \"\") or \"\",\n             \"api_mode\": getattr(self, \"api_mode\", \"\") or \"\",\n+            \"auth_mode\": getattr(self, \"auth_mode\", \"\") or \"\",\n         }\n \n     def _check_compression_model_feasibility(self) -> None:","@@ -6201,21 +6202,28 @@ def run_conversation(\n         acct_token = [redacted:secret-assignment]\n             getattr(self, \"_session_db\", None), getattr(self, \"session_id\", None)\n         )\n-        try:\n-            return run_conversation(\n-                self,\n-                user_message,\n-                system_message,\n-                conversation_history,\n-                task_id,\n-                stream_callback,\n-                persist_user_message,\n-                persist_user_timestamp=persist_user_timestamp,\n-                moa_config=moa_config,\n-            )\n-        finally:\n-            reset_accounting_context(acct_token)\n-            reset_conversation_context(token)\n+        from agent.auxiliary_client import scoped_runtime_main\n+\n+        # The outer token restores the caller's Context even though turn setup\n+        # replaces the value with the live runtime after fallback restoration.\n+        # Keep the scope local instead of storing ContextVar tokens on the agent,\n+        # which may be observed from another thread.\n+        with scoped_runtime_main({}):\n+            try:\n+                return run_conversation(\n+                    self,\n+                    user_message,\n+                    system_message,\n+                    conversation_history,\n+                    task_id,\n+                    stream_callback,\n+                    persist_user_message,\n+                    persist_user_timestamp=persist_user_timestamp,\n+                    moa_config=moa_config,\n+                )\n+            finally:\n+                reset_accounting_context(acct_token)\n+                reset_conversation_context(token)\n \n     def chat(self, message: str, stream_callback: Optional[callable] = None) -> str:\n         \"\"\""]},{"file":"scripts/release.py","hunks":["@@ -371,6 +371,8 @@\n     \"[redacted:email]\": \"synapsesx\",\n     \"[redacted:email]\": \"itsflownium\",\n     \"[redacted:email]\": \"dirtyren\",\n+    \"[redacted:email]\": \"krowd3v\",\n+    \"[redacted:email]\": \"dfein38347g\",\n     \"[redacted:email]\": \"s96919\",\n     \"[redacted:email]\": \"rasitakyol\",\n     \"[redacted:email]\": \"Thatgfsj\","]},{"file":"tests/agent/test_auxiliary_runtime_cache_key.py","hunks":["@@ -0,0 +1,365 @@\n+\"\"\"Regression coverage for implicit live-runtime auxiliary cache keys.\n+\n+#49151/#49156 is specifically the ``provider='auto'`` path where callers omit\n+``main_runtime`` after a mid-session model switch.  This is distinct from\n+#56889, which isolates callers that pass different explicit ``model=`` values.\n+\"\"\"\n+\n+import asyncio\n+from concurrent.futures import ThreadPoolExecutor\n+from threading import Barrier\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+import agent.auxiliary_client as aux\n+\n+\n[redacted:email](autouse=True)\n+def _clean_aux_state():\n+    aux.shutdown_cached_clients()\n+    aux.clear_runtime_main()\n+    yield\n+    aux.shutdown_cached_clients()\n+    aux.clear_runtime_main()\n+\n+\n+def _runtime(model: str, *, provider: str = \"custom:llama-swap\") -> dict:\n+    return {\n+        \"provider\": provider,\n+        \"model\": model,\n+        \"base_url\": \"http://llama-swap.test/v1\",\n+        \"api_key\": \"local-key\",\n+        \"api_mode\": \"chat_completions\",\n+        \"auth_mode\": \"api_key\",\n+    }\n+\n+\n+def test_implicit_auto_cache_rebuilds_after_runtime_model_switch():\n+    \"\"\"A /model switch must not reuse the old implicit-auto cache entry.\"\"\"\n+    built = []\n+\n+    def fake_resolve(_provider, _model, _async_mode, *, main_runtime, **_kwargs):\n+        client = MagicMock(name=f\"client-{main_runtime['model']}\")\n+        built.append((client, dict(main_runtime)))\n+        return client, main_runtime[\"model\"]\n+\n+    with patch.object(aux, \"resolve_provider_client\", side_effect=fake_resolve):\n+        aux.set_runtime_main(**_runtime(\"qwen35b-code\"))\n+        first_client, first_model = aux._get_cached_client(\"auto\")\n+\n+        aux.set_runtime_main(**_runtime(\"qwen27b-code\"))\n+        second_client, second_model = aux._get_cached_client(\"auto\")\n+\n+    assert first_model == \"qwen35b-code\"\n+    assert second_model == \"qwen27b-code\"\n+    assert second_client is not first_client\n+    assert [runtime[\"model\"] for _, runtime in built] == [\n+        \"qwen35b-code\",\n+        \"qwen27b-code\",\n+    ]\n+\n+\n+def test_implicit_runtime_cache_key_covers_full_connection_and_auth_surface():\n+    \"\"\"Provider/endpoint/credential/wire/auth changes all isolate auto clients.\"\"\"\n+    base = _runtime(\"same-model\")\n+    variants = [\n+        {**base, \"provider\": \"custom:other\"},\n+        {**base, \"base_url\": \"https://other.test/v1\"},\n+        {**base, \"api_key\": \"other-key\"},\n+        {**base, \"api_mode\": \"codex_responses\"},\n+        {**base, \"auth_mode\": \"entra_id\", \"api_key\": lambda: \"token\"},\n+    ]\n+\n+    aux.set_runtime_main(**base)\n+    baseline = aux._client_cache_key(\"auto\", async_mode=False)\n+    keys = []\n+    for variant in variants:\n+        aux.set_runtime_main(**variant)\n+        keys.append(aux._client_cache_key(\"auto\", async_mode=False))\n+\n+    assert all(key != baseline for key in keys)\n+    assert len(set(keys)) == len(keys)\n+\n+\n+def test_implicit_runtime_is_isolated_between_concurrent_session_contexts():\n+    \"\"\"Concurrent gateway sessions must not read each other's live runtime.\"\"\"\n+    barrier = Barrier(2)\n+\n+    def session(model: str):\n+        aux.set_runtime_main(**_runtime(model))\n+        barrier.wait()\n+        normalized = aux._normalize_main_runtime(None)\n+        return normalized[\"model\"], aux._client_cache_key(\"auto\", async_mode=False)\n+\n+    with ThreadPoolExecutor(max_workers=2) as pool:\n+        first = pool.submit(session, \"session-a-model\")\n+        second = pool.submit(session, \"session-b-model\")\n+        model_a, key_a = first.result()\n+        model_b, key_b = second.result()\n+\n+    assert model_a == \"session-a-model\"\n+    assert model_b == \"session-b-model\"\n+    assert key_a != key_b\n+\n+\n+def test_context_without_runtime_does_not_fall_back_to_other_session_globals():\n+    \"\"\"A fresh context must not inherit another session's compatibility mirrors.\"\"\"\n+    aux.set_runtime_main(**_runtime(\"other-session-model\"))\n+\n+    def fresh_context():\n+        return aux._normalize_main_runtime(None)\n+\n+    import contextvars\n+\n+    assert contextvars.Context().run(fresh_context) == {}\n+\n+\n+def test_runtime_context_token_restores_previous_value_after_turn():\n+    \"\"\"Turn-scoped runtime binding must not leak into later work in the same context.\"\"\"\n+    token = [redacted:secret-assignment]\"turn-model\"))\n+    assert aux._normalize_main_runtime(None)[\"model\"] == \"turn-model\"\n+\n+    aux.reset_runtime_main(token)\n+\n+    assert aux._normalize_main_runtime(None) == {}\n+\n+\n+def test_aiagent_wrapper_resets_runtime_context_after_turn():\n+    \"\"\"Every production run_conversation exit restores the caller's Context.\"\"\"\n+    from run_agent import AIAgent\n+\n+    agent = SimpleNamespace(\n+        _conversation_root_id=lambda: \"root-session\",\n+        _session_db=None,\n+        session_id=\"session-id\",\n+    )\n+\n+    def fake_turn(*_args, **_kwargs):\n+        aux.set_runtime_main(**_runtime(\"wrapped-turn\"))\n+        return {\"final_response\": \"ok\"}\n+\n+    with patch(\"agent.conversation_loop.run_conversation\", side_effect=fake_turn):\n+        result = AIAgent.run_conversation(agent, \"hello\")\n+\n+    assert result[\"final_response\"] == \"ok\"\n+    assert aux._normalize_main_runtime(None) == {}\n+\n+\n+def test_legacy_patched_globals_are_visible_only_without_an_active_runtime():\n+    \"\"\"Direct legacy patches work, but never override context-local session state.\"\"\"\n+    with patch.object(aux, \"_RUNTIME_MAIN_PROVIDER\", \"custom:legacy\"), patch.object(\n+        aux, \"_RUNTIME_MAIN_MODEL\", \"legacy-model\"\n+    ), patch.object(\n+        aux, \"_RUNTIME_MAIN_BASE_URL\", \"https://legacy.test/v1\"\n+    ):\n+        assert aux._normalize_main_runtime(None)[\"model\"] == \"legacy-model\"\n+\n+        aux.set_runtime_main(**_runtime(\"active-session-model\"))\n+        runtime = aux._normalize_main_runtime(None)\n+\n+    assert runtime[\"model\"] == \"active-session-model\"\n+    assert runtime[\"base_url\"] == \"http://llama-swap.test/v1\"\n+\n+\n+def test_concurrent_vision_probes_use_each_sessions_endpoint_and_model():\n+    \"\"\"Vision auto-routing must not mix custom endpoints across sessions.\"\"\"\n+    barrier = Barrier(2)\n+\n+    def fake_resolve(provider, model, **kwargs):\n+        barrier.wait()\n+        client = MagicMock()\n+        client.probed_base_url = kwargs.get(\"explicit_base_url\")\n+        return client, model\n+\n+    def probe(model: str, base_url: str):\n+        runtime = _runtime(model)\n+        runtime[\"base_url\"] = base_url\n+        aux.set_runtime_main(**runtime)\n+        provider, client, resolved_model = aux.resolve_vision_provider_client()\n+        assert client is not None\n+        return provider, resolved_model, client.probed_base_url\n+\n+    with patch.object(\n+        aux, \"_resolve_task_provider_model\", return_value=(\"auto\", None, None, None, None)\n+    ), patch.object(aux, \"_main_model_supports_vision\", return_value=True), patch.object(\n+        aux, \"resolve_provider_client\", side_effect=fake_resolve\n+    ):\n+        with ThreadPoolExecutor(max_workers=2) as pool:\n+            first = pool.submit(probe, \"vision-a\", \"https://a.test/v1\")\n+            second = pool.submit(probe, \"vision-b\", \"https://b.test/v1\")\n+            result_a = first.result()\n+            result_b = second.result()\n+\n+    assert result_a == (\"custom:llama-swap\", \"vision-a\", \"https://a.test/v1\")\n+    assert result_b == (\"custom:llama-swap\", \"vision-b\", \"https://b.test/v1\")\n+\n+\n+def test_explicit_model_cache_isolation_remains_independent_of_runtime_key():\n+    \"\"\"#56889 remains covered: explicit model values isolate non-auto clients.\"\"\"\n+    first = aux._client_cache_key(\n+        \"openrouter\", async_mode=False, model=\"anthropic/claude-opus-4.8\"\n+    )\n+    second = aux._client_cache_key(\n+        \"openrouter\", async_mode=False, model=\"openai/gpt-5.5\"\n+    )\n+\n+    assert first != second\n+\n+\n+def test_pinned_provider_without_model_inherits_live_runtime_model_in_cache_key():\n+    \"\"\"A pinned provider with model=auto must follow the switched main model.\"\"\"\n+    first = aux._client_cache_key(\n+        \"openrouter\",\n+        async_mode=False,\n+        main_runtime=_runtime(\"old-model\", provider=\"openrouter\"),\n+    )\n+    second = aux._client_cache_key(\n+        \"openrouter\",\n+        async_mode=False,\n+        main_runtime=_runtime(\"new-model\", provider=\"openrouter\"),\n+    )\n+\n+    assert first != second\n+\n+\n+def test_explicit_vision_runtime_wins_over_stale_ambient_runtime():\n+    \"\"\"Vision resolution must use the immutable runtime supplied by its caller.\"\"\"\n+    aux.set_runtime_main(**_runtime(\"ambient-old\"))\n+    explicit = _runtime(\"explicit-new\")\n+    captured = {}\n+\n+    def fake_resolve(provider, model, **kwargs):\n+        captured.update(provider=provider, model=model, **kwargs)\n+        return MagicMock(), model\n+\n+    with patch.object(\n+        aux, \"_resolve_task_provider_model\", return_value=(\"auto\", None, None, None, None)\n+    ), patch.object(aux, \"_main_model_supports_vision\", return_value=True), patch.object(\n+        aux, \"resolve_provider_client\", side_effect=fake_resolve\n+    ):\n+        provider, _client, model = aux.resolve_vision_provider_client(\n+            main_runtime=explicit\n+        )\n+\n+    assert provider == \"custom:llama-swap\"\n+    assert model == \"explicit-new\"\n+    assert captured[\"explicit_base_url\"] == \"http://llama-swap.test/v1\"\n+\n+\n+def test_image_routing_does_not_borrow_base_url_from_different_provider():\n+    \"\"\"An explicit provider must not inherit another runtime's custom endpoint.\"\"\"\n+    from agent.image_routing import _resolve_inference_base_url\n+\n+    aux.set_runtime_main(**_runtime(\"custom-model\"))\n+    cfg = {\n+        \"model\": {\n+            \"provider\": \"openrouter\",\n+            \"base_url\": \"https://openrouter.ai/api/v1\",\n+        }\n+    }\n+\n+    assert (\n+        _resolve_inference_base_url(cfg, \"openrouter\")\n+        == \"https://openrouter.ai/api/v1\"\n+    )\n+\n+\n+def test_async_initial_cache_lookup_receives_explicit_runtime_snapshot():\n+    \"\"\"The first async lookup must not drop main_runtime and only pass it on fallback.\"\"\"\n+    runtime = _runtime(\"async-new\")\n+    response = MagicMock()\n+    response.choices = [MagicMock(message=MagicMock(content=\"ok\"))]\n+    client = MagicMock()\n+    client.chat.completions.create = AsyncMock(return_value=response)\n+\n+    with patch.object(\n+        aux,\n+        \"_resolve_task_provider_model\",\n+        return_value=(\"openrouter\", None, None, None, None),\n+    ), patch.object(aux, \"_get_cached_client\", return_value=(client, \"async-new\")) as get_client:\n+        asyncio.run(\n+            aux.async_call_llm(\n+                task=\"approval\",\n+                main_runtime=runtime,\n+                messages=[{\"role\": \"user\", \"content\": \"approve?\"}],\n+            )\n+        )\n+\n+    assert get_client.call_args.kwargs[\"main_runtime\"] == aux._normalize_main_runtime(runtime)\n+\n+\n+def test_unhashable_callable_runtime_api_keys_are_safe_secret_free_discriminators():\n+    \"\"\"Callable token providers remain cacheable without leaking returned tokens.\"\"\"\n+\n+    class TokenProvider(list):\n+        def __init__(self, token: str):\n+            super().__init__()\n+            self.token = token\n+\n+        def __call__(self) -> str:\n+            return self.token\n+\n+    first_provider = TokenProvider(\"first-super-secret-token\")\n+    second_provider = TokenProvider(\"second-super-secret-token\")\n+\n+    first = aux._client_cache_key(\n+        \"auto\", async_mode=False, main_runtime={**_runtime(\"same\"), \"api_key\": first_provider}\n+    )\n+    second = aux._client_cache_key(\n+        \"auto\", async_mode=False, main_runtime={**_runtime(\"same\"), \"api_key\": second_provider}\n+    )\n+\n+    hash(first)\n+    hash(second)\n+    assert first != second\n+    rendered = repr((first, second))\n+    assert \"first-super-secret-token\" not in rendered\n+    assert \"second-super-secret-token\" not in rendered\n+\n+\n+def test_string_api_keys_are_not_retained_in_cache_key_repr():\n+    \"\"\"String credentials discriminate clients without living in cache-key memory.\"\"\"\n+    first_secret = \"[redacted:secret-assignment]\"\n+    second_secret = \"[redacted:secret-assignment]\"\n+    first = aux._client_cache_key(\n+        \"auto\",\n+        async_mode=False,\n+        api_key=[redacted:secret-assignment]\n+        main_runtime={**_runtime(\"same\"), \"api_key\": first_secret},\n+    )\n+    second = aux._client_cache_key(\n+        \"auto\",\n+        async_mode=False,\n+        api_key=[redacted:secret-assignment]\n+        main_runtime={**_runtime(\"same\"), \"api_key\": second_secret},\n+    )\n+\n+    assert first != second\n+    rendered = repr((first, second))\n+    assert first_secret not in rendered\n+    assert second_secret not in rendered\n+\n+\n+def test_fifo_eviction_does_not_close_client_that_may_have_an_inflight_call():\n+    \"\"\"A bounded-cache eviction must not invalidate another caller's client.\"\"\"\n+    clients = []\n+\n+    def fake_resolve(_provider, model, _async_mode, **_kwargs):\n+        client = MagicMock(name=f\"client-{model}\")\n+        clients.append(client)\n+        return client, model\n+\n+    with patch.object(aux, \"resolve_provider_client\", side_effect=fake_resolve):\n+        for index in range(65):\n+            aux._get_cached_client(\"custom\", model=f\"model-{index}\")\n+\n+    assert len(aux._client_cache) == 64\n+    for client in clients:\n+        client.close.assert_not_called()\n+\n+    aux.shutdown_cached_clients()\n+    clients[0].close.assert_not_called()\n+    for client in clients[1:]:\n+        client.close.assert_called_once_with()"]},{"file":"tests/agent/test_context_compressor.py","hunks":["@@ -3039,6 +3039,31 @@ def test_defer_no_longer_suppresses_after_switch(self):\n         assert comp.should_defer_preflight_to_real_usage(comp.threshold_tokens + 5_000) is False\n \n \n+    def test_summary_failure_cooldown_cleared(self):\n+        \"\"\"Stale summary-failure cooldown from the old model must not block\n+        the new model from generating summaries after a switch.\"\"\"\n+        import time\n+        comp = self._comp()\n+        # Simulate a 600-second cooldown set because the old model had no\n+        # provider configured for summarization.\n+        comp._summary_failure_cooldown_until = time.monotonic() + 600\n+\n+        comp.update_model(\"new-model\", context_length=128_000)\n+\n+        assert comp._summary_failure_cooldown_until == 0.0\n+\n+    def test_summary_failure_cooldown_survives_same_runtime_refresh(self):\n+        \"\"\"Refreshing metadata for the same runtime must not defeat backoff.\"\"\"\n+        import time\n+        comp = self._comp()\n+        cooldown_until = time.monotonic() + 600\n+        comp._summary_failure_cooldown_until = cooldown_until\n+\n+        comp.update_model(\"big-model\", context_length=128_000)\n+\n+        assert comp._summary_failure_cooldown_until == cooldown_until\n+\n+\n class TestTruncateToolCallArgsJson:\n     \"\"\"Regression tests for #11762.\n "]},{"file":"tests/agent/test_turn_context.py","hunks":["@@ -259,6 +259,40 @@ def test_pending_cli_message_uses_clean_override_for_api_local_note():\n     assert agent._pending_cli_user_message is None\n \n \n+def test_runtime_main_sync_happens_after_restore():\n+    agent = _FakeAgent()\n+    agent.model = \"stale-fallback-model\"\n+    agent.provider = \"openai-codex\"\n+    agent.base_url = \"https://chatgpt.com/backend-api/codex\"\n+    agent.api_key = \"[redacted:secret-assignment]\"\n+    agent.api_mode = \"codex_responses\"\n+\n+    def restore_primary():\n+        agent.model = \"primary-model\"\n+        agent.provider = \"anthropic\"\n+        agent.base_url = \"https://api.anthropic.com\"\n+        agent.api_key = \"[redacted:secret-assignment]\"\n+        agent.api_mode = \"anthropic_messages\"\n+\n+    agent._restore_primary_runtime = restore_primary\n+    calls = []\n+    with patch(\n+        \"agent.auxiliary_client.set_runtime_main\",\n+        side_effect=lambda *args, **kwargs: calls.append((args, kwargs)),\n+    ):\n+        _build(agent)\n+\n+    assert calls == [(\n+        (\"anthropic\", \"primary-model\"),\n+        {\n+            \"base_url\": \"https://api.anthropic.com\",\n+            \"api_key\": \"primary-key\",\n+            \"api_mode\": \"anthropic_messages\",\n+            \"auth_mode\": \"\",\n+        },\n+    )]\n+\n+\n def test_memory_nudge_fires_at_interval():\n     agent = _FakeAgent()\n     agent._memory_nudge_interval = 1"]},{"file":"tests/gateway/test_image_input_routing_runtime.py","hunks":["@@ -123,8 +123,13 @@ def fake_supports(provider, model, config):\n     monkeypatch.setattr(\"agent.image_routing._lookup_supports_vision\", fake_supports)\n \n     async def fake_enrich(user_text, image_paths):\n+        from agent import auxiliary_client as aux\n+\n         assert user_text == \"look\"\n         assert image_paths == [\"/tmp/cashback.png\"]\n+        runtime = aux._normalize_main_runtime(None)\n+        assert runtime[\"provider\"] == \"xiaomi\"\n+        assert runtime[\"model\"] == \"mimo-v2.5-pro\"\n         return \"[vision summary]\\n\\nlook\"\n \n     monkeypatch.setattr(runner, \"_enrich_message_with_vision\", fake_enrich)"]},{"file":"tests/run_agent/test_compression_feasibility.py","hunks":["@@ -167,6 +167,7 @@ def test_feasibility_check_passes_live_main_runtime():\n             \"base_url\": \"https://chatgpt.com/backend-api/codex\",\n             \"api_key\": \"codex-token\",\n             \"api_mode\": \"codex_responses\",\n+            \"auth_mode\": \"\",\n         },\n     )\n "]},{"file":"tests/tui_gateway/test_image_routing_stale_model.py","hunks":["@@ -0,0 +1,34 @@\n+\"\"\"Regression coverage for live TUI image-routing identity.\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+from tui_gateway.server import _active_image_routing_identity\n+\n+\n+def test_live_agent_identity_wins_after_model_switch():\n+    agent = SimpleNamespace(provider=\"alibaba\", model=\"qwen3.7-plus\")\n+\n+    with patch(\n+        \"agent.auxiliary_client._read_main_provider\",\n+        side_effect=AssertionError(\"stale provider fallback used\"),\n+    ), patch(\n+        \"agent.auxiliary_client._read_main_model\",\n+        side_effect=AssertionError(\"stale model fallback used\"),\n+    ):\n+        identity = _active_image_routing_identity(agent)\n+\n+    assert identity == (\"alibaba\", \"qwen3.7-plus\")\n+\n+\n+def test_missing_agent_identity_uses_runtime_fallback():\n+    agent = SimpleNamespace(provider=\"\", model=None)\n+\n+    with patch(\n+        \"agent.auxiliary_client._read_main_provider\", return_value=\"openai-codex\"\n+    ), patch(\n+        \"agent.auxiliary_client._read_main_model\", return_value=\"gpt-5.5-codex\"\n+    ):\n+        identity = _active_image_routing_identity(agent)\n+\n+    assert identity == (\"openai-codex\", \"gpt-5.5-codex\")"]},{"file":"tui_gateway/server.py","hunks":["@@ -5091,6 +5091,16 @@ def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str:\n     raise ValueError(f\"Invalid checkpoint number. Use 1-{len(checkpoints)}.\")\n \n \n+def _active_image_routing_identity(agent: Any) -> tuple[str, str]:\n+    \"\"\"Return the live provider/model, falling back before agent startup.\"\"\"\n+    from agent.auxiliary_client import _read_main_model, _read_main_provider\n+\n+    return (\n+        getattr(agent, \"provider\", \"\") or _read_main_provider(),\n+        getattr(agent, \"model\", \"\") or _read_main_model(),\n+    )\n+\n+\n def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str:\n     \"\"\"Pre-analyze attached images via vision and prepend descriptions to user text.\"\"\"\n     import asyncio, json as _json","@@ -9423,16 +9433,13 @@ def run():\n                         decide_image_input_mode,\n                         build_native_content_parts,\n                     )\n-                    from agent.auxiliary_client import (\n-                        _read_main_model,\n-                        _read_main_provider,\n-                    )\n                     from hermes_cli.config import load_config as _tui_load_config\n \n                     _cfg = _tui_load_config()\n+                    _provider, _model = _active_image_routing_identity(agent)\n                     _mode = decide_image_input_mode(\n-                        _read_main_provider(),\n-                        _read_main_model(),\n+                        _provider,\n+                        _model,\n                         _cfg,\n                     )\n                     if getattr(agent, \"api_mode\", \"\") == \"codex_app_server\":\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}