fix(core): keep valid JSON arrays out of tool-call expansion

A valid JSON array of objects took the json.loads success path and was
expanded into multiple calls, bypassing MAX_RECOVERED_ARGUMENT_OBJECTS
and the historical object-only contract for non-object roots. Parse
valid JSON directly with object-only semantics, and hold any sequence
produced by malformed-input recovery to the same per-call bound.
This commit is contained in:
ggbond 2026-09-11 11:21:17 +08:00
parent 3d4e3549fe
commit 0343c96558
2 changed files with 84 additions and 1 deletions

View file

@ -5366,9 +5366,20 @@ def _parse_tool_call_arguments(
return {}
normalized_raw: Final = "{}" if raw == REDACTED_BY_LITELLM else raw
from litellm.litellm_core_utils.prompt_templates.common_utils import (
MAX_RECOVERED_ARGUMENT_OBJECTS,
parse_tool_call_arguments,
)
try:
direct: Final = json.loads(normalized_raw)
except json.JSONDecodeError:
pass # malformed JSON: fall through to repair / concatenated recovery
else:
# Valid JSON keeps the historical object-only contract: a non-object
# root (e.g. a JSON array of objects) degrades to {} rather than
# becoming an uncapped expansion vector.
return direct if isinstance(direct, dict) else {}
try:
parsed: Final = parse_tool_call_arguments(
normalized_raw,
@ -5381,7 +5392,15 @@ def _parse_tool_call_arguments(
return {}
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list) and parsed and all(isinstance(item, dict) for item in parsed):
# Only malformed-input recovery (repair or concatenated split) can yield a
# sequence here; hold it to the same per-call bound as concatenated
# recovery so expansion is never an amplification vector.
if (
isinstance(parsed, list)
and parsed
and all(isinstance(item, dict) for item in parsed)
and len(parsed) <= MAX_RECOVERED_ARGUMENT_OBJECTS
):
return parsed
return {}

View file

@ -3524,6 +3524,70 @@ def test_get_tool_calls_from_response_caps_concatenated_expansion():
assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}]
def test_get_tool_calls_from_response_valid_json_array_not_expanded():
"""
A valid JSON array of objects is not concatenated recovery: it keeps the
historical object-only semantics (degrades to {}) and never expands, so
it cannot bypass the per-call expansion cap.
"""
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "search",
"arguments": json.dumps(
[{"query": f"q{index}"} for index in range(9)]
),
},
}
]
}
}
]
}
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}]
def test_get_tool_calls_from_response_valid_json_array_of_two_not_expanded():
"""Even a small valid JSON array degrades to {} (object-only contract)."""
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,
)
response: Final = {
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call_1",
"function": {
"name": "search",
"arguments": json.dumps([{"query": "a"}, {"query": "b"}]),
},
}
]
}
}
]
}
tool_calls: Final = get_tool_calls_from_response(response)
assert tool_calls == [{"id": "call_1", "name": "search", "arguments": {}}]
def test_get_tool_calls_from_response_non_object_arguments_returns_empty():
from litellm.litellm_core_utils.prompt_templates.factory import (
get_tool_calls_from_response,