litellm/tests/unit/responses/test_responses_api_bridge_flag.py
Mateo Wang 897dda7920
fix(router)!: stop a chat-mode Mantle deployment from rewriting the shared Responses routing row (#46263)
* fix(router): stop a chat-mode Mantle deployment from rewriting the shared Responses routing row

A deployment with an explicit custom_llm_provider and model_info.mode: chat
used to overwrite the shared litellm.model_cost row, moving every other
deployment and plain SDK calls onto Chat Completions until restart, because
the downgrade guard read a double-prefixed key and never fired. The guard now
reads the real key, and a deployment's own mode decides its routing in
responses_api_bridge_check, except on chatgpt and github_copilot where Chat
Completions is dead for responses-mode models.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(responses): keep the bridge-check mode tests in the sharded bridge flag file

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): let a deployment mode pin replace only the shared-row mode so the bridge capability rules still run

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): keep Responses-only backends bridged under a deployment chat pin and satisfy the type gate

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): give the bridge casts a reason and drop the bare noqa so the type discipline gate passes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(responses): keep a responses row with no supported_endpoints bridged under a deployment chat pin

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(router): read the downgrade guard's mode from the row registration writes

A chat-mode openai/<name> deployment merged into the bare <name> row while the guard read
openai/<name>, so the guard never fired and the shared responses row turned to chat. An explicit
responses/ prefix now also outranks a deployment's chat pin.

* fix(router): let an explicit responses/ prefix outrank a registered chat row

The router now registers a chat-pinned openai/responses/<model> deployment under its own name, so the
lookup found mode chat and the request went to Chat Completions as responses/<model>.

* fix(router): keep the shared row's mode for every deployment pin and honor the pin on /v1/messages

A deployment's mode pin now never rewrites the shared cost-map row in either direction, including the responses/-stripped twin key, so siblings and SDK calls keep routing by the registered mode. The backend key formula goes back to main's, which keeps capability fields off rows that registration never wrote at base. The /v1/messages adapter and handler read the deployment's model_info, so a chat-pinned deployment gets a plain reasoning effort on chat completions.

* fix(router): keep copilot responses opt-in, make unmapped pins order-free, send Mantle auto summary as auto

A responses pin on a github_copilot model or a model missing from the cost map still marks the shared row, since that is how those models opt into the Responses API. A chat pin never marks a row, so registration order no longer matters. Mantle's OpenAI path gets summary auto in place of the auto summary detailed. The Azure o3-pro and o3-deep-research rows drop Chat Completions, which Azure does not serve for them.

* fix(router): skip a non-string model_info.mode when keeping the registered mode

* fix(router): keep chat-pinned Mantle tool calls and summaries on the Responses API

A Mantle row that also serves the Responses API now follows the GPT-5 tool and
reasoning summary bridge rules, since Mantle Chat Completions rejects function tools
on gpt-5.4+ unless reasoning is off and rejects the summary aliases. Mantle chat
gets a dict reasoning_effort as its effort level.

The opt-in auto summary asks each Responses config for its level, auto on Mantle's
OpenAI path, so a client's own detailed summary follows drop_params as at base.

No mode replaces a registered responses mode, and chatgpt and github_copilot rows
take any pin until they are responses, which restores the Copilot chat opt-out.

* fix(responses): ask the target for its auto summary level on the chat bridge and reasoning_effort

* test(integration): run the Mantle region-prefixed v1 base case again

This PR routes that deployment by its own chat pin, so the case passes and no longer needs the skip

* fix(bedrock_mantle): keep web search and reasoning summaries on the Responses API under a chat pin

A chat-pinned Mantle GPT-5 deployment now sends web_search_options and a dict reasoning_effort carrying a summary to the Responses API, which serves them. Mantle Chat Completions drops n above 1 and a reasoning summary under drop_params and answers 400 without it. The /v1/messages adapter probes the bridge with only the client's own summary, so an auto-injected one never moves the request

* fix(bedrock_mantle): check n and reasoning summaries only on the Chat Completions request

* fix(router): keep a non-string deployment mode off the shared routing row

A deployment whose model_info.mode was an int, a list, or an object still
reached the shared cost-map row, so an unpinned sibling on the same backend
stopped routing to the Responses API. The shared registration now drops a
mode that is not a string, and the deployment keeps its own raw value.

Adds integration cells for per-deployment routing across the OpenAI and
Anthropic SDKs, raw httpx, wildcard routes, malformed modes, an upstream
outage, config changes under traffic, and a worker kill plus restart.

* test(integration): find each routing cell's spend row by the response it logged

A bridged call logs its row under the wrapped Responses id, which the caller never sees on a streamed Messages answer, so look the row up by the answer's own ids and the upstream ids for its marker. Int and list modes 500 their own model group on main too, so those cells check only the sibling, and the summary cell sends the auto level Mantle's Responses path accepts

* fix(bedrock_mantle): run extra_body fields through the chat checks

A chat-pinned Mantle request that nests n or reasoning_effort in
extra_body now gets the same 400, drop, and effort-level handling as
the top-level fields. The bridge test injects a MockTransport client
instead of patching HTTPHandler.post.

* test(router): cover nested reasoning_effort routing, messages thinking summaries and per-target auto summary levels

* fix(router): probe a pinned deployment's own API on /health and keep a requested summary beside an extra_body effort

* fix(router): keep a custom-priced deployment's mode pin off the shared backend row on each request

* fix(router): run per-request custom pricing through the shared mode guard so a health probe keeps the row's mode

* fix(router): drop the debug log that wrote a request's model name from keep_registered_mode

* test(router): cover custom-priced chat pins, their health probes and summary aliases beside an extra_body effort

* fix(proxy): pass only the mode pin to a /health probe so a failing probe never cools its deployment down

* test(router): cover failing health probes leaving their deployment out of cooldown, with and without background checks

* test(router): kill the worker that holds burst calls, so the worker kill cell never depends on how the kernel spread them

* fix(router): route a fine-tune the same whichever deployment registers first

Main's register_model now seeds an unmapped fine-tune's row from its base
model's built-in row, mode included, on the first registration. The mode
guard read only the live row, so a responses pin marked the shared row only
when it registered before its siblings. The guard now reads the row the
registration merges onto, the live row or else the row it would seed.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 21:39:14 +00:00

854 lines
36 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 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