fix(bedrock): reject unparseable tool call arguments as a 400 instead of a retryable error

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-10 21:33:37 +00:00
parent 3726bceb53
commit ecb905ae5d
2 changed files with 113 additions and 47 deletions

View file

@ -3606,6 +3606,66 @@ class BedrockImageProcessor:
return cls._create_bedrock_block(img_bytes, mime_type, image_format)
def _split_concatenated_json_objects_or_empty(arguments: str) -> tuple[Mapping[str, Any], ...]:
"""
The JSON objects concatenated in *arguments*, or nothing when it is not that shape.
`split_concatenated_json_objects` raises on anything it cannot decode, including the
truncated arguments this is reached for, so the raise is not an outcome the caller wants.
"""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
split_concatenated_json_objects,
)
try:
return tuple(split_concatenated_json_objects(arguments))
except json.JSONDecodeError:
return ()
def _bedrock_tool_use_inputs(
arguments: str,
tool_name: str,
tool_id: str,
model: str | None,
) -> tuple[Mapping[str, Any], ...]:
"""
The `toolUse.input` objects for one OpenAI tool call, one per JSON object in *arguments*.
Bedrock requires an object, so a non-object payload becomes an empty one. A model may also
emit several objects concatenated into one string (#20543), which becomes one input each.
Anything still unparseable is the client's input, not a transport failure, so it raises
BadRequestError: a bare exception maps to a retryable APIConnectionError, which turns one
deterministic pre-network failure into a retry and fallback walk (LIT-5029).
"""
if not arguments.strip():
return ({},)
try:
# A non-object payload cannot be a toolUse input: some providers send arguments: '""'
parsed: Final = json.loads(arguments)
return (parsed,) if isinstance(parsed, dict) else ({},)
except json.JSONDecodeError:
pass
concatenated: Final = _split_concatenated_json_objects_or_empty(arguments)
if concatenated:
return concatenated
try:
repaired: Final = parse_tool_call_arguments(arguments, tool_name=tool_name, context="Bedrock Converse")
except ValueError as parse_error:
raise litellm.BadRequestError(
message=(
f"Invalid tool_calls[].function.arguments for tool_call_id={tool_id!r}, tool={tool_name!r}: "
f"not valid JSON. Error: {parse_error.__cause__ or parse_error}"
),
model=model or "",
llm_provider="bedrock",
) from parse_error
return (repaired,) if isinstance(repaired, dict) else ({},)
def _convert_to_bedrock_tool_call_invoke(
tool_calls: list,
model: str | None = None,
@ -3645,59 +3705,23 @@ def _convert_to_bedrock_tool_call_invoke(
- extract name
- extract id
"""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
split_concatenated_json_objects,
)
try:
_parts_list: Final[list[BedrockContentBlock]] = []
for tool in tool_calls:
if "function" in tool:
tool_id = tool["id"]
name = make_valid_bedrock_tool_name(tool["function"].get("name", ""))
arguments = tool["function"].get("arguments", "")
inputs = _bedrock_tool_use_inputs(
tool["function"].get("arguments", "") or "",
tool_name=name,
tool_id=tool_id,
model=model,
)
if not arguments or not arguments.strip():
arguments_dict = {}
else:
try:
arguments_dict = json.loads(arguments)
# Ensure arguments_dict is always a dict
# (Bedrock requires toolUse.input to be an object).
# Some providers return arguments: '""' which
# json.loads decodes to a bare string.
if not isinstance(arguments_dict, dict):
arguments_dict = {}
except json.JSONDecodeError:
# The model may return multiple JSON objects
# concatenated in a single arguments string, e.g.
# '{"cmd":"a"}{"cmd":"b"}{"cmd":"c"}'
# Split them and emit one toolUse block per object.
# Fixes: https://github.com/BerriAI/litellm/issues/20543
parsed_objects = split_concatenated_json_objects(arguments)
if parsed_objects:
# First object keeps the original tool id.
for obj_idx, obj in enumerate(parsed_objects):
block_id = tool_id if obj_idx == 0 else f"{tool_id}_{obj_idx}"
bedrock_tool = BedrockToolUseBlock(input=obj, name=name, toolUseId=block_id)
_parts_list.append(BedrockContentBlock(toolUse=bedrock_tool))
# cache_control applies to the whole original
# tool call; attach after the last split block.
if tool.get("cache_control", None) is not None:
_cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block(
{"cache_control": tool["cache_control"]},
block_type="content_block",
model=model,
)
if _cache_point_block is not None:
_parts_list.append(_cache_point_block)
continue
# Fallback: no objects extracted — use empty dict.
arguments_dict = {}
bedrock_tool = BedrockToolUseBlock(input=arguments_dict, name=name, toolUseId=tool_id)
bedrock_content_block = BedrockContentBlock(toolUse=bedrock_tool)
_parts_list.append(bedrock_content_block)
for input_idx, tool_input in enumerate(inputs):
block_id = tool_id if input_idx == 0 else f"{tool_id}_{input_idx}"
bedrock_tool = BedrockToolUseBlock(input=tool_input, name=name, toolUseId=block_id)
_parts_list.append(BedrockContentBlock(toolUse=bedrock_tool))
# Check for cache_control and add a separate cachePoint block
if tool.get("cache_control", None) is not None:
@ -3709,8 +3733,10 @@ def _convert_to_bedrock_tool_call_invoke(
if cache_point_block is not None:
_parts_list.append(cache_point_block)
return _parts_list
except litellm.BadRequestError:
raise
except Exception as e:
raise Exception(f"Unable to convert openai tool calls={tool_calls} to bedrock tool calls. Received error={e}")
raise Exception(f"Unable to convert openai tool calls to bedrock tool calls. Received error={e}")
def _append_bedrock_tool_result_media_block(

View file

@ -2286,6 +2286,46 @@ def test_bedrock_tool_call_invoke_non_dict_arguments():
assert result[0]["toolUse"]["input"] == {}
def test_bedrock_tool_call_invoke_truncated_arguments_are_repaired():
"""Truncated arguments are repaired instead of failing the request (LIT-5029)."""
tool_calls = [
{
"id": "tooluse_MAh2QLVjBRkvi5QJkLQ08V",
"type": "function",
"function": {
"name": "replace_note_content",
"arguments": '{"note_id": "999af35c", "title": "WG-example"',
},
}
]
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
assert len(result) == 1
assert result[0]["toolUse"]["input"] == {"note_id": "999af35c", "title": "WG-example"}
def test_bedrock_tool_call_invoke_unparseable_arguments_raise_bad_request():
"""
Unparseable arguments are a client input error, so they must raise a non-retryable 400.
A bare Exception here maps to litellm.APIConnectionError, which the router retries and
walks the fallback graph for, turning one request into thousands of pre-network attempts
(LIT-5029).
"""
tool_calls = [
{
"id": "tooluse_bad",
"type": "function",
"function": {"name": "replace_note_content", "arguments": "this is not json at all ]]"},
}
]
with pytest.raises(litellm.BadRequestError) as exc_info:
_convert_to_bedrock_tool_call_invoke(tool_calls, model="bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0")
assert exc_info.value.status_code == 400
assert "tooluse_bad" in str(exc_info.value)
assert not litellm._should_retry(exc_info.value.status_code)
def test_make_valid_bedrock_tool_name_preserves_hyphens():
assert make_valid_bedrock_tool_name("my-tool") == "my-tool"
assert (