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:
simonp9 2026-10-28 19:46:36 -07:00 • committed by GitHub
parent 4fc2bba1f2
commit 83ae1bd63e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 241 additions and 27 deletions

View file

@ -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"}

View file

@ -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 ##############
#########################################################

View file

@ -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

View file

@ -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 = (

View file

@ -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

View file

@ -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,
}

View file

@ -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}