{"repo":"NousResearch/hermes-agent","public_id":"49151","display_label":"Auxiliary client cache isolation and invalidation fixes","summary":"These issues and pull requests address bugs where the auxiliary client cache was not properly invalidated or isolated when switching models, along with improvements to retry behavior and model-scoped runtime state separation.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":63521,"merged_at":"2026-07-17T16:08:31+00:00"},"resolution":{"state":"resolved","by":"pr-63521"},"freshness":{"state":"verified","last_verified_at":"2026-07-17T16:08:31+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":43516,"pr_number":63521,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-17T16:08:31+00:00"},{"issue_number":49151,"pr_number":56889,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-02T08:09:37+00:00"},{"issue_number":49151,"pr_number":63521,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-17T16:08:31+00:00"}],"members":[{"kind":"issue","number":49151,"title":"[Bug] Aux client cache does not invalidate on /model switch","url":null,"state":"closed"},{"kind":"pr","number":49278,"title":"fix(agent): invalidate aux client cache on /model switch (#49151)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49156","number":49156},{"kind":"pr","value":"#49151","number":49151}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":56889,"title":"fix(auxiliary): retry transient blips harder + isolate client cache per model","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/auxiliary_client.py","hermes_cli/config.py","tests/agent/test_auxiliary_client.py","tests/agent/test_auxiliary_transient_retry.py"],"fix_hunks":[{"file":"agent/auxiliary_client.py","hunks":["@@ -2743,7 +2743,7 @@ def _is_connection_error(exc: Exception) -> bool:\n \n \n def _is_transient_transport_error(exc: Exception) -> bool:\n-    \"\"\"Return True for a one-off transport blip worth retrying ONCE on the\n+    \"\"\"Return True for a one-off transport blip worth retrying ON the\n     same provider before any provider/model fallback.\n \n     Covers connection/streaming-close errors (via the canonical","@@ -2761,6 +2761,34 @@ def _is_transient_transport_error(exc: Exception) -> bool:\n     return isinstance(status, int) and (status == 408 or 500 <= status < 600)\n \n \n+_DEFAULT_TRANSIENT_RETRIES = 2\n+# Base for exponential backoff between transient retries (seconds). Overridable\n+# so tests can zero it out and not sleep real wall-clock time.\n+_TRANSIENT_RETRY_BACKOFF_BASE = 1.0\n+\n+\n+def _transient_retry_count() -> int:\n+    \"\"\"Number of same-provider retries for a transient transport blip.\n+\n+    Read from ``auxiliary.transient_retries`` in config.yaml (default 2 →\n+    3 total attempts). Clamped to [0, 6] to bound worst-case wall time. A\n+    connection blip to a pinned auxiliary target (e.g. a MoA reference\n+    advisor) has no meaningful provider fallback, so a couple of retries with\n+    backoff is the difference between recovering and silently losing the call.\n+    Best-effort: any config-read failure falls back to the default.\n+    \"\"\"\n+    try:\n+        from hermes_cli.config import cfg_get, load_config\n+\n+        val = cfg_get(load_config(), \"auxiliary\", \"transient_retries\")\n+        if val is None:\n+            return _DEFAULT_TRANSIENT_RETRIES\n+        n = int(val)\n+        return max(0, min(n, 6))\n+    except Exception:\n+        return _DEFAULT_TRANSIENT_RETRIES\n+\n+\n def _is_auth_error(exc: Exception) -> bool:\n     \"\"\"Detect auth failures that should trigger provider-specific refresh.\"\"\"\n     status = getattr(exc, \"status_code\", None)","@@ -5036,6 +5064,7 @@ def _client_cache_key(\n     main_runtime: Optional[Dict[str, Any]] = None,\n     is_vision: bool = False,\n     task: Optional[str] = None,\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 ()","@@ -5044,7 +5073,17 @@ def _client_cache_key(\n     # old cache shape because the explicit provider/model tuple is sufficient.\n     task_key = (task or \"\") if provider == \"auto\" else \"\"\n     pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)\n-    return (provider, async_mode, base_url or \"\", api_key or \"\", api_mode or \"\", runtime_key, is_vision, task_key, pool_hint)\n+    # The model MUST participate in the key. Two concurrent auxiliary calls to\n+    # the SAME provider/base_url/key but DIFFERENT models (e.g. a MoA reference\n+    # fan-out running opus + gpt-5.5 in parallel threads) would otherwise share\n+    # one cache entry. On a cache MISS both build a client for the same key; the\n+    # second's _store_cached_client sees the first as the \"old\" entry and CLOSES\n+    # it — while the first call is still mid-request on it — yielding a spurious\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 \n \n def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:","@@ -5100,6 +5139,7 @@ def _refresh_nous_auxiliary_client(\n         api_mode=api_mode,\n         main_runtime=main_runtime,\n         is_vision=is_vision,\n+        model=final_model,\n     )\n     _store_cached_client(cache_key, client, final_model, bound_loop=current_loop)\n     return client, final_model","@@ -5276,6 +5316,7 @@ def _get_cached_client(\n         main_runtime=main_runtime,\n         is_vision=is_vision,\n         task=task,\n+        model=model,\n     )\n     with _client_cache_lock:\n         if cache_key in _client_cache:","@@ -6005,15 +6046,22 @@ def call_llm(\n     # Handle unsupported temperature, max_tokens vs max_completion_tokens retry,\n     # then payment fallback.\n     try:\n-        # Retry ONCE on the same provider for a one-off transient transport\n-        # blip (streaming-close / incomplete chunked read / 5xx / 408) before\n-        # the except-chain below escalates to provider/model fallback. A\n-        # single dropped connection shouldn't abandon an otherwise-healthy\n-        # provider. A second failure (or any non-transient error) falls\n-        # through to ``first_err`` and the existing fallback handling\n-        # unchanged. This is the unified home for the transient retry that\n-        # every auxiliary task (compression, memory flush, title-gen,\n-        # session-search, vision) shares. (PR #16587)\n+        # Retry on the same provider for a transient transport blip\n+        # (connection reset / streaming-close / incomplete chunked read / 5xx /\n+        # 408) before the except-chain below escalates to provider/model\n+        # fallback. A dropped connection shouldn't abandon an otherwise-healthy\n+        # provider — this especially matters for pinned auxiliary calls like MoA\n+        # reference advisors, where \"fallback to another provider\" is not a\n+        # meaningful recovery (the advisor is a specific model), so a transient\n+        # blip that isn't retried simply loses that advisor for the turn (root\n+        # of the run2 double-advisor \"Connection error\" collapse — a genuine\n+        # upstream blip hitting both parallel advisors at once).\n+        #\n+        # Attempts are bounded and use exponential backoff. Count is configurable\n+        # via auxiliary.transient_retries (default 2 retries → 3 total attempts);\n+        # a second/third failure or any non-transient error falls through to\n+        # ``first_err`` and the existing fallback handling unchanged. Unified home\n+        # for the transient retry every auxiliary task shares. (PR #16587)\n         try:\n             return _validate_llm_response(\n                 client.chat.completions.create(**kwargs), task)","@@ -6035,13 +6083,26 @@ def call_llm(\n                     transient_err,\n                 )\n                 raise\n-            logger.info(\n-                \"Auxiliary %s: transient transport error; retrying once on \"\n-                \"the same provider before fallback: %s\",\n-                task or \"call\", transient_err,\n-            )\n-            return _validate_llm_response(\n-                client.chat.completions.create(**kwargs), task)\n+            _max_transient_retries = _transient_retry_count()\n+            _last_transient = transient_err\n+            for _attempt in range(1, _max_transient_retries + 1):\n+                _backoff = min(_TRANSIENT_RETRY_BACKOFF_BASE * (2.0 ** (_attempt - 1)), 8.0)\n+                logger.info(\n+                    \"Auxiliary %s: transient transport error (attempt %d/%d); \"\n+                    \"retrying same provider after %.1fs before fallback: %s\",\n+                    task or \"call\", _attempt, _max_transient_retries, _backoff,\n+                    _last_transient,\n+                )\n+                time.sleep(_backoff)\n+                try:\n+                    return _validate_llm_response(\n+                        client.chat.completions.create(**kwargs), task)\n+                except Exception as retry_transient:\n+                    if not _is_transient_transport_error(retry_transient):\n+                        raise\n+                    _last_transient = retry_transient\n+            # Retries exhausted — fall through to first_err fallback handling.\n+            raise _last_transient\n     except Exception as first_err:\n         if \"temperature\" in kwargs and _is_unsupported_temperature_error(first_err):\n             retry_kwargs = dict(kwargs)"]},{"file":"hermes_cli/config.py","hunks":["@@ -1465,6 +1465,13 @@ def _ensure_hermes_home_managed(home: Path):\n     # Each aux task is independent — main-agent provider_routing and\n     # openrouter.min_coding_score do NOT propagate to aux calls by design.\n     \"auxiliary\": {\n+        # Same-provider retries for a transient transport blip (connection\n+        # reset / timeout / 5xx / 408) on ANY auxiliary call before falling\n+        # back. Default 2 (→ 3 total attempts), clamped [0,6]. Matters most for\n+        # pinned calls like MoA reference advisors, where provider fallback is\n+        # not a meaningful recovery, so an unretried blip silently loses the\n+        # call.\n+        \"transient_retries\": 2,\n         \"vision\": {\n             \"provider\": \"auto\",    # auto | openrouter | nous | codex | custom\n             \"model\": \"\",           # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\""]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -1523,7 +1523,13 @@ def test_cached_gmi_client_keeps_explicit_slash_model_override(self):\n \n         assert client is fake_client\n         assert model == \"openai/gpt-5.4-mini\"\n-        assert mock_resolve.call_count == 1\n+        # A DIFFERENT model resolves its own client (model participates in the\n+        # cache key). This isolation is what stops two concurrent advisors on\n+        # the same provider/base_url/key (e.g. a MoA fan-out) from sharing — and\n+        # racing the lifecycle of — one cached client. Same-model reuse is still\n+        # a single resolve (verified elsewhere); distinct models => distinct\n+        # resolves.\n+        assert mock_resolve.call_count == 2\n \n \n # ── Payment / credit exhaustion fallback ─────────────────────────────────","@@ -2581,6 +2587,8 @@ def test_second_transient_failure_escalates_to_fallback(self):\n         p1, p2, p3 = self._patches(primary)\n         with (\n             p1, p2, p3,\n+            patch(\"agent.auxiliary_client._transient_retry_count\", return_value=1),\n+            patch(\"agent.auxiliary_client._TRANSIENT_RETRY_BACKOFF_BASE\", 0.0),\n             patch(\n                 \"agent.auxiliary_client._try_configured_fallback_chain\",\n                 return_value=(None, None, \"\"),","@@ -2592,7 +2600,7 @@ def test_second_transient_failure_escalates_to_fallback(self):\n         ):\n             result = call_llm(task=\"compression\", messages=[{\"role\": \"user\", \"content\": \"hi\"}])\n         assert result == {\"fallback\": True}\n-        # Primary tried twice (initial + same-target retry), then fallback.\n+        # Primary tried twice (initial + one same-target retry), then fallback.\n         assert primary.chat.completions.create.call_count == 2\n         assert fb_client.chat.completions.create.call_count == 1\n "]},{"file":"tests/agent/test_auxiliary_transient_retry.py","hunks":["@@ -0,0 +1,82 @@\n+\"\"\"Transient-transport retry count + per-model client-cache isolation.\n+\n+Two related hardening behaviors for auxiliary calls (which include MoA\n+reference advisors, a pinned-model path where provider fallback is not a\n+meaningful recovery):\n+\n+1. A transient transport blip (connection reset / timeout / 5xx) is retried\n+   on the SAME provider several times with backoff before giving up — a single\n+   upstream blip should not silently lose a pinned auxiliary call (root of the\n+   run2 double-advisor \"Connection error\" collapse).\n+2. Two auxiliary calls to the same provider/base_url/key but DIFFERENT models\n+   get DISTINCT client-cache keys, so a concurrent fan-out (e.g. opus + gpt-5.5\n+   advisors) never shares one client entry.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+import types\n+from unittest.mock import patch\n+\n+import pytest\n+\n+\n+class _ConnErr(Exception):\n+    \"\"\"Stand-in that the transient detector recognizes as a connection blip.\"\"\"\n+\n+\n+def test_transient_retry_count_default(monkeypatch):\n+    from agent import auxiliary_client as ac\n+\n+    # No config value -> default.\n+    monkeypatch.setattr(ac, \"load_config\", lambda: {}, raising=False)\n+    with patch(\"hermes_cli.config.load_config\", return_value={}), \\\n+         patch(\"hermes_cli.config.cfg_get\", return_value=None):\n+        assert ac._transient_retry_count() == ac._DEFAULT_TRANSIENT_RETRIES\n+\n+\n+def test_transient_retry_count_configurable_and_clamped():\n+    from agent import auxiliary_client as ac\n+\n+    with patch(\"hermes_cli.config.cfg_get\", return_value=4):\n+        assert ac._transient_retry_count() == 4\n+    with patch(\"hermes_cli.config.cfg_get\", return_value=100):\n+        assert ac._transient_retry_count() == 6  # clamped high\n+    with patch(\"hermes_cli.config.cfg_get\", return_value=-3):\n+        assert ac._transient_retry_count() == 0  # clamped low\n+    with patch(\"hermes_cli.config.cfg_get\", side_effect=RuntimeError):\n+        assert ac._transient_retry_count() == ac._DEFAULT_TRANSIENT_RETRIES\n+\n+\n+def test_model_participates_in_client_cache_key():\n+    \"\"\"Same provider/base_url/key, different model -> different cache key.\n+\n+    This is what stops two concurrent advisors from sharing (and racing on)\n+    one cached client entry.\"\"\"\n+    from agent.auxiliary_client import _client_cache_key\n+\n+    k_opus = _client_cache_key(\n+        \"openrouter\", async_mode=False, base_url=\"https://openrouter.ai/api/v1\",\n+        api_key=\"K\", model=\"anthropic/claude-opus-4.8\",\n+    )\n+    k_gpt = _client_cache_key(\n+        \"openrouter\", async_mode=False, base_url=\"https://openrouter.ai/api/v1\",\n+        api_key=\"K\", model=\"openai/gpt-5.5\",\n+    )\n+    assert k_opus != k_gpt\n+    # Same model still collides (cache still works for reuse).\n+    k_opus2 = _client_cache_key(\n+        \"openrouter\", async_mode=False, base_url=\"https://openrouter.ai/api/v1\",\n+        api_key=\"K\", model=\"anthropic/claude-opus-4.8\",\n+    )\n+    assert k_opus == k_opus2\n+\n+\n+def test_missing_model_key_is_stable():\n+    \"\"\"Omitting model (legacy callers) is still a valid, stable key.\"\"\"\n+    from agent.auxiliary_client import _client_cache_key\n+\n+    a = _client_cache_key(\"openrouter\", async_mode=False, base_url=\"u\", api_key=\"k\")\n+    b = _client_cache_key(\"openrouter\", async_mode=False, base_url=\"u\", api_key=\"k\")\n+    assert a == b\n"]}]},{"kind":"pr","number":63521,"title":"fix(auxiliary): isolate model-scoped runtime state","url":null,"state":"merged","close_reason":{"category":"merged"},"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 = api_key.strip() 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 = runtime.get(\"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 = runtime.get(\"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 = _runtime_cache_discriminator(\"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\": [redacted:secret-assignment] \"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 = set_accounting_context(\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     \"290859878+synapsesx@users.noreply.github.com\": \"synapsesx\",\n     \"157689911+itsflownium@users.noreply.github.com\": \"itsflownium\",\n     \"dirtyren@users.noreply.github.com\": \"dirtyren\",\n+    \"krowd3v@users.noreply.github.com\": \"krowd3v\",\n+    \"dfein38347g@users.noreply.github.com\": \"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\": \"[redacted:secret-assignment]\",\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\": \"[redacted:secret-assignment]\"},\n+        {**base, \"api_mode\": \"codex_responses\"},\n+        {**base, \"auth_mode\": \"entra_id\", \"api_key\": [redacted:secret-assignment] \"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\": [redacted:secret-assignment]\n+    )\n+    second = aux._client_cache_key(\n+        \"auto\", async_mode=False, main_runtime={**_runtime(\"same\"), \"api_key\": [redacted:secret-assignment]\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\": [redacted:secret-assignment]\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\": [redacted:secret-assignment]\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\": \"[redacted:secret-assignment]\",\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\": \"[redacted:secret-assignment]\",\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}}