{"repo":"NousResearch/hermes-agent","public_id":"7740","display_label":"Vision image sizing, timeout, and capability fixes","summary":"These items address bugs and fixes related to vision functionality, specifically around oversized image handling (auto-resizing), upload size limits, default timeout values, and vision capability detection on fresh installs.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":7883,"merged_at":"2026-04-11T18:12:50+00:00"},"resolution":{"state":"resolved","by":"pr-7883"},"freshness":{"state":"stale","last_verified_at":"2026-04-11T18:12:50+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":7740,"pr_number":7883,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-11T18:12:50+00:00"}],"members":[{"kind":"issue","number":7740,"title":"[Bug]: Vision completely broken on fresh install with Google/Gemini — oversized images rejected instead of auto-resized, 30s default timeout too low","url":null,"state":"closed"},{"kind":"pr","number":7749,"title":"fix(vision): auto-resize oversized images, increase default timeout, fix vision capability detection","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#7883","number":7883}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":7883,"title":"fix(vision): auto-resize oversized images, raise limit to 20 MB, retry-on-failure","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/models_dev.py","hermes_cli/config.py","tests/agent/test_models_dev.py","tests/tools/test_vision_tools.py","tools/browser_tool.py","tools/vision_tools.py"],"fix_hunks":[{"file":"agent/models_dev.py","hunks":["@@ -383,7 +383,14 @@ def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilit\n \n     # Extract capability flags (default to False if missing)\n     supports_tools = bool(entry.get(\"tool_call\", False))\n-    supports_vision = bool(entry.get(\"attachment\", False))\n+    # Vision: check both the `attachment` flag and `modalities.input` for \"image\".\n+    # Some models (e.g. gemma-4) list image in input modalities but not attachment.\n+    input_mods = entry.get(\"modalities\", {})\n+    if isinstance(input_mods, dict):\n+        input_mods = input_mods.get(\"input\", [])\n+    else:\n+        input_mods = []\n+    supports_vision = bool(entry.get(\"attachment\", False)) or \"image\" in input_mods\n     supports_reasoning = bool(entry.get(\"reasoning\", False))\n \n     # Extract limits"]},{"file":"hermes_cli/config.py","hunks":["@@ -381,7 +381,7 @@ def _ensure_hermes_home_managed(home: Path):\n             \"model\": \"\",           # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n             \"base_url\": \"\",        # direct OpenAI-compatible endpoint (takes precedence over provider)\n             \"api_key\": \"\",         # API key for base_url (falls back to OPENAI_API_KEY)\n-            \"timeout\": 30,         # seconds — LLM API call timeout; increase for slow local vision models\n+            \"timeout\": 120,        # seconds — LLM API call timeout; vision payloads need generous timeout\n             \"download_timeout\": 30,  # seconds — image HTTP download timeout; increase for slow connections\n         },\n         \"web_extract\": {"]},{"file":"tests/agent/test_models_dev.py","hunks":["@@ -7,6 +7,7 @@\n     PROVIDER_TO_MODELS_DEV,\n     _extract_context,\n     fetch_models_dev,\n+    get_model_capabilities,\n     lookup_models_dev_context,\n )\n ","@@ -195,3 +196,88 @@ def test_in_memory_cache_used(self, mock_get):\n         result = fetch_models_dev()\n         mock_get.assert_not_called()\n         assert result == SAMPLE_REGISTRY\n+\n+\n+# ---------------------------------------------------------------------------\n+# get_model_capabilities — vision via modalities.input\n+# ---------------------------------------------------------------------------\n+\n+\n+CAPS_REGISTRY = {\n+    \"google\": {\n+        \"id\": \"google\",\n+        \"models\": {\n+            \"gemma-4-31b-it\": {\n+                \"id\": \"gemma-4-31b-it\",\n+                \"attachment\": False,\n+                \"tool_call\": True,\n+                \"modalities\": {\"input\": [\"text\", \"image\"]},\n+                \"limit\": {\"context\": 128000, \"output\": 8192},\n+            },\n+            \"gemma-3-1b\": {\n+                \"id\": \"gemma-3-1b\",\n+                \"tool_call\": True,\n+                \"limit\": {\"context\": 32000, \"output\": 8192},\n+            },\n+        },\n+    },\n+    \"anthropic\": {\n+        \"id\": \"anthropic\",\n+        \"models\": {\n+            \"claude-sonnet-4\": {\n+                \"id\": \"claude-sonnet-4\",\n+                \"attachment\": True,\n+                \"tool_call\": True,\n+                \"limit\": {\"context\": 200000, \"output\": 64000},\n+            },\n+        },\n+    },\n+}\n+\n+\n+class TestGetModelCapabilities:\n+    \"\"\"Tests for get_model_capabilities vision detection.\"\"\"\n+\n+    def test_vision_from_attachment_flag(self):\n+        \"\"\"Models with attachment=True should report supports_vision=True.\"\"\"\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value=CAPS_REGISTRY):\n+            caps = get_model_capabilities(\"anthropic\", \"claude-sonnet-4\")\n+        assert caps is not None\n+        assert caps.supports_vision is True\n+\n+    def test_vision_from_modalities_input_image(self):\n+        \"\"\"Models with 'image' in modalities.input but attachment=False should\n+        still report supports_vision=True (the core fix in this PR).\"\"\"\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value=CAPS_REGISTRY):\n+            caps = get_model_capabilities(\"google\", \"gemma-4-31b-it\")\n+        assert caps is not None\n+        assert caps.supports_vision is True\n+\n+    def test_no_vision_without_attachment_or_modalities(self):\n+        \"\"\"Models with neither attachment nor image modality should be non-vision.\"\"\"\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value=CAPS_REGISTRY):\n+            caps = get_model_capabilities(\"google\", \"gemma-3-1b\")\n+        assert caps is not None\n+        assert caps.supports_vision is False\n+\n+    def test_modalities_non_dict_handled(self):\n+        \"\"\"Non-dict modalities field should not crash.\"\"\"\n+        registry = {\n+            \"google\": {\"id\": \"google\", \"models\": {\n+                \"weird-model\": {\n+                    \"id\": \"weird-model\",\n+                    \"modalities\": \"text\",  # not a dict\n+                    \"limit\": {\"context\": 200000, \"output\": 8192},\n+                },\n+            }},\n+        }\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value=registry):\n+            caps = get_model_capabilities(\"gemini\", \"weird-model\")\n+        assert caps is not None\n+        assert caps.supports_vision is False\n+\n+    def test_model_not_found_returns_none(self):\n+        \"\"\"Unknown model should return None.\"\"\"\n+        with patch(\"agent.models_dev.fetch_models_dev\", return_value=CAPS_REGISTRY):\n+            caps = get_model_capabilities(\"anthropic\", \"nonexistent-model\")\n+        assert caps is None"]},{"file":"tests/tools/test_vision_tools.py","hunks":["@@ -15,6 +15,10 @@\n     _handle_vision_analyze,\n     _determine_mime_type,\n     _image_to_base64_data_url,\n+    _resize_image_for_vision,\n+    _is_image_size_error,\n+    _MAX_BASE64_BYTES,\n+    _RESIZE_TARGET_BYTES,\n     vision_analyze_tool,\n     check_vision_requirements,\n     get_debug_session_info,","@@ -590,11 +594,13 @@ class TestBase64SizeLimit:\n \n     @pytest.mark.asyncio\n     async def test_oversized_image_rejected_before_api_call(self, tmp_path):\n-        \"\"\"Images exceeding 5 MB base64 should fail with a clear size error.\"\"\"\n+        \"\"\"Images exceeding the 20 MB hard limit should fail with a clear error.\"\"\"\n         img = tmp_path / \"huge.png\"\n         img.write_bytes(b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * (4 * 1024 * 1024))\n \n-        with patch(\"tools.vision_tools.async_call_llm\", new_callable=AsyncMock) as mock_llm:\n+        # Patch the hard limit to a small value so the test runs fast.\n+        with patch(\"tools.vision_tools._MAX_BASE64_BYTES\", 1000), \\\n+             patch(\"tools.vision_tools.async_call_llm\", new_callable=AsyncMock) as mock_llm:\n             result = json.loads(await vision_analyze_tool(str(img), \"describe this\"))\n \n         assert result[\"success\"] is False","@@ -686,3 +692,124 @@ def test_handler_is_callable(self):\n \n         entry = registry._tools.get(\"vision_analyze\")\n         assert callable(entry.handler)\n+\n+\n+# ---------------------------------------------------------------------------\n+# _resize_image_for_vision — auto-resize oversized images\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestResizeImageForVision:\n+    \"\"\"Tests for the auto-resize function.\"\"\"\n+\n+    def test_small_image_returned_as_is(self, tmp_path):\n+        \"\"\"Images under the limit should be returned unchanged.\"\"\"\n+        # Create a small 10x10 red PNG\n+        try:\n+            from PIL import Image\n+        except ImportError:\n+            pytest.skip(\"Pillow not installed\")\n+        img = Image.new(\"RGB\", (10, 10), (255, 0, 0))\n+        path = tmp_path / \"small.png\"\n+        img.save(path, \"PNG\")\n+\n+        result = _resize_image_for_vision(path, mime_type=\"image/png\")\n+        assert result.startswith(\"data:image/png;base64,\")\n+        assert len(result) < _MAX_BASE64_BYTES\n+\n+    def test_large_image_is_resized(self, tmp_path):\n+        \"\"\"Images over the default target should be auto-resized to fit.\"\"\"\n+        try:\n+            from PIL import Image\n+        except ImportError:\n+            pytest.skip(\"Pillow not installed\")\n+        # Create a large image that will exceed 5 MB in base64\n+        # A 4000x4000 uncompressed PNG will be large\n+        img = Image.new(\"RGB\", (4000, 4000), (128, 200, 50))\n+        path = tmp_path / \"large.png\"\n+        img.save(path, \"PNG\")\n+\n+        result = _resize_image_for_vision(path, mime_type=\"image/png\")\n+        assert result.startswith(\"data:image/png;base64,\")\n+        # Default target is _RESIZE_TARGET_BYTES (5 MB), not _MAX_BASE64_BYTES (20 MB)\n+        assert len(result) <= _RESIZE_TARGET_BYTES\n+\n+    def test_custom_max_bytes(self, tmp_path):\n+        \"\"\"The max_base64_bytes parameter should be respected.\"\"\"\n+        try:\n+            from PIL import Image\n+        except ImportError:\n+            pytest.skip(\"Pillow not installed\")\n+        img = Image.new(\"RGB\", (200, 200), (0, 128, 255))\n+        path = tmp_path / \"medium.png\"\n+        img.save(path, \"PNG\")\n+\n+        # Set a very low limit to force resizing\n+        result = _resize_image_for_vision(path, max_base64_bytes=500)\n+        # Should still return a valid data URL\n+        assert result.startswith(\"data:image/\")\n+\n+    def test_jpeg_output_for_non_png(self, tmp_path):\n+        \"\"\"Non-PNG images should be resized as JPEG.\"\"\"\n+        try:\n+            from PIL import Image\n+        except ImportError:\n+            pytest.skip(\"Pillow not installed\")\n+        img = Image.new(\"RGB\", (2000, 2000), (255, 128, 0))\n+        path = tmp_path / \"photo.jpg\"\n+        img.save(path, \"JPEG\", quality=95)\n+\n+        result = _resize_image_for_vision(path, mime_type=\"image/jpeg\",\n+                                           max_base64_bytes=50_000)\n+        assert result.startswith(\"data:image/jpeg;base64,\")\n+\n+    def test_constants_sane(self):\n+        \"\"\"Hard limit should be larger than resize target.\"\"\"\n+        assert _MAX_BASE64_BYTES == 20 * 1024 * 1024\n+        assert _RESIZE_TARGET_BYTES == 5 * 1024 * 1024\n+        assert _MAX_BASE64_BYTES > _RESIZE_TARGET_BYTES\n+\n+    def test_no_pillow_returns_original(self, tmp_path):\n+        \"\"\"Without Pillow, oversized images should be returned as-is.\"\"\"\n+        # Create a dummy file\n+        path = tmp_path / \"test.png\"\n+        # Write enough bytes to exceed a tiny limit\n+        path.write_bytes(b\"\\x89PNG\\r\\n\\x1a\\n\" + b\"\\x00\" * 1000)\n+\n+        with patch(\"tools.vision_tools._image_to_base64_data_url\") as mock_b64:\n+            # Simulate a large base64 result\n+            mock_b64.return_value = \"data:image/png;base64,\" + \"A\" * 200\n+            with patch.dict(\"sys.modules\", {\"PIL\": None, \"PIL.Image\": None}):\n+                result = _resize_image_for_vision(path, max_base64_bytes=100)\n+                # Should return the original (oversized) data url\n+                assert len(result) > 100\n+\n+\n+# ---------------------------------------------------------------------------\n+# _is_image_size_error — detect size-related API errors\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestIsImageSizeError:\n+    \"\"\"Tests for the size-error detection helper.\"\"\"\n+\n+    def test_too_large_message(self):\n+        assert _is_image_size_error(Exception(\"Request payload too large\"))\n+\n+    def test_413_status(self):\n+        assert _is_image_size_error(Exception(\"HTTP 413 Payload Too Large\"))\n+\n+    def test_invalid_request(self):\n+        assert _is_image_size_error(Exception(\"invalid_request_error: image too big\"))\n+\n+    def test_exceeds_limit(self):\n+        assert _is_image_size_error(Exception(\"Image exceeds maximum size\"))\n+\n+    def test_unrelated_error(self):\n+        assert not _is_image_size_error(Exception(\"Connection refused\"))\n+\n+    def test_auth_error(self):\n+        assert not _is_image_size_error(Exception(\"401 Unauthorized\"))\n+\n+    def test_empty_message(self):\n+        assert not _is_image_size_error(Exception(\"\"))"]},{"file":"tools/browser_tool.py","hunks":["@@ -1873,10 +1873,10 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]\n                 ),\n             }, ensure_ascii=False)\n         \n-        # Read and convert to base64\n-        image_data = screenshot_path.read_bytes()\n-        image_base64 = base64.b64encode(image_data).decode(\"ascii\")\n-        data_url = f\"data:image/png;base64,{image_base64}\"\n+        # Convert screenshot to base64 at full resolution.\n+        _screenshot_bytes = screenshot_path.read_bytes()\n+        _screenshot_b64 = base64.b64encode(_screenshot_bytes).decode(\"ascii\")\n+        data_url = f\"data:image/png;base64,{_screenshot_b64}\"\n         \n         vision_prompt = (\n             f\"You are analyzing a screenshot of a web browser.\\n\\n\"","@@ -1890,7 +1890,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]\n         # Use the centralized LLM router\n         vision_model = _get_vision_model()\n         logger.debug(\"browser_vision: analysing screenshot (%d bytes)\",\n-                     len(image_data))\n+                     len(_screenshot_bytes))\n \n         # Read vision timeout from config (auxiliary.vision.timeout), default 120s.\n         # Local vision models (llama.cpp, ollama) can take well over 30s for","@@ -1922,7 +1922,27 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]\n         }\n         if vision_model:\n             call_kwargs[\"model\"] = vision_model\n-        response = call_llm(**call_kwargs)\n+        # Try full-size screenshot; on size-related rejection, downscale and retry.\n+        try:\n+            response = call_llm(**call_kwargs)\n+        except Exception as _api_err:\n+            from tools.vision_tools import (\n+                _is_image_size_error, _resize_image_for_vision, _RESIZE_TARGET_BYTES,\n+            )\n+            if (_is_image_size_error(_api_err)\n+                    and len(data_url) > _RESIZE_TARGET_BYTES):\n+                logger.info(\n+                    \"Vision API rejected screenshot (%.1f MB); \"\n+                    \"auto-resizing to ~%.0f MB and retrying...\",\n+                    len(data_url) / (1024 * 1024),\n+                    _RESIZE_TARGET_BYTES / (1024 * 1024),\n+                )\n+                data_url = _resize_image_for_vision(\n+                    screenshot_path, mime_type=\"image/png\")\n+                call_kwargs[\"messages\"][0][\"content\"][1][\"image_url\"][\"url\"] = data_url\n+                response = call_llm(**call_kwargs)\n+            else:\n+                raise\n         \n         analysis = (response.choices[0].message.content or \"\").strip()\n         # Redact secrets the vision LLM may have read from the screenshot."]},{"file":"tools/vision_tools.py","hunks":["@@ -277,6 +277,120 @@ def _image_to_base64_data_url(image_path: Path, mime_type: Optional[str] = None)\n     return data_url\n \n \n+# Hard limit for vision API payloads (20 MB) — matches the most restrictive\n+# major provider (Gemini inline data limit).  Images above this are rejected.\n+_MAX_BASE64_BYTES = 20 * 1024 * 1024\n+\n+# Target size when auto-resizing on API failure (5 MB).  After a provider\n+# rejects an image, we downscale to this target and retry once.\n+_RESIZE_TARGET_BYTES = 5 * 1024 * 1024\n+\n+\n+def _is_image_size_error(error: Exception) -> bool:\n+    \"\"\"Detect if an API error is related to image or payload size.\"\"\"\n+    err_str = str(error).lower()\n+    return any(hint in err_str for hint in (\n+        \"too large\", \"payload\", \"413\", \"content_too_large\",\n+        \"request_too_large\", \"image_url\", \"invalid_request\",\n+        \"exceeds\", \"size limit\",\n+    ))\n+\n+\n+def _resize_image_for_vision(image_path: Path, mime_type: Optional[str] = None,\n+                              max_base64_bytes: int = _RESIZE_TARGET_BYTES) -> str:\n+    \"\"\"Convert an image to a base64 data URL, auto-resizing if too large.\n+\n+    Tries Pillow first to progressively downscale oversized images.  If Pillow\n+    is not installed or resizing still exceeds the limit, falls back to the raw\n+    bytes and lets the caller handle the size check.\n+\n+    Returns the base64 data URL string.\n+    \"\"\"\n+    # Quick file-size estimate: base64 expands by ~4/3, plus data URL header.\n+    # Skip the expensive full-read + encode if Pillow can resize directly.\n+    file_size = image_path.stat().st_size\n+    estimated_b64 = (file_size * 4) // 3 + 100  # ~header overhead\n+    if estimated_b64 <= max_base64_bytes:\n+        # Small enough — just encode directly.\n+        data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)\n+        if len(data_url) <= max_base64_bytes:\n+            return data_url\n+    else:\n+        data_url = None  # defer full encode; try Pillow resize first\n+\n+    # Attempt auto-resize with Pillow (soft dependency)\n+    try:\n+        from PIL import Image\n+        import io as _io\n+    except ImportError:\n+        logger.info(\"Pillow not installed — cannot auto-resize oversized image\")\n+        if data_url is None:\n+            data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)\n+        return data_url  # caller will raise the size error\n+\n+    logger.info(\"Image file is %.1f MB (estimated base64 %.1f MB, limit %.1f MB), auto-resizing...\",\n+                file_size / (1024 * 1024), estimated_b64 / (1024 * 1024),\n+                max_base64_bytes / (1024 * 1024))\n+\n+    mime = mime_type or _determine_mime_type(image_path)\n+    # Choose output format: JPEG for photos (smaller), PNG for transparency\n+    pil_format = \"PNG\" if mime == \"image/png\" else \"JPEG\"\n+    out_mime = \"image/png\" if pil_format == \"PNG\" else \"image/jpeg\"\n+\n+    try:\n+        img = Image.open(image_path)\n+    except Exception as exc:\n+        logger.info(\"Pillow cannot open image for resizing: %s\", exc)\n+        if data_url is None:\n+            data_url = _image_to_base64_data_url(image_path, mime_type=mime_type)\n+        return data_url  # fall through to size-check in caller\n+    # Convert RGBA to RGB for JPEG output\n+    if pil_format == \"JPEG\" and img.mode in (\"RGBA\", \"P\"):\n+        img = img.convert(\"RGB\")\n+\n+    # Strategy: halve dimensions until base64 fits, up to 4 rounds.\n+    # For JPEG, also try reducing quality at each size step.\n+    # For PNG, quality is irrelevant — only dimension reduction helps.\n+    quality_steps = (85, 70, 50) if pil_format == \"JPEG\" else (None,)\n+    prev_dims = (img.width, img.height)\n+    candidate = None  # will be set on first loop iteration\n+\n+    for attempt in range(5):\n+        if attempt > 0:\n+            new_w = max(img.width // 2, 64)\n+            new_h = max(img.height // 2, 64)\n+            # Stop if dimensions can't shrink further\n+            if (new_w, new_h) == prev_dims:\n+                break\n+            img = img.resize((new_w, new_h), Image.LANCZOS)\n+            prev_dims = (new_w, new_h)\n+            logger.info(\"Resized to %dx%d (attempt %d)\", new_w, new_h, attempt)\n+\n+        for q in quality_steps:\n+            buf = _io.BytesIO()\n+            save_kwargs = {\"format\": pil_format}\n+            if q is not None:\n+                save_kwargs[\"quality\"] = q\n+            img.save(buf, **save_kwargs)\n+            encoded = base64.b64encode(buf.getvalue()).decode(\"ascii\")\n+            candidate = f\"data:{out_mime};base64,{encoded}\"\n+            if len(candidate) <= max_base64_bytes:\n+                logger.info(\"Auto-resized image fits: %.1f MB (quality=%s, %dx%d)\",\n+                            len(candidate) / (1024 * 1024), q,\n+                            img.width, img.height)\n+                return candidate\n+\n+    # If we still can't get it small enough, return the best attempt\n+    # and let the caller decide\n+    if candidate is not None:\n+        logger.warning(\"Auto-resize could not fit image under %.1f MB (best: %.1f MB)\",\n+                       max_base64_bytes / (1024 * 1024), len(candidate) / (1024 * 1024))\n+        return candidate\n+\n+    # Shouldn't reach here, but fall back to full encode\n+    return data_url or _image_to_base64_data_url(image_path, mime_type=mime_type)\n+\n+\n async def vision_analyze_tool(\n     image_url: str,\n     user_prompt: str,","@@ -376,24 +490,27 @@ async def vision_analyze_tool(\n         if not detected_mime_type:\n             raise ValueError(\"Only real image files are supported for vision analysis.\")\n         \n-        # Convert image to base64 data URL\n+        # Convert image to base64 — send at full resolution first.\n+        # If the provider rejects it as too large, we auto-resize and retry.\n         logger.info(\"Converting image to base64...\")\n         image_data_url = _image_to_base64_data_url(temp_image_path, mime_type=detected_mime_type)\n-        # Calculate size in KB for better readability\n         data_size_kb = len(image_data_url) / 1024\n         logger.info(\"Image converted to base64 (%.1f KB)\", data_size_kb)\n \n-        # Pre-flight size check: most vision APIs cap base64 payloads at 5 MB.\n-        # Reject early with a clear message instead of a cryptic provider 400.\n-        _MAX_BASE64_BYTES = 5 * 1024 * 1024  # 5 MB\n-        # The data URL includes the header (e.g. \"data:image/jpeg;base64,\") which\n-        # is negligible, but measure the full string to be safe.\n+        # Hard limit (20 MB) — no provider accepts payloads this large.\n         if len(image_data_url) > _MAX_BASE64_BYTES:\n-            raise ValueError(\n-                f\"Image too large for vision API: base64 payload is \"\n-                f\"{len(image_data_url) / (1024 * 1024):.1f} MB (limit 5 MB). \"\n-                f\"Resize or compress the image and try again.\"\n-            )\n+            # Try to resize down to 5 MB before giving up.\n+            image_data_url = _resize_image_for_vision(\n+                temp_image_path, mime_type=detected_mime_type)\n+            if len(image_data_url) > _MAX_BASE64_BYTES:\n+                raise ValueError(\n+                    f\"Image too large for vision API: base64 payload is \"\n+                    f\"{len(image_data_url) / (1024 * 1024):.1f} MB \"\n+                    f\"(limit {_MAX_BASE64_BYTES / (1024 * 1024):.0f} MB) \"\n+                    f\"even after resizing. \"\n+                    f\"Install Pillow (`pip install Pillow`) for better auto-resize, \"\n+                    f\"or compress the image manually.\"\n+                )\n \n         debug_call_data[\"image_size_bytes\"] = image_size_bytes\n         ","@@ -442,7 +559,24 @@ async def vision_analyze_tool(\n         }\n         if model:\n             call_kwargs[\"model\"] = model\n-        response = await async_call_llm(**call_kwargs)\n+        # Try full-size image first; on size-related rejection, downscale and retry.\n+        try:\n+            response = await async_call_llm(**call_kwargs)\n+        except Exception as _api_err:\n+            if (_is_image_size_error(_api_err)\n+                    and len(image_data_url) > _RESIZE_TARGET_BYTES):\n+                logger.info(\n+                    \"API rejected image (%.1f MB, likely too large); \"\n+                    \"auto-resizing to ~%.0f MB and retrying...\",\n+                    len(image_data_url) / (1024 * 1024),\n+                    _RESIZE_TARGET_BYTES / (1024 * 1024),\n+                )\n+                image_data_url = _resize_image_for_vision(\n+                    temp_image_path, mime_type=detected_mime_type)\n+                messages[0][\"content\"][1][\"image_url\"][\"url\"] = image_data_url\n+                response = await async_call_llm(**call_kwargs)\n+            else:\n+                raise\n         \n         # Extract the analysis — fall back to reasoning if content is empty\n         analysis = extract_content_or_reasoning(response)","@@ -498,8 +632,8 @@ async def vision_analyze_tool(\n         elif \"invalid_request\" in err_str or \"image_url\" in err_str:\n             analysis = (\n                 \"The vision API rejected the image. This can happen when the \"\n-                \"image is too large, in an unsupported format, or corrupted. \"\n-                \"Try a smaller JPEG/PNG (under 3.5 MB) and retry. \"\n+                \"image is in an unsupported format, corrupted, or still too \"\n+                \"large after auto-resize. Try a smaller JPEG/PNG and retry. \"\n                 f\"Error: {e}\"\n             )\n         else:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}