diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index d5dab7bafff2..51d0afbe3cbe 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -528,10 +528,19 @@ def _convert_content_to_converse(content) -> List[Dict]: mime_part = header[5:].split(";")[0] if mime_part: media_type = mime_part + # Decode base64 to raw bytes — boto3 re-encodes at the + # wire layer, so passing the base64 string directly + # results in double-encoding and Bedrock rejects it with + # "Failed to sanitize image". Ref: #33317. + import base64 + try: + raw_bytes = base64.b64decode(data) + except Exception: + raw_bytes = data.encode("utf-8") blocks.append({ "image": { "format": media_type.split("/")[-1] if "/" in media_type else "jpeg", - "source": {"bytes": data}, + "source": {"bytes": raw_bytes}, } }) else: diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index ca97280e1eaf..420d12670e62 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -2625,6 +2625,31 @@ def run_conversation( ) continue + # ── Bedrock AnthropicBedrock SDK streaming failure ── + # The Anthropic SDK's stream accumulator raises RuntimeError + # "Unexpected event order" when Bedrock returns an error event + # before message_start (throttling, overload, validation). + # Fall back to the native Converse API path for the rest of + # this session — it handles these errors gracefully. Ref: #28156. + if ( + isinstance(api_error, RuntimeError) + and "unexpected event order" in str(api_error).lower() + and getattr(agent, "provider", "") == "bedrock" + and agent.api_mode == "anthropic_messages" + and not getattr(agent, "_bedrock_converse_fallback_attempted", False) + ): + agent._bedrock_converse_fallback_attempted = True + agent.api_mode = "bedrock_converse" + agent._bedrock_region = getattr(agent, "_bedrock_region", None) or "us-east-1" + agent.client = None # Drop the AnthropicBedrock client + agent._client_kwargs = {} + agent._vprint( + f"{agent.log_prefix}⚠️ AnthropicBedrock SDK streaming failed — " + f"falling back to native Converse API for this session.", + force=True, + ) + continue + status_code = getattr(api_error, "status_code", None) error_context = agent._extract_api_error_context(api_error) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index a3a26b268d06..fba92e9b350d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1975,8 +1975,15 @@ def resolve_runtime_provider( # Dual-path routing: Claude models use AnthropicBedrock SDK for full # feature parity (prompt caching, thinking budgets, adaptive thinking). # Non-Claude models use the Converse API for multi-model support. + # + # Exception: Bearer Token auth (AWS_BEARER_TOKEN_BEDROCK) is NOT + # supported by the AnthropicBedrock SDK (it only does SigV4 signing — + # a bearer-only setup fails at runtime with "could not resolve + # credentials from session"). Route these users through the Converse + # API regardless of model. Ref: #28156. _current_model = str(target_model or model_cfg.get("default") or "").strip() - if is_anthropic_bedrock_model(_current_model): + _has_bearer_token = bool(os.environ.get("AWS_BEARER_TOKEN_BEDROCK", "").strip()) + if is_anthropic_bedrock_model(_current_model) and not _has_bearer_token: # Claude on Bedrock → AnthropicBedrock SDK → anthropic_messages path runtime = { "provider": "bedrock", diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index ac2b557aeace..172de4be1333 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -1712,3 +1712,74 @@ class TestRequireBoto3VersionCheck: with patch.dict("sys.modules", {"boto3": fake_boto3}): result = _require_boto3() assert result is fake_boto3 + +class TestImageBase64Decoding: + """Image data URLs must be decoded to raw bytes before passing to Converse API. + + boto3 re-encodes at the wire layer, so passing the base64 string directly + results in double-encoding. Bedrock rejects with 'Failed to sanitize image'. + Ref: #33317. + """ + + def test_data_url_decoded_to_bytes(self): + from agent.bedrock_adapter import _convert_content_to_converse + import base64 + + # A tiny 1x1 red PNG + raw_png = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + ) + data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + + content = [{"type": "image_url", "image_url": {"url": data_url}}] + blocks = _convert_content_to_converse(content) + + assert len(blocks) == 1 + img_block = blocks[0]["image"] + assert img_block["format"] == "png" + # Must be raw bytes, not a base64 string + assert isinstance(img_block["source"]["bytes"], bytes) + assert img_block["source"]["bytes"] == raw_png + + def test_invalid_base64_falls_back_to_encode(self): + from agent.bedrock_adapter import _convert_content_to_converse + + data_url = "data:image/jpeg;base64,NOT_VALID_BASE64!!!" + content = [{"type": "image_url", "image_url": {"url": data_url}}] + blocks = _convert_content_to_converse(content) + + # Should not crash — falls back to encoding the string as bytes + assert len(blocks) == 1 + assert isinstance(blocks[0]["image"]["source"]["bytes"], bytes) + + +class TestBearerTokenRoutesToConverse: + """Bearer Token users must go through Converse API, not AnthropicBedrock SDK. + + The AnthropicBedrock SDK only supports SigV4 signing — it cannot use + AWS_BEARER_TOKEN_BEDROCK. Ref: #28156. + """ + + def test_bearer_token_forces_converse_for_claude(self): + """Claude model + Bearer Token → bedrock_converse, not anthropic_messages.""" + from hermes_cli.runtime_provider import resolve_runtime_provider + + env = { + "AWS_BEARER_TOKEN_BEDROCK": "test-bearer-token-123", + "AWS_DEFAULT_REGION": "us-east-1", + } + with patch.dict(os.environ, env, clear=False), \ + patch("hermes_cli.runtime_provider.resolve_provider", return_value="bedrock"), \ + patch("hermes_cli.runtime_provider._get_model_config", return_value={ + "default": "us.anthropic.claude-sonnet-4-6", + "provider": "bedrock", + }), \ + patch("hermes_cli.runtime_provider.load_config", return_value={"bedrock": {}}), \ + patch("agent.bedrock_adapter.has_aws_credentials", return_value=True), \ + patch("agent.bedrock_adapter.resolve_aws_auth_env_var", return_value="bearer-token"), \ + patch("agent.bedrock_adapter.resolve_bedrock_region", return_value="us-east-1"), \ + patch("agent.bedrock_adapter.is_anthropic_bedrock_model", return_value=True): + runtime = resolve_runtime_provider(requested="bedrock") + + assert runtime["api_mode"] == "bedrock_converse" + assert "bedrock_anthropic" not in runtime