diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index fb29a92e306..0de0f6f046c 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -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 diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index b24c75e69bc..42edcf3b1b0 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -1921,10 +1921,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) diff --git a/litellm/main.py b/litellm/main.py index bdb35823757..06e8612a071 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -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, ) @@ -5986,19 +5987,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 = { @@ -6270,27 +6291,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. diff --git a/litellm/responses/bridge_check.py b/litellm/responses/bridge_check.py index 826de8cfdc9..39a283c72d8 100644 --- a/litellm/responses/bridge_check.py +++ b/litellm/responses/bridge_check.py @@ -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: diff --git a/tests/integration/translation/chat_completions/params/__init__.py b/tests/integration/translation/chat_completions/params/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/translation/chat_completions/params/test_chat_completions_params_openai_responses_web_search.py b/tests/integration/translation/chat_completions/params/test_chat_completions_params_openai_responses_web_search.py new file mode 100644 index 00000000000..068c1bd189b --- /dev/null +++ b/tests/integration/translation/chat_completions/params/test_chat_completions_params_openai_responses_web_search.py @@ -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) diff --git a/tests/unit/completion_extras/test_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/test_litellm_responses_transformation_transformation.py index 626e554d476..cd423abe042 100644 --- a/tests/unit/completion_extras/test_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/test_litellm_responses_transformation_transformation.py @@ -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"] diff --git a/tests/unit/responses/test_bridge_check.py b/tests/unit/responses/test_bridge_check.py new file mode 100644 index 00000000000..d7cee7a16ba --- /dev/null +++ b/tests/unit/responses/test_bridge_check.py @@ -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"}