mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
944 lines
40 KiB
Python
944 lines
40 KiB
Python
"""
|
|
Tests for forcing the /responses → /chat/completions bridge for `openai/` models
|
|
(via `use_chat_completions_api` or the `openai/chat_completions/<model>` model id).
|
|
|
|
Includes file_search emulation: the flag must be forwarded on inner aresponses
|
|
calls so routed requests do not hit a custom api_base /v1/responses endpoint.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
from collections.abc import Mapping
|
|
from importlib import import_module
|
|
from pathlib import Path
|
|
from typing import Final, cast
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
import openai
|
|
import pytest
|
|
import respx
|
|
from pydantic import JsonValue
|
|
|
|
import litellm
|
|
from litellm import Router
|
|
from litellm.llms.custom_httpx.http_handler import HTTPHandler
|
|
from litellm.responses.bridge_check import (
|
|
backend_serves_chat_completions,
|
|
bridge_reasoning_effort,
|
|
deployment_routing_mode,
|
|
responses_api_bridge_check,
|
|
)
|
|
from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
|
|
from litellm.types.utils import Choices, Message, ModelResponse, Usage
|
|
|
|
|
|
class TestUseResponsesApiBridgeFlag:
|
|
"""Test that bridge opt-in forces the chat completions path."""
|
|
|
|
@pytest.mark.parametrize("model", ["openai/chat_completions/gpt-6-astra", "xai/test-classifier"])
|
|
def test_encrypted_classifier_rejection_preserves_public_error(self, model: str) -> None:
|
|
respond: Final = MagicMock(side_effect=AssertionError("Incompatible classifier sent an upstream request"))
|
|
with httpx.Client(transport=httpx.MockTransport(respond)) as client:
|
|
with pytest.raises(
|
|
litellm.APIConnectionError,
|
|
match="Encrypted task classification requires a compatible native Responses deployment",
|
|
) as error:
|
|
litellm.responses(
|
|
model=model,
|
|
input="Delegated task",
|
|
api_key="test-key",
|
|
api_base="https://classifier.test/v1",
|
|
client=HTTPHandler(client=client),
|
|
_require_encrypted_task_support=True,
|
|
num_retries=0,
|
|
)
|
|
assert error.value.status_code == 500
|
|
respond.assert_not_called()
|
|
|
|
@patch.object(
|
|
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
|
|
)
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
def test_bridge_used_when_use_chat_completions_api_true(self, mock_get_config, mock_bridge_handler):
|
|
"""When use_chat_completions_api=True, the bridge handler should be called."""
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_bridge_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="openai/my-custom-model",
|
|
input="Hello",
|
|
use_chat_completions_api=True,
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
mock_bridge_handler.assert_called_once()
|
|
|
|
@patch.object(
|
|
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
|
|
)
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
def test_provider_affinity_header_is_forwarded_through_bridge(self, mock_get_config, mock_bridge_handler):
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_bridge_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="openai/my-custom-model",
|
|
input="Hello",
|
|
use_chat_completions_api=True,
|
|
litellm_session_id="session-bridge",
|
|
provider_affinity_header="X-Conversation-Id",
|
|
extra_headers={"X-Customer-Header": "customer-value"},
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
forwarded_headers = mock_bridge_handler.call_args.kwargs["extra_headers"]
|
|
assert forwarded_headers["X-Conversation-Id"] == "session-bridge"
|
|
assert forwarded_headers["X-Customer-Header"] == "customer-value"
|
|
|
|
@patch.object(
|
|
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
|
|
)
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
def test_bridge_used_when_model_uses_chat_completions_prefix(self, mock_get_config, mock_bridge_handler):
|
|
"""`openai/chat_completions/<name>` normalizes to `openai/<name>` and uses the bridge."""
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_bridge_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="openai/chat_completions/my-custom-model",
|
|
input="Hello",
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
mock_bridge_handler.assert_called_once()
|
|
# Model string is provider-normalized after resolution; prefix only forces the bridge.
|
|
assert mock_bridge_handler.call_args.kwargs["model"].endswith("my-custom-model")
|
|
|
|
@patch.object(import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler")
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
def test_native_forwarding_when_flag_absent(self, mock_get_config, mock_native_handler):
|
|
"""When use_chat_completions_api is not set, openai/ models should use
|
|
native responses API forwarding (existing behavior)."""
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_native_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="openai/gpt-4o",
|
|
input="Hello",
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
mock_native_handler.assert_called_once()
|
|
|
|
@patch.object(
|
|
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
|
|
)
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
def test_flag_does_not_leak_into_kwargs(self, mock_get_config, mock_bridge_handler):
|
|
"""use_chat_completions_api should be popped and not passed to the bridge handler."""
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_bridge_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="openai/my-custom-model",
|
|
input="Hello",
|
|
use_chat_completions_api=True,
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
call_kwargs = mock_bridge_handler.call_args
|
|
all_kwargs = call_kwargs.kwargs if call_kwargs.kwargs else {}
|
|
assert "use_chat_completions_api" not in all_kwargs
|
|
|
|
@patch.object(
|
|
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
|
|
)
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
def test_bridge_used_when_provider_config_none(self, mock_get_config, mock_bridge_handler):
|
|
"""When the provider has no native responses API config (returns None),
|
|
the bridge should be used regardless of the flag (existing behavior)."""
|
|
mock_get_config.return_value = None
|
|
mock_bridge_handler.return_value = MagicMock()
|
|
|
|
litellm.responses(
|
|
model="anthropic/claude-3-haiku",
|
|
input="Hello",
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
mock_bridge_handler.assert_called_once()
|
|
|
|
@patch("litellm.acompletion")
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
async def test_allowed_openai_params_forwarded_through_bridge(self, mock_get_config, mock_acompletion):
|
|
"""allowed_openai_params is a named param of responses(), so it must be
|
|
explicitly forwarded to the bridge; otherwise litellm.acompletion raises
|
|
UnsupportedParamsError for params the caller explicitly allowed."""
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_acompletion.return_value = ModelResponse(
|
|
id="chatcmpl_123",
|
|
model="openai/my-custom-model",
|
|
choices=[
|
|
Choices(
|
|
index=0,
|
|
message=Message(role="assistant", content="Answer"),
|
|
finish_reason="stop",
|
|
)
|
|
],
|
|
usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
|
)
|
|
|
|
await litellm.aresponses(
|
|
model="openai/my-custom-model",
|
|
input="Hello",
|
|
use_chat_completions_api=True,
|
|
allowed_openai_params=["reasoning_effort"],
|
|
reasoning={"effort": "high"},
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
mock_acompletion.assert_called_once()
|
|
assert mock_acompletion.call_args.kwargs.get("allowed_openai_params") == ["reasoning_effort"]
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "upstream_url", "use_chat_completions_api", "allowed_openai_params", "expected_chat_template_kwargs"),
|
|
[
|
|
pytest.param(
|
|
"openai/my-custom-model",
|
|
"https://api.openai.com/v1/chat/completions",
|
|
True,
|
|
None,
|
|
None,
|
|
id="native-config-drops-unknown-param",
|
|
),
|
|
pytest.param(
|
|
"openai/my-custom-model",
|
|
"https://api.openai.com/v1/chat/completions",
|
|
True,
|
|
["chat_template_kwargs"],
|
|
{"thinking": True},
|
|
id="native-config-keeps-allowed-param",
|
|
),
|
|
pytest.param(
|
|
"together_ai/my-custom-model",
|
|
"https://api.together.ai/v1/chat/completions",
|
|
False,
|
|
None,
|
|
{"thinking": True},
|
|
id="no-native-config-keeps-passthrough",
|
|
),
|
|
],
|
|
)
|
|
def test_bridge_forwards_same_params_as_native_dispatch(
|
|
self,
|
|
model: str,
|
|
upstream_url: str,
|
|
use_chat_completions_api: bool,
|
|
allowed_openai_params: list[str] | None,
|
|
expected_chat_template_kwargs: dict[str, bool] | None,
|
|
respx_mock: respx.MockRouter,
|
|
):
|
|
upstream: Final = respx_mock.post(upstream_url).mock(
|
|
return_value=httpx.Response(
|
|
status_code=200,
|
|
json={
|
|
"id": "chatcmpl-123",
|
|
"object": "chat.completion",
|
|
"created": 1677652288,
|
|
"model": "my-custom-model",
|
|
"choices": [
|
|
{"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = litellm.responses(
|
|
model=model,
|
|
input="Hello",
|
|
use_chat_completions_api=use_chat_completions_api,
|
|
allowed_openai_params=allowed_openai_params,
|
|
chat_template_kwargs={"thinking": True},
|
|
drop_params=True,
|
|
api_key="fake-provider-api-key",
|
|
num_retries=0,
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert request_body.get("chat_template_kwargs") == expected_chat_template_kwargs
|
|
assert request_body["messages"] == [{"role": "user", "content": "Hello"}]
|
|
assert response.output[0].content[0].text == "Answer"
|
|
|
|
def test_bridge_drops_client_metadata_even_when_allowed_openai_params_names_it(self, respx_mock: respx.MockRouter):
|
|
upstream: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
|
return_value=httpx.Response(
|
|
status_code=200,
|
|
json={
|
|
"id": "chatcmpl-123",
|
|
"object": "chat.completion",
|
|
"created": 1677652288,
|
|
"model": "my-custom-model",
|
|
"choices": [
|
|
{"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = litellm.responses(
|
|
model="openai/my-custom-model",
|
|
input="Hello",
|
|
use_chat_completions_api=True,
|
|
allowed_openai_params=["client_metadata"],
|
|
client_metadata={"turn_id": "turn-1", "thread_id": "thread-1"},
|
|
api_key="fake-provider-api-key",
|
|
num_retries=0,
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert "client_metadata" not in request_body
|
|
assert request_body["messages"] == [{"role": "user", "content": "Hello"}]
|
|
assert response.output[0].content[0].text == "Answer"
|
|
|
|
def test_bridge_merges_instructions_and_developer_input_for_databricks(self, respx_mock: respx.MockRouter):
|
|
upstream: Final = respx_mock.post("https://example.databricks.test/serving-endpoints/chat/completions").mock(
|
|
return_value=httpx.Response(
|
|
status_code=200,
|
|
json={
|
|
"id": "chatcmpl-123",
|
|
"object": "chat.completion",
|
|
"created": 1677652288,
|
|
"model": "my-custom-model",
|
|
"choices": [
|
|
{"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = litellm.responses(
|
|
model="databricks/my-custom-model",
|
|
instructions="You are terse.",
|
|
input=[
|
|
{"role": "developer", "content": [{"type": "input_text", "text": "Skills: none."}]},
|
|
{"role": "user", "content": [{"type": "input_text", "text": "Hello"}]},
|
|
],
|
|
client_metadata={"turn_id": "turn-1", "thread_id": "thread-1"},
|
|
chat_template_kwargs={"thinking": True},
|
|
api_base="https://example.databricks.test/serving-endpoints",
|
|
api_key="fake-databricks-api-key",
|
|
num_retries=0,
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert request_body["messages"] == [
|
|
{"role": "system", "content": "You are terse.\n\nSkills: none."},
|
|
{"role": "user", "content": "Hello"},
|
|
]
|
|
assert "client_metadata" not in request_body
|
|
assert request_body["chat_template_kwargs"] == {"thinking": True}
|
|
assert response.output[0].content[0].text == "Answer"
|
|
|
|
def test_bridge_drops_client_metadata_for_provider_without_native_config(self, respx_mock: respx.MockRouter):
|
|
upstream: Final = respx_mock.post("https://example.databricks.test/serving-endpoints/chat/completions").mock(
|
|
return_value=httpx.Response(
|
|
status_code=200,
|
|
json={
|
|
"id": "chatcmpl-123",
|
|
"object": "chat.completion",
|
|
"created": 1677652288,
|
|
"model": "my-custom-model",
|
|
"choices": [
|
|
{"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10},
|
|
},
|
|
)
|
|
)
|
|
|
|
response: Final = litellm.responses(
|
|
model="databricks/my-custom-model",
|
|
input="Hello",
|
|
client_metadata={
|
|
"turn_id": "turn-1",
|
|
"thread_id": "thread-1",
|
|
"session_id": "session-1",
|
|
"root_turn_id": "turn-1",
|
|
"x-codex-installation-id": "install-1",
|
|
"x-codex-turn-metadata": '{"turn_id":"turn-1"}',
|
|
},
|
|
chat_template_kwargs={"thinking": True},
|
|
api_base="https://example.databricks.test/serving-endpoints",
|
|
api_key="fake-databricks-api-key",
|
|
num_retries=0,
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request_body: Final = json.loads(upstream.calls[0].request.read())
|
|
assert "client_metadata" not in request_body
|
|
assert request_body["chat_template_kwargs"] == {"thinking": True}
|
|
assert request_body["messages"] == [{"role": "user", "content": "Hello"}]
|
|
assert response.output[0].content[0].text == "Answer"
|
|
|
|
def test_bridge_keeps_deployment_credentials_while_dropping_unknown_params(self, respx_mock: respx.MockRouter):
|
|
upstream: Final = respx_mock.post(
|
|
"https://example-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions",
|
|
params={"api-version": "2024-10-21"},
|
|
).mock(
|
|
return_value=httpx.Response(
|
|
status_code=200,
|
|
json={
|
|
"id": "chatcmpl-123",
|
|
"object": "chat.completion",
|
|
"created": 1677652288,
|
|
"model": "my-deployment",
|
|
"choices": [
|
|
{"index": 0, "message": {"role": "assistant", "content": "Answer"}, "finish_reason": "stop"}
|
|
],
|
|
"usage": {"prompt_tokens": 9, "completion_tokens": 1, "total_tokens": 10},
|
|
},
|
|
)
|
|
)
|
|
|
|
litellm.responses(
|
|
model="azure/my-deployment",
|
|
input="Hello",
|
|
use_chat_completions_api=True,
|
|
api_base="https://example-resource.openai.azure.com",
|
|
api_version="2024-10-21",
|
|
azure_ad_token="fake-azure-ad-token",
|
|
chat_template_kwargs={"thinking": True},
|
|
num_retries=0,
|
|
)
|
|
|
|
assert upstream.call_count == 1
|
|
request: Final = upstream.calls[0].request
|
|
assert request.headers["authorization"] == "Bearer fake-azure-ad-token"
|
|
assert "chat_template_kwargs" not in json.loads(request.read())
|
|
|
|
@patch.object(import_module("litellm.responses.file_search.emulated_handler"), "_call_aresponses")
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
async def test_bridge_flag_forwarded_to_file_search_emulation(self, mock_get_config, mock_call_aresponses):
|
|
"""When use_chat_completions_api=True and file_search tool is present,
|
|
the flag should be forwarded to the inner aresponses call in the
|
|
file_search emulation path."""
|
|
# Setup: provider has native responses API support
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
|
|
# Mock the inner aresponses call to return a valid response
|
|
mock_response = ResponsesAPIResponse(
|
|
id="resp_123",
|
|
model="openai/my-custom-model",
|
|
created_at=1234567890,
|
|
output=[{"type": "message", "content": [{"type": "text", "text": "Answer"}]}],
|
|
usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15),
|
|
)
|
|
mock_call_aresponses.return_value = mock_response
|
|
|
|
await litellm.aresponses(
|
|
model="openai/my-custom-model",
|
|
input="Search for information",
|
|
tools=[{"type": "file_search"}],
|
|
use_chat_completions_api=True,
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
# Verify _call_aresponses was called with use_chat_completions_api=True
|
|
mock_call_aresponses.assert_called_once()
|
|
call_kwargs = mock_call_aresponses.call_args.kwargs
|
|
assert call_kwargs.get("use_chat_completions_api") is True, (
|
|
"use_chat_completions_api should be forwarded to inner aresponses call"
|
|
)
|
|
|
|
@patch.object(
|
|
import_module("litellm.responses.main").litellm_completion_transformation_handler, "response_api_handler"
|
|
)
|
|
@patch("litellm.vector_stores.main.asearch")
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
async def test_bridge_flag_prevents_native_responses_endpoint_call(
|
|
self, mock_get_config, mock_asearch, mock_bridge_handler
|
|
):
|
|
"""
|
|
Concrete failing scenario: native OpenAI responses config + bridge flag +
|
|
file_search → emulation must still route inner calls through the bridge
|
|
(chat completions), not POST to api_base /v1/responses.
|
|
"""
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_asearch.return_value = []
|
|
|
|
first_response = ResponsesAPIResponse(
|
|
id="resp_first",
|
|
model="openai/my-local-model",
|
|
created_at=1234567890,
|
|
output=[
|
|
{
|
|
"type": "function_call",
|
|
"name": "litellm_file_search",
|
|
"call_id": "call_123",
|
|
"arguments": '{"queries": ["test query"]}',
|
|
}
|
|
],
|
|
usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15),
|
|
)
|
|
second_response = ResponsesAPIResponse(
|
|
id="resp_second",
|
|
model="openai/my-local-model",
|
|
created_at=1234567891,
|
|
output=[
|
|
{
|
|
"type": "message",
|
|
"content": [{"type": "text", "text": "Final answer"}],
|
|
}
|
|
],
|
|
usage=ResponseAPIUsage(input_tokens=20, output_tokens=10, total_tokens=30),
|
|
)
|
|
mock_bridge_handler.side_effect = [first_response, second_response]
|
|
|
|
result = await litellm.aresponses(
|
|
model="openai/my-local-model",
|
|
input="Search for information",
|
|
tools=[
|
|
{
|
|
"type": "file_search",
|
|
"file_search": {"vector_store_ids": ["vs_123"]},
|
|
}
|
|
],
|
|
use_chat_completions_api=True,
|
|
api_base="http://localhost:8080/v1",
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
assert mock_bridge_handler.call_count == 2, (
|
|
"Bridge handler should be called twice: initial function-tool call and follow-up with tool results"
|
|
)
|
|
for call in mock_bridge_handler.call_args_list:
|
|
all_kwargs = call.kwargs if call.kwargs else {}
|
|
assert "use_chat_completions_api" not in all_kwargs
|
|
assert result is not None
|
|
assert result.id is not None
|
|
|
|
@patch.object(import_module("litellm.responses.main").base_llm_http_handler, "response_api_handler")
|
|
@patch("litellm.vector_stores.main.asearch")
|
|
@patch.object(import_module("litellm.responses.main").ProviderConfigManager, "get_provider_responses_api_config")
|
|
async def test_without_bridge_flag_uses_native_endpoint(self, mock_get_config, mock_asearch, mock_native_handler):
|
|
"""Without the bridge flag, openai/ with native config uses the native handler."""
|
|
mock_get_config.return_value = litellm.OpenAIResponsesAPIConfig()
|
|
mock_asearch.return_value = []
|
|
mock_native_handler.return_value = ResponsesAPIResponse(
|
|
id="resp_native",
|
|
model="openai/gpt-4o",
|
|
created_at=1234567890,
|
|
output=[
|
|
{
|
|
"type": "message",
|
|
"content": [{"type": "text", "text": "Native response"}],
|
|
}
|
|
],
|
|
usage=ResponseAPIUsage(input_tokens=10, output_tokens=5, total_tokens=15),
|
|
)
|
|
|
|
result = await litellm.aresponses(
|
|
model="openai/gpt-4o",
|
|
input="Hello",
|
|
litellm_logging_obj=MagicMock(),
|
|
)
|
|
|
|
mock_native_handler.assert_called_once()
|
|
assert result is not None
|
|
|
|
def test_bridge_still_rejects_an_invalid_stream_chunk_size(self) -> None:
|
|
send: Final = MagicMock(return_value=httpx.Response(200))
|
|
client: Final = openai.OpenAI(api_key="fake-key", http_client=httpx.Client(transport=httpx.MockTransport(send)))
|
|
|
|
with pytest.raises(litellm.BadRequestError) as exc_info:
|
|
litellm.responses(
|
|
model="openai/gpt-4.1-mini",
|
|
input="hi",
|
|
use_chat_completions_api=True,
|
|
stream_chunk_size="sixty-four",
|
|
client=client,
|
|
num_retries=0,
|
|
)
|
|
|
|
assert exc_info.value.param == "stream_chunk_size"
|
|
send.assert_not_called()
|
|
|
|
|
|
# Deployment-pinned routing mode (deployment_routing_mode / responses_api_bridge_check)
|
|
_CHAT: Final[Mapping[str, JsonValue]] = {"id": "dep-1", "mode": "chat"}
|
|
_RESPONSES: Final[Mapping[str, JsonValue]] = {"id": "dep-1", "mode": "responses"}
|
|
|
|
|
|
def _pin_shared_mode(monkeypatch: pytest.MonkeyPatch, model: str, mode: str) -> None:
|
|
cost_map: Final = cast(dict[str, dict[str, object]], litellm.model_cost)
|
|
monkeypatch.setitem(cost_map, model, {**cost_map.get(model, {}), "mode": mode})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"deployment_model_info, custom_llm_provider, expected",
|
|
[
|
|
(_CHAT, "bedrock_mantle", "chat"),
|
|
(_RESPONSES, "bedrock_mantle", "responses"),
|
|
(_CHAT, "openai", "chat"),
|
|
(_CHAT, "chatgpt", None),
|
|
(_CHAT, "github_copilot", None),
|
|
({"id": "dep-1", "mode": "embedding"}, "bedrock_mantle", None),
|
|
({"id": "dep-1"}, "bedrock_mantle", None),
|
|
(None, "bedrock_mantle", None),
|
|
],
|
|
)
|
|
def test_deployment_routing_mode_pins_chat_or_responses_except_on_responses_only_backends(
|
|
deployment_model_info: Mapping[str, JsonValue] | None, custom_llm_provider: str, expected: str | None
|
|
) -> None:
|
|
assert deployment_routing_mode(deployment_model_info, custom_llm_provider) == expected
|
|
|
|
|
|
def test_a_chat_pinned_mantle_deployment_stays_on_chat_completions_while_the_shared_row_says_responses(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
model: Final = "bedrock_mantle/openai.gpt-5.6-sol"
|
|
_pin_shared_mode(monkeypatch, model, "responses")
|
|
shared_info, _ = responses_api_bridge_check(model=model, custom_llm_provider="bedrock_mantle")
|
|
pinned_info, pinned_model = responses_api_bridge_check(
|
|
model=model, custom_llm_provider="bedrock_mantle", deployment_model_info=_CHAT
|
|
)
|
|
assert shared_info["mode"] == "responses"
|
|
assert (pinned_info["mode"], pinned_model) == ("chat", model)
|
|
|
|
|
|
def test_a_chat_pinned_chatgpt_alias_still_bridges_to_the_responses_api(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_pin_shared_mode(monkeypatch, "chatgpt/gpt-5.4", "responses")
|
|
info, _ = responses_api_bridge_check(model="gpt-5.4", custom_llm_provider="chatgpt", deployment_model_info=_CHAT)
|
|
assert info["mode"] == "responses"
|
|
|
|
|
|
_FUNCTION_TOOL: Final[dict[str, JsonValue]] = {
|
|
"type": "function",
|
|
"function": {"name": "lookup", "parameters": {"type": "object", "properties": {}}},
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize("custom_llm_provider", ["openai", "azure"])
|
|
def test_a_chat_pin_cannot_pull_gpt_5_4_function_tool_calls_off_the_responses_api(custom_llm_provider: str) -> None:
|
|
"""Chat Completions rejects function tools while reasoning is on for gpt-5.4+, so the capability rule
|
|
must still bridge the request even when the deployment pins ``mode: chat``."""
|
|
info, model = responses_api_bridge_check(
|
|
model="gpt-5.4",
|
|
custom_llm_provider=custom_llm_provider,
|
|
tools=[_FUNCTION_TOOL],
|
|
api_base="https://example.openai.azure.com" if custom_llm_provider == "azure" else None,
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert (info["mode"], model) == ("responses", "gpt-5.4")
|
|
|
|
|
|
@pytest.mark.parametrize("model", ["openai.gpt-5.6-terra", "us-east-1/openai.gpt-5.6-terra"])
|
|
@pytest.mark.parametrize("reasoning_effort", [None, "low"])
|
|
def test_a_chat_pinned_mantle_deployment_sends_function_tools_to_the_responses_api(
|
|
model: str, reasoning_effort: str | None
|
|
) -> None:
|
|
"""Mantle Chat Completions rejects function tools on gpt-5.4+ unless reasoning is off, so a ``mode: chat``
|
|
pin cannot keep a tool call there."""
|
|
info, routed_model = responses_api_bridge_check(
|
|
model=model,
|
|
custom_llm_provider="bedrock_mantle",
|
|
tools=[_FUNCTION_TOOL],
|
|
reasoning_effort=reasoning_effort,
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert (info["mode"], routed_model) == ("responses", model)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"reasoning_effort, reasoning_summary",
|
|
[("low", "auto"), ({"effort": "low", "summary": "auto"}, None)],
|
|
)
|
|
def test_a_chat_pinned_mantle_deployment_sends_a_reasoning_summary_to_the_responses_api(
|
|
reasoning_effort: str | Mapping[str, JsonValue], reasoning_summary: str | None
|
|
) -> None:
|
|
info, _ = responses_api_bridge_check(
|
|
model="openai.gpt-5.6-terra",
|
|
custom_llm_provider="bedrock_mantle",
|
|
reasoning_effort=reasoning_effort,
|
|
reasoning_summary=reasoning_summary,
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert info["mode"] == "responses"
|
|
|
|
|
|
def test_a_chat_pinned_mantle_deployment_sends_web_search_to_the_responses_api() -> None:
|
|
info, model = responses_api_bridge_check(
|
|
model="openai.gpt-5.6-terra",
|
|
custom_llm_provider="bedrock_mantle",
|
|
web_search_options={"search_context_size": "medium"},
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert (info["mode"], model) == ("responses", "openai.gpt-5.6-terra")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model, reasoning_effort",
|
|
[
|
|
("openai.gpt-5.6-terra", "none"),
|
|
("openai.gpt-oss-120b", "low"),
|
|
("openai.gpt-oss-safeguard-120b", "low"),
|
|
],
|
|
)
|
|
def test_a_chat_pinned_mantle_tool_call_stays_on_chat_completions_when_chat_accepts_it(
|
|
model: str, reasoning_effort: str
|
|
) -> None:
|
|
info, routed_model = responses_api_bridge_check(
|
|
model=model,
|
|
custom_llm_provider="bedrock_mantle",
|
|
tools=[_FUNCTION_TOOL],
|
|
reasoning_effort=reasoning_effort,
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert (info["mode"], routed_model) == ("chat", model)
|
|
|
|
|
|
def test_a_chat_pin_cannot_pull_xai_web_search_off_the_responses_api() -> None:
|
|
info, model = responses_api_bridge_check(
|
|
model="grok-4",
|
|
custom_llm_provider="xai",
|
|
web_search_options={"search_context_size": "medium"},
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert (info["mode"], model) == ("responses", "grok-4")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model, custom_llm_provider, api_base",
|
|
[
|
|
("responses/my-azure-deployment", "azure", "https://example.openai.azure.com"),
|
|
("responses/gpt-5.4", "openai", None),
|
|
],
|
|
)
|
|
def test_a_chat_pin_does_not_override_an_explicit_responses_prefix(
|
|
model: str, custom_llm_provider: str, api_base: str | None
|
|
) -> None:
|
|
info, sent_model = responses_api_bridge_check(
|
|
model=model,
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_base=api_base,
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert (info["mode"], sent_model) == ("responses", model.removeprefix("responses/"))
|
|
|
|
|
|
def test_a_chat_pinned_responses_prefixed_router_deployment_reaches_the_responses_api(
|
|
monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter
|
|
) -> None:
|
|
"""The router registers a ``mode: chat`` row under the deployment's own ``responses/gpt-5.6`` name,
|
|
so the prefix has to win over that row too, or the request goes to Chat Completions as ``responses/gpt-5.6``."""
|
|
monkeypatch.setattr(litellm, "model_cost", copy.deepcopy(litellm.model_cost))
|
|
responses_route: Final = respx_mock.post("https://api.openai.com/v1/responses").mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "resp_1",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": "completed",
|
|
"model": "gpt-5.6",
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "msg_1",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "ok", "annotations": []}],
|
|
}
|
|
],
|
|
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
},
|
|
)
|
|
)
|
|
router: Final = Router(
|
|
model_list=[
|
|
{
|
|
"model_name": "prefixed-chat",
|
|
"litellm_params": {
|
|
"model": "openai/responses/gpt-5.6",
|
|
"custom_llm_provider": "openai",
|
|
"api_key": "sk-test",
|
|
},
|
|
"model_info": {"id": "prefixed-chat-id", "mode": "chat"},
|
|
}
|
|
]
|
|
)
|
|
|
|
response: Final = router.completion(model="prefixed-chat", messages=[{"role": "user", "content": "Say ok"}])
|
|
|
|
assert json.loads(responses_route.calls[0].request.read())["model"] == "gpt-5.6"
|
|
assert response.choices[0].message.content == "ok"
|
|
|
|
|
|
def test_a_chat_pin_keeps_a_plain_gpt_5_4_chat_request_on_chat_completions() -> None:
|
|
"""Without tools or a reasoning summary nothing forces the Responses API, so the deployment's own
|
|
mode decides and the shared ``responses`` row is left alone."""
|
|
shared_mode = litellm.model_cost["gpt-5.4"]["mode"]
|
|
info, model = responses_api_bridge_check(model="gpt-5.4", custom_llm_provider="openai", deployment_model_info=_CHAT)
|
|
assert (info["mode"], model) == ("chat", "gpt-5.4")
|
|
assert litellm.model_cost["gpt-5.4"]["mode"] == shared_mode
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"model, custom_llm_provider",
|
|
[
|
|
("gpt-5.1-codex", "azure"),
|
|
("o3-pro", "openai"),
|
|
("gpt-5.4-pro", "openai"),
|
|
("eu/gpt-5-codex", "azure"),
|
|
("us/gpt-5.4-pro", "azure"),
|
|
("databricks-gpt-5-3-codex", "databricks"),
|
|
],
|
|
)
|
|
def test_a_chat_pin_cannot_pull_a_responses_only_model_off_the_responses_api(
|
|
model: str, custom_llm_provider: str
|
|
) -> None:
|
|
"""These rows list only ``/v1/responses`` in ``supported_endpoints``, or are ``responses`` rows with no
|
|
endpoint list at all, so a deployment pin to chat has nowhere to send the request and the bridge keeps it."""
|
|
cost_map: Final = cast(dict[str, dict[str, object]], litellm.model_cost)
|
|
assert not backend_serves_chat_completions(
|
|
cost_map[model if custom_llm_provider == "openai" else f"{custom_llm_provider}/{model}"]
|
|
)
|
|
info, routed_model = responses_api_bridge_check(
|
|
model=model,
|
|
custom_llm_provider=custom_llm_provider,
|
|
api_base="https://example.openai.azure.com" if custom_llm_provider == "azure" else None,
|
|
deployment_model_info=_CHAT,
|
|
)
|
|
assert (info["mode"], routed_model) == ("responses", model)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"shared_model_info, expected",
|
|
[
|
|
({}, True),
|
|
({"mode": "chat"}, True),
|
|
({"mode": "responses"}, False),
|
|
({"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]}, True),
|
|
({"supported_endpoints": ["/v1/responses", "/v1/batch"]}, False),
|
|
({"supported_endpoints": "/v1/chat/completions"}, True),
|
|
],
|
|
)
|
|
def test_backend_serves_chat_completions_reads_the_shared_row(
|
|
shared_model_info: Mapping[str, JsonValue], expected: bool
|
|
) -> None:
|
|
assert backend_serves_chat_completions(shared_model_info) is expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra_body, expected",
|
|
[
|
|
({"reasoning_effort": {"effort": "low", "summary": "auto"}}, {"effort": "low", "summary": "auto"}),
|
|
({"reasoning_effort": "low"}, "low"),
|
|
({"reasoning_effort": 5}, None),
|
|
({"reasoning_effort": ["low"]}, None),
|
|
({}, None),
|
|
("reasoning_effort=low", None),
|
|
(None, None),
|
|
],
|
|
ids=["dict", "string", "int", "list", "absent", "not-a-mapping", "no-extra-body"],
|
|
)
|
|
def test_bridge_reasoning_effort_reads_only_a_string_or_mapping_effort_nested_in_extra_body(
|
|
extra_body: JsonValue, expected: JsonValue
|
|
) -> None:
|
|
assert bridge_reasoning_effort(None, extra_body) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("asynchronous", [False, True])
|
|
@pytest.mark.parametrize("stream", [False, True])
|
|
@pytest.mark.parametrize("prefix", ["", "responses/"])
|
|
@pytest.mark.parametrize("catalog_mode", [None, "chat", "responses"])
|
|
@pytest.mark.asyncio
|
|
async def test_chatgpt_completion_routes_to_responses_without_a_provider_catalog_entry(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
respx_mock: respx.MockRouter,
|
|
asynchronous: bool,
|
|
stream: bool,
|
|
prefix: str,
|
|
catalog_mode: str | None,
|
|
) -> None:
|
|
model: Final = "test-chatgpt-routing"
|
|
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
|
|
monkeypatch.setenv("CHATGPT_API_BASE", "https://chatgpt.example")
|
|
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
|
(tmp_path / "auth.json").write_text(
|
|
json.dumps({"access_token": "fake-access-token", "expires_at": 253402300799, "account_id": "fake-account"})
|
|
)
|
|
monkeypatch.delitem(litellm.model_cost, f"chatgpt/{model}", raising=False)
|
|
monkeypatch.delitem(litellm.model_cost, model, raising=False)
|
|
if catalog_mode is not None:
|
|
monkeypatch.setitem(litellm.model_cost, model, {"mode": catalog_mode, "litellm_provider": "openai"})
|
|
litellm.get_model_info.cache_clear()
|
|
payload: Final = {
|
|
"id": "resp_test",
|
|
"object": "response",
|
|
"created_at": 1,
|
|
"status": "completed",
|
|
"model": model,
|
|
"output": [
|
|
{
|
|
"type": "message",
|
|
"id": "msg_test",
|
|
"status": "completed",
|
|
"role": "assistant",
|
|
"content": [{"type": "output_text", "text": "hello", "annotations": []}],
|
|
}
|
|
],
|
|
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
}
|
|
events: Final = (
|
|
{"type": "response.created", "response": {**payload, "status": "in_progress", "output": []}},
|
|
{
|
|
"type": "response.output_text.delta",
|
|
"delta": "hello",
|
|
"item_id": "msg_test",
|
|
"output_index": 0,
|
|
"content_index": 0,
|
|
},
|
|
{"type": "response.completed", "response": payload},
|
|
)
|
|
upstream: Final = respx_mock.post("https://chatgpt.example/responses").respond(
|
|
200,
|
|
headers={"content-type": "text/event-stream"},
|
|
text="".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events),
|
|
)
|
|
kwargs: Final = {
|
|
"model": f"chatgpt/{prefix}{model}",
|
|
"messages": [{"role": "user", "content": "Say hello"}],
|
|
"api_base": "https://chatgpt.example",
|
|
"stream": stream,
|
|
"num_retries": 0,
|
|
}
|
|
response: Final = await litellm.acompletion(**kwargs) if asynchronous else litellm.completion(**kwargs)
|
|
result: Final = (
|
|
litellm.stream_chunk_builder([chunk async for chunk in response] if asynchronous else list(response))
|
|
if stream
|
|
else response
|
|
)
|
|
assert result is not None
|
|
assert result.choices[0].message.content == "hello"
|
|
assert upstream.call_count == 1
|
|
sent: Final = json.loads(upstream.calls[0].request.content)
|
|
assert sent["model"] == model
|
|
assert sent["store"] is False
|
|
assert sent["input"] == [
|
|
{"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Say hello"}]}
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("provider", ["openai", "azure", "xai", "github_copilot"])
|
|
def test_missing_catalog_entry_does_not_force_other_providers_to_responses(provider: str) -> None:
|
|
info, model = responses_api_bridge_check(model="test-chatgpt-routing-control", custom_llm_provider=provider)
|
|
assert (info.get("mode"), model) == (None, "test-chatgpt-routing-control")
|