diff --git a/src/anthropic/lib/bedrock/_client.py b/src/anthropic/lib/bedrock/_client.py index cda0690df..644beea9c 100644 --- a/src/anthropic/lib/bedrock/_client.py +++ b/src/anthropic/lib/bedrock/_client.py @@ -3,7 +3,7 @@ import os import logging import urllib.parse -from typing import Any, Union, Mapping, TypeVar +from typing import Any, Dict, Union, Mapping, TypeVar, cast from typing_extensions import Self, override import httpx @@ -35,6 +35,23 @@ _DefaultStreamT = TypeVar("_DefaultStreamT", bound=Union[Stream[Any], AsyncStream[Any]]) +def _strip_none_values(data: Dict[str, object]) -> Dict[str, object]: + result: Dict[str, object] = {} + for k, v in data.items(): + if v is None: + continue + if isinstance(v, dict): + result[k] = _strip_none_values(cast(Dict[str, object], v)) + elif isinstance(v, list): + result[k] = [ + _strip_none_values(cast(Dict[str, object], item)) if isinstance(item, dict) else item + for item in cast("list[object]", v) + ] + else: + result[k] = v + return result + + def _prepare_options(input_options: FinalRequestOptions) -> FinalRequestOptions: options = model_copy(input_options, deep=True) @@ -46,6 +63,17 @@ def _prepare_options(input_options: FinalRequestOptions) -> FinalRequestOptions: if betas: options.json_data.setdefault("anthropic_beta", betas.split(",")) + messages = options.json_data.get("messages") + if isinstance(messages, list): + for message in cast("list[object]", messages): + if is_dict(message): + content = message.get("content") + if isinstance(content, list): + message["content"] = [ + _strip_none_values(cast(Dict[str, object], block)) if is_dict(block) else block + for block in cast("list[object]", content) + ] + if options.url in {"/v1/complete", "/v1/messages", "/v1/messages?beta=true"} and options.method == "post": if not is_dict(options.json_data): raise RuntimeError("Expected dictionary json_data for post /completions endpoint") diff --git a/tests/lib/test_bedrock.py b/tests/lib/test_bedrock.py index 6e45c27f7..1c3847c2e 100644 --- a/tests/lib/test_bedrock.py +++ b/tests/lib/test_bedrock.py @@ -275,3 +275,52 @@ def test_region_infer_from_specified_profile( client = AnthropicBedrock() assert client.aws_region == next(profile for profile in profiles if profile["name"] == aws_profile)["region"] + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +@pytest.mark.respx() +def test_none_values_stripped_from_content_blocks(respx_mock: MockRouter) -> None: + """ToolUseBlock.caller is Optional[Caller] = None in responses but + ToolUseBlockParam.caller expects a valid Caller dict when present. + Bedrock rejects {"caller": null}, so _prepare_options must strip it.""" + import json + + respx_mock.post(re.compile(r"https://bedrock-runtime\.us-east-1\.amazonaws\.com/model/.*/invoke")).mock( + return_value=httpx.Response(200, json={"foo": "bar"}), + ) + + sync_client.messages.create( + max_tokens=1024, + messages=[ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "toolu_01A", + "name": "get_weather", + "input": {"location": "Paris"}, + "caller": None, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "toolu_01A", + "content": "Sunny, 22°C", + } + ], + }, + ], + model="anthropic.claude-3-5-sonnet-20241022-v2:0", + ) + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + + body = json.loads(calls[0].request.content) + tool_use_block = body["messages"][0]["content"][0] + assert "caller" not in tool_use_block