feat(openai/): convert chat completion tool calls to responses api

enables gpt-5-codex to work on claude code

 Closes LIT-1088
This commit is contained in:
Krrish Dholakia
2025-10-10 19:41:11 -07:00
parent 144ea09549
commit fed1ba2891
22 changed files with 614 additions and 140 deletions
@@ -18,13 +18,15 @@ from typing import (
cast,
)
from openai.types.responses.tool_param import FunctionToolParam
from litellm import ModelResponse
from litellm._logging import verbose_logger
from litellm.llms.base_llm.base_model_iterator import BaseModelResponseIterator
from litellm.llms.base_llm.bridges.completion_transformation import (
CompletionTransformationBridge,
)
from litellm.types.llms.openai import Reasoning
from litellm.types.llms.openai import ChatCompletionToolParamFunctionChunk, Reasoning
if TYPE_CHECKING:
from openai.types.responses import ResponseInputImageParam
@@ -464,9 +466,25 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
self, tools: List[Dict[str, Any]]
) -> List["ALL_RESPONSES_API_TOOL_PARAMS"]:
"""Convert chat completion tools to responses API tools format"""
responses_tools = []
responses_tools: List["ALL_RESPONSES_API_TOOL_PARAMS"] = []
for tool in tools:
responses_tools.append(tool)
# convert function tool from chat completion to responses API format
if tool.get("type") == "function":
function_tool = cast(
ChatCompletionToolParamFunctionChunk, tool.get("function")
)
responses_tools.append(
FunctionToolParam(
name=function_tool["name"],
parameters=function_tool.get("parameters"),
strict=function_tool.get("strict"),
type="function",
description=function_tool.get("description"),
)
)
else:
responses_tools.append(tool) # type: ignore
return cast(List["ALL_RESPONSES_API_TOOL_PARAMS"], responses_tools)
def _map_reasoning_effort(self, reasoning_effort: str) -> Optional[Reasoning]:
@@ -133,7 +133,6 @@ class LiteLLMMessagesToCompletionTransformationHandler:
**kwargs,
) -> Union[AnthropicMessagesResponse, AsyncIterator]:
"""Handle non-Anthropic models asynchronously using the adapter"""
completion_kwargs = (
LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
max_tokens=max_tokens,
@@ -13,13 +13,13 @@ from typing import (
cast,
)
from litellm._logging import verbose_logger
import httpx # type: ignore
import litellm
import litellm.litellm_core_utils
import litellm.types
import litellm.types.utils
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.realtime_streaming import RealTimeStreaming
from litellm.llms.base_llm.anthropic_messages.transformation import (
BaseAnthropicMessagesConfig,
@@ -239,7 +239,7 @@ class BaseLLMHTTPHandler:
json_mode: bool = False,
signed_json_body: Optional[bytes] = None,
shared_session: Optional["ClientSession"] = None,
):
):
if client is None:
verbose_logger.debug(
f"Creating HTTP client with shared_session: {id(shared_session) if shared_session else None}"
@@ -1533,6 +1533,7 @@ class BaseLLMHTTPHandler:
data=data,
fake_stream=fake_stream,
)
response = sync_httpx_client.post(
url=api_base,
headers=headers,
@@ -13074,34 +13074,6 @@
"supports_vision": true,
"supports_web_search": true
},
"gpt-5-codex": {
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-5-2025-08-07": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_flex": 6.25e-08,
File diff suppressed because one or more lines are too long
+2 -22
View File
@@ -1,24 +1,4 @@
model_list:
- model_name: gpt-5-mini
- model_name: gpt-5-codex
litellm_params:
model: openai/gpt-4o-mini
api_base: "https://webhook.site/2f385e05-00aa-402b-86d1-efc9261471a5"
api_key: dummy
- model_name: "gemini-2.5-flash"
litellm_params:
model: gemini/gemini-2.5-flash
- model_name: xai-grok-3
litellm_params:
model: xai/grok-3
- model_name: hosted_vllm/whisper-v3
litellm_params:
model: hosted_vllm/whisper-v3
api_base: "https://webhook.site/2f385e05-00aa-402b-86d1-efc9261471a5"
api_key: dummy
mcp_servers:
my_api_mcp:
url: "http://0.0.0.0:8090"
spec_path: "/Users/krrishdholakia/Documents/temp_py_folder/example_openapi.json"
auth_type: none
allowed_tools: ["getpetbyid", "my_api_mcp-findpetsbystatus"]
model: gpt-5-codex
-28
View File
@@ -13074,34 +13074,6 @@
"supports_vision": true,
"supports_web_search": true
},
"gpt-5-codex": {
"cache_read_input_token_cost": 1.25e-07,
"input_cost_per_token": 1.25e-06,
"litellm_provider": "openai",
"max_input_tokens": 400000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 1e-05,
"supported_endpoints": [
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"gpt-5-2025-08-07": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost_flex": 6.25e-08,
+76 -55
View File
@@ -286,20 +286,16 @@ def test_gemini_2_5_flash_image_preview():
mock_response = ImageResponse()
mock_response.data = [ImageObject(b64_json="test_base64_data", url=None)]
with patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post") as mock_post:
with patch(
"litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post"
) as mock_post:
# Mock successful HTTP response
mock_http_response = MagicMock()
mock_http_response.json.return_value = {
"candidates": [
{
"content": {
"parts": [
{
"inlineData": {
"data": "test_base64_image_data"
}
}
]
"parts": [{"inlineData": {"data": "test_base64_image_data"}}]
}
}
]
@@ -311,33 +307,38 @@ def test_gemini_2_5_flash_image_preview():
response = litellm.image_generation(
model="gemini/gemini-2.5-flash-image-preview",
prompt="Generate a simple test image",
api_key="test_api_key"
api_key="test_api_key",
)
# Validate response structure
assert response is not None
assert hasattr(response, 'data')
assert hasattr(response, "data")
assert response.data is not None
assert len(response.data) > 0
# Validate the correct endpoint was called
mock_post.assert_called_once()
call_args = mock_post.call_args
called_url = call_args[0][0] if call_args[0] else call_args.kwargs.get('url', '')
called_url = (
call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "")
)
# Verify it uses generateContent endpoint for gemini-2.5-flash-image-preview (not predict)
assert ":generateContent" in called_url
assert "gemini-2.5-flash-image-preview" in called_url
# Verify request format is Gemini format (not Imagen)
request_data = call_args.kwargs.get('json', {})
request_data = call_args.kwargs.get("json", {})
assert "contents" in request_data
assert "parts" in request_data["contents"][0]
# Verify response_modalities is set correctly for image generation
assert "generationConfig" in request_data
assert "response_modalities" in request_data["generationConfig"]
assert request_data["generationConfig"]["response_modalities"] == ["IMAGE", "TEXT"]
assert request_data["generationConfig"]["response_modalities"] == [
"IMAGE",
"TEXT",
]
def test_gemini_imagen_models_use_predict_endpoint():
@@ -347,15 +348,13 @@ def test_gemini_imagen_models_use_predict_endpoint():
from unittest.mock import patch, MagicMock
from litellm.types.utils import ImageResponse, ImageObject
with patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post") as mock_post:
with patch(
"litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post"
) as mock_post:
# Mock successful HTTP response for Imagen
mock_http_response = MagicMock()
mock_http_response.json.return_value = {
"predictions": [
{
"bytesBase64Encoded": "test_base64_image_data"
}
]
"predictions": [{"bytesBase64Encoded": "test_base64_image_data"}]
}
mock_http_response.status_code = 200
mock_post.return_value = mock_http_response
@@ -364,17 +363,19 @@ def test_gemini_imagen_models_use_predict_endpoint():
response = litellm.image_generation(
model="gemini/imagen-3.0-generate-001",
prompt="Generate a simple test image",
api_key="test_api_key"
api_key="test_api_key",
)
# Validate response structure
assert response is not None
assert hasattr(response, 'data')
assert hasattr(response, "data")
# Validate the correct endpoint was called for Imagen models
mock_post.assert_called_once()
call_args = mock_post.call_args
called_url = call_args[0][0] if call_args[0] else call_args.kwargs.get('url', '')
called_url = (
call_args[0][0] if call_args[0] else call_args.kwargs.get("url", "")
)
# Verify Imagen models use predict endpoint (not generateContent)
assert ":predict" in called_url
@@ -382,7 +383,7 @@ def test_gemini_imagen_models_use_predict_endpoint():
assert ":generateContent" not in called_url
# Verify request format is Imagen format (not Gemini)
request_data = call_args.kwargs.get('json', {})
request_data = call_args.kwargs.get("json", {})
assert "instances" in request_data
assert "parameters" in request_data
@@ -981,9 +982,7 @@ def test_gemini_exception_message_format():
# Create a mock exception that simulates a Gemini API error
mock_exception = httpx.HTTPStatusError(
message="Bad Request",
request=Mock(),
response=mock_response
message="Bad Request", request=Mock(), response=mock_response
)
mock_exception.response = mock_response
mock_exception.status_code = 400
@@ -995,7 +994,7 @@ def test_gemini_exception_message_format():
original_exception=mock_exception,
custom_llm_provider="gemini",
completion_kwargs={},
extra_kwargs={}
extra_kwargs={},
)
# Should not reach here - exception should be raised
assert False, "Expected BadRequestError to be raised"
@@ -1010,22 +1009,25 @@ def test_gemini_exception_message_format():
f"Expected 'GeminiException' in error message, got: {error_message}. "
f"This test should fail before the fix is implemented."
)
assert "VertexAIException" not in error_message, (
f"Should not contain 'VertexAIException' in error message, got: {error_message}"
)
assert (
"VertexAIException" not in error_message
), f"Should not contain 'VertexAIException' in error message, got: {error_message}"
@pytest.mark.parametrize("status_code,expected_exception", [
(400, "BadRequestError"),
(401, "AuthenticationError"),
(403, "PermissionDeniedError"),
(404, "NotFoundError"),
(408, "Timeout"),
(429, "RateLimitError"),
(500, "InternalServerError"),
(502, "APIConnectionError"),
(503, "ServiceUnavailableError"),
])
@pytest.mark.parametrize(
"status_code,expected_exception",
[
(400, "BadRequestError"),
(401, "AuthenticationError"),
(403, "PermissionDeniedError"),
(404, "NotFoundError"),
(408, "Timeout"),
(429, "RateLimitError"),
(500, "InternalServerError"),
(502, "APIConnectionError"),
(503, "ServiceUnavailableError"),
],
)
def l(status_code, expected_exception):
"""
Test comprehensive Gemini error handling for all HTTP status codes.
@@ -1037,8 +1039,15 @@ def l(status_code, expected_exception):
from unittest.mock import Mock
from litellm.litellm_core_utils.exception_mapping_utils import exception_type
from litellm.exceptions import (
BadRequestError, AuthenticationError, PermissionDeniedError, NotFoundError,
Timeout, RateLimitError, InternalServerError, APIConnectionError, ServiceUnavailableError
BadRequestError,
AuthenticationError,
PermissionDeniedError,
NotFoundError,
Timeout,
RateLimitError,
InternalServerError,
APIConnectionError,
ServiceUnavailableError,
)
# Mock the appropriate error response
@@ -1049,9 +1058,7 @@ def l(status_code, expected_exception):
# Create a mock exception
mock_exception = httpx.HTTPStatusError(
message=f"HTTP {status_code}",
request=Mock(),
response=mock_response
message=f"HTTP {status_code}", request=Mock(), response=mock_response
)
mock_exception.response = mock_response
mock_exception.status_code = status_code
@@ -1065,9 +1072,11 @@ def l(status_code, expected_exception):
original_exception=mock_exception,
custom_llm_provider="gemini",
completion_kwargs={},
extra_kwargs={}
extra_kwargs={},
)
assert False, f"Expected {expected_exception} to be raised for status {status_code}"
assert (
False
), f"Expected {expected_exception} to be raised for status {status_code}"
except Exception as e:
# Verify the correct exception type is raised
exception_classes = {
@@ -1082,13 +1091,25 @@ def l(status_code, expected_exception):
"ServiceUnavailableError": ServiceUnavailableError,
}
expected_class = exception_classes[expected_exception]
assert isinstance(e, expected_class), f"Expected {expected_exception}, got {type(e).__name__}"
assert isinstance(
e, expected_class
), f"Expected {expected_exception}, got {type(e).__name__}"
# Verify the error message contains GeminiException
error_message = str(e)
assert "GeminiException" in error_message, (
f"Expected 'GeminiException' in error message for status {status_code}, got: {error_message}"
)
assert "VertexAIException" not in error_message, (
f"Should not contain 'VertexAIException' for status {status_code}, got: {error_message}"
)
assert (
"GeminiException" in error_message
), f"Expected 'GeminiException' in error message for status {status_code}, got: {error_message}"
assert (
"VertexAIException" not in error_message
), f"Should not contain 'VertexAIException' for status {status_code}, got: {error_message}"
def test_gemini_embedding():
litellm._turn_on_debug()
response = litellm.embedding(
model="gemini/gemini-embedding-001",
input="Hello, world!",
)
print("response: ", response)
assert response is not None
File diff suppressed because one or more lines are too long
@@ -259,3 +259,17 @@ and I learn to carry this small calm home."""
assert choice.message.reasoning_content == reasoning_summary.text
print("✓ transform_response correctly handled reasoning items and output messages")
def test_convert_tools_to_responses_format():
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
)
handler = LiteLLMResponsesTransformationHandler()
tools = [{"type": "function", "function": {"name": "test", "arguments": "test"}}]
result = handler._convert_tools_to_responses_format(tools)
assert result[0]["name"] == "test"