fix(tools): salvage concatenated JSON in tool call arguments

Models sometimes emit several JSON objects concatenated into a single
tool-call `arguments` string. `json.loads` reports "Extra data" and
`_attempt_json_repair` cannot help because nothing is truncated, so
`parse_tool_call_arguments` raised and the chat completions caller
converted that into `{}` - silently discarding the tool call. An empty
dict is indistinguishable from the model asking for nothing, so the
failure was invisible from both ends: the tool server saw no request at
all, and the model retried the same malformed shape.

`split_concatenated_json_objects` already handles this exact provider
behaviour on the Bedrock request path, but was never wired into the
chat completions response path. Reuse it before raising, keeping the
first object to mirror the "first call keeps the original tool id"
semantics in factory.py, and warn with the number discarded.

Fixes #40582

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
deepak7lal 2026-09-11 00:27:46 +05:30
parent 218b3280d1
commit 47ce07253c
2 changed files with 65 additions and 0 deletions

View file

@ -2360,6 +2360,30 @@ 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 the
# helper is reused here rather than dropping the call: returning ``{}``
# is indistinguishable from the model asking for nothing.
concatenated: Final = split_concatenated_json_objects(arguments)
if concatenated:
verbose_logger.warning(
"Recovered %d concatenated JSON object(s) from tool call arguments for tool '%s' (%s); "
"using the first and discarding %d. Original (%d chars): %.200s%s",
len(concatenated),
tool_name or "<unknown>",
context or "unknown context",
len(concatenated) - 1,
len(arguments),
arguments,
"..." if len(arguments) > 200 else "",
)
# 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,46 @@ 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}
# ---------------------------------------------------------------------------
# Regression tests for non-OpenAI file content blocks.
#