mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
fix(responses): drop params compact rejects and send Azure compaction to the v1 route (#48876)
* fix(responses): drop metadata from compact requests to OpenAI and Azure Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): drop user and drop_params-rejected fields on compact and route Azure compaction to v1 * fix(responses): keep stream on compact and keep non-dated Azure api-versions on the v1 compact route * fix(responses): drop compact params only for providers verified to reject them --------- Co-authored-by: mateo <mateo@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
4fc2bba1f2
commit
83ae1bd63e
7 changed files with 241 additions and 27 deletions
|
|
@ -621,7 +621,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
|
||||
# For Azure v1 API, use standard OpenAI client instead of AzureOpenAI
|
||||
# See: https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs
|
||||
if self._is_azure_v1_api_version(api_version):
|
||||
if self.is_azure_v1_api_version(api_version):
|
||||
# Extract only params that OpenAI client accepts
|
||||
# Always use /openai/v1/ regardless of whether user passed "v1", "latest", or "preview"
|
||||
# The OpenAI client accepts a callable for `api_key` and re-invokes it
|
||||
|
|
@ -922,7 +922,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
else:
|
||||
new_url = api_base
|
||||
|
||||
if BaseAzureLLM._is_azure_v1_api_version(api_version):
|
||||
if BaseAzureLLM.is_azure_v1_api_version(api_version):
|
||||
# ensure the request go to /openai/v1 and not just /openai
|
||||
if "/openai/v1" not in new_url:
|
||||
parsed_url: Final = httpx.URL(new_url)
|
||||
|
|
@ -952,7 +952,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
|
||||
Returns None when ``api_version`` is a dated one, which still uses the deployment route.
|
||||
"""
|
||||
if not BaseAzureLLM._is_azure_v1_api_version(api_version):
|
||||
if not BaseAzureLLM.is_azure_v1_api_version(api_version):
|
||||
return None
|
||||
|
||||
base_url: Final = httpx.URL(api_base)
|
||||
|
|
@ -970,7 +970,7 @@ class BaseAzureLLM(BaseOpenAILLM):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_azure_v1_api_version(api_version: str | None) -> bool:
|
||||
def is_azure_v1_api_version(api_version: str | None) -> bool:
|
||||
if api_version is None:
|
||||
return False
|
||||
return api_version in {"preview", "latest", "v1"}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,16 @@ def _resolve_responses_api_version(api_base: str | None, litellm_params: Mapping
|
|||
return api_version
|
||||
|
||||
|
||||
def _v1_responses_url(api_base: str) -> httpx.URL:
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
path: Final = parsed_url.path.rstrip("/")
|
||||
root: Final = next(
|
||||
(path.removesuffix(route) for route in ("/openai/v1/responses", "/openai/responses") if path.endswith(route)),
|
||||
path,
|
||||
)
|
||||
return parsed_url.copy_with(path=f"{root}/openai/v1/responses", query=None)
|
||||
|
||||
|
||||
def _with_responses_api_version(api_base: str | None, litellm_params: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return MappingProxyType({**litellm_params, "api_version": _resolve_responses_api_version(api_base, litellm_params)})
|
||||
|
||||
|
|
@ -247,20 +257,20 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
if api_base is None:
|
||||
raise ValueError("api_base is required for Azure WebSocket")
|
||||
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
path = parsed_url.path.rstrip("/")
|
||||
# Strip existing /openai/responses path if the api_base already contains it
|
||||
for suffix in ("/openai/v1/responses", "/openai/responses"):
|
||||
if path.endswith(suffix):
|
||||
path = path[: -len(suffix)]
|
||||
break
|
||||
scheme: Final = "wss" if parsed_url.scheme == "https" else "ws"
|
||||
return str(parsed_url.copy_with(scheme=scheme, path=f"{path}/openai/v1/responses", query=None))
|
||||
v1_url: Final = _v1_responses_url(api_base)
|
||||
return str(v1_url.copy_with(scheme="wss" if v1_url.scheme == "https" else "ws"))
|
||||
|
||||
def model_in_websocket_url(self) -> bool:
|
||||
# Azure sends the model in the response.create body, not the URL
|
||||
return False
|
||||
|
||||
def compact_url(self, api_base: str) -> str:
|
||||
params: Final = httpx.URL(api_base).params
|
||||
v1_params: Final = (
|
||||
params if BaseAzureLLM.is_azure_v1_api_version(params.get("api-version")) else params.remove("api-version")
|
||||
)
|
||||
return super().compact_url(str(_v1_responses_url(api_base).copy_with(params=v1_params)))
|
||||
|
||||
#########################################################
|
||||
########## DELETE RESPONSE API TRANSFORMATION ##############
|
||||
#########################################################
|
||||
|
|
|
|||
|
|
@ -66,6 +66,29 @@ _PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS: Final = frozenset({LlmProviders.AZURE,
|
|||
_PROVIDERS_REPLAYING_ONLY_THEIR_OWN_REASONING: Final = _PROVIDERS_VALIDATING_TOOL_CALL_ITEM_IDS
|
||||
_ROLES_READING_PROMPT_CACHE_BREAKPOINTS: Final = frozenset({"system", "developer", "user"})
|
||||
_PART_TYPES_READING_PROMPT_CACHE_BREAKPOINTS: Final = frozenset({"input_text", "input_image", "input_file"})
|
||||
_COMPACT_TRACKING_PARAMS: Final = frozenset({"metadata", "user"})
|
||||
_COMPACT_REJECTED_PARAMS: Final = frozenset(
|
||||
{
|
||||
"background",
|
||||
"context_management",
|
||||
"max_output_tokens",
|
||||
"max_tool_calls",
|
||||
"moderation",
|
||||
"partial_images",
|
||||
"prompt",
|
||||
"safety_identifier",
|
||||
"store",
|
||||
"stream_options",
|
||||
"temperature",
|
||||
"tool_choice",
|
||||
"top_logprobs",
|
||||
"top_p",
|
||||
"truncation",
|
||||
}
|
||||
)
|
||||
_PROVIDERS_WITH_STRICT_COMPACT_PARAMS: Final = frozenset(
|
||||
{LlmProviders.OPENAI, LlmProviders.AZURE, LlmProviders.AZURE_AI}
|
||||
)
|
||||
_INPUT_ITEM: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
|
||||
_CONTENT_PARTS: Final[TypeAdapter[Sequence[Mapping[str, object]]]] = TypeAdapter(Sequence[Mapping[str, object]])
|
||||
|
||||
|
|
@ -1182,6 +1205,17 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
#########################################################
|
||||
########## COMPACT RESPONSE API TRANSFORMATION ##########
|
||||
#########################################################
|
||||
def compact_url(self, api_base: str) -> str:
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
return str(parsed_url.copy_with(path=parsed_url.path.rstrip("/") + "/compact"))
|
||||
|
||||
def compact_dropped_params(self, litellm_params: GenericLiteLLMParams) -> frozenset[str]:
|
||||
if self.custom_llm_provider not in _PROVIDERS_WITH_STRICT_COMPACT_PARAMS:
|
||||
return frozenset()
|
||||
if _drops_params(litellm_params):
|
||||
return _COMPACT_TRACKING_PARAMS | _COMPACT_REJECTED_PARAMS
|
||||
return _COMPACT_TRACKING_PARAMS
|
||||
|
||||
def transform_compact_response_api_request(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -1197,10 +1231,8 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
OpenAI API expects the following request
|
||||
- POST /v1/responses/compact
|
||||
"""
|
||||
# Preserve query params (e.g., api-version) while appending /compact.
|
||||
parsed_url: Final = httpx.URL(api_base)
|
||||
compact_path: Final = parsed_url.path.rstrip("/") + "/compact"
|
||||
url: Final = str(parsed_url.copy_with(path=compact_path))
|
||||
url: Final = self.compact_url(api_base)
|
||||
dropped_params: Final = self.compact_dropped_params(litellm_params)
|
||||
|
||||
request_params: Final = cast( # cast-ok: the base signature leaves the params bag untyped
|
||||
"Mapping[str, object]", response_api_optional_request_params
|
||||
|
|
@ -1214,14 +1246,11 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
)
|
||||
if sanitized_tools is not None:
|
||||
response_api_optional_request_params["tools"] = sanitized_tools
|
||||
data: Final = cast( # cast-ok: typed dict spread is inferred as Unknown by basedpyright
|
||||
dict[str, object],
|
||||
{
|
||||
"model": model,
|
||||
"input": replay_safe_input,
|
||||
**response_api_optional_request_params,
|
||||
},
|
||||
)
|
||||
data: Final = {
|
||||
"model": model,
|
||||
"input": replay_safe_input,
|
||||
**{key: value for key, value in request_params.items() if key not in dropped_params},
|
||||
}
|
||||
|
||||
return url, data
|
||||
|
||||
|
|
|
|||
|
|
@ -165,6 +165,44 @@ def test_explicit_v1_api_base_keeps_responses_api_version_selection(
|
|||
|
||||
|
||||
@pytest.mark.serial
|
||||
@pytest.mark.parametrize(
|
||||
("api_base", "api_version", "expected_url"),
|
||||
[
|
||||
(
|
||||
"https://x.openai.azure.com",
|
||||
"2025-04-01-preview",
|
||||
"https://x.openai.azure.com/openai/v1/responses/compact",
|
||||
),
|
||||
(
|
||||
"https://x.openai.azure.com",
|
||||
"preview",
|
||||
"https://x.openai.azure.com/openai/v1/responses/compact?api-version=preview",
|
||||
),
|
||||
(
|
||||
"https://gw.example.com/foundry/openai/openai/v1?tenant=example",
|
||||
"v1",
|
||||
"https://gw.example.com/foundry/openai/openai/v1/responses/compact?tenant=example&api-version=v1",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_compact_reaches_the_v1_route_and_drops_only_a_dated_api_version(
|
||||
api_base: str, api_version: str, expected_url: str
|
||||
) -> None:
|
||||
config: Final = AzureOpenAIResponsesAPIConfig()
|
||||
responses_url: Final = config.get_complete_url(api_base=api_base, litellm_params={"api_version": api_version})
|
||||
|
||||
url, _ = config.transform_compact_response_api_request(
|
||||
model="gpt-5.4-mini",
|
||||
input="hi",
|
||||
response_api_optional_request_params={},
|
||||
api_base=responses_url,
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert url == expected_url
|
||||
|
||||
|
||||
def test_response_id_path_requests_encode_response_id():
|
||||
config = AzureOpenAIResponsesAPIConfig()
|
||||
api_base = (
|
||||
|
|
|
|||
|
|
@ -1658,9 +1658,9 @@ def test_get_azure_ad_token_fallback_to_default_azure_credential(
|
|||
)
|
||||
def test_is_azure_v1_api_version(api_version, expected):
|
||||
"""
|
||||
Test that _is_azure_v1_api_version correctly identifies v1 API versions.
|
||||
Test that is_azure_v1_api_version correctly identifies v1 API versions.
|
||||
"""
|
||||
result = BaseAzureLLM._is_azure_v1_api_version(api_version=api_version)
|
||||
result = BaseAzureLLM.is_azure_v1_api_version(api_version=api_version)
|
||||
assert result == expected
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import respx
|
|||
|
||||
import litellm
|
||||
from litellm.llms.litellm_proxy.responses.transformation import LiteLLMProxyResponsesAPIConfig
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.utils import ProviderConfigManager
|
||||
from tests.unit.proxy.conftest import httpx_transport
|
||||
|
|
@ -73,3 +74,23 @@ def test_get_complete_url_requires_api_base(monkeypatch: pytest.MonkeyPatch) ->
|
|||
monkeypatch.delenv("LITELLM_PROXY_API_BASE", raising=False)
|
||||
with pytest.raises(ValueError, match="api_base not set"):
|
||||
LiteLLMProxyResponsesAPIConfig().get_complete_url(api_base=None, litellm_params={})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("drop_params", [False, True])
|
||||
def test_compact_request_forwards_metadata_and_user_to_the_downstream_proxy(drop_params: bool) -> None:
|
||||
url, data = LiteLLMProxyResponsesAPIConfig().transform_compact_response_api_request(
|
||||
model="gpt-5.5",
|
||||
input="hi",
|
||||
response_api_optional_request_params={"metadata": {"ticket": "qa-1"}, "user": "end-user-1", "temperature": 0.2},
|
||||
api_base="https://my-proxy.example.com/responses",
|
||||
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert url == "https://my-proxy.example.com/responses/compact"
|
||||
assert {key: data[key] for key in data if key != "input"} == {
|
||||
"model": "gpt-5.5",
|
||||
"metadata": {"ticket": "qa-1"},
|
||||
"user": "end-user-1",
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ from pydantic import JsonValue, TypeAdapter
|
|||
import litellm
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
|
||||
from litellm.llms.azure_ai.responses.transformation import AzureAIResponsesAPIConfig
|
||||
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
|
||||
from litellm.llms.hosted_vllm.responses.transformation import HostedVLLMResponsesAPIConfig
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.llms.xai.responses.transformation import XAIResponsesAPIConfig
|
||||
from litellm.responses.litellm_completion_transformation.transformation import LiteLLMCompletionResponsesConfig
|
||||
from litellm.types.llms.openai import (
|
||||
ImageGenerationPartialImageEvent,
|
||||
|
|
@ -4277,3 +4280,116 @@ def test_dict_responses_input_filters_unset_reasoning_fields() -> None:
|
|||
function_call_item: Final = validated_input[2]
|
||||
assert function_call_item["type"] == "function_call"
|
||||
assert function_call_item["status"] == "completed"
|
||||
|
||||
|
||||
_COMPACT_CONFIGS: Final = (
|
||||
pytest.param(OpenAIResponsesAPIConfig(), "https://api.openai.com/v1/responses", id="openai"),
|
||||
pytest.param(
|
||||
AzureOpenAIResponsesAPIConfig(),
|
||||
"https://x.openai.azure.com/openai/responses?api-version=2025-04-01-preview",
|
||||
id="azure",
|
||||
),
|
||||
pytest.param(AzureAIResponsesAPIConfig(), "https://x.services.ai.azure.com/openai/v1/responses", id="azure_ai"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config", "api_base"), _COMPACT_CONFIGS)
|
||||
def test_compact_request_omits_metadata_and_user_and_keeps_the_rest_without_drop_params(
|
||||
config: OpenAIResponsesAPIConfig, api_base: str
|
||||
) -> None:
|
||||
_, data = config.transform_compact_response_api_request(
|
||||
model="gpt-5.4-mini",
|
||||
input="hi",
|
||||
response_api_optional_request_params={
|
||||
"metadata": {"ticket": "qa-1"},
|
||||
"user": "end-user-1",
|
||||
"instructions": "be brief",
|
||||
"previous_response_id": "resp_1",
|
||||
"reasoning": {"effort": "low"},
|
||||
"temperature": 0.2,
|
||||
},
|
||||
api_base=api_base,
|
||||
litellm_params=GenericLiteLLMParams(drop_params=False),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert {key: data[key] for key in data if key != "input"} == {
|
||||
"model": "gpt-5.4-mini",
|
||||
"instructions": "be brief",
|
||||
"previous_response_id": "resp_1",
|
||||
"reasoning": {"effort": "low"},
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config", "api_base"), _COMPACT_CONFIGS)
|
||||
def test_compact_request_drops_params_compact_rejects_under_drop_params(
|
||||
config: OpenAIResponsesAPIConfig, api_base: str
|
||||
) -> None:
|
||||
_, data = config.transform_compact_response_api_request(
|
||||
model="gpt-5.4-mini",
|
||||
input="hi",
|
||||
response_api_optional_request_params={
|
||||
"metadata": {"ticket": "qa-1"},
|
||||
"user": "end-user-1",
|
||||
"temperature": 0.2,
|
||||
"store": False,
|
||||
"truncation": "auto",
|
||||
"max_output_tokens": 100,
|
||||
"tool_choice": "auto",
|
||||
"reasoning": {"effort": "low"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"text": {"format": {"type": "text"}},
|
||||
},
|
||||
api_base=api_base,
|
||||
litellm_params=GenericLiteLLMParams(drop_params=True),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert {key: data[key] for key in data if key != "input"} == {
|
||||
"model": "gpt-5.4-mini",
|
||||
"reasoning": {"effort": "low"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"text": {"format": {"type": "text"}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "api_base"),
|
||||
(
|
||||
pytest.param(XAIResponsesAPIConfig(), "https://api.x.ai/v1/responses", id="xai"),
|
||||
pytest.param(HostedVLLMResponsesAPIConfig(), "http://vllm.example.com/v1/responses", id="hosted_vllm"),
|
||||
),
|
||||
)
|
||||
def test_compact_request_forwards_every_param_for_providers_not_known_to_reject_them(
|
||||
config: OpenAIResponsesAPIConfig, api_base: str
|
||||
) -> None:
|
||||
_, data = config.transform_compact_response_api_request(
|
||||
model="some-model",
|
||||
input="hi",
|
||||
response_api_optional_request_params={"metadata": {"ticket": "qa-1"}, "user": "end-user-1", "temperature": 0.2},
|
||||
api_base=api_base,
|
||||
litellm_params=GenericLiteLLMParams(drop_params=True),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert {key: data[key] for key in data if key != "input"} == {
|
||||
"model": "some-model",
|
||||
"metadata": {"ticket": "qa-1"},
|
||||
"user": "end-user-1",
|
||||
"temperature": 0.2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("config", "api_base"), _COMPACT_CONFIGS)
|
||||
def test_compact_request_keeps_stream_under_drop_params(config: OpenAIResponsesAPIConfig, api_base: str) -> None:
|
||||
_, data = config.transform_compact_response_api_request(
|
||||
model="gpt-5.4-mini",
|
||||
input="hi",
|
||||
response_api_optional_request_params={"stream": True, "stream_options": {"include_obfuscation": False}},
|
||||
api_base=api_base,
|
||||
litellm_params=GenericLiteLLMParams(drop_params=True),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert {key: data[key] for key in data if key != "input"} == {"model": "gpt-5.4-mini", "stream": True}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue