Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ def _handle_raw_dict_response_item(
if item_type == "reasoning":
return None, index

# Handle function_call items (for gpt-5-codex and similar models)
# Issue: https://github.com/BerriAI/litellm/issues/14846
if item_type == "function_call":
msg = Message(
content=None,
tool_calls=[{
"id": item.get("call_id"),
"function": {
"name": item.get("name"),
"arguments": item.get("arguments", ""),
},
"type": "function",
}]
)
choice = Choices(message=msg, finish_reason="tool_calls", index=index)
return choice, index + 1

# Handle message items with output_text content
if item_type == "message":
content_list = item.get("content", [])
Expand Down
4 changes: 3 additions & 1 deletion litellm/llms/openai/responses/transformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ def _handle_reasoning_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
# Ensure required fields are present for ResponseReasoningItem
item_data = dict(item)
if "id" not in item_data:
item_data["id"] = f"rs_{hash(str(item_data))}"
# Use abs() to ensure ID is always positive (hash() can return negative values)
# Issue: https://github.com/BerriAI/litellm/issues/14846
item_data["id"] = f"rs_{abs(hash(str(item_data)))}"
if "summary" not in item_data:
item_data["summary"] = (
item_data.get("reasoning_content", "")[:100] + "..."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -775,4 +775,160 @@ def test_get_supported_openai_params():
assert "temperature" in params
assert "stream" in params
assert "background" in params
assert "stream" in params
assert "stream" in params


def test_reasoning_id_generation_always_positive():
"""
Test that reasoning IDs generated by _handle_reasoning_item are always positive.
Bug fix for issue #14846 - hash() can return negative values causing OpenAI API errors.
"""
config = OpenAIResponsesAPIConfig()

# Test multiple iterations to increase chance of catching negative hash() values
for i in range(100):
test_item = {
"type": "reasoning",
"summary": [{"type": "summary_text", "text": f"test reasoning {i}"}]
# No ID provided - will be generated
}

result = config._handle_reasoning_item(test_item)

# Verify ID exists and is properly formatted
assert "id" in result, f"Iteration {i}: ID should be generated"
assert result["id"].startswith("rs_"), f"Iteration {i}: ID should start with 'rs_'"

# Critical assertion: ID should NEVER contain negative sign
assert "rs_-" not in result["id"], f"Iteration {i}: ID should not contain negative number: {result['id']}"

# Verify ID is numeric after 'rs_' prefix
id_value = result["id"][3:] # Remove 'rs_' prefix
assert id_value.isdigit(), f"Iteration {i}: ID value should be numeric: {result['id']}"


def test_reasoning_id_generation_with_existing_id():
"""Test that existing reasoning IDs are preserved and not regenerated."""
config = OpenAIResponsesAPIConfig()

existing_id = "rs_12345678"
test_item = {
"type": "reasoning",
"id": existing_id,
"summary": [{"type": "summary_text", "text": "test"}]
}

result = config._handle_reasoning_item(test_item)

# Existing ID should be preserved
assert result["id"] == existing_id


def test_handle_raw_dict_response_function_call():
"""
Test that function_call items from Responses API are properly handled.
Bug fix for issue #14846 - gpt-5-codex tool calls were being ignored.
"""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
from litellm.types.utils import Choices

handler = LiteLLMResponsesTransformationHandler()

# Simulate a function_call item as returned by gpt-5-codex
function_call_item = {
"type": "function_call",
"id": "fc_12345",
"call_id": "call_abcdef",
"name": "shell",
"arguments": '{"command": ["ls", "-la"]}',
"status": "completed"
}

choice, new_index = handler._handle_raw_dict_response_item(function_call_item, 0)

# Verify choice was created (not None)
assert choice is not None, "function_call items should return a Choice object"
assert isinstance(choice, Choices), "Result should be a Choices object"

# Verify index was incremented
assert new_index == 1, "Index should be incremented after handling function_call"

# Verify the choice has correct structure
assert choice.finish_reason == "tool_calls", "finish_reason should be 'tool_calls'"
assert choice.index == 0, "Choice index should match input index"

# Verify message contains tool_calls
assert choice.message is not None, "Message should not be None"
assert choice.message.content is None, "Content should be None for tool calls"
assert choice.message.tool_calls is not None, "tool_calls should not be None"
assert len(choice.message.tool_calls) == 1, "Should have one tool call"

# Verify tool call structure
tool_call = choice.message.tool_calls[0]
assert tool_call["id"] == "call_abcdef", "Tool call ID should match call_id"
assert tool_call["type"] == "function", "Tool call type should be 'function'"
assert tool_call["function"]["name"] == "shell", "Function name should match"
assert tool_call["function"]["arguments"] == '{"command": ["ls", "-la"]}', "Arguments should match"


def test_handle_raw_dict_response_multiple_function_calls():
"""Test handling multiple function_call items in sequence."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

function_calls = [
{
"type": "function_call",
"call_id": "call_1",
"name": "shell",
"arguments": '{"command": ["ls"]}'
},
{
"type": "function_call",
"call_id": "call_2",
"name": "read_file",
"arguments": '{"path": "test.txt"}'
}
]

index = 0
choices = []

for fc in function_calls:
choice, index = handler._handle_raw_dict_response_item(fc, index)
if choice is not None:
choices.append(choice)

# Verify both function calls were handled
assert len(choices) == 2, "Should have two choices for two function calls"
assert index == 2, "Index should be incremented for each function call"

# Verify each choice has correct tool call
assert choices[0].message.tool_calls[0]["function"]["name"] == "shell"
assert choices[1].message.tool_calls[0]["function"]["name"] == "read_file"


def test_handle_raw_dict_response_reasoning_ignored():
"""Test that reasoning items are still ignored (no change to existing behavior)."""
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)

handler = LiteLLMResponsesTransformationHandler()

reasoning_item = {
"type": "reasoning",
"id": "rs_12345",
"summary": [{"type": "summary_text", "text": "test reasoning"}]
}

choice, new_index = handler._handle_raw_dict_response_item(reasoning_item, 0)

# Reasoning items should still return None
assert choice is None, "Reasoning items should return None"
assert new_index == 0, "Index should not change for reasoning items"
Loading