fix(bedrock): degrade gracefully on malformed tool-call arguments

split_concatenated_json_objects re-raised JSONDecodeError on genuinely
malformed (non-concatenated) tool-call arguments, which propagated out of
_convert_to_bedrock_tool_call_invoke and turned every replayed Bedrock
conversation into a 500. Catch the decode error, keep whatever complete
objects parsed, log a warning, and let the caller fall back to input={}
so the conversation continues.

Fixes #18667

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
shivam 2026-07-18 20:17:57 +00:00
parent 66dea7df8f
commit 3b843708b0
3 changed files with 89 additions and 11 deletions

View file

@ -1679,16 +1679,19 @@ def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]:
This helper uses ``json.JSONDecoder.raw_decode()`` to walk the string
and extract each JSON object individually.
The walk degrades gracefully: if the string is malformed or truncated
(e.g. a stream that ended mid-tool-call), whatever complete objects were
parsed before the bad tail are returned and the remainder is discarded
with a warning, rather than raising. The sole caller
(``_convert_to_bedrock_tool_call_invoke``) treats an empty result as
``input={}`` so the conversation can continue instead of hard-failing.
Returns
-------
list[dict]
A list of parsed dicts – one per JSON object found. If *raw* is
empty or whitespace-only, an empty list is returned.
Raises
------
json.JSONDecodeError
If the string contains text that cannot be parsed as JSON at all.
empty, whitespace-only, or wholly unparseable, an empty list is
returned.
"""
import json
@ -1708,7 +1711,17 @@ def split_concatenated_json_objects(raw: str) -> List[Dict[str, Any]]:
if idx >= length:
break
obj, end_idx = decoder.raw_decode(raw, idx)
try:
obj, end_idx = decoder.raw_decode(raw, idx)
except json.JSONDecodeError as e:
verbose_logger.warning(
"split_concatenated_json_objects: discarding unparseable tool-call "
"arguments tail after %d complete object(s); error=%s at char %d",
len(results),
e,
idx,
)
break
if isinstance(obj, dict):
results.append(obj)
else:

View file

@ -251,10 +251,32 @@ def test_split_concatenated_json_non_dict_value():
assert result == [{}]
def test_split_concatenated_json_invalid_raises():
"""Completely invalid JSON raises JSONDecodeError."""
with pytest.raises(json.JSONDecodeError):
split_concatenated_json_objects("not json at all")
def test_split_concatenated_json_wholly_invalid_returns_empty():
"""
Wholly unparseable JSON degrades to an empty list instead of raising.
Regression for https://github.com/BerriAI/litellm/issues/18667: a raise
here propagated out of `_convert_to_bedrock_tool_call_invoke` and turned
every replayed conversation into a 500.
"""
assert split_concatenated_json_objects("not json at all") == []
def test_split_concatenated_json_malformed_object_returns_empty():
"""
A single malformed object (missing comma between keys) degrades to an
empty list rather than raising `Expecting ',' delimiter`.
"""
assert split_concatenated_json_objects('{"location": "Boston" "unit": "celsius"}') == []
def test_split_concatenated_json_salvages_prefix_before_truncated_tail():
"""
Complete objects parsed before an unparseable/truncated tail are kept;
only the bad tail is discarded.
"""
result = split_concatenated_json_objects('{"a": 1}{"b": 2}{"c":')
assert result == [{"a": 1}, {"b": 2}]
# ---------------------------------------------------------------------------

View file

@ -2286,6 +2286,49 @@ def test_bedrock_tool_call_invoke_non_dict_arguments():
assert result[0]["toolUse"]["input"] == {}
def test_bedrock_tool_call_invoke_malformed_json_does_not_raise():
"""
Regression for https://github.com/BerriAI/litellm/issues/18667.
When the model emits malformed JSON in tool-call arguments (here a
missing comma between keys), replaying that history must NOT raise
`Unable to convert openai tool calls ... Expecting ',' delimiter`.
It degrades to an empty-object input so the conversation can continue.
"""
tool_calls = [
{
"id": "toolu_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "Boston" "unit": "celsius"}',
},
}
]
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
assert len(result) == 1
assert result[0]["toolUse"]["toolUseId"] == "toolu_abc123"
assert result[0]["toolUse"]["name"] == "get_weather"
assert result[0]["toolUse"]["input"] == {}
def test_bedrock_tool_call_invoke_salvages_valid_prefix_before_truncated_tail():
"""
A valid leading object followed by a truncated tail keeps the valid
object rather than dropping everything or raising.
"""
tool_calls = [
{
"id": "call_partial",
"type": "function",
"function": {"name": "shell", "arguments": '{"cmd": "ls"}{"cmd":'},
}
]
result = _convert_to_bedrock_tool_call_invoke(tool_calls)
assert len(result) == 1
assert result[0]["toolUse"]["input"] == {"cmd": "ls"}
def test_make_valid_bedrock_tool_name_preserves_hyphens():
assert make_valid_bedrock_tool_name("my-tool") == "my-tool"
assert (