fix(bedrock): streaming fallback to Converse API + image base64 decode + bearer token routing

Three fixes for the Bedrock Claude path:

1. Streaming fallback: When AnthropicBedrock SDK raises 'Unexpected event
   order' (SDK misparses Bedrock error events as message_start), auto-switch
   to native Converse API for the rest of the session instead of failing
   after 3 retries.

2. Image base64 decode (#33317): data URL payloads were passed as base64
   strings to source.bytes, but boto3 re-encodes at the wire layer. Now
   decoded to raw bytes before passing to Converse API.

3. Bearer token routing (#28156): Users with AWS_BEARER_TOKEN_BEDROCK are
   now routed through Converse API regardless of model, since the
   AnthropicBedrock SDK only supports SigV4 signing.

3 new tests. 121 bedrock_adapter tests passing.
This commit is contained in:
JiaDe-Wu 2026-05-30 01:11:37 +08:00 committed by Teknium
parent 2106f63723
commit 5e6a0d9eea
4 changed files with 114 additions and 2 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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",

View file

@ -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