From 4b5ca49cd2218699897dfff396171b04d85d9b45 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Tue, 31 Mar 2026 12:59:01 -0700 Subject: [PATCH] fix: map file_url -> file_id in Responses->Completions translation --- .../transformation.py | 26 +- .../test_litellm_completion_responses.py | 556 ++++++++++-------- 2 files changed, 339 insertions(+), 243 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index b6479a36998..9075373f1cf 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1202,22 +1202,32 @@ class LiteLLMCompletionResponsesConfig: return [chat_completion_response_message] + @staticmethod + def _resolve_file_id(item: Dict[str, Any]) -> Optional[str]: + """ + Return the effective file_id for a Responses API input_file item. + Explicit file_id takes precedence; file_url is used as fallback so + downstream providers (Anthropic, Gemini) can handle the URL natively. + """ + return item.get("file_id") or item.get("file_url") or None + @staticmethod def _transform_input_file_item_to_file_item(item: Dict[str, Any]) -> Dict[str, Any]: """ Transform a Responses API input_file item to a Chat Completion file item Args: - item: Dictionary containing input_file type with file_id and/or file_data + item: Dictionary containing input_file type with file_id, file_data, and/or file_url Returns: Dictionary with transformed file structure for Chat Completion """ file_dict: Dict[str, Any] = {} - keys = ["file_id", "file_data"] - for key in keys: - if item.get(key): - file_dict[key] = item.get(key) + file_id = LiteLLMCompletionResponsesConfig._resolve_file_id(item) + if file_id: + file_dict["file_id"] = file_id + if item.get("file_data"): + file_dict["file_data"] = item["file_data"] new_item: Dict[str, Any] = {"type": "file", "file": file_dict} return new_item @@ -2113,9 +2123,9 @@ class LiteLLMCompletionResponsesConfig: hasattr(completion_details, "reasoning_tokens") and completion_details.reasoning_tokens is not None ): - output_details_dict[ - "reasoning_tokens" - ] = completion_details.reasoning_tokens + output_details_dict["reasoning_tokens"] = ( + completion_details.reasoning_tokens + ) else: output_details_dict["reasoning_tokens"] = 0 diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py index aaa131c23dc..4e44ef9e50c 100644 --- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py @@ -6,20 +6,20 @@ sys.path.insert( ) # Adds the parent directory to the system path from litellm.responses.litellm_completion_transformation.transformation import ( - LiteLLMCompletionResponsesConfig, TOOL_CALLS_CACHE, + LiteLLMCompletionResponsesConfig, ) from litellm.types.llms.openai import ( ChatCompletionResponseMessage, ChatCompletionToolMessage, ) from litellm.types.utils import ( + ChatCompletionMessageToolCall, Choices, CompletionTokensDetailsWrapper, + Function, Message, ModelResponse, - Function, - ChatCompletionMessageToolCall, PromptTokensDetailsWrapper, Usage, ) @@ -130,6 +130,31 @@ class TestLiteLLMCompletionResponsesConfig: assert "extra_field" not in result["file"] assert "another_field" not in result["file"] + def test_transform_input_file_item_to_file_item_with_file_url(self): + """file_url should be mapped to file_id for downstream URL handling""" + result = ( + LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( + {"type": "input_file", "file_url": "https://example.com/doc.pdf"} + ) + ) + assert result == { + "type": "file", + "file": {"file_id": "https://example.com/doc.pdf"}, + } + + def test_transform_input_file_item_file_id_takes_precedence_over_file_url(self): + """explicit file_id should not be overwritten by file_url""" + result = ( + LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item( + { + "type": "input_file", + "file_id": "file-abc123", + "file_url": "https://example.com/doc.pdf", + } + ) + ) + assert result == {"type": "file", "file": {"file_id": "file-abc123"}} + def test_transform_input_image_item_to_image_item_with_image_url(self): """Test transformation of input_image item with image_url to Chat Completion image format""" # Setup @@ -144,7 +169,10 @@ class TestLiteLLMCompletionResponsesConfig: ) # Assert - expected = {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}} + expected = { + "type": "image_url", + "image_url": {"url": image_url, "detail": "high"}, + } assert result == expected assert result["type"] == "image_url" assert result["image_url"]["url"] == image_url @@ -164,7 +192,10 @@ class TestLiteLLMCompletionResponsesConfig: ) # Assert - expected = {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}} + expected = { + "type": "image_url", + "image_url": {"url": image_url, "detail": "high"}, + } assert result == expected assert result["type"] == "image_url" assert result["image_url"]["url"] == image_url @@ -184,7 +215,10 @@ class TestLiteLLMCompletionResponsesConfig: ) # Assert - expected = {"type": "image_url", "image_url": {"url": image_url, "detail": "auto"}} + expected = { + "type": "image_url", + "image_url": {"url": image_url, "detail": "auto"}, + } assert result == expected assert result["type"] == "image_url" assert result["image_url"]["url"] == image_url @@ -227,7 +261,10 @@ class TestLiteLLMCompletionResponsesConfig: ) # Assert - expected = {"type": "image_url", "image_url": {"url": "https://example.com/image.png", "detail": "auto"}} + expected = { + "type": "image_url", + "image_url": {"url": "https://example.com/image.png", "detail": "auto"}, + } assert result == expected assert result["type"] == "image_url" assert result["image_url"]["url"] == "https://example.com/image.png" @@ -265,9 +302,7 @@ class TestLiteLLMCompletionResponsesConfig: # Assert assert hasattr(responses_api_response, "output") - assert ( - len(responses_api_response.output) >= 2 - ) + assert len(responses_api_response.output) >= 2 reasoning_items = [ item for item in responses_api_response.output if item.type == "reasoning" @@ -277,8 +312,10 @@ class TestLiteLLMCompletionResponsesConfig: reasoning_item = reasoning_items[0] # Note: ID auto-generation was disabled, so reasoning items may not have IDs # Only assert ID format if an ID is present - if hasattr(reasoning_item, 'id') and reasoning_item.id: - assert reasoning_item.id.startswith("rs_"), f"Expected ID to start with 'rs_', got: {reasoning_item.id}" + if hasattr(reasoning_item, "id") and reasoning_item.id: + assert reasoning_item.id.startswith( + "rs_" + ), f"Expected ID to start with 'rs_', got: {reasoning_item.id}" assert reasoning_item.status == "completed" assert reasoning_item.role == "assistant" assert len(reasoning_item.content) == 1 @@ -386,7 +423,7 @@ class TestLiteLLMCompletionResponsesConfig: """ Test that transforming a chat completion response with 'stop' finish_reason results in 'completed' status in the responses API response. - + This is the main test case for GitHub issue #15714. """ chat_completion_response = ModelResponse( @@ -406,12 +443,10 @@ class TestLiteLLMCompletionResponsesConfig: ], ) - responses_api_response = ( - LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="this is a test", - responses_api_request={}, - chat_completion_response=chat_completion_response, - ) + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="this is a test", + responses_api_request={}, + chat_completion_response=chat_completion_response, ) assert responses_api_response.status == "completed" @@ -427,7 +462,7 @@ class TestLiteLLMCompletionResponsesConfig: def test_transform_chat_completion_response_output_item_status(self): """ Test that output items in the transformed response also have valid status values. - + This verifies the fix for GitHub issue #15714. """ chat_completion_response = ModelResponse( @@ -447,12 +482,10 @@ class TestLiteLLMCompletionResponsesConfig: ], ) - responses_api_response = ( - LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( - request_input="this is a test", - responses_api_request={}, - chat_completion_response=chat_completion_response, - ) + responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response( + request_input="this is a test", + responses_api_request={}, + chat_completion_response=chat_completion_response, ) message_items = [ @@ -543,6 +576,7 @@ class TestLiteLLMCompletionResponsesConfig: assert hasattr(responses_api_response, "_hidden_params") assert responses_api_response._hidden_params == {} + class TestFunctionCallTransformation: """Test cases for function_call input transformation""" @@ -552,30 +586,38 @@ class TestFunctionCallTransformation: "type": "function_call", "name": "get_weather", "arguments": '{"location": "test"}', - "call_id": "test_id" + "call_id": "test_id", } - + function_call_output_item = { "type": "function_call_output", "call_id": "test_id", - "output": "result" + "output": "result", } - - regular_message = { - "type": "message", - "role": "user", - "content": "Hello" - } - + + regular_message = {"type": "message", "role": "user", "content": "Hello"} + # Test function_call detection - assert LiteLLMCompletionResponsesConfig._is_input_item_function_call(function_call_item) - assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(function_call_output_item) - assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call(regular_message) - + assert LiteLLMCompletionResponsesConfig._is_input_item_function_call( + function_call_item + ) + assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call( + function_call_output_item + ) + assert not LiteLLMCompletionResponsesConfig._is_input_item_function_call( + regular_message + ) + # Test function_call_output detection (should still work) - assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(function_call_output_item) - assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(function_call_item) - assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output(regular_message) + assert LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output( + function_call_output_item + ) + assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output( + function_call_item + ) + assert not LiteLLMCompletionResponsesConfig._is_input_item_tool_call_output( + regular_message + ) def test_function_call_transformation(self): """Test that function_call items are correctly transformed to assistant messages with tool calls""" @@ -585,28 +627,28 @@ class TestFunctionCallTransformation: "arguments": '{"location": "São Paulo, Brazil"}', "call_id": "call_123", "id": "call_123", - "status": "completed" + "status": "completed", } - + result = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( function_call=function_call_item ) - + assert len(result) == 1 message = result[0] - + # Should be an assistant message assert message.get("role") == "assistant" assert message.get("content") is None # Function calls don't have content - + # Should have tool calls tool_calls = message.get("tool_calls", []) assert len(tool_calls) == 1 - + tool_call = tool_calls[0] assert tool_call.get("id") == "call_123" assert tool_call.get("type") == "function" - + function = tool_call.get("function", {}) assert function.get("name") == "get_weather" assert function.get("arguments") == '{"location": "São Paulo, Brazil"}' @@ -617,7 +659,7 @@ class TestFunctionCallTransformation: { "type": "message", "role": "user", - "content": "How is the weather in São Paulo today ?" + "content": "How is the weather in São Paulo today ?", }, { "type": "function_call", @@ -625,49 +667,51 @@ class TestFunctionCallTransformation: "call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5", "name": "get_weather", "id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5", - "status": "completed" + "status": "completed", }, { "type": "function_call_output", "call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5", - "output": "Rainy" - } + "output": "Rainy", + }, ] - + # This should not raise an error (previously would raise "Invalid content type: ") messages = LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message( input=test_input ) - + assert len(messages) == 3 - + # First message: user message user_msg = messages[0] assert user_msg.get("role") == "user" assert user_msg.get("content") == "How is the weather in São Paulo today ?" - + # Second message: assistant message with tool call assistant_msg = messages[1] assert assistant_msg.get("role") == "assistant" assert assistant_msg.get("tool_calls") is not None assert len(assistant_msg.get("tool_calls", [])) == 1 - + tool_call = assistant_msg.get("tool_calls")[0] assert tool_call.get("function", {}).get("name") == "get_weather" - + # Third message: tool output tool_msg = messages[2] assert tool_msg.get("role") == "tool" assert tool_msg.get("content") == "Rainy" - assert tool_msg.get("tool_call_id") == "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5" + assert ( + tool_msg.get("tool_call_id") == "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5" + ) def test_complete_request_transformation_with_function_calls(self): """Test the complete request transformation that would be used by the responses API""" test_input = [ { "type": "message", - "role": "user", - "content": "How is the weather in São Paulo today ?" + "role": "user", + "content": "How is the weather in São Paulo today ?", }, { "type": "function_call", @@ -675,15 +719,15 @@ class TestFunctionCallTransformation: "call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5", "name": "get_weather", "id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5", - "status": "completed" + "status": "completed", }, { "type": "function_call_output", "call_id": "call_1fe70e2a-a596-45ef-b72c-9b8567c460e5", - "output": "Rainy" - } + "output": "Rainy", + }, ] - + tools = [ { "type": "function", @@ -694,44 +738,41 @@ class TestFunctionCallTransformation: "properties": { "location": { "type": "string", - "description": "City and country e.g. Bogotá, Colombia" + "description": "City and country e.g. Bogotá, Colombia", } }, "required": ["location"], - "additionalProperties": False - } + "additionalProperties": False, + }, } ] - - responses_api_request = { - "store": False, - "tools": tools - } - + + responses_api_request = {"store": False, "tools": tools} + # This should work without errors for non-OpenAI models result = LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request( model="gemini/gemini-2.0-flash", input=test_input, responses_api_request=responses_api_request, - extra_headers={"X-Test-Header": "test-value"} + extra_headers={"X-Test-Header": "test-value"}, ) - + assert "messages" in result assert "model" in result assert "tools" in result - + messages = result["messages"] assert len(messages) == 3 assert result["model"] == "gemini/gemini-2.0-flash" - + # Verify the structure is correct for chat completion user_msg = messages[0] assert user_msg["role"] == "user" - - assistant_msg = messages[1] + + assistant_msg = messages[1] assert assistant_msg["role"] == "assistant" assert "tool_calls" in assistant_msg - + tool_msg = messages[2] assert tool_msg["role"] == "tool" @@ -743,18 +784,18 @@ class TestFunctionCallTransformation: "type": "function_call", "name": "get_weather", "arguments": '{"location": "test"}', - "id": "fallback_id" # Only has 'id', not 'call_id' + "id": "fallback_id", # Only has 'id', not 'call_id' } - + result = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message( function_call=function_call_item ) - + assert len(result) == 1 message = result[0] tool_calls = message.get("tool_calls", []) assert len(tool_calls) == 1 - + tool_call = tool_calls[0] assert tool_call.get("id") == "fallback_id" @@ -778,7 +819,11 @@ class TestFunctionCallTransformation: messages_missing_tool_calls = [ {"role": "user", "content": "Search for python bugs"}, {"role": "assistant", "content": None, "tool_calls": []}, - {"role": "tool", "content": "Found 5 results", "tool_call_id": tool_call_id}, + { + "role": "tool", + "content": "Found 5 results", + "tool_call_id": tool_call_id, + }, ] try: @@ -830,7 +875,11 @@ class TestFunctionCallTransformation: messages_missing_tool_calls = [ {"role": "user", "content": "Search using attr object"}, {"role": "assistant", "content": None, "tool_calls": []}, - {"role": "tool", "content": "Found 3 results", "tool_call_id": tool_call_id}, + { + "role": "tool", + "content": "Found 3 results", + "tool_call_id": tool_call_id, + }, ] try: @@ -859,7 +908,9 @@ class TestToolChoiceTransformation: Test that {"type": "tool"} is transformed to "required". This fixes the Anthropic error: "tool_choice.tool.name: Field required" """ - result = LiteLLMCompletionResponsesConfig._transform_tool_choice({"type": "tool"}) + result = LiteLLMCompletionResponsesConfig._transform_tool_choice( + {"type": "tool"} + ) assert result == "required" def test_transform_tool_choice_preserves_function_with_name(self): @@ -877,12 +928,20 @@ class TestContentTypeTransformation: Test that 'tool_result' content type is transformed to 'text'. This fixes: Invalid user message - content type 'tool_result' not valid. """ - result = LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type("tool_result") + result = ( + LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type( + "tool_result" + ) + ) assert result == "text" def test_input_text_content_type_transformed_to_text(self): """Test that 'input_text' content type is transformed to 'text'""" - result = LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type("input_text") + result = ( + LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type( + "input_text" + ) + ) assert result == "text" def test_none_text_blocks_filtered_out(self): @@ -896,7 +955,9 @@ class TestContentTypeTransformation: {"type": "text", "text": None}, # Should be filtered out {"type": "text", "text": "another valid"}, ] - result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(content) + result = LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content( + content + ) assert len(result) == 2 assert result[0]["text"] == "valid text" assert result[1]["text"] == "another valid" @@ -911,14 +972,16 @@ class TestToolTransformation: # Create a Vertex AI tool using the enum value vertex_tool = {VertexToolName.CODE_EXECUTION.value: {}} - + tools = [vertex_tool] - + # Execute - result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 assert result_tools[0] == vertex_tool @@ -930,18 +993,18 @@ class TestToolTransformation: "type": "mcp", "server_label": "zapier", "server_url": "https://mcp.zapier.com/api/mcp/mcp", - "headers": { - "Authorization": "Bearer token123" - }, + "headers": {"Authorization": "Bearer token123"}, } - + tools = [mcp_tool] - + # Execute - result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 assert result_tools[0] == mcp_tool @@ -953,16 +1016,18 @@ class TestToolTransformation: computer_use_tool = { "type": "computer_use", "display_width_px": 1024, - "display_height_px": 768 + "display_height_px": 768, } - + tools = [computer_use_tool] - + # Execute - result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 assert result_tools[0] == computer_use_tool @@ -974,16 +1039,18 @@ class TestToolTransformation: web_search_tool = { "type": "web_search_preview", "search_context_size": "medium", - "user_location": {"country": "US"} + "user_location": {"country": "US"}, } - + tools = [web_search_tool] - + # Execute - result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 0 # Web search is not added to tools assert web_search_options is not None @@ -998,24 +1065,24 @@ class TestToolTransformation: "description": "Get weather for a location", "parameters": { "type": "object", - "properties": { - "location": {"type": "string"} - }, - "required": ["location"] + "properties": {"location": {"type": "string"}}, + "required": ["location"], }, "cache_control": {"type": "ephemeral"}, "defer_loading": True, "allowed_callers": ["user"], - "input_examples": [{"location": "San Francisco"}] + "input_examples": [{"location": "San Francisco"}], } - + tools = [function_tool] - + # Execute - result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 result_tool = result_tools[0] @@ -1035,16 +1102,18 @@ class TestToolTransformation: "name": "search", "description": "Search function", "parameters": {"type": "object"}, - "cache_control": {"type": "ephemeral"} + "cache_control": {"type": "ephemeral"}, } - + tools = [function_tool] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 result_tool = result_tools[0] @@ -1059,19 +1128,19 @@ class TestToolTransformation: "description": "A simple function", "parameters": { "type": "object", - "properties": { - "param": {"type": "string"} - } - } + "properties": {"param": {"type": "string"}}, + }, } - + tools = [function_tool] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 result_tool = result_tools[0] @@ -1087,16 +1156,18 @@ class TestToolTransformation: """Test that code_execution tools are passed through as-is""" code_execution_tool = { "type": "code_execution_20250825", - "name": "python_code_execution" + "name": "python_code_execution", } - + tools = [code_execution_tool] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 assert result_tools[0]["type"] == "code_execution_20250825" @@ -1105,21 +1176,23 @@ class TestToolTransformation: """Test that tool_search tools are passed through as-is""" tool_search_regex = { "name": "tool_search_tool_regex", - "description": "Search tools using regex" + "description": "Search tools using regex", } - + tool_search_bm25 = { "name": "tool_search_tool_bm25", - "description": "Search tools using BM25" + "description": "Search tools using BM25", } - + tools = [tool_search_regex, tool_search_bm25] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 2 assert result_tools[0]["name"] == "tool_search_tool_regex" @@ -1128,7 +1201,7 @@ class TestToolTransformation: def test_transform_mixed_tools_list(self): """Test transforming a mixed list of different tool types""" from litellm.types.llms.vertex_ai import VertexToolName - + tools = [ # Regular function tool with anthropic fields { @@ -1136,40 +1209,38 @@ class TestToolTransformation: "name": "get_weather", "description": "Get weather", "parameters": {"type": "object"}, - "cache_control": {"type": "ephemeral"} + "cache_control": {"type": "ephemeral"}, }, # MCP tool - { - "type": "mcp", - "server_label": "zapier" - }, + {"type": "mcp", "server_label": "zapier"}, # Web search tool - { - "type": "web_search_preview", - "search_context_size": "high" - }, + {"type": "web_search_preview", "search_context_size": "high"}, # Vertex AI tool - {VertexToolName.CODE_EXECUTION.value: {}} + {VertexToolName.CODE_EXECUTION.value: {}}, ] - + # Execute - result_tools, web_search_options = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, web_search_options = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert - assert len(result_tools) == 3 # function, mcp, vertex (web_search becomes options) + assert ( + len(result_tools) == 3 + ) # function, mcp, vertex (web_search becomes options) assert web_search_options is not None - + # Check function tool func_tools = [t for t in result_tools if t.get("type") == "function"] assert len(func_tools) == 1 assert func_tools[0]["cache_control"]["type"] == "ephemeral" - + # Check MCP tool mcp_tools = [t for t in result_tools if t.get("type") == "mcp"] assert len(mcp_tools) == 1 - + # Check web search was converted to options assert web_search_options.get("search_context_size") == "high" @@ -1179,20 +1250,18 @@ class TestToolTransformation: "type": "function", "name": "test_function", "description": "Test function", - "parameters": { - "properties": { - "arg": {"type": "string"} - } - } + "parameters": {"properties": {"arg": {"type": "string"}}}, } - + tools = [function_tool] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 result_tool = result_tools[0] @@ -1205,16 +1274,18 @@ class TestToolTransformation: "type": "function", "name": "test_function", "description": "Test function", - "parameters": {} + "parameters": {}, } - + tools = [function_tool] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 result_tool = result_tools[0] @@ -1225,16 +1296,18 @@ class TestToolTransformation: function_tool = { "type": "function", "name": "test_function", - "description": "Test function" + "description": "Test function", } - + tools = [function_tool] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 result_tool = result_tools[0] @@ -1246,27 +1319,27 @@ class TestToolTransformation: "type": "function", "name": "test_function", "description": "Test function", - "parameters": { - "type": "object", - "properties": { - "arg": {"type": "string"} - } - } + "parameters": {"type": "object", "properties": {"arg": {"type": "string"}}}, } - + tools = [function_tool] - + # Execute - result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( - tools=tools + result_tools, _ = ( + LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools( + tools=tools + ) ) - + # Assert assert len(result_tools) == 1 result_tool = result_tools[0] assert result_tool["function"]["parameters"]["type"] == "object" assert "properties" in result_tool["function"]["parameters"] - assert result_tool["function"]["parameters"]["properties"]["arg"]["type"] == "string" + assert ( + result_tool["function"]["parameters"]["properties"]["arg"]["type"] + == "string" + ) class TestUsageTransformation: @@ -1434,12 +1507,12 @@ class TestUsageTransformation: assert response_usage.input_tokens == 13 assert response_usage.output_tokens == 100 assert response_usage.total_tokens == 113 - + # Verify input_tokens_details assert response_usage.input_tokens_details is not None assert response_usage.input_tokens_details.cached_tokens == 5 assert response_usage.input_tokens_details.text_tokens == 8 - + # Verify output_tokens_details assert response_usage.output_tokens_details is not None assert response_usage.output_tokens_details.reasoning_tokens == 50 @@ -1543,7 +1616,9 @@ class TestUsageTransformation: Choices( finish_reason="stop", index=0, - message=Message(content="Here is the generated image.", role="assistant"), + message=Message( + content="Here is the generated image.", role="assistant" + ), ) ], ) @@ -1569,7 +1644,7 @@ class TestStreamingIDConsistency: Test that all streaming events use the same item_id throughout the stream. This fixes the issue where text-start, text-delta, and text-end events had different IDs, breaking SDK text accumulation. - + Reproduces: https://github.com/BerriAI/litellm/issues/14962 """ from unittest.mock import Mock @@ -1645,25 +1720,27 @@ class TestStreamingIDConsistency: # Assert: All events should use the same item_id (from the first chunk) assert event1 is not None, "First event should not be None" assert event2 is not None, "Second event should not be None" - + # Extract item_ids from events item_id_1 = getattr(event1, "item_id", None) item_id_2 = getattr(event2, "item_id", None) - + assert item_id_1 is not None, "First event should have an item_id" assert item_id_2 is not None, "Second event should have an item_id" - + # The critical assertion: IDs should match across all events assert item_id_1 == item_id_2, ( f"Item IDs should be consistent across streaming events. " f"Got {item_id_1} and {item_id_2}. " f"This breaks SDK text accumulation (issue #14962)." ) - + # Verify the cached ID is set and matches assert iterator._cached_item_id is not None, "Iterator should cache the item_id" assert iterator._cached_item_id == item_id_1, "Cached ID should match event IDs" - assert iterator._cached_item_id == "chatcmpl-first-id", "Should use the first chunk's ID" + assert ( + iterator._cached_item_id == "chatcmpl-first-id" + ), "Should use the first chunk's ID" def test_streaming_iterator_initial_events_use_cached_id(self): """ @@ -1704,7 +1781,7 @@ class TestStreamingIDConsistency: f"Initial events should use consistent IDs. " f"Got output_item_id={output_item_id}, content_part_id={content_part_id}" ) - + # Verify it matches the cached ID assert iterator._cached_item_id is not None assert iterator._cached_item_id == output_item_id @@ -1753,7 +1830,9 @@ class TestStreamingIDConsistency: # Create done events text_done_event = iterator.create_output_text_done_event(complete_response) - content_done_event = iterator.create_output_content_part_done_event(complete_response) + content_done_event = iterator.create_output_content_part_done_event( + complete_response + ) item_done_event = iterator.create_output_item_done_event(complete_response) # Extract IDs @@ -1765,12 +1844,12 @@ class TestStreamingIDConsistency: assert text_done_id is not None, "Text done event should have an item_id" assert content_done_id is not None, "Content done event should have an item_id" assert item_done_id is not None, "Item done event should have an id" - + assert text_done_id == content_done_id == item_done_id, ( f"All done events should use consistent IDs. " f"Got text_done={text_done_id}, content_done={content_done_id}, item_done={item_done_id}" ) - + # Verify it matches the cached ID assert iterator._cached_item_id is not None assert iterator._cached_item_id == text_done_id @@ -1826,13 +1905,14 @@ class TestStreamingIDConsistency: # The single assistant message must contain BOTH tool_calls assistant_messages = [ - m for m in messages + m + for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "assistant" ] - assert len(assistant_messages) == 1, ( - f"Expected 1 assistant message, got {len(assistant_messages)}" - ) + assert ( + len(assistant_messages) == 1 + ), f"Expected 1 assistant message, got {len(assistant_messages)}" assistant_msg = assistant_messages[0] tool_calls = ( @@ -1840,9 +1920,9 @@ class TestStreamingIDConsistency: if isinstance(assistant_msg, dict) else getattr(assistant_msg, "tool_calls", None) ) - assert tool_calls is not None and len(tool_calls) == 2, ( - f"Expected 2 tool_calls in the merged assistant message, got: {tool_calls}" - ) + assert ( + tool_calls is not None and len(tool_calls) == 2 + ), f"Expected 2 tool_calls in the merged assistant message, got: {tool_calls}" call_ids = [ (tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", None)) @@ -1853,13 +1933,14 @@ class TestStreamingIDConsistency: # Both tool messages must be present tool_messages = [ - m for m in messages + m + for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "tool" ] - assert len(tool_messages) == 2, ( - f"Expected 2 tool messages, got {len(tool_messages)}" - ) + assert ( + len(tool_messages) == 2 + ), f"Expected 2 tool messages, got {len(tool_messages)}" def test_single_tool_call_still_works_after_merge_fix(self): """ @@ -1890,7 +1971,12 @@ class TestStreamingIDConsistency: assert "assistant" in roles assert "tool" in roles - assistant_messages = [m for m in messages if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) == "assistant"] + assistant_messages = [ + m + for m in messages + if (m.get("role") if isinstance(m, dict) else getattr(m, "role", None)) + == "assistant" + ] assert len(assistant_messages) == 1 tool_calls = (