{"repo":"NousResearch/hermes-agent","public_id":"27020","display_label":"Image routing in API server and gateway","summary":"These issues and pull requests concern how inline image inputs are handled and routed through the API server and OpenAI-compatible gateway, particularly ensuring vision-capable models receive image content via the appropriate pipeline. Work spans detection of model vision capability, routing logic, and fixing gaps where image content was bypassed or dropped.","subsystem":"gateway","counts":{"members":4,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16506,"merged_at":"2026-04-27T13:28:00+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-27T13:28:00+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.3933333333333333},"fix_edges":[{"issue_number":27232,"pr_number":16506,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-27T13:28:00+00:00"}],"members":[{"kind":"issue","number":27020,"title":"Gateway /v1/chat/completions passthrough does not apply image_routing.py — OpenAI-compat clients bypass auxiliary.vision routing","url":null,"state":"closed"},{"kind":"issue","number":27232,"title":"[Bug]: OpenAI API gateway silently drops image content — Open WebUI users can't upload photos","url":null,"state":"open"},{"kind":"pr","number":16506,"title":"feat(image-input): native multimodal routing based on model vision capability","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/context_compressor.py","agent/error_classifier.py","agent/image_routing.py","cli.py","gateway/run.py","hermes_cli/config.py","run_agent.py","tests/agent/test_compressor_image_tokens.py","tests/agent/test_error_classifier.py","tests/agent/test_image_routing.py","tests/run_agent/test_image_shrink_recovery.py","tests/run_agent/test_vision_aware_preprocessing.py","tools/vision_tools.py","tui_gateway/server.py"],"fix_hunks":[{"file":"agent/context_compressor.py","hunks":["@@ -61,9 +61,52 @@\n \n # Chars per token rough estimate\n _CHARS_PER_TOKEN = 4\n+# Flat token cost per attached image part.  Real cost varies by provider and\n+# dimensions (Anthropic ≈ width×height/750, GPT-4o up to ~1700 for\n+# high-detail 2048×2048, Gemini 258/tile), but 1600 is a realistic ceiling\n+# that keeps compression budgeting honest for multi-image conversations.\n+# Matches Claude Code's IMAGE_TOKEN_ESTIMATE constant.\n+_IMAGE_TOKEN_ESTIMATE = 1600\n+# Same figure expressed in the char-budget currency the rest of the\n+# compressor speaks in.  Used when accumulating message \"content length\"\n+# for tail-cut decisions.\n+_IMAGE_CHAR_EQUIVALENT = _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN\n _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600\n \n \n+def _content_length_for_budget(raw_content: Any) -> int:\n+    \"\"\"Return the effective char-length of a message's content for token budgeting.\n+\n+    Plain strings: ``len(content)``. Multimodal lists: sum of text-part\n+    ``len(text)`` plus a flat ``_IMAGE_CHAR_EQUIVALENT`` per image part\n+    (``image_url`` / ``input_image`` / Anthropic-style ``image``). This\n+    keeps the compressor from treating a turn with 5 attached images as\n+    near-zero tokens just because the text part is empty.\n+    \"\"\"\n+    if isinstance(raw_content, str):\n+        return len(raw_content)\n+    if not isinstance(raw_content, list):\n+        return len(str(raw_content or \"\"))\n+\n+    total = 0\n+    for p in raw_content:\n+        if isinstance(p, str):\n+            total += len(p)\n+            continue\n+        if not isinstance(p, dict):\n+            total += len(str(p))\n+            continue\n+        ptype = p.get(\"type\")\n+        if ptype in {\"image_url\", \"input_image\", \"image\"}:\n+            total += _IMAGE_CHAR_EQUIVALENT\n+        else:\n+            # text / input_text / tool_result-with-text / anything else with\n+            # a text field.  Ignore the raw base64 payload inside image_url\n+            # dicts — dimensions don't matter, only whether it's an image.\n+            total += len(p.get(\"text\", \"\") or \"\")\n+    return total\n+\n+\n def _content_text_for_contains(content: Any) -> str:\n     \"\"\"Return a best-effort text view of message content.\n ","@@ -484,18 +527,7 @@ def _prune_old_tool_results(\n             for i in range(len(result) - 1, -1, -1):\n                 msg = result[i]\n                 raw_content = msg.get(\"content\") or \"\"\n-                content_len = (\n-                    sum(\n-                        len(p.get(\"text\", \"\"))\n-                        if isinstance(p, dict)\n-                        else len(p)\n-                        if isinstance(p, str)\n-                        else len(str(p))\n-                        for p in raw_content\n-                    )\n-                    if isinstance(raw_content, list)\n-                    else len(raw_content)\n-                )\n+                content_len = _content_length_for_budget(raw_content)\n                 msg_tokens = [redacted:secret-assignment] // _CHARS_PER_TOKEN + 10\n                 for tc in msg.get(\"tool_calls\") or []:\n                     if isinstance(tc, dict):","@@ -1094,18 +1126,7 @@ def _find_tail_cut_by_tokens(\n         for i in range(n - 1, head_end - 1, -1):\n             msg = messages[i]\n             raw_content = msg.get(\"content\") or \"\"\n-            content_len = (\n-                sum(\n-                    len(p.get(\"text\", \"\"))\n-                    if isinstance(p, dict)\n-                    else len(p)\n-                    if isinstance(p, str)\n-                    else len(str(p))\n-                    for p in raw_content\n-                )\n-                if isinstance(raw_content, list)\n-                else len(raw_content)\n-            )\n+            content_len = _content_length_for_budget(raw_content)\n             msg_tokens = [redacted:secret-assignment] // _CHARS_PER_TOKEN + 10  # +10 for role/metadata\n             # Include tool call arguments in estimate\n             for tc in msg.get(\"tool_calls\") or []:"]},{"file":"agent/error_classifier.py","hunks":["@@ -42,6 +42,7 @@ class FailoverReason(enum.Enum):\n     # Context / payload\n     context_overflow = \"context_overflow\"  # Context too large — compress, not failover\n     payload_too_large = \"payload_too_large\"  # 413 — compress payload\n+    image_too_large = \"image_too_large\"   # Native image part exceeds provider's per-image limit — shrink and retry\n \n     # Model\n     model_not_found = \"model_not_found\"  # 404 or invalid model — fallback to different model","@@ -147,6 +148,20 @@ def is_auth(self) -> bool:\n     \"error code: 413\",\n ]\n \n+# Image-size patterns.  Matched against 400 bodies (not 413) because most\n+# providers return a 400 with a specific image-too-big message before the\n+# whole request hits the 413 size limit.  Anthropic's wording is the most\n+# important here (hard 5 MB per image, returned as\n+# \"messages.N.content.K.image.source.base64: image exceeds 5 MB maximum\").\n+_IMAGE_TOO_LARGE_PATTERNS = [\n+    \"image exceeds\",        # Anthropic: \"image exceeds 5 MB maximum\"\n+    \"image too large\",      # generic\n+    \"image_too_large\",      # error_code variant\n+    \"image size exceeds\",   # variant\n+    # \"request_too_large\" on a request known to contain an image → image is\n+    # the likely culprit; we still try the shrink path before giving up.\n+]\n+\n # Context overflow patterns\n _CONTEXT_OVERFLOW_PATTERNS = [\n     \"context length\",","@@ -671,6 +686,15 @@ def _classify_400(\n ) -> ClassifiedError:\n     \"\"\"Classify 400 Bad Request — context overflow, format error, or generic.\"\"\"\n \n+    # Image-too-large from 400 (Anthropic's 5 MB per-image check fires this way).\n+    # Must be checked BEFORE context_overflow because messages can trip both\n+    # patterns (\"exceeds\" + \"image\") and image-shrink is a cheaper recovery.\n+    if any(p in error_msg for p in _IMAGE_TOO_LARGE_PATTERNS):\n+        return result_fn(\n+            FailoverReason.image_too_large,\n+            retryable=True,\n+        )\n+\n     # Context overflow from 400\n     if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):\n         return result_fn(","@@ -798,6 +822,13 @@ def _classify_by_message(\n             should_compress=True,\n         )\n \n+    # Image-too-large patterns (from message text when no status_code)\n+    if any(p in error_msg for p in _IMAGE_TOO_LARGE_PATTERNS):\n+        return result_fn(\n+            FailoverReason.image_too_large,\n+            retryable=True,\n+        )\n+\n     # Usage-limit patterns need the same disambiguation as 402: some providers\n     # surface \"usage limit\" errors without an HTTP status code.  A transient\n     # signal (\"try again\", \"resets at\", …) means it's a periodic quota, not"]},{"file":"agent/image_routing.py","hunks":["@@ -0,0 +1,236 @@\n+\"\"\"Routing helpers for inbound user-attached images.\n+\n+Two modes:\n+\n+  native  — attach images as OpenAI-style ``image_url`` content parts on the\n+            user turn. Provider adapters (Anthropic, Gemini, Bedrock, Codex,\n+            OpenAI chat.completions) already translate these into their\n+            vendor-specific multimodal formats.\n+\n+  text    — run ``vision_analyze`` on each image up-front and prepend the\n+            description to the user's text. The model never sees the pixels;\n+            it only sees a lossy text summary. This is the pre-existing\n+            behaviour and still the right choice for non-vision models.\n+\n+The decision is made once per message turn by :func:`decide_image_input_mode`.\n+It reads ``agent.image_input_mode`` from config.yaml (``auto`` | ``native``\n+| ``text``, default ``auto``) and the active model's capability metadata.\n+\n+In ``auto`` mode:\n+  - If the user has explicitly configured ``auxiliary.vision.provider``\n+    (i.e. not ``auto`` and not empty), we assume they want the text pipeline\n+    regardless of the main model — they've opted in to a specific vision\n+    backend for a reason (cost, quality, local-only, etc.).\n+  - Otherwise, if the active model reports ``supports_vision=True`` in its\n+    models.dev metadata, we attach natively.\n+  - Otherwise (non-vision model, no explicit override), we fall back to text.\n+\n+This keeps ``vision_analyze`` surfaced as a tool in every session — skills\n+and agent flows that chain it (browser screenshots, deeper inspection of\n+URL-referenced images, style-gating loops) keep working. The routing only\n+affects *how user-attached images on the current turn* are presented to the\n+main model.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import base64\n+import logging\n+import mimetypes\n+from pathlib import Path\n+from typing import Any, Dict, List, Optional, Tuple\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+_VALID_MODES = frozenset({\"auto\", \"native\", \"text\"})\n+\n+\n+def _coerce_mode(raw: Any) -> str:\n+    \"\"\"Normalize a config value into one of the valid modes.\"\"\"\n+    if not isinstance(raw, str):\n+        return \"auto\"\n+    val = raw.strip().lower()\n+    if val in _VALID_MODES:\n+        return val\n+    return \"auto\"\n+\n+\n+def _explicit_aux_vision_override(cfg: Optional[Dict[str, Any]]) -> bool:\n+    \"\"\"True when the user configured a specific auxiliary vision backend.\n+\n+    An explicit override means the user *wants* the text pipeline (they're\n+    paying for a dedicated vision model), so we don't silently bypass it.\n+    \"\"\"\n+    if not isinstance(cfg, dict):\n+        return False\n+    aux = cfg.get(\"auxiliary\") or {}\n+    if not isinstance(aux, dict):\n+        return False\n+    vision = aux.get(\"vision\") or {}\n+    if not isinstance(vision, dict):\n+        return False\n+\n+    provider = str(vision.get(\"provider\") or \"\").strip().lower()\n+    model = str(vision.get(\"model\") or \"\").strip()\n+    base_url = str(vision.get(\"base_url\") or \"\").strip()\n+\n+    # \"auto\" / \"\" / blank = not explicit\n+    if provider in (\"\", \"auto\") and not model and not base_url:\n+        return False\n+    return True\n+\n+\n+def _lookup_supports_vision(provider: str, model: str) -> Optional[bool]:\n+    \"\"\"Return True/False if we can resolve caps, None if unknown.\"\"\"\n+    if not provider or not model:\n+        return None\n+    try:\n+        from agent.models_dev import get_model_capabilities\n+        caps = get_model_capabilities(provider, model)\n+    except Exception as exc:  # pragma: no cover - defensive\n+        logger.debug(\"image_routing: caps lookup failed for %s:%s — %s\", provider, model, exc)\n+        return None\n+    if caps is None:\n+        return None\n+    return bool(caps.supports_vision)\n+\n+\n+def decide_image_input_mode(\n+    provider: str,\n+    model: str,\n+    cfg: Optional[Dict[str, Any]],\n+) -> str:\n+    \"\"\"Return ``\"native\"`` or ``\"text\"`` for the given turn.\n+\n+    Args:\n+      provider: active inference provider ID (e.g. ``\"anthropic\"``, ``\"openrouter\"``).\n+      model:    active model slug as it would be sent to the provider.\n+      cfg:      loaded config.yaml dict, or None. When None, behaves as auto.\n+    \"\"\"\n+    mode_cfg = \"auto\"\n+    if isinstance(cfg, dict):\n+        agent_cfg = cfg.get(\"agent\") or {}\n+        if isinstance(agent_cfg, dict):\n+            mode_cfg = _coerce_mode(agent_cfg.get(\"image_input_mode\"))\n+\n+    if mode_cfg == \"native\":\n+        return \"native\"\n+    if mode_cfg == \"text\":\n+        return \"text\"\n+\n+    # auto\n+    if _explicit_aux_vision_override(cfg):\n+        return \"text\"\n+\n+    supports = _lookup_supports_vision(provider, model)\n+    if supports is True:\n+        return \"native\"\n+    return \"text\"\n+\n+\n+# Image size handling is REACTIVE rather than proactive: we attempt native\n+# attachment at full size regardless of provider, and rely on\n+# ``run_agent._try_shrink_image_parts_in_messages`` to shrink + retry if\n+# the provider rejects the request (e.g. Anthropic's hard 5 MB per-image\n+# ceiling returned as HTTP 400 \"image exceeds 5 MB maximum\").\n+#\n+# Why reactive: our knowledge of provider ceilings is partial and evolving\n+# (OpenAI accepts 49 MB+, Anthropic 5 MB, Gemini 100 MB, others unknown).\n+# A proactive per-provider table would be stale the moment a provider raises\n+# or lowers its limit, and silently degrading quality for users on providers\n+# that would have accepted the full image is the worse failure mode.\n+# The shrink-on-reject path loses 1 API call + maybe 1s of Pillow work when\n+# it fires, which is cheaper than permanent quality loss.\n+\n+\n+def _guess_mime(path: Path) -> str:\n+    mime, _ = mimetypes.guess_type(str(path))\n+    if mime and mime.startswith(\"image/\"):\n+        return mime\n+    # mimetypes on some Linux distros mis-maps .jpg; default to jpeg when\n+    # the suffix looks imagey.\n+    suffix = path.suffix.lower()\n+    return {\n+        \".jpg\": \"image/jpeg\",\n+        \".jpeg\": \"image/jpeg\",\n+        \".png\": \"image/png\",\n+        \".gif\": \"image/gif\",\n+        \".webp\": \"image/webp\",\n+        \".bmp\": \"image/bmp\",\n+    }.get(suffix, \"image/jpeg\")\n+\n+\n+def _file_to_data_url(path: Path) -> Optional[str]:\n+    \"\"\"Encode a local image as a base64 data URL at its native size.\n+\n+    Size limits are NOT enforced here — the agent retry loop\n+    (``run_agent._try_shrink_image_parts_in_messages``) shrinks on the\n+    provider's first rejection. Keeping this simple means providers that\n+    accept large images (OpenAI 49 MB+, Gemini 100 MB) don't pay a silent\n+    quality tax just because one other provider is stricter.\n+\n+    Returns None only if the file can't be read (missing, permission\n+    denied, etc.); the caller reports those paths in ``skipped``.\n+    \"\"\"\n+    try:\n+        raw = path.read_bytes()\n+    except Exception as exc:\n+        logger.warning(\"image_routing: failed to read %s — %s\", path, exc)\n+        return None\n+    mime = _guess_mime(path)\n+    b64 = base64.b64encode(raw).decode(\"ascii\")\n+    return f\"data:{mime};base64,{b64}\"\n+\n+\n+def build_native_content_parts(\n+    user_text: str,\n+    image_paths: List[str],\n+) -> Tuple[List[Dict[str, Any]], List[str]]:\n+    \"\"\"Build an OpenAI-style ``content`` list for a user turn.\n+\n+    Shape:\n+      [{\"type\": \"text\", \"text\": \"...\"},\n+       {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,...\"}},\n+       ...]\n+\n+    Images are attached at their native size. If a provider rejects the\n+    request because an image is too large (e.g. Anthropic's 5 MB per-image\n+    ceiling), the agent's retry loop transparently shrinks and retries\n+    once — see ``run_agent._try_shrink_image_parts_in_messages``.\n+\n+    Returns (content_parts, skipped_paths). Skipped paths are files that\n+    couldn't be read from disk.\n+    \"\"\"\n+    parts: List[Dict[str, Any]] = []\n+    skipped: List[str] = []\n+\n+    text = (user_text or \"\").strip()\n+    if text:\n+        parts.append({\"type\": \"text\", \"text\": text})\n+\n+    for raw_path in image_paths:\n+        p = Path(raw_path)\n+        if not p.exists() or not p.is_file():\n+            skipped.append(str(raw_path))\n+            continue\n+        data_url = _file_to_data_url(p)\n+        if not data_url:\n+            skipped.append(str(raw_path))\n+            continue\n+        parts.append({\n+            \"type\": \"image_url\",\n+            \"image_url\": {\"url\": data_url},\n+        })\n+\n+    # If the text was empty, add a neutral prompt so the turn isn't just images.\n+    if not text and any(p.get(\"type\") == \"image_url\" for p in parts):\n+        parts.insert(0, {\"type\": \"text\", \"text\": \"What do you see in this image?\"})\n+\n+    return parts, skipped\n+\n+\n+__all__ = [\n+    \"decide_image_input_mode\",\n+    \"build_native_content_parts\",\n+]"]},{"file":"cli.py","hunks":["@@ -8363,13 +8363,62 @@ def chat(self, message, images: list = None) -> Optional[str]:\n         ):\n             return None\n         \n-        # Pre-process images through the vision tool (Gemini Flash) so the\n-        # main model receives text descriptions instead of raw base64 image\n-        # content — works with any model, not just vision-capable ones.\n+        # Route image attachments based on the active model's vision capability.\n+        # \"native\" → pass pixels as OpenAI-style content parts (adapters\n+        #            translate for Anthropic/Gemini/Bedrock).\n+        # \"text\"   → pre-analyze each image with vision_analyze and prepend the\n+        #            description as text — works with non-vision models.\n+        # See agent/image_routing.py for the decision table.\n         if images:\n-            message = self._preprocess_images_with_vision(\n-                message if isinstance(message, str) else \"\", images\n-            )\n+            try:\n+                from agent.image_routing import (\n+                    build_native_content_parts,\n+                    decide_image_input_mode,\n+                )\n+                from hermes_cli.config import load_config\n+\n+                _img_mode = decide_image_input_mode(\n+                    (self.provider or \"\").strip(),\n+                    (self.model or \"\").strip(),\n+                    load_config(),\n+                )\n+            except Exception as _img_exc:\n+                logging.debug(\"image_routing decision failed, defaulting to text: %s\", _img_exc)\n+                _img_mode = \"text\"\n+\n+            if _img_mode == \"native\":\n+                try:\n+                    _text_for_parts = message if isinstance(message, str) else \"\"\n+                    _img_str_paths = [str(p) for p in images]\n+                    _parts, _skipped = build_native_content_parts(\n+                        _text_for_parts,\n+                        _img_str_paths,\n+                    )\n+                    if _skipped:\n+                        _cprint(\n+                            f\"  {_DIM}⚠ skipped {len(_skipped)} unreadable image path(s){_RST}\"\n+                        )\n+                    if any(p.get(\"type\") == \"image_url\" for p in _parts):\n+                        _img_names = \", \".join(Path(p).name for p in _img_str_paths)\n+                        _cprint(\n+                            f\"  {_DIM}📎 attaching {len(images)} image(s) natively \"\n+                            f\"(model supports vision): {_img_names}{_RST}\"\n+                        )\n+                        message = _parts\n+                    else:\n+                        # All images unreadable — fall back to text enrichment.\n+                        message = self._preprocess_images_with_vision(\n+                            message if isinstance(message, str) else \"\", images\n+                        )\n+                except Exception as _img_exc:\n+                    logging.warning(\"native image attach failed, falling back to text: %s\", _img_exc)\n+                    message = self._preprocess_images_with_vision(\n+                        message if isinstance(message, str) else \"\", images\n+                    )\n+            else:\n+                message = self._preprocess_images_with_vision(\n+                    message if isinstance(message, str) else \"\", images\n+                )\n \n         # Expand @ context references (e.g. @file:main.py, @diff, @folder:src/)\n         if isinstance(message, str) and \"@\" in message:"]},{"file":"gateway/run.py","hunks":["@@ -4199,9 +4199,18 @@ async def _prepare_inbound_message_text(\n         Keep the normal inbound path and the queued follow-up path on the same\n         preprocessing pipeline so sender attribution, image enrichment, STT,\n         document notes, reply context, and @ references all behave the same.\n+\n+        Side effect: writes ``self._pending_native_image_paths`` to a list of\n+        local image paths when the active model supports native vision AND\n+        the user has images attached. The caller consumes and clears this\n+        attribute at the ``run_conversation`` site to build a multimodal user\n+        turn. When the list is empty, the ``_enrich_message_with_vision``\n+        text path has already run and images are represented in-text.\n         \"\"\"\n         history = history or []\n         message_text = event.text or \"\"\n+        # Reset per-call buffer; set only when native routing is chosen.\n+        self._pending_native_image_paths = []\n \n         _is_shared_multi_user = is_shared_multi_user_session(\n             source,","@@ -4222,10 +4231,25 @@ async def _prepare_inbound_message_text(\n                     audio_paths.append(path)\n \n             if image_paths:\n-                message_text = await self._enrich_message_with_vision(\n-                    message_text,\n-                    image_paths,\n-                )\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+                if _img_mode == \"native\":\n+                    # Defer attachment to the run_conversation call site.\n+                    self._pending_native_image_paths = list(image_paths)\n+                    logger.info(\n+                        \"Image routing: native (model supports vision). %d image(s) will be attached inline.\",\n+                        len(image_paths),\n+                    )\n+                else:\n+                    logger.info(\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 \n             if audio_paths:\n                 message_text = await self._enrich_message_with_transcription(","@@ -8378,6 +8402,29 @@ async def _run_in_executor_with_context(self, func, *args):\n         ctx = copy_context()\n         return await loop.run_in_executor(None, ctx.run, func, *args)\n \n+    def _decide_image_input_mode(self) -> str:\n+        \"\"\"Resolve the image-input routing for the currently active model.\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+        \"\"\"\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+        except Exception as exc:\n+            logger.debug(\"image_routing: decision failed, falling back to text — %s\", exc)\n+            return \"text\"\n+\n     async def _enrich_message_with_vision(\n         self,\n         user_text: str,","@@ -10394,7 +10441,39 @@ def _approval_notify_sync(approval_data: dict) -> None:\n             _approval_session_token = [redacted:secret-assignment]\n             register_gateway_notify(_approval_session_key, _approval_notify_sync)\n             try:\n-                result = agent.run_conversation(message, conversation_history=agent_history, task_id=session_id)\n+                # If _prepare_inbound_message_text buffered image paths for native\n+                # attachment, wrap the user turn as an OpenAI-style multimodal\n+                # content list. Consume-and-clear so subsequent turns on the same\n+                # runner instance don't re-attach stale images.\n+                _native_imgs = list(getattr(self, \"_pending_native_image_paths\", []) or [])\n+                self._pending_native_image_paths = []\n+                if _native_imgs:\n+                    try:\n+                        from agent.image_routing import build_native_content_parts\n+                        _parts, _skipped = build_native_content_parts(\n+                            message,\n+                            _native_imgs,\n+                        )\n+                        if _skipped:\n+                            logger.warning(\n+                                \"Native image attachment: skipped %d unreadable path(s): %s\",\n+                                len(_skipped), _skipped,\n+                            )\n+                        if any(p.get(\"type\") == \"image_url\" for p in _parts):\n+                            _run_message: Any = _parts\n+                        else:\n+                            # All images failed to read — fall back to plain text.\n+                            _run_message = message\n+                    except Exception as _img_exc:\n+                        logger.warning(\n+                            \"Native image attachment failed, falling back to text: %s\",\n+                            _img_exc,\n+                        )\n+                        _run_message = message\n+                else:\n+                    _run_message = message\n+\n+                result = agent.run_conversation(_run_message, conversation_history=agent_history, task_id=session_id)\n             finally:\n                 unregister_gateway_notify(_approval_session_key)\n                 reset_current_session_key(_approval_session_token)"]},{"file":"hermes_cli/config.py","hunks":["@@ -389,6 +389,20 @@ def _ensure_hermes_home_managed(home: Path):\n         # (60+ tool iterations with tiny output) before users assume the\n         # bot is dead and /restart.\n         \"gateway_notify_interval\": 180,\n+        # How user-attached images are presented to the main model on each turn.\n+        #   \"auto\"   — attach natively when the active model reports\n+        #              supports_vision=True AND the user hasn't explicitly\n+        #              configured auxiliary.vision.provider.  Otherwise fall\n+        #              back to text (vision_analyze pre-analysis).\n+        #   \"native\" — always attach natively; non-vision models will either\n+        #              error at the provider or get a last-chance text fallback\n+        #              (see run_agent._prepare_messages_for_api).\n+        #   \"text\"   — always pre-analyze with vision_analyze and prepend the\n+        #              description as text; the main model never sees pixels.\n+        # Affects gateway platforms, the TUI, and CLI /attach.  vision_analyze\n+        # remains available as a tool regardless of this setting — the routing\n+        # only controls how inbound user images are presented.\n+        \"image_input_mode\": \"auto\",\n     },\n     \n     \"terminal\": {"]},{"file":"run_agent.py","hunks":["@@ -7283,6 +7283,26 @@ def _describe_image_for_anthropic_fallback(self, image_url: str, role: str) -> s\n         self._anthropic_image_fallback_cache[cache_key] = note\n         return note\n \n+    def _model_supports_vision(self) -> bool:\n+        \"\"\"Return True if the active provider+model reports native vision.\n+\n+        Used to decide whether to strip image content parts from API-bound\n+        messages (for non-vision models) or let the provider adapter handle\n+        them natively (for vision-capable models).\n+        \"\"\"\n+        try:\n+            from agent.models_dev import get_model_capabilities\n+            provider = (getattr(self, \"provider\", \"\") or \"\").strip()\n+            model = (getattr(self, \"model\", \"\") or \"\").strip()\n+            if not provider or not model:\n+                return False\n+            caps = get_model_capabilities(provider, model)\n+            if caps is None:\n+                return False\n+            return bool(caps.supports_vision)\n+        except Exception:\n+            return False\n+\n     def _preprocess_anthropic_content(self, content: Any, role: str) -> Any:\n         if not self._content_has_image_parts(content):\n             return content","@@ -7346,12 +7366,23 @@ def _get_transport(self, api_mode: str = None):\n         return t\n \n     def _prepare_anthropic_messages_for_api(self, api_messages: list) -> list:\n+        # Fast exit when no message carries image content at all.\n         if not any(\n             isinstance(msg, dict) and self._content_has_image_parts(msg.get(\"content\"))\n             for msg in api_messages\n         ):\n             return api_messages\n \n+        # The Anthropic adapter (agent/anthropic_adapter.py:_convert_content_part_to_anthropic)\n+        # already translates OpenAI-style image_url/input_image parts into\n+        # native Anthropic ``{\"type\": \"image\", \"source\": ...}`` blocks. When\n+        # the active model supports vision we let the adapter do its job and\n+        # skip this legacy text-fallback preprocessor entirely.\n+        if self._model_supports_vision():\n+            return api_messages\n+\n+        # Non-vision Anthropic model (rare today, but keep the fallback for\n+        # compat): replace each image part with a vision_analyze text note.\n         transformed = copy.deepcopy(api_messages)\n         for msg in transformed:\n             if not isinstance(msg, dict):","@@ -7362,6 +7393,150 @@ def _prepare_anthropic_messages_for_api(self, api_messages: list) -> list:\n             )\n         return transformed\n \n+    def _prepare_messages_for_non_vision_model(self, api_messages: list) -> list:\n+        \"\"\"Strip native image parts when the active model lacks vision.\n+\n+        Runs on the chat.completions / codex_responses paths. Vision-capable\n+        models pass through unchanged (provider and any downstream translator\n+        handle the image parts natively). Non-vision models get each image\n+        replaced by a cached vision_analyze text description so the turn\n+        doesn't fail with \"model does not support image input\".\n+        \"\"\"\n+        if not any(\n+            isinstance(msg, dict) and self._content_has_image_parts(msg.get(\"content\"))\n+            for msg in api_messages\n+        ):\n+            return api_messages\n+\n+        if self._model_supports_vision():\n+            return api_messages\n+\n+        transformed = copy.deepcopy(api_messages)\n+        for msg in transformed:\n+            if not isinstance(msg, dict):\n+                continue\n+            # Reuse the Anthropic text-fallback preprocessor — the behaviour is\n+            # identical (walk content parts, replace images with cached\n+            # descriptions, merge back into a single text or structured\n+            # content). Naming is historical.\n+            msg[\"content\"] = self._preprocess_anthropic_content(\n+                msg.get(\"content\"),\n+                str(msg.get(\"role\", \"user\") or \"user\"),\n+            )\n+        return transformed\n+\n+    def _try_shrink_image_parts_in_messages(self, api_messages: list) -> bool:\n+        \"\"\"Re-encode all native image parts at a smaller size to recover from\n+        image-too-large errors (Anthropic 5 MB, unknown other providers).\n+\n+        Mutates ``api_messages`` in place. Returns True if any image part was\n+        actually replaced, False if there were no image parts to shrink or\n+        Pillow couldn't help (caller should surface the original error).\n+\n+        Strategy: look for ``image_url`` / ``input_image`` parts carrying a\n+        ``data:image/...;base64,...`` payload.  For each one whose encoded\n+        size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB\n+        ceiling with header overhead), write the base64 to a tempfile, call\n+        ``vision_tools._resize_image_for_vision`` to produce a smaller data\n+        URL, and substitute it in place.\n+\n+        Non-data-URL images (http/https URLs) are not touched — the provider\n+        fetches those itself and the size limit is different.\n+        \"\"\"\n+        if not api_messages:\n+            return False\n+\n+        try:\n+            from tools.vision_tools import _resize_image_for_vision\n+        except Exception as exc:\n+            logger.warning(\"image-shrink recovery: vision_tools unavailable — %s\", exc)\n+            return False\n+\n+        # 4 MB target leaves comfortable headroom under Anthropic's 5 MB.\n+        # Non-Anthropic providers we haven't observed rejecting are fine with\n+        # much larger; shrinking to 4 MB here loses quality but only fires\n+        # after a confirmed provider rejection, so the alternative is failure.\n+        target_bytes = 4 * 1024 * 1024\n+        changed_count = 0\n+\n+        def _shrink_data_url(url: str) -> Optional[str]:\n+            \"\"\"Return a smaller data URL, or None if shrink can't help.\"\"\"\n+            if not isinstance(url, str) or not url.startswith(\"data:\"):\n+                return None\n+            if len(url) <= target_bytes:\n+                # This specific image wasn't the oversized one.\n+                return None\n+            try:\n+                header, _, data = url.partition(\",\")\n+                mime = \"image/jpeg\"\n+                if header.startswith(\"data:\"):\n+                    mime_part = header[len(\"data:\"):].split(\";\", 1)[0].strip()\n+                    if mime_part.startswith(\"image/\"):\n+                        mime = mime_part\n+                import base64 as _b64\n+                raw = _b64.b64decode(data)\n+                suffix = {\n+                    \"image/png\": \".png\", \"image/gif\": \".gif\", \"image/webp\": \".webp\",\n+                    \"image/jpeg\": \".jpg\", \"image/jpg\": \".jpg\", \"image/bmp\": \".bmp\",\n+                }.get(mime, \".jpg\")\n+                tmp = tempfile.NamedTemporaryFile(\n+                    prefix=\"hermes_shrink_\", suffix=suffix, delete=False,\n+                )\n+                try:\n+                    tmp.write(raw)\n+                    tmp.close()\n+                    resized = _resize_image_for_vision(\n+                        Path(tmp.name),\n+                        mime_type=mime,\n+                        max_base64_bytes=target_bytes,\n+                    )\n+                finally:\n+                    try:\n+                        Path(tmp.name).unlink(missing_ok=True)\n+                    except Exception:\n+                        pass\n+                if not resized or len(resized) >= len(url):\n+                    # Shrink didn't help (or made it bigger — corrupt input?).\n+                    return None\n+                return resized\n+            except Exception as exc:\n+                logger.warning(\"image-shrink recovery: re-encode failed — %s\", exc)\n+                return None\n+\n+        for msg in api_messages:\n+            if not isinstance(msg, dict):\n+                continue\n+            content = msg.get(\"content\")\n+            if not isinstance(content, list):\n+                continue\n+            for part in content:\n+                if not isinstance(part, dict):\n+                    continue\n+                ptype = part.get(\"type\")\n+                if ptype not in {\"image_url\", \"input_image\"}:\n+                    continue\n+                image_value = part.get(\"image_url\")\n+                # OpenAI chat.completions: {\"image_url\": {\"url\": \"data:...\"}}\n+                # OpenAI Responses: {\"image_url\": \"data:...\"}\n+                if isinstance(image_value, dict):\n+                    url = image_value.get(\"url\", \"\")\n+                    resized = _shrink_data_url(url)\n+                    if resized:\n+                        image_value[\"url\"] = resized\n+                        changed_count += 1\n+                elif isinstance(image_value, str):\n+                    resized = _shrink_data_url(image_value)\n+                    if resized:\n+                        part[\"image_url\"] = resized\n+                        changed_count += 1\n+\n+        if changed_count:\n+            logger.info(\n+                \"image-shrink recovery: re-encoded %d image part(s) to fit under %.0f MB\",\n+                changed_count, target_bytes / (1024 * 1024),\n+            )\n+        return changed_count > 0\n+\n     def _anthropic_preserve_dots(self) -> bool:\n         \"\"\"True when using an anthropic-compatible endpoint that preserves dots in model names.\n         Alibaba/DashScope keeps dots (e.g. qwen3.5-plus).","@@ -7510,9 +7685,10 @@ def _build_api_kwargs(self, api_messages: list) -> dict:\n                 )\n             )\n             is_xai_responses = self.provider == \"xai\" or self._base_url_hostname == \"api.x.ai\"\n+            _msgs_for_codex = self._prepare_messages_for_non_vision_model(api_messages)\n             return _ct.build_kwargs(\n                 model=self.model,\n-                messages=api_messages,\n+                messages=_msgs_for_codex,\n                 tools=self.tools,\n                 reasoning_config=self.reasoning_config,\n                 session_id=getattr(self, \"session_id\", None),","@@ -7591,9 +7767,12 @@ def _build_api_kwargs(self, api_messages: list) -> dict:\n         if _ephemeral_out is not None:\n             self._ephemeral_max_output_tokens = None\n \n+        # Strip image parts for non-vision models (no-op when vision-capable).\n+        _msgs_for_chat = self._prepare_messages_for_non_vision_model(api_messages)\n+\n         return _ct.build_kwargs(\n             model=self.model,\n-            messages=api_messages,\n+            messages=_msgs_for_chat,\n             tools=self.tools,\n             timeout=self._resolved_api_call_timeout(),\n             max_tokens=self.max_tokens,","@@ -9887,6 +10066,7 @@ def run_conversation(\n             nous_auth_retry_attempted=False\n             copilot_auth_retry_attempted=False\n             thinking_sig_retry_attempted = False\n+            image_shrink_retry_attempted = False\n             has_retried_429 = False\n             restart_with_compressed_messages = False\n             restart_with_length_continuation = False","@@ -10808,6 +10988,31 @@ def _stop_spinner():\n                     )\n                     if recovered_with_pool:\n                         continue\n+\n+                    # Image-too-large recovery: shrink oversized native image\n+                    # parts in-place and retry once.  Triggered by Anthropic's\n+                    # per-image 5 MB ceiling (400 with \"image exceeds 5 MB\n+                    # maximum\") or any other provider that complains about\n+                    # image size.  If shrink fails or a second attempt still\n+                    # fails, fall through to normal error handling.\n+                    if (\n+                        classified.reason == FailoverReason.image_too_large\n+                        and not image_shrink_retry_attempted\n+                    ):\n+                        image_shrink_retry_attempted = True\n+                        if self._try_shrink_image_parts_in_messages(api_messages):\n+                            self._vprint(\n+                                f\"{self.log_prefix}📐 Image(s) exceeded provider size limit — \"\n+                                f\"shrank and retrying...\",\n+                                force=True,\n+                            )\n+                            continue\n+                        else:\n+                            logger.info(\n+                                \"image-shrink recovery: no data-URL image parts found \"\n+                                \"or shrink didn't reduce size; surfacing original error.\"\n+                            )\n+\n                     if (\n                         self.api_mode == \"codex_responses\"\n                         and self.provider == \"openai-codex\""]},{"file":"tests/agent/test_compressor_image_tokens.py","hunks":["@@ -0,0 +1,141 @@\n+\"\"\"Tests for image-token accounting in the context compressor.\n+\n+Covers the native-image-routing PR's companion change: the compressor's\n+multimodal message length counter now charges ~1600 tokens per attached\n+image part instead of 0, so tail-cut / prune decisions are accurate for\n+creative workflows that iterate on images across many turns.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import pytest\n+\n+from agent.context_compressor import (\n+    _CHARS_PER_TOKEN,\n+    _IMAGE_CHAR_EQUIVALENT,\n+    _IMAGE_TOKEN_ESTIMATE,\n+    _content_length_for_budget,\n+)\n+\n+\n+class TestContentLengthForBudget:\n+    def test_plain_string(self):\n+        assert _content_length_for_budget(\"hello world\") == 11\n+\n+    def test_empty_string(self):\n+        assert _content_length_for_budget(\"\") == 0\n+\n+    def test_none_coerces_to_zero(self):\n+        assert _content_length_for_budget(None) == 0\n+\n+    def test_text_only_list(self):\n+        content = [\n+            {\"type\": \"text\", \"text\": \"first\"},\n+            {\"type\": \"text\", \"text\": \"second\"},\n+        ]\n+        assert _content_length_for_budget(content) == 5 + 6\n+\n+    def test_single_image_part_charges_fixed_budget(self):\n+        content = [\n+            {\"type\": \"text\", \"text\": \"look\"},\n+            {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,XXXX\"}},\n+        ]\n+        # 4 chars of text + 1 image at fixed char-equivalent\n+        assert _content_length_for_budget(content) == 4 + _IMAGE_CHAR_EQUIVALENT\n+\n+    def test_image_url_raw_base64_is_not_counted_as_chars(self):\n+        \"\"\"A 1MB base64 blob inside an image_url must NOT inflate token count.\n+\n+        The flat image estimate is what the provider actually bills; the raw\n+        base64 is transport payload, not context tokens.\n+        \"\"\"\n+        huge_url = \"data:image/png;base64,\" + (\"A\" * 1_000_000)\n+        content = [\n+            {\"type\": \"image_url\", \"image_url\": {\"url\": huge_url}},\n+        ]\n+        # Exactly one image's worth, not 1M + something.\n+        assert _content_length_for_budget(content) == _IMAGE_CHAR_EQUIVALENT\n+\n+    def test_multiple_image_parts(self):\n+        content = [\n+            {\"type\": \"text\", \"text\": \"compare\"},\n+            {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,AAA\"}},\n+            {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,BBB\"}},\n+            {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,CCC\"}},\n+        ]\n+        assert _content_length_for_budget(content) == 7 + 3 * _IMAGE_CHAR_EQUIVALENT\n+\n+    def test_openai_responses_input_image_shape(self):\n+        \"\"\"Responses API uses type=input_image with top-level image_url string.\"\"\"\n+        content = [\n+            {\"type\": \"input_text\", \"text\": \"hey\"},\n+            {\"type\": \"input_image\", \"image_url\": \"data:image/png;base64,XX\"},\n+        ]\n+        # input_text has .text \"hey\" (3 chars) + 1 image\n+        assert _content_length_for_budget(content) == 3 + _IMAGE_CHAR_EQUIVALENT\n+\n+    def test_anthropic_native_image_shape(self):\n+        \"\"\"Anthropic native shape: {type: image, source: {...}}.\"\"\"\n+        content = [\n+            {\"type\": \"text\", \"text\": \"hi\"},\n+            {\"type\": \"image\", \"source\": {\"type\": \"base64\", \"media_type\": \"image/png\", \"data\": \"XX\"}},\n+        ]\n+        assert _content_length_for_budget(content) == 2 + _IMAGE_CHAR_EQUIVALENT\n+\n+    def test_bare_string_part_in_list(self):\n+        \"\"\"Older code paths sometimes produce mixed list-of-strings content.\"\"\"\n+        content = [\"hello\", {\"type\": \"text\", \"text\": \"world\"}]\n+        assert _content_length_for_budget(content) == 5 + 5\n+\n+    def test_image_estimate_constant_is_reasonable(self):\n+        \"\"\"Sanity-check the estimate aligns with real provider billing.\n+\n+        Anthropic ≈ width*height/750 → ~1600 for 1000×1200.\n+        OpenAI GPT-4o high-detail 2048×2048 ≈ 1445.\n+        Gemini 258/tile × 6 tiles for a 2048×2048 ≈ 1548.\n+        Anything in the 800-2000 range is defensible. Enforce bounds so an\n+        accidental edit doesn't drop it to e.g. 16.\n+        \"\"\"\n+        assert 800 <= _IMAGE_TOKEN_ESTIMATE <= 2500\n+        assert _IMAGE_CHAR_EQUIVALENT == _IMAGE_TOKEN_ESTIMATE * _CHARS_PER_TOKEN\n+\n+\n+class TestTokenBudgetWithImages:\n+    \"\"\"Integration: the compressor's tail-cut decision now respects image cost.\"\"\"\n+\n+    def test_image_heavy_turns_count_toward_budget(self):\n+        \"\"\"A tail with 5 image-bearing turns should blow past a 5K token budget.\"\"\"\n+        from agent.context_compressor import ContextCompressor\n+\n+        # Minimal compressor fixture — just enough to call _find_tail_cut_by_tokens\n+        cc = object.__new__(ContextCompressor)\n+        cc.tail_token_budget = 5000\n+\n+        # Build 10 messages: 5 with images, 5 with short text. Without the\n+        # image-tokens fix, the compressor would think all 10 fit in 5K and\n+        # protect them all. With the fix, images alone cost 5 × 1600 = 8K,\n+        # so the tail should be trimmed.\n+        messages = [{\"role\": \"system\", \"content\": \"sys\"}]\n+        for i in range(5):\n+            messages.append({\n+                \"role\": \"user\",\n+                \"content\": [\n+                    {\"type\": \"text\", \"text\": f\"turn {i}\"},\n+                    {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,AAA\"}},\n+                ],\n+            })\n+            messages.append({\n+                \"role\": \"assistant\",\n+                \"content\": f\"response {i}\",\n+            })\n+\n+        cut = cc._find_tail_cut_by_tokens(messages, head_end=0, token_budget=5000)\n+\n+        # Budget is 5K, soft ceiling 7.5K. 5 images alone = 8000 image-tokens.\n+        # Walking backward, the compressor should stop before including all 5.\n+        # Exact cut depends on text lengths and min_tail, but it MUST be > 1\n+        # (at least some head-side messages should be compressible).\n+        assert cut > 1, (\n+            f\"Expected image-heavy tail to be trimmed; compressor placed cut at \"\n+            f\"{cut} out of {len(messages)} (image tokens were likely ignored).\"\n+        )"]},{"file":"tests/agent/test_error_classifier.py","hunks":["@@ -54,7 +54,7 @@ def test_enum_members_exist(self):\n         expected = {\n             \"auth\", \"auth_permanent\", \"billing\", \"rate_limit\",\n             \"overloaded\", \"server_error\", \"timeout\",\n-            \"context_overflow\", \"payload_too_large\",\n+            \"context_overflow\", \"payload_too_large\", \"image_too_large\",\n             \"model_not_found\", \"format_error\",\n             \"provider_policy_blocked\",\n             \"thinking_signature\", \"long_context_tier\", \"unknown\","]},{"file":"tests/agent/test_image_routing.py","hunks":["@@ -0,0 +1,213 @@\n+\"\"\"Tests for agent/image_routing.py — the per-turn image input mode decision.\"\"\"\n+\n+from __future__ import annotations\n+\n+import base64\n+from pathlib import Path\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from agent.image_routing import (\n+    _coerce_mode,\n+    _explicit_aux_vision_override,\n+    build_native_content_parts,\n+    decide_image_input_mode,\n+)\n+\n+\n+# ─── _coerce_mode ────────────────────────────────────────────────────────────\n+\n+\n+class TestCoerceMode:\n+    def test_valid_modes_pass_through(self):\n+        assert _coerce_mode(\"auto\") == \"auto\"\n+        assert _coerce_mode(\"native\") == \"native\"\n+        assert _coerce_mode(\"text\") == \"text\"\n+\n+    def test_case_insensitive(self):\n+        assert _coerce_mode(\"NATIVE\") == \"native\"\n+        assert _coerce_mode(\"Auto\") == \"auto\"\n+\n+    def test_invalid_falls_back_to_auto(self):\n+        assert _coerce_mode(\"nonsense\") == \"auto\"\n+        assert _coerce_mode(\"\") == \"auto\"\n+        assert _coerce_mode(None) == \"auto\"\n+        assert _coerce_mode(42) == \"auto\"\n+\n+    def test_strips_whitespace(self):\n+        assert _coerce_mode(\"  native  \") == \"native\"\n+\n+\n+# ─── _explicit_aux_vision_override ───────────────────────────────────────────\n+\n+\n+class TestExplicitAuxVisionOverride:\n+    def test_none_config(self):\n+        assert _explicit_aux_vision_override(None) is False\n+\n+    def test_empty_config(self):\n+        assert _explicit_aux_vision_override({}) is False\n+\n+    def test_default_auto_is_not_explicit(self):\n+        cfg = {\"auxiliary\": {\"vision\": {\"provider\": \"auto\", \"model\": \"\", \"base_url\": \"\"}}}\n+        assert _explicit_aux_vision_override(cfg) is False\n+\n+    def test_provider_set_is_explicit(self):\n+        cfg = {\"auxiliary\": {\"vision\": {\"provider\": \"openrouter\", \"model\": \"\"}}}\n+        assert _explicit_aux_vision_override(cfg) is True\n+\n+    def test_model_set_is_explicit(self):\n+        cfg = {\"auxiliary\": {\"vision\": {\"provider\": \"auto\", \"model\": \"google/gemini-2.5-flash\"}}}\n+        assert _explicit_aux_vision_override(cfg) is True\n+\n+    def test_base_url_set_is_explicit(self):\n+        cfg = {\"auxiliary\": {\"vision\": {\"provider\": \"auto\", \"base_url\": \"http://localhost:11434\"}}}\n+        assert _explicit_aux_vision_override(cfg) is True\n+\n+\n+# ─── decide_image_input_mode ─────────────────────────────────────────────────\n+\n+\n+class TestDecideImageInputMode:\n+    def test_explicit_native_overrides_everything(self):\n+        cfg = {\"agent\": {\"image_input_mode\": \"native\"}}\n+        # Non-vision model, aux-vision explicitly configured: native still wins.\n+        cfg[\"auxiliary\"] = {\"vision\": {\"provider\": \"openrouter\", \"model\": \"foo\"}}\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=False):\n+            assert decide_image_input_mode(\"openrouter\", \"some-non-vision-model\", cfg) == \"native\"\n+\n+    def test_explicit_text_overrides_everything(self):\n+        cfg = {\"agent\": {\"image_input_mode\": \"text\"}}\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=True):\n+            assert decide_image_input_mode(\"anthropic\", \"claude-sonnet-4\", cfg) == \"text\"\n+\n+    def test_auto_with_vision_capable_model(self):\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=True):\n+            assert decide_image_input_mode(\"anthropic\", \"claude-sonnet-4\", {}) == \"native\"\n+\n+    def test_auto_with_non_vision_model(self):\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=False):\n+            assert decide_image_input_mode(\"openrouter\", \"qwen/qwen3-235b\", {}) == \"text\"\n+\n+    def test_auto_with_unknown_model(self):\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=None):\n+            assert decide_image_input_mode(\"openrouter\", \"brand-new-slug\", {}) == \"text\"\n+\n+    def test_auto_respects_aux_vision_override_even_for_vision_model(self):\n+        \"\"\"If the user configured a dedicated vision backend, don't bypass it.\"\"\"\n+        cfg = {\"auxiliary\": {\"vision\": {\"provider\": \"openrouter\", \"model\": \"google/gemini-2.5-flash\"}}}\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=True):\n+            assert decide_image_input_mode(\"anthropic\", \"claude-sonnet-4\", cfg) == \"text\"\n+\n+    def test_none_config_is_auto(self):\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=True):\n+            assert decide_image_input_mode(\"anthropic\", \"claude-sonnet-4\", None) == \"native\"\n+\n+    def test_invalid_mode_coerces_to_auto(self):\n+        cfg = {\"agent\": {\"image_input_mode\": \"weird-value\"}}\n+        with patch(\"agent.image_routing._lookup_supports_vision\", return_value=True):\n+            assert decide_image_input_mode(\"anthropic\", \"claude-sonnet-4\", cfg) == \"native\"\n+\n+\n+# ─── build_native_content_parts ──────────────────────────────────────────────\n+\n+\n+def _png_bytes() -> bytes:\n+    \"\"\"Return a tiny valid 1x1 transparent PNG.\"\"\"\n+    return base64.b64decode(\n+        \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg==\"\n+    )\n+\n+\n+class TestBuildNativeContentParts:\n+    def test_text_then_image(self, tmp_path: Path):\n+        img = tmp_path / \"cat.png\"\n+        img.write_bytes(_png_bytes())\n+        parts, skipped = build_native_content_parts(\"hello\", [str(img)])\n+        assert skipped == []\n+        assert len(parts) == 2\n+        assert parts[0] == {\"type\": \"text\", \"text\": \"hello\"}\n+        assert parts[1][\"type\"] == \"image_url\"\n+        assert parts[1][\"image_url\"][\"url\"].startswith(\"data:image/png;base64,\")\n+\n+    def test_empty_text_inserts_default_prompt(self, tmp_path: Path):\n+        img = tmp_path / \"cat.jpg\"\n+        img.write_bytes(_png_bytes())\n+        parts, skipped = build_native_content_parts(\"\", [str(img)])\n+        assert skipped == []\n+        # Even with empty user text, we insert a neutral prompt so the turn\n+        # isn't just pixels.\n+        assert parts[0][\"type\"] == \"text\"\n+        assert parts[0][\"text\"] == \"What do you see in this image?\"\n+        assert parts[1][\"type\"] == \"image_url\"\n+\n+    def test_missing_file_is_skipped(self, tmp_path: Path):\n+        parts, skipped = build_native_content_parts(\"hi\", [str(tmp_path / \"missing.png\")])\n+        assert skipped == [str(tmp_path / \"missing.png\")]\n+        # Only text remains.\n+        assert parts == [{\"type\": \"text\", \"text\": \"hi\"}]\n+\n+    def test_multiple_images(self, tmp_path: Path):\n+        img1 = tmp_path / \"a.png\"\n+        img2 = tmp_path / \"b.png\"\n+        img1.write_bytes(_png_bytes())\n+        img2.write_bytes(_png_bytes())\n+        parts, skipped = build_native_content_parts(\"compare these\", [str(img1), str(img2)])\n+        assert skipped == []\n+        image_parts = [p for p in parts if p.get(\"type\") == \"image_url\"]\n+        assert len(image_parts) == 2\n+\n+    def test_mime_inference_jpg(self, tmp_path: Path):\n+        img = tmp_path / \"photo.jpg\"\n+        img.write_bytes(_png_bytes())  # bytes are PNG but extension is jpg\n+        parts, _ = build_native_content_parts(\"x\", [str(img)])\n+        url = parts[1][\"image_url\"][\"url\"]\n+        assert url.startswith(\"data:image/jpeg;base64,\")\n+\n+    def test_mime_inference_webp(self, tmp_path: Path):\n+        img = tmp_path / \"pic.webp\"\n+        img.write_bytes(_png_bytes())\n+        parts, _ = build_native_content_parts(\"\", [str(img)])\n+        url = parts[1][\"image_url\"][\"url\"]\n+        assert url.startswith(\"data:image/webp;base64,\")\n+\n+\n+# ─── Oversize handling ───────────────────────────────────────────────────────\n+\n+\n+class TestLargeImageHandling:\n+    \"\"\"Large images attach at native size; shrink is handled reactively at\n+    retry time in ``run_agent._try_shrink_image_parts_in_messages`` rather\n+    than proactively here.\n+    \"\"\"\n+\n+    def test_large_image_passes_through_unchanged(self, tmp_path: Path):\n+        \"\"\"A multi-MB image is attached as-is — no resize, no skip.\"\"\"\n+        from agent import image_routing as _ir\n+\n+        img = tmp_path / \"medium.png\"\n+        # 200 KB of real bytes; not huge but enough to verify no size gate fires.\n+        img.write_bytes(b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"X\" * 200_000)\n+        url = _ir._file_to_data_url(img)\n+        assert url is not None\n+        assert url.startswith(\"data:image/png;base64,\")\n+        # Base64 expansion means output is ~4/3 of input, plus header.\n+        assert len(url) > 200_000\n+\n+    def test_missing_file_returns_none(self, tmp_path: Path):\n+        from agent import image_routing as _ir\n+        missing = tmp_path / \"does_not_exist.png\"\n+        assert _ir._file_to_data_url(missing) is None\n+\n+    def test_build_native_parts_no_provider_kwarg(self, tmp_path: Path):\n+        \"\"\"build_native_content_parts takes text + paths, no provider kwarg.\"\"\"\n+        from agent import image_routing as _ir\n+\n+        img = tmp_path / \"cat.png\"\n+        img.write_bytes(_png_bytes())\n+        parts, skipped = _ir.build_native_content_parts(\"hi\", [str(img)])\n+        assert skipped == []\n+        assert len(parts) == 2\n+        assert parts[0][\"type\"] == \"text\"\n+        assert parts[1][\"type\"] == \"image_url\""]},{"file":"tests/run_agent/test_image_shrink_recovery.py","hunks":["@@ -0,0 +1,277 @@\n+\"\"\"Tests for reactive image-shrink recovery.\n+\n+Covers the full chain for Anthropic's 5 MB per-image ceiling (and any\n+future provider that returns an image-too-large error):\n+\n+  1. agent/error_classifier.py: 400 with \"image exceeds 5 MB maximum\"\n+     gets FailoverReason.image_too_large, not context_overflow.\n+  2. run_agent._try_shrink_image_parts_in_messages mutates the API\n+     payload in-place, re-encoding native data: URL image parts to fit\n+     under 4 MB using vision_tools._resize_image_for_vision.\n+\n+The end-to-end wiring in the retry loop is not unit-tested here — it's\n+covered by the live E2E in the PR description. These tests lock in the\n+two pieces that matter independently: the classifier signal and the\n+payload rewriter.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import base64\n+from pathlib import Path\n+\n+import pytest\n+\n+from agent.error_classifier import FailoverReason, classify_api_error\n+\n+\n+class _FakeApiError(Exception):\n+    \"\"\"Stand-in for an openai.BadRequestError with status_code + body.\"\"\"\n+\n+    def __init__(self, status_code: int, message: str, body: dict | None = None):\n+        super().__init__(message)\n+        self.status_code = status_code\n+        self.body = body or {\"error\": {\"message\": message}}\n+        self.response = None  # required by some code paths\n+\n+\n+# ─── Classifier ──────────────────────────────────────────────────────────────\n+\n+\n+class TestImageTooLargeClassification:\n+    def test_anthropic_400_image_exceeds_message(self):\n+        \"\"\"Anthropic's exact wording must classify as image_too_large, not context.\"\"\"\n+        err = _FakeApiError(\n+            status_code=400,\n+            message=(\n+                \"messages.0.content.1.image.source.base64: image exceeds 5 MB \"\n+                \"maximum: 12966600 bytes > 5242880 bytes\"\n+            ),\n+        )\n+        result = classify_api_error(err, provider=\"anthropic\", model=\"claude-sonnet-4-6\")\n+        assert result.reason == FailoverReason.image_too_large\n+        assert result.retryable is True\n+\n+    def test_generic_image_too_large_no_status(self):\n+        \"\"\"No status_code path: message text alone triggers classification.\"\"\"\n+        err = Exception(\"image too large for this endpoint\")\n+        result = classify_api_error(err, provider=\"some-provider\", model=\"some-model\")\n+        assert result.reason == FailoverReason.image_too_large\n+        assert result.retryable is True\n+\n+    def test_image_too_large_not_confused_with_context_overflow(self):\n+        \"\"\"'image exceeds' must NOT be mis-classified as context_overflow.\n+\n+        The context_overflow patterns include 'exceeds the limit' which is a\n+        superstring risk — verify the image-too-large check fires first.\n+        \"\"\"\n+        err = _FakeApiError(\n+            status_code=400,\n+            message=\"image exceeds the limit for this model\",\n+        )\n+        result = classify_api_error(err, provider=\"anthropic\", model=\"claude-sonnet-4-6\")\n+        assert result.reason == FailoverReason.image_too_large\n+\n+    def test_regular_context_overflow_unaffected(self):\n+        \"\"\"Context-overflow errors without image keywords still classify correctly.\"\"\"\n+        err = _FakeApiError(\n+            status_code=400,\n+            message=\"prompt is too long: context length 300000 exceeds max of 200000\",\n+        )\n+        result = classify_api_error(err, provider=\"anthropic\", model=\"claude-sonnet-4-6\")\n+        assert result.reason == FailoverReason.context_overflow\n+\n+\n+# ─── Shrink helper ───────────────────────────────────────────────────────────\n+\n+\n+def _big_png_data_url(size_kb: int) -> str:\n+    \"\"\"Build a data URL with a plausible large base64 payload.\"\"\"\n+    # Use real PNG header so MIME detection works; fill to target size.\n+    raw = b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"X\" * (size_kb * 1024)\n+    return \"data:image/png;base64,\" + base64.b64encode(raw).decode(\"ascii\")\n+\n+\n+def _make_agent():\n+    \"\"\"Build a bare AIAgent for method-level testing, no provider setup.\"\"\"\n+    from run_agent import AIAgent\n+    agent = object.__new__(AIAgent)\n+    agent.provider = \"anthropic\"\n+    agent.model = \"claude-sonnet-4-6\"\n+    return agent\n+\n+\n+class TestShrinkImagePartsHelper:\n+    def test_no_messages_returns_false(self):\n+        agent = _make_agent()\n+        assert agent._try_shrink_image_parts_in_messages([]) is False\n+        assert agent._try_shrink_image_parts_in_messages(None) is False\n+\n+    def test_no_image_parts_returns_false(self):\n+        agent = _make_agent()\n+        msgs = [\n+            {\"role\": \"user\", \"content\": \"plain text\"},\n+            {\"role\": \"assistant\", \"content\": \"ack\"},\n+        ]\n+        assert agent._try_shrink_image_parts_in_messages(msgs) is False\n+\n+    def test_small_image_part_not_shrunk(self, monkeypatch):\n+        \"\"\"An image under 4 MB is left alone — shrink helper only touches oversized ones.\"\"\"\n+        agent = _make_agent()\n+        small_url = _big_png_data_url(100)  # ~100 KB + b64 overhead\n+\n+        resize_hits = {\"count\": 0}\n+        monkeypatch.setattr(\n+            \"tools.vision_tools._resize_image_for_vision\",\n+            lambda *a, **kw: resize_hits.__setitem__(\"count\", resize_hits[\"count\"] + 1) or small_url,\n+            raising=False,\n+        )\n+\n+        msgs = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"text\", \"text\": \"hi\"},\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": small_url}},\n+            ],\n+        }]\n+        assert agent._try_shrink_image_parts_in_messages(msgs) is False\n+        assert resize_hits[\"count\"] == 0\n+        # URL unchanged.\n+        assert msgs[0][\"content\"][1][\"image_url\"][\"url\"] == small_url\n+\n+    def test_oversized_image_url_dict_shape_rewritten(self, monkeypatch):\n+        \"\"\"OpenAI chat.completions shape: {image_url: {url: data:...}}.\"\"\"\n+        agent = _make_agent()\n+        oversized_url = _big_png_data_url(5000)  # ~5 MB raw → ~6.7 MB b64\n+        shrunk = \"data:image/jpeg;base64,\" + \"A\" * 1000  # small\n+\n+        def _fake_resize(path, mime_type=None, max_base64_bytes=None):\n+            return shrunk\n+\n+        monkeypatch.setattr(\n+            \"tools.vision_tools._resize_image_for_vision\",\n+            _fake_resize,\n+            raising=False,\n+        )\n+\n+        msgs = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"text\", \"text\": \"look\"},\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": oversized_url}},\n+            ],\n+        }]\n+        changed = agent._try_shrink_image_parts_in_messages(msgs)\n+        assert changed is True\n+        assert msgs[0][\"content\"][1][\"image_url\"][\"url\"] == shrunk\n+\n+    def test_oversized_input_image_string_shape_rewritten(self, monkeypatch):\n+        \"\"\"OpenAI Responses shape: {type: input_image, image_url: \"data:...\"}.\"\"\"\n+        agent = _make_agent()\n+        oversized_url = _big_png_data_url(5000)\n+        shrunk = \"data:image/jpeg;base64,\" + \"B\" * 1000\n+\n+        monkeypatch.setattr(\n+            \"tools.vision_tools._resize_image_for_vision\",\n+            lambda *a, **kw: shrunk,\n+            raising=False,\n+        )\n+\n+        msgs = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"input_text\", \"text\": \"look\"},\n+                {\"type\": \"input_image\", \"image_url\": oversized_url},\n+            ],\n+        }]\n+        changed = agent._try_shrink_image_parts_in_messages(msgs)\n+        assert changed is True\n+        assert msgs[0][\"content\"][1][\"image_url\"] == shrunk\n+\n+    def test_multiple_images_all_shrunk(self, monkeypatch):\n+        agent = _make_agent()\n+        big1 = _big_png_data_url(5000)\n+        big2 = _big_png_data_url(6000)\n+        shrunk = \"data:image/jpeg;base64,\" + \"C\" * 500\n+\n+        monkeypatch.setattr(\n+            \"tools.vision_tools._resize_image_for_vision\",\n+            lambda *a, **kw: shrunk,\n+            raising=False,\n+        )\n+\n+        msgs = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"text\", \"text\": \"compare\"},\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": big1}},\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": big2}},\n+            ],\n+        }]\n+        changed = agent._try_shrink_image_parts_in_messages(msgs)\n+        assert changed is True\n+        assert msgs[0][\"content\"][1][\"image_url\"][\"url\"] == shrunk\n+        assert msgs[0][\"content\"][2][\"image_url\"][\"url\"] == shrunk\n+\n+    def test_http_url_images_not_touched(self, monkeypatch):\n+        \"\"\"Only data: URLs are candidates — http URLs are server-fetched.\"\"\"\n+        agent = _make_agent()\n+\n+        resize_hits = {\"count\": 0}\n+        monkeypatch.setattr(\n+            \"tools.vision_tools._resize_image_for_vision\",\n+            lambda *a, **kw: resize_hits.__setitem__(\"count\", resize_hits[\"count\"] + 1) or \"shrunk\",\n+            raising=False,\n+        )\n+\n+        msgs = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"text\", \"text\": \"at this url\"},\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": \"https://example.com/big.png\"}},\n+            ],\n+        }]\n+        assert agent._try_shrink_image_parts_in_messages(msgs) is False\n+        assert resize_hits[\"count\"] == 0\n+\n+    def test_shrink_failure_returns_false_and_leaves_url_intact(self, monkeypatch):\n+        \"\"\"If re-encode fails, leave the URL alone so the caller surfaces the original error.\"\"\"\n+        agent = _make_agent()\n+        oversized_url = _big_png_data_url(5000)\n+\n+        monkeypatch.setattr(\n+            \"tools.vision_tools._resize_image_for_vision\",\n+            lambda *a, **kw: None,  # resize returned nothing usable\n+            raising=False,\n+        )\n+\n+        msgs = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": oversized_url}},\n+            ],\n+        }]\n+        assert agent._try_shrink_image_parts_in_messages(msgs) is False\n+        assert msgs[0][\"content\"][0][\"image_url\"][\"url\"] == oversized_url\n+\n+    def test_shrink_that_makes_it_bigger_rejected(self, monkeypatch):\n+        \"\"\"If the 'shrink' somehow produces a larger payload, skip it.\"\"\"\n+        agent = _make_agent()\n+        oversized_url = _big_png_data_url(5000)\n+        even_bigger = \"data:image/png;base64,\" + \"Z\" * (10 * 1024 * 1024)\n+\n+        monkeypatch.setattr(\n+            \"tools.vision_tools._resize_image_for_vision\",\n+            lambda *a, **kw: even_bigger,\n+            raising=False,\n+        )\n+\n+        msgs = [{\n+            \"role\": \"user\",\n+            \"content\": [\n+                {\"type\": \"image_url\", \"image_url\": {\"url\": oversized_url}},\n+            ],\n+        }]\n+        assert agent._try_shrink_image_parts_in_messages(msgs) is False\n+        # Original URL still in place, not replaced by the bigger one.\n+        assert msgs[0][\"content\"][0][\"image_url\"][\"url\"] == oversized_url"]},{"file":"tests/run_agent/test_vision_aware_preprocessing.py","hunks":["@@ -0,0 +1,170 @@\n+\"\"\"Tests for the vision-aware image preprocessing in run_agent.py.\n+\n+Covers:\n+\n+* ``_prepare_anthropic_messages_for_api`` — passes image parts through\n+  unchanged when the active model reports ``supports_vision=True`` (the\n+  adapter handles them natively), and falls back to text-description\n+  replacement when the model lacks vision.\n+\n+* ``_prepare_messages_for_non_vision_model`` — the mirror method for the\n+  chat.completions / codex_responses paths. Same contract.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+\n+\n+def _make_agent() -> AIAgent:\n+    \"\"\"Build a bare-bones AIAgent instance without running __init__.\n+\n+    Avoids the heavy provider/credential setup for these pure-method tests.\n+    \"\"\"\n+    agent = object.__new__(AIAgent)\n+    agent.provider = \"anthropic\"\n+    agent.model = \"claude-sonnet-4\"\n+    agent._anthropic_image_fallback_cache = {}\n+    return agent\n+\n+\n+IMG_PARTS_USER_MSG = {\n+    \"role\": \"user\",\n+    \"content\": [\n+        {\"type\": \"text\", \"text\": \"What's in this image?\"},\n+        {\"type\": \"image_url\", \"image_url\": {\"url\": \"data:image/png;base64,AAAA\"}},\n+    ],\n+}\n+\n+PLAIN_USER_MSG = {\"role\": \"user\", \"content\": \"hello, no images here\"}\n+\n+\n+# ─── _prepare_anthropic_messages_for_api ─────────────────────────────────────\n+\n+\n+class TestPrepareAnthropicMessages:\n+    def test_no_images_passes_through(self):\n+        agent = _make_agent()\n+        msgs = [PLAIN_USER_MSG]\n+        out = agent._prepare_anthropic_messages_for_api(msgs)\n+        assert out is msgs  # unchanged reference\n+\n+    def test_vision_capable_passes_images_through(self):\n+        \"\"\"The Anthropic adapter handles image_url/input_image natively.\"\"\"\n+        agent = _make_agent()\n+        with patch.object(agent, \"_model_supports_vision\", return_value=True):\n+            out = agent._prepare_anthropic_messages_for_api([IMG_PARTS_USER_MSG])\n+        # Passes through unchanged — image_url parts still present.\n+        assert out[0][\"content\"][1][\"type\"] == \"image_url\"\n+\n+    def test_non_vision_replaces_images_with_text(self):\n+        agent = _make_agent()\n+        with patch.object(agent, \"_model_supports_vision\", return_value=False), \\\n+             patch.object(\n+                 agent,\n+                 \"_describe_image_for_anthropic_fallback\",\n+                 return_value=\"[Image description: a cat]\",\n+             ):\n+            out = agent._prepare_anthropic_messages_for_api([IMG_PARTS_USER_MSG])\n+        # Content collapsed to a string containing the description + user text.\n+        content = out[0][\"content\"]\n+        assert isinstance(content, str)\n+        assert \"[Image description: a cat]\" in content\n+        assert \"What's in this image?\" in content\n+        # No more image parts.\n+        assert \"image_url\" not in content\n+\n+\n+# ─── _prepare_messages_for_non_vision_model ──────────────────────────────────\n+\n+\n+class TestPrepareMessagesForNonVision:\n+    def test_no_images_passes_through(self):\n+        agent = _make_agent()\n+        msgs = [PLAIN_USER_MSG]\n+        out = agent._prepare_messages_for_non_vision_model(msgs)\n+        assert out is msgs\n+\n+    def test_vision_capable_passes_through(self):\n+        \"\"\"For vision-capable models on chat.completions path, provider handles pixels.\"\"\"\n+        agent = _make_agent()\n+        agent.provider = \"openrouter\"\n+        agent.model = \"anthropic/claude-sonnet-4\"\n+        with patch.object(agent, \"_model_supports_vision\", return_value=True):\n+            out = agent._prepare_messages_for_non_vision_model([IMG_PARTS_USER_MSG])\n+        assert out[0][\"content\"][1][\"type\"] == \"image_url\"\n+\n+    def test_non_vision_strips_images(self):\n+        agent = _make_agent()\n+        agent.provider = \"openrouter\"\n+        agent.model = \"qwen/qwen3-235b-a22b\"\n+        with patch.object(agent, \"_model_supports_vision\", return_value=False), \\\n+             patch.object(\n+                 agent,\n+                 \"_describe_image_for_anthropic_fallback\",\n+                 return_value=\"[Image description: a dog]\",\n+             ):\n+            out = agent._prepare_messages_for_non_vision_model([IMG_PARTS_USER_MSG])\n+        content = out[0][\"content\"]\n+        assert isinstance(content, str)\n+        assert \"[Image description: a dog]\" in content\n+        assert \"image_url\" not in content\n+\n+    def test_multiple_messages_with_mixed_content(self):\n+        agent = _make_agent()\n+        agent.model = \"qwen/qwen3-235b\"\n+        msgs = [\n+            {\"role\": \"user\", \"content\": \"first turn\"},\n+            {\"role\": \"assistant\", \"content\": \"ack\"},\n+            IMG_PARTS_USER_MSG,\n+        ]\n+        with patch.object(agent, \"_model_supports_vision\", return_value=False), \\\n+             patch.object(\n+                 agent,\n+                 \"_describe_image_for_anthropic_fallback\",\n+                 return_value=\"[Image: thing]\",\n+             ):\n+            out = agent._prepare_messages_for_non_vision_model(msgs)\n+        # First two messages unchanged (no images), third stripped.\n+        assert out[0][\"content\"] == \"first turn\"\n+        assert out[1][\"content\"] == \"ack\"\n+        assert isinstance(out[2][\"content\"], str)\n+        assert \"[Image: thing]\" in out[2][\"content\"]\n+\n+\n+# ─── _model_supports_vision ──────────────────────────────────────────────────\n+\n+\n+class TestModelSupportsVision:\n+    def test_missing_provider_or_model_returns_false(self):\n+        agent = _make_agent()\n+        agent.provider = \"\"\n+        agent.model = \"claude-sonnet-4\"\n+        assert agent._model_supports_vision() is False\n+        agent.provider = \"anthropic\"\n+        agent.model = \"\"\n+        assert agent._model_supports_vision() is False\n+\n+    def test_uses_get_model_capabilities(self):\n+        agent = _make_agent()\n+        fake_caps = MagicMock()\n+        fake_caps.supports_vision = True\n+        with patch(\"agent.models_dev.get_model_capabilities\", return_value=fake_caps):\n+            assert agent._model_supports_vision() is True\n+        fake_caps.supports_vision = False\n+        with patch(\"agent.models_dev.get_model_capabilities\", return_value=fake_caps):\n+            assert agent._model_supports_vision() is False\n+\n+    def test_none_caps_returns_false(self):\n+        agent = _make_agent()\n+        with patch(\"agent.models_dev.get_model_capabilities\", return_value=None):\n+            assert agent._model_supports_vision() is False\n+\n+    def test_exception_returns_false(self):\n+        agent = _make_agent()\n+        with patch(\"agent.models_dev.get_model_capabilities\", side_effect=RuntimeError(\"boom\")):\n+            assert agent._model_supports_vision() is False"]},{"file":"tools/vision_tools.py","hunks":["@@ -754,7 +754,15 @@ def check_vision_requirements() -> bool:\n \n VISION_ANALYZE_SCHEMA = {\n     \"name\": \"vision_analyze\",\n-    \"description\": \"Analyze images using AI vision. Provides a comprehensive description and answers a specific question about the image content.\",\n+    \"description\": (\n+        \"Inspect an image from a URL, file path, or tool output when you need \"\n+        \"closer detail than what's visible in the conversation. If the user's \"\n+        \"image is already attached to the conversation and you can see it, \"\n+        \"just answer directly — only call this tool for images referenced by \"\n+        \"URL/path, images returned inside other tool results (browser \"\n+        \"screenshots, search thumbnails), or when you need a deeper look at \"\n+        \"a specific region the main model's vision may have missed.\"\n+    ),\n     \"parameters\": {\n         \"type\": \"object\",\n         \"properties\": {"]},{"file":"tui_gateway/server.py","hunks":["@@ -13,7 +13,7 @@\n import uuid\n from datetime import datetime\n from pathlib import Path\n-from typing import Optional\n+from typing import Any, Optional\n \n from hermes_constants import get_hermes_home\n from hermes_cli.env_loader import load_hermes_dotenv","@@ -2274,7 +2274,60 @@ def run():\n                     return\n                 prompt = ctx.message\n \n-            prompt = _enrich_with_attached_images(prompt, images) if images else prompt\n+            # Decide image routing per-turn based on active provider/model.\n+            # \"native\" → pass pixels to the main model as OpenAI-style content\n+            # parts (adapters translate for Anthropic/Gemini/Bedrock/etc.).\n+            # \"text\"   → pre-analyze with vision_analyze and prepend the text.\n+            # See agent/image_routing.py for the full decision table.\n+            run_message: Any = prompt\n+            if images:\n+                try:\n+                    from agent.image_routing import (\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+                    _mode = decide_image_input_mode(\n+                        _read_main_provider(),\n+                        _read_main_model(),\n+                        _cfg,\n+                    )\n+                except Exception as _img_exc:\n+                    print(\n+                        f\"[tui_gateway] image_routing decision failed, defaulting to text: {_img_exc}\",\n+                        file=sys.stderr,\n+                    )\n+                    _mode = \"text\"\n+\n+                if _mode == \"native\":\n+                    try:\n+                        _parts, _skipped = build_native_content_parts(\n+                            prompt,\n+                            images,\n+                        )\n+                        if _skipped:\n+                            print(\n+                                f\"[tui_gateway] native image attachment skipped {len(_skipped)} unreadable path(s)\",\n+                                file=sys.stderr,\n+                            )\n+                        if any(p.get(\"type\") == \"image_url\" for p in _parts):\n+                            run_message = _parts\n+                        else:\n+                            run_message = _enrich_with_attached_images(prompt, images)\n+                    except Exception as _img_exc:\n+                        print(\n+                            f\"[tui_gateway] native attach failed, falling back to text: {_img_exc}\",\n+                            file=sys.stderr,\n+                        )\n+                        run_message = _enrich_with_attached_images(prompt, images)\n+                else:\n+                    run_message = _enrich_with_attached_images(prompt, images)\n \n             def _stream(delta):\n                 payload = {\"text\": delta}","@@ -2283,7 +2336,7 @@ def _stream(delta):\n                 _emit(\"message.delta\", sid, payload)\n \n             result = agent.run_conversation(\n-                prompt,\n+                run_message,\n                 conversation_history=list(history),\n                 stream_callback=_stream,\n             )\n"]}]},{"kind":"pr","number":27238,"title":"fix(api-server): route inline images through the auxiliary vision pipeline (#27232)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ec671c41546eaf63630a441f67e264acc6ff77c6","number":0},{"kind":"pr","value":"#16506","number":16506}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}