mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 16:21:54 +00:00
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:
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
+14
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user