This commit is contained in:
Mateo Wang 2026-11-06 05:04:21 +00:00 • committed by GitHub
commit 540315cecf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 280 additions and 20 deletions

View file

@ -754,6 +754,7 @@ jobs:
tests/unit/responses/test_no_duplicate_spend_logs.py
tests/unit/responses/test_null_test_fix.py
tests/unit/responses/test_responses_api_bridge_flag.py
tests/unit/responses/test_bridge_check.py
tests/unit/responses/test_responses_api_lifecycle.py
tests/unit/responses/test_responses_api_request_body.py
tests/unit/responses/test_responses_prompt_management.py

View file

@ -1909,10 +1909,30 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
web_search_tool: Final[dict[str, object]] = {"type": "web_search"}
if isinstance(web_search_options, dict):
web_search_tool.update(web_search_options)
typed_options: Final = cast(Mapping[str, object], web_search_options) # cast-ok: caller-supplied dict
user_location: Final = self._responses_web_search_user_location(typed_options.get("user_location"))
if user_location is not None:
web_search_tool["user_location"] = user_location
# Cast to Any to match the expected union type for tools list items
tools.append(cast(Any, web_search_tool))
@staticmethod
def _responses_web_search_user_location(user_location: object) -> Mapping[str, object] | None:
"""Chat Completions nests the location under ``approximate``; the Responses web_search tool
takes ``{"type": "approximate", "city": ..., "country": ...}`` flat and rejects the nested key."""
if not isinstance(user_location, dict):
return None
typed_location: Final = cast(Mapping[object, object], user_location) # cast-ok: caller-supplied dict
approximate: Final = typed_location.get("approximate")
if not isinstance(approximate, dict):
return None
typed_approximate: Final = cast(Mapping[object, object], approximate) # cast-ok: caller-supplied dict
return {
"type": "approximate",
**{str(key): value for key, value in typed_approximate.items() if value is not None},
}
def transform_response_format_to_text_format(self, response_format: object) -> "ResponseText | None":
return self._transform_response_format_to_text_format(response_format)

View file

@ -1329,6 +1329,7 @@ from litellm.responses.bridge_check import ( # noqa: E402 # re-export at the o
_resolve_openai_api_base as _resolve_openai_api_base, # pyright: ignore[reportPrivateUsage] # noqa: PLC0414 # re-export of moved helper
)
from litellm.responses.bridge_check import (
bridge_check_request_params,
bridge_reasoning_effort,
str_keyed_mapping,
)
@ -5965,19 +5966,39 @@ def completion(
if dynamic_api_key is not None:
api_key = dynamic_api_key
if responses_api_model_info.get("mode") != "responses":
route_request_params: Final = bridge_check_request_params(routing_kwargs)
responses_api_model_info, model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
tools=tools,
reasoning_effort=bridge_reasoning_effort(
resolved_reasoning_effort, route_request_params.get("extra_body")
),
reasoning_summary=peek_reasoning_summary_aliases(route_request_params),
api_base=api_base,
deployment_model_info=deployment_model_info,
)
# check if user passed in any of the OpenAI optional params
bridges_to_responses_api: Final = (
responses_api_model_info.get("mode") == "responses" and not skip_responses_api_bridge
)
allowed_openai_params: Final[list[str] | None] = (
[*(kwargs.get("allowed_openai_params") or []), "reasoning_effort"]
bridge_allowed_params: Final[tuple[str, ...]] = (
("reasoning_effort", "web_search_options")
if bridges_to_responses_api
or reasoning_effort_needs_allowing(
else ("reasoning_effort",)
if reasoning_effort_needs_allowing(
chat_reasoning.effort,
model=model,
custom_llm_provider=custom_llm_provider,
base_model=base_model if isinstance(base_model, str) else None,
)
else ()
)
allowed_openai_params: Final[list[str] | None] = (
[*(kwargs.get("allowed_openai_params") or []), *bridge_allowed_params]
if bridge_allowed_params
else kwargs.get("allowed_openai_params")
)
optional_param_args: Final = {
@ -6249,27 +6270,11 @@ def completion(
),
)
## RESPONSES API BRIDGE LOGIC ## - check if model has 'mode: responses' in litellm.model_cost map
# Only run the second bridge check if the first one didn't already
# detect responses mode (e.g. via the "responses/" prefix). The second
# check handles cases like gpt-5.4+ with tools+reasoning_effort or
# reasoningSummary/reasoning_summary without tools (AI SDK) that the first
# (early) check doesn't cover.
## RESPONSES API BRIDGE LOGIC ## - route the request over the Responses API when selected above
_reasoning_summary_for_bridge: Final = peek_reasoning_summary_aliases(optional_params)
typed_deployment_model_info: Final = str_keyed_mapping(
cast(object, model_info) # cast-ok: model_info is the untyped dict kwarg
)
if responses_api_model_info.get("mode") != "responses":
responses_api_model_info, model = responses_api_bridge_check(
model=model,
custom_llm_provider=custom_llm_provider,
web_search_options=web_search_options,
tools=tools,
reasoning_effort=bridge_reasoning_effort(resolved_reasoning_effort, optional_params.get("extra_body")),
reasoning_summary=_reasoning_summary_for_bridge,
api_base=api_base,
deployment_model_info=typed_deployment_model_info,
)
# Use base_model (the true underlying model) for Azure model-type
# detection when the deployment name differs from the model name.

View file

@ -102,6 +102,24 @@ def str_keyed_mapping(value: object) -> Mapping[str, object] | None:
)
def bridge_check_request_params(request_params: Mapping[str, object]) -> Mapping[str, object]:
"""The request as ``get_optional_params`` will hand it to the provider: every key named in
``additional_drop_params`` removed at the top level and inside ``extra_body``, so the Responses route
decision taken before that call reads the same effort and summary alias the parsed params will carry."""
raw_dropped: Final = request_params.get("additional_drop_params")
dropped_params: Final = (
cast(Sequence[object], raw_dropped) # cast-ok: isinstance only narrows to Sequence[Unknown]
if isinstance(raw_dropped, Sequence) and not isinstance(raw_dropped, str)
else ()
)
dropped: Final = frozenset(param for param in dropped_params if isinstance(param, str))
kept: Final = {key: value for key, value in request_params.items() if key not in dropped}
extra_body: Final = str_keyed_mapping(kept.get("extra_body"))
if extra_body is None:
return kept
return {**kept, "extra_body": {key: value for key, value in extra_body.items() if key not in dropped}}
def bridge_reasoning_effort(
reasoning_effort: str | Mapping[str, object] | None, extra_body: object
) -> str | Mapping[str, object] | None:

View file

@ -0,0 +1,130 @@
from dataclasses import replace
from typing import Final
import pytest
from integration._support.client import Gateway
from integration._support.provider import SharedProvider
from integration.translation.case import TranslationTestCase
from integration.translation.chat_completions.bases.openai import GPT_5_6_SOL_TEST_CASE as GPT_5_6_SOL_CHAT_TEST_CASE
from integration.translation.chat_completions.bases.openai_responses import GPT_5_6_SOL_TEST_CASE
from integration.translation.runner import assert_translation
GPT_5_6_SOL_WEB_SEARCH_TEST_CASE: Final = replace(
GPT_5_6_SOL_TEST_CASE,
scenario="web_search_options",
litellm_request={**GPT_5_6_SOL_TEST_CASE.litellm_request, "web_search_options": {"search_context_size": "low"}},
expected_provider_request={
**GPT_5_6_SOL_TEST_CASE.expected_provider_request,
"tools": [{"type": "web_search", "search_context_size": "low"}],
},
)
GPT_5_6_SOL_WEB_SEARCH_USER_LOCATION_TEST_CASE: Final = replace(
GPT_5_6_SOL_TEST_CASE,
scenario="web_search_options_user_location",
litellm_request={
**GPT_5_6_SOL_TEST_CASE.litellm_request,
"web_search_options": {
"search_context_size": "medium",
"user_location": {"type": "approximate", "approximate": {"country": "US", "city": "San Francisco"}},
},
},
expected_provider_request={
**GPT_5_6_SOL_TEST_CASE.expected_provider_request,
"tools": [
{
"type": "web_search",
"search_context_size": "medium",
"user_location": {"type": "approximate", "country": "US", "city": "San Francisco"},
}
],
},
)
# No `responses/` prefix: a gpt-5.6 chat request with function tools and a reasoning effort is bridged by the
# tools-plus-reasoning rule rather than the deployment's mode, and web_search_options must survive that too
GPT_5_6_SOL_LATE_BRIDGE_WEB_SEARCH_TEST_CASE: Final = replace(
GPT_5_6_SOL_TEST_CASE,
scenario="late_bridge_web_search_options",
litellm_request={
**GPT_5_6_SOL_TEST_CASE.litellm_request,
"model": "openai/gpt-5.6-sol",
"reasoning_effort": "low",
"web_search_options": {"search_context_size": "low"},
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
],
},
expected_provider_request={
**GPT_5_6_SOL_TEST_CASE.expected_provider_request,
"tools": [
{
"name": "get_weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
"strict": None,
"type": "function",
"description": "Get weather",
},
{"type": "web_search", "search_context_size": "low"},
],
"reasoning": {"effort": "low"},
},
expected_litellm_response={**GPT_5_6_SOL_TEST_CASE.expected_litellm_response, "model": "openai/gpt-5.6-sol"},
)
# The rig's deployment has a custom api_base, which the bridge treats as a chat-only endpoint unless the
# request names an effort. An effort the caller drops through additional_drop_params must not pick the
# Responses route, so this request stays on /chat/completions exactly as before the early route decision
GPT_5_6_SOL_DROPPED_EFFORT_STAYS_ON_CHAT_TEST_CASE: Final = replace(
GPT_5_6_SOL_CHAT_TEST_CASE,
scenario="dropped_extra_body_effort_stays_on_chat",
litellm_request={
**GPT_5_6_SOL_CHAT_TEST_CASE.litellm_request,
"extra_body": {"reasoning_effort": "low"},
"additional_drop_params": ["reasoning_effort"],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
],
},
expected_provider_request={
**GPT_5_6_SOL_CHAT_TEST_CASE.expected_provider_request,
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
},
}
],
},
)
CASES: Final = (
GPT_5_6_SOL_WEB_SEARCH_TEST_CASE,
GPT_5_6_SOL_WEB_SEARCH_USER_LOCATION_TEST_CASE,
GPT_5_6_SOL_LATE_BRIDGE_WEB_SEARCH_TEST_CASE,
GPT_5_6_SOL_DROPPED_EFFORT_STAYS_ON_CHAT_TEST_CASE,
)
@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id)
def test_chat_completions_params_openai_responses_web_search(
case: TranslationTestCase, gateway: Gateway, provider: SharedProvider
) -> None:
assert_translation(case, gateway, provider)

View file

@ -2,6 +2,8 @@
Test for response_format to text.format conversion in completion -> responses bridge
"""
from typing import Final
import pytest
from litellm.completion_extras.litellm_responses_transformation.transformation import (
LiteLLMResponsesTransformationHandler,
@ -258,3 +260,50 @@ def test_translate_responses_chunk_passthrough_chat_completion_chunk():
assert result.choices[0].delta.content == "Hi! How can I help?"
assert result.choices[0].finish_reason is None
class _LoggingObj:
pass
def test_transform_request_flattens_chat_web_search_user_location_for_the_responses_tool():
"""Chat Completions nests the location under `approximate`; OpenAI's Responses web_search tool rejects that
key ("Unknown parameter: 'tools[0].user_location.approximate'") and takes the fields flat."""
handler: Final = LiteLLMResponsesTransformationHandler()
result: Final = handler.transform_request(
model="gpt-5.6-sol",
messages=[{"role": "user", "content": "What is a top local headline today?"}],
optional_params={
"web_search_options": {
"search_context_size": "medium",
"user_location": {"type": "approximate", "approximate": {"country": "US", "city": "San Francisco"}},
}
},
litellm_params={},
headers={},
litellm_logging_obj=_LoggingObj(),
)
assert result["tools"] == [
{
"type": "web_search",
"search_context_size": "medium",
"user_location": {"type": "approximate", "country": "US", "city": "San Francisco"},
}
], result["tools"]
def test_transform_request_keeps_a_web_search_option_without_a_location_as_sent():
handler: Final = LiteLLMResponsesTransformationHandler()
result: Final = handler.transform_request(
model="gpt-5.6-sol",
messages=[{"role": "user", "content": "What is the top headline today?"}],
optional_params={"web_search_options": {"search_context_size": "low"}},
litellm_params={},
headers={},
litellm_logging_obj=_LoggingObj(),
)
assert result["tools"] == [{"type": "web_search", "search_context_size": "low"}], result["tools"]

View file

@ -0,0 +1,37 @@
from typing import Final
from litellm.responses.bridge_check import bridge_check_request_params
def test_a_dropped_effort_inside_extra_body_is_not_read_by_the_route_decision():
request: Final = {
"extra_body": {"reasoning_effort": "low", "reasoningSummary": "auto"},
"additional_drop_params": ["reasoning_effort"],
"tools": [{"type": "function", "function": {"name": "get_weather"}}],
}
params: Final = bridge_check_request_params(request)
assert params["extra_body"] == {"reasoningSummary": "auto"}, params
assert params["tools"] is request["tools"]
def test_a_dropped_top_level_alias_is_removed_too():
params: Final = bridge_check_request_params({"reasoningSummary": "auto", "additional_drop_params": ["reasoningSummary"]})
assert "reasoningSummary" not in params, params
def test_a_request_without_drops_is_returned_whole():
request: Final = {"extra_body": {"reasoning_effort": "low"}, "tools": []}
params: Final = bridge_check_request_params(request)
assert params == request
assert params["extra_body"] == {"reasoning_effort": "low"}
def test_a_non_list_additional_drop_params_drops_nothing():
request: Final = {"extra_body": {"reasoning_effort": "low"}, "additional_drop_params": "reasoning_effort"}
assert bridge_check_request_params(request)["extra_body"] == {"reasoning_effort": "low"}