litellm/tests/unit/responses/test_responses_api_bridge_flag.py

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")