mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge 39de3bcbab into aeede53112
This commit is contained in:
commit
0fa7ff8159
8 changed files with 280 additions and 20 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
37
tests/unit/responses/test_bridge_check.py
Normal file
37
tests/unit/responses/test_bridge_check.py
Normal 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"}
|
||||
Loading…
Add table
Reference in a new issue