This commit is contained in:
deepak 2026-09-12 14:55:18 -04:00 committed by GitHub
commit 5bcfc91a27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 112 additions and 0 deletions

View file

@ -2314,6 +2314,35 @@ def _attempt_json_repair(s: str) -> object | None:
return None
def _split_complete_json_objects(raw: str) -> list[dict[str, object]] | None:
"""
Split *raw* into JSON objects, requiring the entire string to be consumed.
Unlike :func:`split_concatenated_json_objects`, which deliberately salvages
whatever prefix it can before a malformed tail, this returns ``None`` unless
*raw* is exactly a sequence of complete JSON objects. Tool call arguments
are executed, so a truncated or trailing-garbage payload must keep failing
rather than invoke a tool with partial input.
"""
import json
decoder: Final = json.JSONDecoder()
objects: list[dict[str, object]] = []
index = 0
while index < len(raw):
if raw[index].isspace():
index += 1
continue
try:
obj, index = decoder.raw_decode(raw, index)
except json.JSONDecodeError:
return None
if not isinstance(obj, dict):
return None
objects.append(obj)
return objects or None
def parse_tool_call_arguments(
arguments: str | None,
tool_name: str | None = None,
@ -2360,6 +2389,29 @@ def parse_tool_call_arguments(
)
return repaired
# Some providers emit several JSON objects concatenated into a single
# arguments string, which ``json.loads`` reports as "Extra data" and
# ``_attempt_json_repair`` cannot fix because nothing is truncated.
# This is the same provider behaviour already repaired on the Bedrock
# request path (see ``_convert_to_bedrock_tool_call_invoke``), so it is
# salvaged here too rather than dropping the call: returning ``{}`` is
# indistinguishable from the model asking for nothing.
concatenated: Final = _split_complete_json_objects(arguments)
if concatenated is not None and len(concatenated) > 1:
# Structural metadata only - the arguments themselves may carry
# PII or credentials and must not reach warning logs.
verbose_logger.warning(
"Recovered %d concatenated JSON objects from tool call arguments "
"for tool '%s' (%s); using the first and discarding %d.",
len(concatenated),
tool_name or "<unknown>",
context or "unknown context",
len(concatenated) - 1,
)
# Mirrors factory.py, where the first parsed object keeps the
# original tool call id.
return concatenated[0]
error_parts: Final = ["Failed to parse tool call arguments"]
if tool_name:

View file

@ -20,6 +20,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
handle_any_messages_to_chat_completion_str_messages_conversion,
hoist_images_from_tool_messages,
is_encrypted_reasoning_block,
parse_tool_call_arguments,
responses_reasoning_items_from_thinking_blocks,
split_concatenated_json_objects,
strip_encrypted_reasoning_from_messages,
@ -268,6 +269,65 @@ def test_split_concatenated_json_salvages_prefix_before_truncated_tail():
assert result == [{"a": 1}, {"b": 2}]
def test_parse_tool_call_arguments_salvages_concatenated_objects():
"""
Regression test for #40582.
Models sometimes emit several JSON objects concatenated into a single
tool-call ``arguments`` string. ``json.loads`` fails on this with
``Extra data``, and ``_attempt_json_repair`` cannot help because nothing is
truncated. Previously this raised ``ValueError``, which the chat
completions caller converted into ``{}`` - silently discarding the tool
call. ``split_concatenated_json_objects`` already handled this exact shape
on the Bedrock request path (#20543); the response path must salvage it too.
"""
raw = (
'{"args": "{\\"flag\\": true}"}'
'{"args": "{\\"box\\": \\"A\\", \\"limit\\": 50}"}'
'{"args": "{\\"since\\": \\"01-Jan-2025\\"}"}'
)
result = parse_tool_call_arguments(raw, tool_name="demo", context="chat completions")
# The first object is kept, mirroring the "first call keeps the original
# tool id" semantics already used in factory.py for the Bedrock path.
assert result == {"args": '{"flag": true}'}
def test_parse_tool_call_arguments_concatenated_is_not_dropped_silently():
"""
The chat completions caller must no longer turn a concatenated-arguments
tool call into an empty dict, which is indistinguishable from the model
asking for nothing.
"""
from litellm.litellm_core_utils.prompt_templates.factory import (
_parse_tool_call_arguments,
)
result = _parse_tool_call_arguments('{"a": 1}{"b": 2}', tool_name="demo", context="chat completions")
assert result == {"a": 1}
@pytest.mark.parametrize(
"raw",
[
'{"a": 1}{"b":', # truncated tail
'{"a": 1} garbage', # trailing garbage
'{"a": 1}{"b": 2} x', # complete objects followed by junk
'{"a": 1}[1, 2]', # valid JSON, but not an object
],
)
def test_parse_tool_call_arguments_rejects_incomplete_concatenation(raw):
"""
Salvage is restricted to input wholly consumed as complete JSON objects.
Tool call arguments are executed, so a truncated or trailing-garbage
payload must keep failing rather than invoke a tool with partial input.
"""
with pytest.raises(ValueError, match="Failed to parse tool call arguments"):
parse_tool_call_arguments(raw, tool_name="demo", context="chat completions")
# ---------------------------------------------------------------------------
# Regression tests for non-OpenAI file content blocks.
#