Merge pull request #35417 from BerriAI/litellm_fix_responses_bridge_tool_call_arguments_json

fix(responses): json-encode object tool call arguments in the chat completions bridge
This commit is contained in:
Mateo Wang 2026-09-01 10:08:12 -07:00 committed by GitHub
commit 30bf592aaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 109 additions and 9 deletions

View file

@ -45,6 +45,21 @@ def is_custom_tool_call(tool_name: str, custom_tool_names: set[str]) -> bool:
return tool_name in custom_tool_names
def serialize_tool_call_arguments(raw_arguments: object, default: str = "") -> str:
"""Render tool call arguments as the JSON string tool-call schemas require.
Arguments normally arrive already JSON-encoded, but clients and providers
also send the decoded object. ``str()`` on a dict yields a Python repr with
single quotes, which every downstream JSON parser rejects with errors like
"Expecting ',' delimiter".
"""
if isinstance(raw_arguments, str):
return raw_arguments or default
if raw_arguments is None:
return default
return json.dumps(raw_arguments, default=str)
def unwrap_custom_tool_arguments(arguments: str) -> str:
"""Extract the raw content string from JSON-wrapped arguments.

View file

@ -8,6 +8,7 @@ from litellm.main import stream_chunk_builder
from litellm.responses.litellm_completion_transformation.custom_tools import (
build_tool_call_item_kwargs,
extract_custom_tool_names,
serialize_tool_call_arguments,
)
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
@ -213,10 +214,10 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
fn_args_delta = ""
if isinstance(fn, dict):
fn_name = str(fn.get("name") or "")
fn_args_delta = str(fn.get("arguments") or "")
fn_args_delta = serialize_tool_call_arguments(fn.get("arguments"))
else:
fn_name = str(getattr(fn, "name", "") or "")
fn_args_delta = str(getattr(fn, "arguments", "") or "")
fn_args_delta = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
output_index = self._get_or_assign_tool_output_index(call_id)
@ -284,10 +285,10 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
fn_args = ""
if isinstance(fn, dict):
fn_name = str(fn.get("name") or "")
fn_args = str(fn.get("arguments") or "")
fn_args = serialize_tool_call_arguments(fn.get("arguments"))
else:
fn_name = str(getattr(fn, "name", "") or "")
fn_args = str(getattr(fn, "arguments", "") or "")
fn_args = serialize_tool_call_arguments(getattr(fn, "arguments", ""))
tool_name, tool_namespace = self._responses_namespace_tool_call_fields(fn_name)
# Track if this is a new tool call that wasn't streamed

View file

@ -93,6 +93,7 @@ from .custom_tools import (
convert_custom_tool_to_function_tool,
extract_custom_tool_names,
is_custom_tool_call,
serialize_tool_call_arguments,
unwrap_custom_tool_arguments,
validated_allowed_callers,
)
@ -1010,7 +1011,7 @@ class LiteLLMCompletionResponsesConfig:
type=cast(Literal["function"], tool_use_type),
function=ChatCompletionToolCallFunctionChunk(
name=str(function.get("name", "")),
arguments=str(function.get("arguments", "{}")),
arguments=serialize_tool_call_arguments(function.get("arguments"), "{}"),
),
index=index,
)
@ -1539,7 +1540,7 @@ class LiteLLMCompletionResponsesConfig:
type=cast(Literal["function"], _tool_use_definition.get("type") or "function"),
function=ChatCompletionToolCallFunctionChunk(
name=function.get("name") or "",
arguments=str(function.get("arguments") or ""),
arguments=serialize_tool_call_arguments(function.get("arguments")),
),
index=0,
)
@ -1589,7 +1590,7 @@ class LiteLLMCompletionResponsesConfig:
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=f"{namespace}__{raw_name}" if qualify else raw_name,
arguments=str(raw_arguments or ""),
arguments=serialize_tool_call_arguments(raw_arguments),
),
index=0,
)
@ -2024,7 +2025,7 @@ class LiteLLMCompletionResponsesConfig:
function_definition = tool.function
tool_name = function_definition.name or ""
tool_id = tool.id or ""
tool_arguments = function_definition.get("arguments") or ""
tool_arguments = serialize_tool_call_arguments(function_definition.get("arguments"))
# Check if this is a custom tool
if is_custom_tool_call(tool_name, custom_tool_names):
@ -2559,7 +2560,7 @@ class LiteLLMCompletionResponsesConfig:
type="function",
function=Function(
name=tool_call.get("name") or "",
arguments=tool_call.get("arguments") or "",
arguments=serialize_tool_call_arguments(tool_call.get("arguments")),
),
)

View file

@ -985,6 +985,55 @@ class TestFunctionCallTransformation:
assert result[0]["tool_calls"][0]["function"]["arguments"] == "{}"
def test_function_call_transformation_json_encodes_object_arguments(self):
"""A decoded arguments object must be JSON-encoded, not str()'d.
Clients and providers sometimes send `arguments` as an object rather
than a JSON string; `str()` on a dict produces a Python repr with
single quotes, which downstream JSON parsers reject with errors like
"Expecting ',' delimiter".
"""
function_call_item = {
"type": "function_call",
"name": "shell",
"arguments": {"command": "ls", "timeout": 30, "flags": ["-l", "-a"]},
"call_id": "call_123",
"id": "call_123",
"status": "completed",
}
result = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
function_call=function_call_item
)
arguments = result[0].get("tool_calls", [])[0].get("function", {}).get("arguments")
assert json.loads(arguments) == {"command": "ls", "timeout": 30, "flags": ["-l", "-a"]}
assert "'" not in arguments
def test_create_tool_call_chunk_json_encodes_object_arguments(self):
"""Cached tool_call definitions with object arguments stay valid JSON."""
chunk = LiteLLMCompletionResponsesConfig._create_tool_call_chunk(
tool_use_definition={
"id": "call_456",
"type": "function",
"function": {"name": "shell", "arguments": {"command": "ls"}},
},
tool_call_id="call_456",
index=0,
)
assert json.loads(chunk["function"]["arguments"]) == {"command": "ls"}
def test_create_tool_call_chunk_keeps_empty_arguments_default(self):
"""Missing arguments still fall back to an empty JSON object."""
chunk = LiteLLMCompletionResponsesConfig._create_tool_call_chunk(
tool_use_definition={"id": "call_789", "type": "function", "function": {"name": "shell"}},
tool_call_id="call_789",
index=0,
)
assert chunk["function"]["arguments"] == "{}"
def test_complete_input_transformation_with_function_calls(self):
"""Test the complete transformation with the exact input from the issue"""
test_input = [

View file

@ -10,6 +10,7 @@ before response.completed, and that every event of a bridged stream carries the
spend tracking stores, so a follow-up previous_response_id still finds the conversation.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -523,3 +524,36 @@ async def test_streaming_response_id_falls_back_when_upstream_yields_nothing():
assert response_ids
assert len(set(response_ids)) == 1
assert response_ids[0].startswith("resp_")
def test_object_tool_call_arguments_stream_as_valid_json():
"""A provider that sends decoded object arguments must still stream valid JSON.
`str()` on a dict yields a Python repr with single quotes, which clients
parsing function_call_arguments reject with errors like
"Expecting ',' delimiter".
"""
iterator = LiteLLMCompletionStreamingIterator(
model="test-model",
litellm_custom_stream_wrapper=AsyncMock(),
request_input="Test input",
responses_api_request={},
)
iterator._queue_tool_call_delta_events(
[
{
"index": 0,
"id": "call_obj",
"type": "function",
"function": {"name": "shell", "arguments": {"command": "ls", "flags": ["-l"]}},
}
]
)
streamed_arguments = "".join(
evt.delta
for evt in iterator._pending_tool_events
if evt.type == ResponsesAPIStreamEvents.FUNCTION_CALL_ARGUMENTS_DELTA
)
assert json.loads(streamed_arguments) == {"command": "ls", "flags": ["-l"]}