fix(bedrock): map Bedrock's 200 error body to a 400 on the Anthropic invoke /v1/messages route

This commit is contained in:
mateo-berri 2026-10-30 04:24:00 -07:00
parent ab02277d2d
commit e11c0d9a9c
4 changed files with 75 additions and 8 deletions

View file

@ -48,6 +48,7 @@ from litellm.types.utils import GenericStreamingChunk as GChunk
from ..common_utils import (
BedrockError,
BedrockEventStreamResponseDict,
bedrock_answered_json,
bedrock_error_in_success_body,
bedrock_event_stream_header,
bedrock_event_stream_response,
@ -193,10 +194,6 @@ def response_format_schema_from_request(data: str, json_mode: bool | None) -> Re
return JsonModeRequest.model_validate(body).tool_schema()
def _answered_json(response: httpx.Response) -> bool:
return "application/json" in (response.headers.get("content-type") or "")
def _stream_decoder(
bedrock_invoke_provider: litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL | None,
*,
@ -264,7 +261,7 @@ async def make_call(
response=response,
)
if _answered_json(response):
if bedrock_answered_json(response):
await response.aread()
success_body_error: Final = bedrock_error_in_success_body(response)
if success_body_error is not None:
@ -366,7 +363,7 @@ def make_sync_call(
response=response,
)
if _answered_json(response):
if bedrock_answered_json(response):
response.read()
success_body_error: Final = bedrock_error_in_success_body(response)
if success_body_error is not None:

View file

@ -10,7 +10,7 @@ import json
import os
import re
import warnings
from collections.abc import Iterator, Mapping, Sequence
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias
@ -147,6 +147,22 @@ def bedrock_error_in_success_body(response: httpx.Response) -> BedrockError | No
)
def bedrock_answered_json(response: httpx.Response) -> bool:
return "application/json" in (response.headers.get("content-type") or "")
async def _json_body_or_bedrock_error(response: httpx.Response) -> AsyncIterator[bytes]:
body: Final = await response.aread()
error: Final = bedrock_error_in_success_body(response)
if error is not None:
raise error
yield body
def bedrock_event_stream_bytes(response: httpx.Response) -> AsyncIterator[bytes]:
return _json_body_or_bedrock_error(response) if bedrock_answered_json(response) else response.aiter_bytes()
_BEDROCK_AWS_AUTH_PARAMETER_KEYS: Final[tuple[str, ...]] = (*AWS_AUTH_PARAM_KEYS, "aws_region_name")

View file

@ -39,6 +39,8 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
from litellm.llms.bedrock.common_utils import (
BedrockError,
apply_bedrock_invoke_structured_output,
bedrock_error_in_success_body,
bedrock_event_stream_bytes,
bedrock_supports_tool_search,
ensure_bedrock_anthropic_messages_tool_names,
get_anthropic_beta_from_headers,
@ -62,6 +64,7 @@ from litellm.types.llms.anthropic import (
ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER,
ANTHROPIC_TOOL_SEARCH_BETA_HEADER,
)
from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse
from litellm.types.llms.bedrock import BedrockInvokeAnthropicMessagesRequest
from litellm.types.llms.openai import AllMessageValues
from litellm.types.router import GenericLiteLLMParams
@ -868,6 +871,17 @@ class AmazonAnthropicClaudeMessagesConfig(
return anthropic_messages_request
def transform_anthropic_messages_response(
self,
model: str,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> AnthropicMessagesResponse:
success_body_error: Final = bedrock_error_in_success_body(raw_response)
if success_body_error is not None:
raise success_body_error
return super().transform_anthropic_messages_response(model, raw_response, logging_obj)
def get_async_streaming_response_iterator(
self,
model: str,
@ -879,7 +893,7 @@ class AmazonAnthropicClaudeMessagesConfig(
model=model,
)
completion_stream: Final = aws_decoder.aiter_bytes(
httpx_response.aiter_bytes(), response_headers=httpx_response.headers
bedrock_event_stream_bytes(httpx_response), response_headers=httpx_response.headers
)
# Convert decoded Bedrock events to Server-Sent Events expected by Anthropic clients.
return self.bedrock_sse_wrapper(

View file

@ -33,6 +33,7 @@ from litellm.llms.anthropic.messages.native.mid_conversation_system import (
as_system_content_blocks,
)
from litellm.llms.bedrock.common_utils import (
BedrockError,
ensure_bedrock_anthropic_messages_tool_names,
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
@ -4553,3 +4554,42 @@ async def test_bedrock_chat_fallback_to_anthropic_sends_the_tool_bedrock_cleaned
(body,) = upstream.anthropic_bodies
assert body["tools"] == [{"name": "get_weather", "input_schema": schema, **sent_extras}]
_UNKNOWN_OPERATION_BODY: Final = {
"Output": {"__type": "com.amazon.coral.service#UnknownOperationException"},
"Version": "1.0",
}
@pytest.mark.usefixtures("local_model_cost_map")
@pytest.mark.parametrize(
("stream", "raised"),
[(False, litellm.BadRequestError), (True, BedrockError)],
ids=["non-stream", "stream"],
)
async def test_bedrock_messages_raise_a_400_when_bedrock_answers_200_with_an_unknown_operation_body(
stream: bool,
raised: type[litellm.BadRequestError] | type[BedrockError],
) -> None:
bedrock: Final = ScriptedBedrockInvoke(replies=((200, _UNKNOWN_OPERATION_BODY),))
async def _call_and_drain() -> None:
response: Final = await litellm.anthropic_messages(
model="bedrock/invoke/anthropic/us.anthropic.claude-haiku-5-5",
messages=[{"role": "user", "content": "Say hi in three words"}],
max_tokens=20,
stream=stream,
aws_access_key_id="test-access-key",
aws_secret_access_key="test-secret-key",
aws_region_name="us-east-1",
client=AsyncHTTPHandler(transport=httpx.MockTransport(bedrock)),
)
if isinstance(response, AsyncIterator):
_ = [chunk async for chunk in response]
with pytest.raises(raised) as error:
await _call_and_drain()
assert error.value.status_code == 400
assert "UnknownOperationException" in str(error.value)