mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
2106f63723
commit
5e6a0d9eea
4 changed files with 114 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue