mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
commit
30bf592aaf
5 changed files with 109 additions and 9 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue