mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge c6c61c8ddd into 0c98afa780
This commit is contained in:
commit
5bcfc91a27
2 changed files with 112 additions and 0 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
#
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue