{"repo":"NousResearch/hermes-agent","public_id":"33317","display_label":"Bedrock image encoding and auth routing fixes","summary":"These issues and PRs address bugs in the Bedrock integration involving incorrect base64/bytes handling for image data URLs causing upload rejections, and bearer-token routing with region-scoped selection for the Converse API.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64628,"merged_at":"2026-07-15T16:59:39+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-15T16:59:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":28156,"pr_number":64628,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-15T16:59:39+00:00"},{"issue_number":33317,"pr_number":64628,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-15T16:59:39+00:00"}],"members":[{"kind":"issue","number":33317,"title":"bedrock_adapter: image data URLs sent as base64 string instead of raw bytes — all image uploads rejected by Bedrock 'Failed to sanitize image'","url":null,"state":"closed"},{"kind":"pr","number":33536,"title":"fix(bedrock): decode base64 data URL to bytes for Converse image source","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64628","number":64628}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":34742,"title":"fix(bedrock): streaming fallback to Converse API + image base64 decode + bearer token routing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#28085","number":28085},{"kind":"pr","value":"#33536","number":33536},{"kind":"pr","value":"#24507","number":24507},{"kind":"pr","value":"#26531","number":26531},{"kind":"pr","value":"#14737","number":14737}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":64628,"title":"fix(bedrock): bearer-token Converse routing + region-scoped picker (#28156)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/bedrock_adapter.py","agent/conversation_loop.py","hermes_cli/model_setup_flows.py","hermes_cli/runtime_provider.py","scripts/release.py","tests/agent/test_bedrock_adapter.py","tests/hermes_cli/test_bedrock_region_scoped_picker.py"],"fix_hunks":[{"file":"agent/bedrock_adapter.py","hunks":["@@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]:\n                         mime_part = header[5:].split(\";\")[0]\n                         if mime_part:\n                             media_type = mime_part\n+                    # Decode base64 to raw bytes — boto3 re-encodes at the\n+                    # wire layer, so passing the base64 string directly\n+                    # results in double-encoding and Bedrock rejects it with\n+                    # \"Failed to sanitize image\".  Ref: #33317.\n+                    import base64\n+                    try:\n+                        raw_bytes = base64.b64decode(data)\n+                    except Exception:\n+                        raw_bytes = data.encode(\"utf-8\")\n                     blocks.append({\n                         \"image\": {\n                             \"format\": media_type.split(\"/\")[-1] if \"/\" in media_type else \"jpeg\",\n-                            \"source\": {\"bytes\": data},\n+                            \"source\": {\"bytes\": raw_bytes},\n                         }\n                     })\n                 else:"]},{"file":"agent/conversation_loop.py","hunks":["@@ -2619,6 +2619,31 @@ def _perform_api_call(next_api_kwargs):\n                     )\n                     continue\n \n+                # ── Bedrock AnthropicBedrock SDK streaming failure ──\n+                # The Anthropic SDK's stream accumulator raises RuntimeError\n+                # \"Unexpected event order\" when Bedrock returns an error event\n+                # before message_start (throttling, overload, validation).\n+                # Fall back to the native Converse API path for the rest of\n+                # this session — it handles these errors gracefully.  Ref: #28156.\n+                if (\n+                    isinstance(api_error, RuntimeError)\n+                    and \"unexpected event order\" in str(api_error).lower()\n+                    and getattr(agent, \"provider\", \"\") == \"bedrock\"\n+                    and agent.api_mode == \"anthropic_messages\"\n+                    and not getattr(agent, \"_bedrock_converse_fallback_attempted\", False)\n+                ):\n+                    agent._bedrock_converse_fallback_attempted = True\n+                    agent.api_mode = \"bedrock_converse\"\n+                    agent._bedrock_region = getattr(agent, \"_bedrock_region\", None) or \"us-east-1\"\n+                    agent.client = None  # Drop the AnthropicBedrock client\n+                    agent._client_kwargs = {}\n+                    agent._vprint(\n+                        f\"{agent.log_prefix}⚠️  AnthropicBedrock SDK streaming failed — \"\n+                        f\"falling back to native Converse API for this session.\",\n+                        force=True,\n+                    )\n+                    continue\n+\n                 status_code = getattr(api_error, \"status_code\", None)\n                 error_context = agent._extract_api_error_context(api_error)\n "]},{"file":"hermes_cli/model_setup_flows.py","hunks":["@@ -27,6 +27,54 @@\n from hermes_cli.config import clear_model_endpoint_credentials\n \n \n+# AWS cross-region inference profile prefixes. Any geo-prefixed profile only\n+# routes from endpoints in its own geography, so the Bedrock picker must not\n+# offer (e.g.) us.* profiles to an eu-central-2 endpoint — selecting one\n+# produces a config AWS rejects regardless of credentials (#28156).\n+# global.* routes from everywhere. Full set per the AWS cross-region\n+# inference docs.\n+BEDROCK_GEO_PREFIXES = (\n+    \"us.\", \"eu.\", \"ap.\", \"apac.\", \"jp.\", \"ca.\", \"sa.\", \"me.\", \"af.\",\n+)\n+\n+\n+def bedrock_region_geo_prefix(region_name: str) -> str:\n+    \"\"\"Map an AWS region name to its inference-profile geo prefix ('' = unknown).\"\"\"\n+    r = (region_name or \"\").lower()\n+    for geo, region_prefixes in (\n+        (\"us.\", (\"us-\", \"us_gov\")),\n+        (\"eu.\", (\"eu-\",)),\n+        (\"ap.\", (\"ap-\",)),\n+        (\"ca.\", (\"ca-\",)),\n+        (\"sa.\", (\"sa-\",)),\n+        (\"me.\", (\"me-\",)),\n+        (\"af.\", (\"af-\",)),\n+    ):\n+        if r.startswith(region_prefixes):\n+            return geo\n+    return \"\"\n+\n+\n+def bedrock_model_routable_from_region(model_id: str, region_name: str) -> bool:\n+    \"\"\"True when *model_id* can be invoked from *region_name*'s endpoint.\n+\n+    Bare foundation-model ids and ``global.*`` profiles route from anywhere.\n+    Geo-prefixed inference profiles (``us.*``, ``eu.*``, ...) only route from\n+    endpoints in their own geography. Unknown region shapes hide nothing.\n+    \"\"\"\n+    mid = (model_id or \"\").lower()\n+    matched_geo = next((p for p in BEDROCK_GEO_PREFIXES if mid.startswith(p)), None)\n+    if matched_geo is None or mid.startswith(\"global.\"):\n+        return True\n+    geo = bedrock_region_geo_prefix(region_name)\n+    if not geo:\n+        return True\n+    if geo == \"ap.\":\n+        # Asia-Pacific regions can carry ap./apac./jp. profile spellings.\n+        return matched_geo in (\"ap.\", \"apac.\", \"jp.\")\n+    return matched_geo == geo\n+\n+\n def _prune_replaced_custom_model_config_credentials(\n     base_url: str,\n     *,","@@ -2265,6 +2313,8 @@ def _model_flow_bedrock(config, current_model=\"\"):\n             \"global.twelvelabs.\",\n         )\n         _EXCLUDE_SUBSTRINGS = (\"safeguard\", \"voxtral\", \"palmyra-vision\")\n+\n+\n         filtered = []\n         for m in live_models:\n             mid = m[\"id\"]","@@ -2272,44 +2322,59 @@ def _model_flow_bedrock(config, current_model=\"\"):\n                 continue\n             if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS):\n                 continue\n+            if not bedrock_model_routable_from_region(mid, region):\n+                continue\n             filtered.append(m)\n \n-        # Deduplicate: prefer inference profiles (us.*, global.*) over bare\n-        # foundation model IDs.\n+        # Deduplicate: prefer inference profiles (geo-prefixed or global.*)\n+        # over bare foundation model IDs.\n+        _PROFILE_PREFIXES = BEDROCK_GEO_PREFIXES + (\"global.\",)\n         profile_base_ids = set()\n         for m in filtered:\n             mid = m[\"id\"]\n-            if mid.startswith((\"us.\", \"global.\")):\n-                base = mid.split(\".\", 1)[1] if \".\" in mid[3:] else mid\n-                profile_base_ids.add(base)\n+            _pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None)\n+            if _pp:\n+                profile_base_ids.add(mid[len(_pp):])\n \n         deduped = []\n         for m in filtered:\n             mid = m[\"id\"]\n-            if not mid.startswith((\"us.\", \"global.\")) and mid in profile_base_ids:\n+            if (\n+                not mid.startswith(_PROFILE_PREFIXES)\n+                and mid in profile_base_ids\n+            ):\n                 continue\n             deduped.append(m)\n \n-        _RECOMMENDED = [\n-            \"us.anthropic.claude-sonnet-4-6\",\n-            \"us.anthropic.claude-opus-4-6\",\n-            \"us.anthropic.claude-haiku-4-5\",\n-            \"us.amazon.nova-pro\",\n-            \"us.amazon.nova-lite\",\n-            \"us.amazon.nova-micro\",\n+        # Recommended models, matched geo-agnostically so an EU (eu.*) or\n+        # APAC (apac.*) picker pins its own region's profile of the same\n+        # model rather than a us.* one it can't route to (#28156).\n+        _RECOMMENDED_BASES = [\n+            \"anthropic.claude-sonnet-4-6\",\n+            \"anthropic.claude-opus-4-6\",\n+            \"anthropic.claude-haiku-4-5\",\n+            \"amazon.nova-pro\",\n+            \"amazon.nova-lite\",\n+            \"amazon.nova-micro\",\n             \"deepseek.v3\",\n-            \"us.meta.llama4-maverick\",\n-            \"us.meta.llama4-scout\",\n+            \"meta.llama4-maverick\",\n+            \"meta.llama4-scout\",\n         ]\n \n+        def _base_id(mid: str) -> str:\n+            _pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None)\n+            return mid[len(_pp):] if _pp else mid\n+\n         def _sort_key(m):\n             mid = m[\"id\"]\n-            for i, rec in enumerate(_RECOMMENDED):\n-                if mid.startswith(rec):\n-                    return (0, i, mid)\n+            base = _base_id(mid)\n+            for i, rec in enumerate(_RECOMMENDED_BASES):\n+                if base.startswith(rec):\n+                    # In-region geo profile beats global.* for the same model\n+                    return (0, i, 0 if not mid.startswith(\"global.\") else 1, mid)\n             if mid.startswith(\"global.\"):\n-                return (1, 0, mid)\n-            return (2, 0, mid)\n+                return (1, 0, 0, mid)\n+            return (2, 0, 0, mid)\n \n         deduped.sort(key=_sort_key)\n         model_list = [m[\"id\"] for m in deduped]"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -1975,8 +1975,15 @@ def resolve_runtime_provider(\n         # Dual-path routing: Claude models use AnthropicBedrock SDK for full\n         # feature parity (prompt caching, thinking budgets, adaptive thinking).\n         # Non-Claude models use the Converse API for multi-model support.\n+        #\n+        # Exception: Bearer Token auth (AWS_BEARER_TOKEN_BEDROCK) is NOT\n+        # supported by the AnthropicBedrock SDK (it only does SigV4 signing —\n+        # a bearer-only setup fails at runtime with \"could not resolve\n+        # credentials from session\"). Route these users through the Converse\n+        # API regardless of model. Ref: #28156.\n         _current_model = str(target_model or model_cfg.get(\"default\") or \"\").strip()\n-        if is_anthropic_bedrock_model(_current_model):\n+        _has_bearer_token = [redacted:secret-assignment]\"AWS_BEARER_TOKEN_BEDROCK\", \"\").strip())\n+        if is_anthropic_bedrock_model(_current_model) and not _has_bearer_token:\n             # Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path\n             runtime = {\n                 \"provider\": \"bedrock\","]},{"file":"scripts/release.py","hunks":["@@ -47,6 +47,7 @@\n AUTHOR_MAP = {\n     \"75556242+webtecnica@users.noreply.github.com\": \"webtecnica\",  # PR #63360 salvage (nous: restore inference-api base_url)\n     \"[redacted:email]\": \"Epoxidex\",  # PR #29820 salvage (ollama: top-level reasoning_effort=none; #25758)\n+    \"[redacted:email]\": \"JiaDe-Wu\",  # PR #34742 salvage (bedrock: bearer routing + streaming fallback + image decode; #28156)\n     \"[redacted:email]\": \"minchang\",  # PR #42231 salvage (providers: add Upstage Solar)\n     \"[redacted:email]\": \"neo-claw-bot\",  # PR #58465 salvage (moa: drop empty user turns from advisory view)\n     \"[redacted:email]\": \"pixel4039\",  # PR #64420 salvage (streaming: retry zero-chunk streams)"]},{"file":"tests/agent/test_bedrock_adapter.py","hunks":["@@ -1712,3 +1712,85 @@ def test_accepts_boto3_with_unparseable_version(self):\n         with patch.dict(\"sys.modules\", {\"boto3\": fake_boto3}):\n             result = _require_boto3()\n             assert result is fake_boto3\n+\n+class TestImageBase64Decoding:\n+    \"\"\"Image data URLs must be decoded to raw bytes before passing to Converse API.\n+\n+    boto3 re-encodes at the wire layer, so passing the base64 string directly\n+    results in double-encoding. Bedrock rejects with 'Failed to sanitize image'.\n+    Ref: #33317.\n+    \"\"\"\n+\n+    def test_data_url_decoded_to_bytes(self):\n+        from agent.bedrock_adapter import _convert_content_to_converse\n+        import base64\n+\n+        # A tiny 1x1 red PNG\n+        raw_png = base64.b64decode(\n+            \"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==\"\n+        )\n+        data_url = \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==\"\n+\n+        content = [{\"type\": \"image_url\", \"image_url\": {\"url\": data_url}}]\n+        blocks = _convert_content_to_converse(content)\n+\n+        assert len(blocks) == 1\n+        img_block = blocks[0][\"image\"]\n+        assert img_block[\"format\"] == \"png\"\n+        # Must be raw bytes, not a base64 string\n+        assert isinstance(img_block[\"source\"][\"bytes\"], bytes)\n+        assert img_block[\"source\"][\"bytes\"] == raw_png\n+\n+    def test_invalid_base64_falls_back_to_encode(self):\n+        from agent.bedrock_adapter import _convert_content_to_converse\n+\n+        data_url = \"data:image/jpeg;base64,NOT_VALID_BASE64!!!\"\n+        content = [{\"type\": \"image_url\", \"image_url\": {\"url\": data_url}}]\n+        blocks = _convert_content_to_converse(content)\n+\n+        # Should not crash — falls back to encoding the string as bytes\n+        assert len(blocks) == 1\n+        assert isinstance(blocks[0][\"image\"][\"source\"][\"bytes\"], bytes)\n+\n+\n+class TestBearerTokenRoutesToConverse:\n+    \"\"\"Bearer Token users must go through Converse API, not AnthropicBedrock SDK.\n+\n+    The AnthropicBedrock SDK only supports SigV4 signing — it cannot use\n+    AWS_BEARER_TOKEN_BEDROCK. Ref: #28156.\n+    \"\"\"\n+\n+    def _resolve(self, monkeypatch, *, bearer: bool):\n+        import os\n+\n+        from hermes_cli import runtime_provider as rp\n+\n+        if bearer:\n+            monkeypatch.setenv(\"AWS_BEARER_TOKEN_BEDROCK\", \"test-bearer-token-123\")\n+        else:\n+            monkeypatch.delenv(\"AWS_BEARER_TOKEN_BEDROCK\", raising=False)\n+        monkeypatch.setenv(\"AWS_DEFAULT_REGION\", \"us-east-1\")\n+        assert \"AWS_BEARER_TOKEN_BEDROCK\" in os.environ or not bearer\n+\n+        monkeypatch.setattr(\n+            rp,\n+            \"_get_model_config\",\n+            lambda: {\n+                \"default\": \"us.anthropic.claude-sonnet-4-6\",\n+                \"provider\": \"bedrock\",\n+            },\n+        )\n+        monkeypatch.setattr(rp, \"load_config\", lambda: {\"bedrock\": {}})\n+        return rp.resolve_runtime_provider(requested=\"bedrock\")\n+\n+    def test_bearer_token_forces_converse_for_claude(self, monkeypatch):\n+        \"\"\"Claude model + Bearer Token → bedrock_converse, not anthropic_messages.\"\"\"\n+        runtime = self._resolve(monkeypatch, bearer=True)\n+        assert runtime[\"api_mode\"] == \"bedrock_converse\"\n+        assert \"bedrock_anthropic\" not in runtime\n+\n+    def test_sigv4_claude_still_uses_anthropic_bedrock_sdk(self, monkeypatch):\n+        \"\"\"Without a bearer token, Claude keeps the AnthropicBedrock SDK path.\"\"\"\n+        runtime = self._resolve(monkeypatch, bearer=False)\n+        assert runtime[\"api_mode\"] == \"anthropic_messages\"\n+        assert runtime.get(\"bedrock_anthropic\") is True"]},{"file":"tests/hermes_cli/test_bedrock_region_scoped_picker.py","hunks":["@@ -0,0 +1,82 @@\n+\"\"\"Regression tests for #28156 — Bedrock picker must be region-scoped.\n+\n+Geo-prefixed cross-region inference profiles (us.*, eu.*, apac.*, ...) only\n+route from endpoints in their own geography. Offering us.* profiles to an\n+eu-central-2 picker produces configs AWS rejects regardless of credentials.\n+\"\"\"\n+\n+from hermes_cli.model_setup_flows import (\n+    BEDROCK_GEO_PREFIXES,\n+    bedrock_model_routable_from_region,\n+    bedrock_region_geo_prefix,\n+)\n+\n+\n+class TestRegionGeoPrefix:\n+    def test_known_geographies(self):\n+        assert bedrock_region_geo_prefix(\"us-east-1\") == \"us.\"\n+        assert bedrock_region_geo_prefix(\"eu-central-2\") == \"eu.\"\n+        assert bedrock_region_geo_prefix(\"ap-southeast-1\") == \"ap.\"\n+        assert bedrock_region_geo_prefix(\"ca-central-1\") == \"ca.\"\n+        assert bedrock_region_geo_prefix(\"sa-east-1\") == \"sa.\"\n+        assert bedrock_region_geo_prefix(\"me-south-1\") == \"me.\"\n+        assert bedrock_region_geo_prefix(\"af-south-1\") == \"af.\"\n+\n+    def test_unknown_region_is_empty(self):\n+        assert bedrock_region_geo_prefix(\"\") == \"\"\n+        assert bedrock_region_geo_prefix(\"moon-base-1\") == \"\"\n+\n+\n+class TestRoutableFromRegion:\n+    def test_us_profile_not_offered_in_eu(self):\n+        assert not bedrock_model_routable_from_region(\n+            \"us.anthropic.claude-sonnet-4-6\", \"eu-central-2\"\n+        )\n+\n+    def test_eu_profile_offered_in_eu(self):\n+        assert bedrock_model_routable_from_region(\n+            \"eu.anthropic.claude-sonnet-4-6\", \"eu-central-2\"\n+        )\n+\n+    def test_global_profile_offered_everywhere(self):\n+        for region in (\"eu-central-2\", \"us-east-1\", \"ap-southeast-1\"):\n+            assert bedrock_model_routable_from_region(\n+                \"global.anthropic.claude-sonnet-4-6\", region\n+            )\n+\n+    def test_bare_foundation_id_offered_everywhere(self):\n+        assert bedrock_model_routable_from_region(\n+            \"anthropic.claude-3-sonnet-20240229-v1:0\", \"eu-central-2\"\n+        )\n+\n+    def test_apac_spellings_route_in_ap_regions(self):\n+        for prefix in (\"ap.\", \"apac.\", \"jp.\"):\n+            assert bedrock_model_routable_from_region(\n+                f\"{prefix}anthropic.claude-sonnet-4-6\", \"ap-northeast-1\"\n+            )\n+\n+    def test_eu_profile_not_offered_in_us(self):\n+        assert not bedrock_model_routable_from_region(\n+            \"eu.anthropic.claude-sonnet-4-6\", \"us-east-1\"\n+        )\n+\n+    def test_unknown_region_hides_nothing(self):\n+        assert bedrock_model_routable_from_region(\n+            \"us.anthropic.claude-sonnet-4-6\", \"\"\n+        )\n+\n+\n+class TestGeoPrefixContract:\n+    def test_every_geo_prefix_maps_to_a_routable_region_or_is_alias(self):\n+        \"\"\"Invariant: each geo prefix is either reachable from some region's\n+        geo mapping or an ap-family alias — no dead entries.\"\"\"\n+        mapped = {\n+            bedrock_region_geo_prefix(r)\n+            for r in (\n+                \"us-east-1\", \"eu-central-2\", \"ap-southeast-1\",\n+                \"ca-central-1\", \"sa-east-1\", \"me-south-1\", \"af-south-1\",\n+            )\n+        }\n+        ap_aliases = {\"apac.\", \"jp.\"}\n+        for prefix in BEDROCK_GEO_PREFIXES:\n+            assert prefix in mapped or prefix in ap_aliases\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}