mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-12 18:25:41 +00:00
fix handling of ResponseApplyPatchToolCall in completion bridge (#20913)
* fix handling of ResponseApplyPatchToolCall in completion bridge * refactor * style: fix black formatting * fix: clean up lint errors in test file (unused imports, print statements, formatting) * refactor: extract _map_optional_params_to_responses_api to fix PLR0915 * what * this linter cannot be me * revert cause idk what's going on * weird * idk why this got removed * revert more stuff * revert pt 3
This commit is contained in:
@@ -401,6 +401,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
ResponseOutputMessage,
|
||||
ResponseReasoningItem,
|
||||
)
|
||||
from openai.types.responses.response_output_item import ResponseApplyPatchToolCall
|
||||
|
||||
from litellm.types.utils import Choices, Message
|
||||
|
||||
@@ -457,6 +458,18 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
accumulated_tool_calls.append(tool_call_dict)
|
||||
tool_call_index += 1
|
||||
|
||||
elif isinstance(item, ResponseApplyPatchToolCall):
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
tool_call_dict = LiteLLMCompletionResponsesConfig.convert_apply_patch_tool_call_to_chat_completion_tool_call(
|
||||
tool_call_item=item,
|
||||
index=tool_call_index,
|
||||
)
|
||||
accumulated_tool_calls.append(tool_call_dict)
|
||||
tool_call_index += 1
|
||||
|
||||
elif isinstance(item, dict) and handle_raw_dict_callback is not None:
|
||||
# Handle raw dict responses (e.g., from GPT-5 Codex)
|
||||
choice, index = handle_raw_dict_callback(item=item, index=index)
|
||||
@@ -533,7 +546,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
raw_response.usage
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# Preserve hidden params from the ResponsesAPIResponse, especially the headers
|
||||
# which contain important provider information like x-request-id
|
||||
raw_response_hidden_params = getattr(raw_response, "_hidden_params", {})
|
||||
@@ -550,7 +563,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
model_response._hidden_params[key] = merged_headers
|
||||
else:
|
||||
model_response._hidden_params[key] = value
|
||||
|
||||
|
||||
return model_response
|
||||
|
||||
def get_model_response_iterator(
|
||||
@@ -855,7 +868,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
||||
return {"format": {"type": "text"}}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _convert_annotations_to_chat_format(
|
||||
annotations: Optional[List[Any]],
|
||||
|
||||
@@ -291,14 +291,14 @@ class LiteLLMCompletionResponsesConfig:
|
||||
)
|
||||
_messages = litellm_completion_request.get("messages") or []
|
||||
session_messages = chat_completion_session.get("messages") or []
|
||||
|
||||
|
||||
# If session messages are empty (e.g., no database in test environment),
|
||||
# we still need to process the new input messages
|
||||
# Store original _messages before combining for safety check
|
||||
original_new_messages = _messages.copy() if _messages else []
|
||||
|
||||
|
||||
combined_messages = session_messages + _messages
|
||||
|
||||
|
||||
# Fix: Ensure tool_results have corresponding tool_calls in previous assistant message
|
||||
# Pass tools parameter to help reconstruct tool_calls if not in cache
|
||||
tools = litellm_completion_request.get("tools") or []
|
||||
@@ -306,7 +306,7 @@ class LiteLLMCompletionResponsesConfig:
|
||||
messages=combined_messages,
|
||||
tools=tools
|
||||
)
|
||||
|
||||
|
||||
# Safety check: Ensure we don't end up with empty messages
|
||||
# This can happen when using previous_response_id without a database (e.g., in tests)
|
||||
# and session messages are empty but new input messages exist
|
||||
@@ -337,7 +337,7 @@ class LiteLLMCompletionResponsesConfig:
|
||||
model=litellm_completion_request.get("model", ""),
|
||||
llm_provider=litellm_completion_request.get("custom_llm_provider", ""),
|
||||
)
|
||||
|
||||
|
||||
litellm_completion_request["messages"] = combined_messages
|
||||
litellm_completion_request["litellm_trace_id"] = chat_completion_session.get(
|
||||
"litellm_session_id"
|
||||
@@ -385,8 +385,8 @@ class LiteLLMCompletionResponsesConfig:
|
||||
|
||||
#########################################################
|
||||
# If Input Item is a Tool Call Output, add it to the tool_call_output_messages list
|
||||
# preserving the ordering of tool call outputs. Some models require the tool
|
||||
# result to immediately follow the assistant tool call.
|
||||
# preserving the ordering of tool call outputs. Some models require the tool
|
||||
# result to immediately follow the assistant tool call.
|
||||
#########################################################
|
||||
if LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(
|
||||
input_item=_input
|
||||
@@ -743,47 +743,47 @@ class LiteLLMCompletionResponsesConfig:
|
||||
) -> List[Union[AllMessageValues, GenericChatCompletionMessage, ChatCompletionResponseMessage]]:
|
||||
"""
|
||||
Ensure that tool_result messages have corresponding tool_calls in the previous assistant message.
|
||||
|
||||
|
||||
This is critical for Anthropic API which requires that each tool_result block has a
|
||||
corresponding tool_use block in the previous assistant message.
|
||||
|
||||
|
||||
Args:
|
||||
messages: List of messages that may include tool_result messages
|
||||
tools: Optional list of tools that can be used to reconstruct tool_calls if not in cache
|
||||
|
||||
|
||||
Returns:
|
||||
List of messages with tool_calls added to assistant messages when needed
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
|
||||
# Create a deep copy to avoid modifying the original
|
||||
import copy
|
||||
fixed_messages = copy.deepcopy(messages)
|
||||
messages_to_remove = []
|
||||
|
||||
|
||||
# Count non-tool messages to avoid removing all messages
|
||||
# This prevents empty messages list when using previous_response_id without a database
|
||||
non_tool_messages_count = sum(
|
||||
1 for msg in fixed_messages if msg.get("role") != "tool"
|
||||
)
|
||||
|
||||
|
||||
for i, message in enumerate(fixed_messages):
|
||||
# Only process tool messages - check role first to narrow the type
|
||||
if message.get("role") != "tool":
|
||||
continue
|
||||
|
||||
|
||||
# At this point, we know it's a tool message, so it should have tool_call_id
|
||||
# Use get() with default to safely access tool_call_id
|
||||
tool_call_id_raw = message.get("tool_call_id") if isinstance(message, dict) else getattr(message, "tool_call_id", None)
|
||||
tool_call_id: str = (
|
||||
str(tool_call_id_raw) if tool_call_id_raw is not None else ""
|
||||
)
|
||||
|
||||
|
||||
prev_assistant_idx = LiteLLMCompletionResponsesConfig._find_previous_assistant_idx(
|
||||
fixed_messages, i
|
||||
)
|
||||
|
||||
|
||||
# Try to recover empty tool_call_id from previous assistant message
|
||||
if not tool_call_id and prev_assistant_idx is not None:
|
||||
prev_assistant = fixed_messages[prev_assistant_idx]
|
||||
@@ -798,7 +798,7 @@ class LiteLLMCompletionResponsesConfig:
|
||||
message_dict["tool_call_id"] = tool_call_id
|
||||
elif hasattr(message, "tool_call_id"):
|
||||
setattr(message, "tool_call_id", tool_call_id)
|
||||
|
||||
|
||||
# Only remove messages with empty tool_call_id if we have other non-tool messages
|
||||
# This prevents ending up with an empty messages list when using previous_response_id
|
||||
# without a database (e.g., in tests where session messages are empty)
|
||||
@@ -810,7 +810,7 @@ class LiteLLMCompletionResponsesConfig:
|
||||
# If no non-tool messages, keep the tool message even with empty call_id
|
||||
# The API will return a proper error message about the missing tool_use block
|
||||
continue
|
||||
|
||||
|
||||
# Check if the previous assistant message has the corresponding tool_call
|
||||
# This needs to run for ALL tool messages with a valid tool_call_id,
|
||||
# not just those that had an empty tool_call_id initially
|
||||
@@ -819,12 +819,12 @@ class LiteLLMCompletionResponsesConfig:
|
||||
tool_calls = LiteLLMCompletionResponsesConfig._get_tool_calls_list(
|
||||
prev_assistant
|
||||
)
|
||||
|
||||
|
||||
if not LiteLLMCompletionResponsesConfig._check_tool_call_exists(
|
||||
tool_calls, tool_call_id
|
||||
):
|
||||
_tool_use_definition = TOOL_CALLS_CACHE.get_cache(key=tool_call_id)
|
||||
|
||||
|
||||
if not _tool_use_definition and tools:
|
||||
_tool_use_definition = (
|
||||
LiteLLMCompletionResponsesConfig._reconstruct_tool_call_from_tools(
|
||||
@@ -849,11 +849,11 @@ class LiteLLMCompletionResponsesConfig:
|
||||
LiteLLMCompletionResponsesConfig._add_tool_call_to_assistant(
|
||||
prev_assistant, tool_call_chunk
|
||||
)
|
||||
|
||||
|
||||
# Remove messages with empty tool_call_id that couldn't be fixed
|
||||
for idx in reversed(messages_to_remove):
|
||||
fixed_messages.pop(idx)
|
||||
|
||||
|
||||
return fixed_messages
|
||||
|
||||
@staticmethod
|
||||
@@ -1454,6 +1454,39 @@ class LiteLLMCompletionResponsesConfig:
|
||||
|
||||
return tool_call_dict
|
||||
|
||||
@staticmethod
|
||||
def convert_apply_patch_tool_call_to_chat_completion_tool_call(
|
||||
tool_call_item: Any,
|
||||
index: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert ResponseApplyPatchToolCall to ChatCompletionToolCallChunk format.
|
||||
|
||||
The operation (create_file / update_file / delete_file) is serialised
|
||||
as JSON so it appears in function.arguments, just like any other
|
||||
tool call.
|
||||
|
||||
Args:
|
||||
tool_call_item: ResponseApplyPatchToolCall object with call_id and operation
|
||||
index: The index of this tool call
|
||||
|
||||
Returns:
|
||||
Dictionary in ChatCompletionToolCallChunk format
|
||||
"""
|
||||
import json
|
||||
|
||||
operation_dict = tool_call_item.operation.model_dump()
|
||||
tool_call_dict: Dict[str, Any] = {
|
||||
"id": tool_call_item.call_id,
|
||||
"function": {
|
||||
"name": "apply_patch",
|
||||
"arguments": json.dumps(operation_dict),
|
||||
},
|
||||
"type": "function",
|
||||
"index": index,
|
||||
}
|
||||
return tool_call_dict
|
||||
|
||||
@staticmethod
|
||||
def transform_chat_completion_response_to_responses_api_response(
|
||||
request_input: Union[str, ResponseInputParam],
|
||||
|
||||
+154
-20
@@ -1012,11 +1012,11 @@ def test_multiple_tool_calls_in_single_choice():
|
||||
def test_map_reasoning_effort_adds_summary_detailed():
|
||||
"""
|
||||
Test that _map_reasoning_effort behavior with reasoning_auto_summary flag.
|
||||
|
||||
|
||||
By default (flag=False), summary should NOT be added to avoid:
|
||||
1. Breaking for users without verified OpenAI orgs (400 errors)
|
||||
2. Making requests more expensive by including summary reasoning tokens
|
||||
|
||||
|
||||
When flag is enabled (flag=True or env var), summary="detailed" is added.
|
||||
"""
|
||||
import os
|
||||
@@ -1030,64 +1030,64 @@ def test_map_reasoning_effort_adds_summary_detailed():
|
||||
|
||||
# Test all string effort levels - DEFAULT BEHAVIOR (no summary)
|
||||
effort_levels = ["none", "low", "medium", "high", "xhigh", "minimal"]
|
||||
|
||||
|
||||
# Save original flag value
|
||||
original_flag = litellm.reasoning_auto_summary
|
||||
original_env = os.environ.get("LITELLM_REASONING_AUTO_SUMMARY")
|
||||
|
||||
|
||||
try:
|
||||
# Test 1: Default behavior (flag=False, no env var) - NO summary
|
||||
litellm.reasoning_auto_summary = False
|
||||
if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ:
|
||||
del os.environ["LITELLM_REASONING_AUTO_SUMMARY"]
|
||||
|
||||
|
||||
for effort in effort_levels:
|
||||
result = handler._map_reasoning_effort(effort)
|
||||
|
||||
|
||||
assert result is not None, f"Result should not be None for effort={effort}"
|
||||
assert result["effort"] == effort, f"Effort should be {effort}"
|
||||
assert "summary" not in result, f"Summary should NOT be present by default for effort={effort}"
|
||||
|
||||
|
||||
print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}' (no summary by default)")
|
||||
|
||||
|
||||
# Test 2: With flag enabled - summary IS added
|
||||
litellm.reasoning_auto_summary = True
|
||||
|
||||
|
||||
for effort in effort_levels:
|
||||
result = handler._map_reasoning_effort(effort)
|
||||
|
||||
|
||||
assert result is not None, f"Result should not be None for effort={effort}"
|
||||
assert result["effort"] == effort, f"Effort should be {effort}"
|
||||
assert result["summary"] == "detailed", f"Summary should be 'detailed' when flag is enabled for effort={effort}"
|
||||
|
||||
|
||||
print(f"✓ reasoning_effort='{effort}' correctly maps to effort='{effort}', summary='detailed' (flag enabled)")
|
||||
|
||||
|
||||
# Test 3: With env var enabled (flag disabled) - summary IS added
|
||||
litellm.reasoning_auto_summary = False
|
||||
os.environ["LITELLM_REASONING_AUTO_SUMMARY"] = "true"
|
||||
|
||||
|
||||
result = handler._map_reasoning_effort("high")
|
||||
assert result["summary"] == "detailed", "Summary should be 'detailed' when env var is enabled"
|
||||
print("✓ LITELLM_REASONING_AUTO_SUMMARY env var works correctly")
|
||||
|
||||
|
||||
# Test 4: Dict input is passed through as-is (no modification)
|
||||
litellm.reasoning_auto_summary = False
|
||||
if "LITELLM_REASONING_AUTO_SUMMARY" in os.environ:
|
||||
del os.environ["LITELLM_REASONING_AUTO_SUMMARY"]
|
||||
|
||||
|
||||
dict_input = {"effort": "high", "summary": "custom_summary"}
|
||||
result_dict = handler._map_reasoning_effort(dict_input)
|
||||
assert result_dict["effort"] == "high"
|
||||
assert result_dict["summary"] == "custom_summary"
|
||||
print("✓ Dict input is passed through without modification")
|
||||
|
||||
|
||||
# Test 5: None/unknown values return None
|
||||
result_unknown = handler._map_reasoning_effort("unknown_value")
|
||||
assert result_unknown is None
|
||||
print("✓ Unknown reasoning_effort values return None")
|
||||
|
||||
|
||||
print("✓ All reasoning_effort behaviors work correctly with flag/env var control")
|
||||
|
||||
|
||||
finally:
|
||||
# Restore original values
|
||||
litellm.reasoning_auto_summary = original_flag
|
||||
@@ -1100,10 +1100,10 @@ def test_map_reasoning_effort_adds_summary_detailed():
|
||||
def test_transform_response_preserves_annotations():
|
||||
"""
|
||||
Test that annotations from Responses API are preserved when transforming to Chat Completions format.
|
||||
|
||||
|
||||
This is a regression test for the bug where annotations (like url_citation) were being
|
||||
dropped during the transformation from ResponsesAPIResponse to ModelResponse.
|
||||
|
||||
|
||||
The fix ensures annotations are extracted from ResponseOutputText content items and
|
||||
passed through to the Message object in the Chat Completions response.
|
||||
"""
|
||||
@@ -1278,3 +1278,137 @@ def test_transform_response_preserves_annotations():
|
||||
assert result.usage.total_tokens == 30
|
||||
|
||||
print("✓ Annotations from Responses API are correctly preserved in Chat Completions format")
|
||||
|
||||
|
||||
def test_apply_patch_tool_call_converted_to_chat_completion_tool_call():
|
||||
"""
|
||||
Test that ResponseApplyPatchToolCall items from the Responses API are
|
||||
correctly converted to ChatCompletions-style tool calls by the bridge.
|
||||
|
||||
This is a regression test for a bug where litellm.completion() with a
|
||||
responses/ model prefix crashed when the model returned an
|
||||
apply_patch_call, because _convert_response_output_to_choices did not
|
||||
handle ResponseApplyPatchToolCall items. The model DID use the tool,
|
||||
but the bridge silently dropped it (or raised an error), while the
|
||||
native litellm.responses() path worked correctly.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
|
||||
from openai.types.responses.response_apply_patch_tool_call import (
|
||||
OperationCreateFile,
|
||||
)
|
||||
from openai.types.responses.response_output_item import (
|
||||
ResponseApplyPatchToolCall,
|
||||
)
|
||||
|
||||
from litellm.completion_extras.litellm_responses_transformation.transformation import (
|
||||
LiteLLMResponsesTransformationHandler,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
InputTokensDetails,
|
||||
OutputTokensDetails,
|
||||
ResponseAPIUsage,
|
||||
ResponsesAPIResponse,
|
||||
)
|
||||
from litellm.types.utils import ModelResponse, Usage
|
||||
|
||||
handler = LiteLLMResponsesTransformationHandler()
|
||||
|
||||
# Build an apply_patch_call item like the model would return
|
||||
operation = OperationCreateFile(
|
||||
diff="--- /dev/null\n+++ b/hello.py\n@@ -0,0 +1 @@\n+print('hello world')\n",
|
||||
path="hello.py",
|
||||
type="create_file",
|
||||
)
|
||||
apply_patch_item = ResponseApplyPatchToolCall(
|
||||
id="apc_001",
|
||||
call_id="call_patch_hello",
|
||||
operation=operation,
|
||||
status="completed",
|
||||
type="apply_patch_call",
|
||||
)
|
||||
|
||||
# Minimal usage
|
||||
usage = ResponseAPIUsage(
|
||||
input_tokens=30,
|
||||
input_tokens_details=InputTokensDetails(cached_tokens=0),
|
||||
output_tokens=40,
|
||||
output_tokens_details=OutputTokensDetails(reasoning_tokens=0),
|
||||
total_tokens=70,
|
||||
)
|
||||
|
||||
raw_response = ResponsesAPIResponse(
|
||||
id="resp_apply_patch_test",
|
||||
created_at=1234567890,
|
||||
error=None,
|
||||
incomplete_details=None,
|
||||
instructions=None,
|
||||
metadata={},
|
||||
model="gpt-5.2-codex",
|
||||
object="response",
|
||||
output=[apply_patch_item],
|
||||
parallel_tool_calls=True,
|
||||
temperature=1.0,
|
||||
tool_choice="auto",
|
||||
tools=[],
|
||||
top_p=1.0,
|
||||
max_output_tokens=None,
|
||||
previous_response_id=None,
|
||||
reasoning=None,
|
||||
status="completed",
|
||||
text=None,
|
||||
truncation="disabled",
|
||||
usage=usage,
|
||||
user=None,
|
||||
store=True,
|
||||
background=False,
|
||||
)
|
||||
|
||||
model_response = ModelResponse(
|
||||
id="chatcmpl-apply-patch",
|
||||
created=1234567890,
|
||||
model=None,
|
||||
object="chat.completion",
|
||||
choices=[],
|
||||
usage=Usage(completion_tokens=0, prompt_tokens=0, total_tokens=0),
|
||||
)
|
||||
|
||||
logging_obj = Mock()
|
||||
|
||||
result = handler.transform_response(
|
||||
model="gpt-5.2-codex",
|
||||
raw_response=raw_response,
|
||||
model_response=model_response,
|
||||
logging_obj=logging_obj,
|
||||
request_data={"model": "gpt-5.2-codex"},
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a coding assistant."},
|
||||
{"role": "user", "content": "Create hello.py"},
|
||||
],
|
||||
optional_params={},
|
||||
litellm_params={},
|
||||
encoding=Mock(),
|
||||
)
|
||||
|
||||
# Should have exactly one choice with finish_reason="tool_calls"
|
||||
assert len(result.choices) == 1, f"Expected 1 choice, got {len(result.choices)}"
|
||||
|
||||
choice = result.choices[0]
|
||||
assert choice.finish_reason == "tool_calls"
|
||||
|
||||
# The choice should contain one tool call for apply_patch
|
||||
tool_calls = choice.message.tool_calls
|
||||
assert tool_calls is not None, "tool_calls should not be None"
|
||||
assert len(tool_calls) == 1, f"Expected 1 tool_call, got {len(tool_calls)}"
|
||||
|
||||
tc = tool_calls[0]
|
||||
assert tc["id"] == "call_patch_hello"
|
||||
assert tc["type"] == "function"
|
||||
assert tc["function"]["name"] == "apply_patch"
|
||||
|
||||
# The operation should be serialised as JSON in arguments
|
||||
args = json.loads(tc["function"]["arguments"])
|
||||
assert args["type"] == "create_file"
|
||||
assert args["path"] == "hello.py"
|
||||
assert "print('hello world')" in args["diff"]
|
||||
|
||||
Reference in New Issue
Block a user