fix(azure): strip output-only reasoning content from Responses input (#50046)

* fix(azure): strip output-only reasoning content from Responses input

* test(azure): cover idless encrypted reasoning replay

* fix(azure): preserve existing status handling during reasoning replay

* fix(azure): scope reasoning content filtering by provider

* fix(azure): drop status and content from replayed reasoning items

* fix(azure): forward non-object input items and drop reasoning content on every Azure route

* fix(azure): drop replayed reasoning content only under drop_params

Reasoning status is still dropped on every Azure route. Caller-set reasoning
content now follows the created_by convention from #48783: it is dropped when
drop_params is on and forwarded otherwise, so Azure answers its own 400.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
qibinjin 2026-11-05 09:05:03 +08:00 • committed by GitHub
parent 8404bc094c
commit 91f039f4e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 299 additions and 70 deletions

View file

@ -3,11 +3,10 @@ from collections.abc import Callable, Mapping
from copy import deepcopy
from functools import partial
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal
from typing import TYPE_CHECKING, Any, Final, Literal, cast
from urllib.parse import urlsplit
import httpx
from openai.types.responses import ResponseReasoningItem
from pydantic import TypeAdapter
from litellm._logging import verbose_logger
@ -16,6 +15,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import prompt_cach
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
from litellm.llms.azure.chat.gpt_reasoning_transformation import AzureOpenAIGPT5Config
from litellm.llms.azure.common_utils import BaseAzureLLM
from litellm.llms.azure.ptu_capacity import is_str_keyed_mapping
from litellm.llms.base_llm.responses.transformation import SUMMARY_ONLY_REASONING_FIELD_VALUES, ReasoningFieldValues
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.types.llms.openai import *
@ -72,6 +72,21 @@ def _with_responses_api_version(api_base: str | None, litellm_params: Mapping[st
return MappingProxyType({**litellm_params, "api_version": _resolve_responses_api_version(api_base, litellm_params)})
_AZURE_REJECTED_INPUT_FIELDS: Final = MappingProxyType(
{"message": frozenset({"status"}), "reasoning": frozenset({"status"})}
)
_AZURE_REJECTED_INPUT_FIELDS_UNDER_DROP_PARAMS: Final = MappingProxyType(
{**_AZURE_REJECTED_INPUT_FIELDS, "reasoning": frozenset({"status", "content"})}
)
def _without_fields_azure_rejects(item: object, rejected_fields: Mapping[str, frozenset[str]]) -> object:
if not is_str_keyed_mapping(item):
return item
rejected: Final = rejected_fields.get(str(item.get("type")), frozenset())
return {key: value for key, value in item.items() if key not in rejected} if rejected else item
class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
# Parameters not supported by Azure Responses API
AZURE_UNSUPPORTED_PARAMS = ["context_management"]
@ -122,68 +137,17 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
def get_stripped_model_name(self, model: str) -> str:
return model.replace("responses/", "").replace("o_series/", "").replace("azure_ai/", "")
def _handle_reasoning_item(self, item: dict[str, Any]) -> Mapping[str, object]:
"""
Handle reasoning items to filter out the status field.
Issue: https://github.com/BerriAI/litellm/issues/13484
Azure OpenAI API does not accept 'status' field in reasoning input items.
"""
if item.get("type") == "reasoning":
try:
# Ensure required fields are present for ResponseReasoningItem
item_data: Final = dict(item)
if "summary" not in item_data:
item_data["summary"] = (
item_data.get("reasoning_content", "")[:100] + "..."
if len(item_data.get("reasoning_content", "")) > 100
else item_data.get("reasoning_content", "")
)
# Create ResponseReasoningItem object from the item data
reasoning_item: Final = ResponseReasoningItem.model_validate(item_data)
# Convert back to dict with exclude_none=True to exclude None fields
dict_reasoning_item: Final = reasoning_item.model_dump(exclude_none=True)
dict_reasoning_item.pop("status", None)
return dict_reasoning_item
except Exception as e:
verbose_logger.debug("Failed to create ResponseReasoningItem, falling back to manual filtering: %s", e)
# Fallback: manually filter out known None fields
filtered_item: Final = {
k: v
for k, v in item.items()
if v is not None or k not in {"status", "content", "encrypted_content"}
}
return filtered_item
return item
def _validate_input_param(
self, input: str | ResponseInputParam, *, drops_set_created_by: bool = False
self, input: str | ResponseInputParam, *, drop_params: bool = False
) -> str | ResponseInputParam:
"""
Override parent method to also filter out 'status' field from message items.
Azure OpenAI API does not accept 'status' field in input messages.
"""
from typing import cast
# First call parent's validation
validated_input: Final = super()._validate_input_param(input, drops_set_created_by=drops_set_created_by)
# Then filter out status from message items
if isinstance(validated_input, list):
filtered_input: Final[list[object]] = []
for item in validated_input:
if isinstance(item, dict) and item.get("type") == "message":
# Filter out status field from message items
filtered_item = {k: v for k, v in item.items() if k != "status"}
filtered_input.append(filtered_item)
else:
filtered_input.append(item)
return cast(ResponseInputParam, filtered_input)
return validated_input
validated_input: Final = super()._validate_input_param(input, drop_params=drop_params)
if not isinstance(validated_input, list):
return validated_input
rejected_fields: Final = (
_AZURE_REJECTED_INPUT_FIELDS_UNDER_DROP_PARAMS if drop_params else _AZURE_REJECTED_INPUT_FIELDS
)
sanitized_items: Final = [_without_fields_azure_rejects(item, rejected_fields) for item in validated_input]
return cast("ResponseInputParam", sanitized_items) # cast-ok: items keep their shape minus rejected fields
def transform_responses_api_request(
self,

View file

@ -669,7 +669,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
prompt_cache_options: object = None,
) -> tuple[str | ResponseInputParam, Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None]:
validated_input: Final = self._validate_input_param(
self._drop_bridge_minted_reasoning_items(input), drops_set_created_by=drops_params(litellm_params)
self._drop_bridge_minted_reasoning_items(input), drop_params=drops_params(litellm_params)
)
reads_breakpoints: Final = self._reads_prompt_cache_breakpoints(
model=model, litellm_params=litellm_params, prompt_cache_options=prompt_cache_options
@ -951,7 +951,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
return tools if all(new is old for new, old in zip(sanitized, tools, strict=True)) else sanitized
def _validate_input_param(
self, input: str | ResponseInputParam, *, drops_set_created_by: bool = False
self, input: str | ResponseInputParam, *, drop_params: bool = False
) -> str | ResponseInputParam:
"""
Ensure all input fields if pydantic are converted to dict
@ -965,9 +965,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
# if it's pydantic, convert to dict
if isinstance(item, BaseModel):
validated_input.append(
replayable_responses_item(
item.model_dump(exclude_none=True), drops_set_created_by=drops_set_created_by
)
replayable_responses_item(item.model_dump(exclude_none=True), drops_set_created_by=drop_params)
)
elif isinstance(item, dict):
validated_input.append(
@ -975,7 +973,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
self._handle_reasoning_item(cast(dict[str, object], item))
if item.get("type") == "reasoning"
else item,
drops_set_created_by=drops_set_created_by,
drops_set_created_by=drop_params,
)
)
else:

View file

@ -89,7 +89,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
"""Handle preset/ model prefix: send as {"preset": name} instead of {"model": name}."""
input = self._ensure_message_type(input)
if model.startswith("preset/"):
input = self._validate_input_param(input, drops_set_created_by=drops_params(litellm_params))
input = self._validate_input_param(input, drop_params=drops_params(litellm_params))
data: Final[dict] = {
"preset": model[len("preset/") :],
"input": input,

View file

@ -7,15 +7,18 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
import respx
from openai.types.responses import ResponseReasoningItem
from pydantic import JsonValue, TypeAdapter
import litellm
from litellm.constants import AZURE_DEFAULT_RESPONSES_API_VERSION
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.azure.responses.o_series_transformation import (
AzureOpenAIOSeriesResponsesAPIConfig,
)
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
from litellm.llms.azure_ai.responses.transformation import AzureAIAgentResponsesAPIConfig, AzureAIResponsesAPIConfig
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
@ -1547,3 +1550,267 @@ def test_openai_gpt5_resolves_to_responses_config_with_temperature() -> None:
config: Final = ProviderConfigManager.get_provider_responses_api_config(provider=LlmProviders.OPENAI, model="gpt-5")
assert type(config) is OpenAIResponsesAPIConfig
assert "temperature" in config.get_supported_openai_params("gpt-5")
@pytest.mark.parametrize(
("config", "model"),
[
(AzureOpenAIResponsesAPIConfig(), "gpt-6.1-sol"),
(AzureOpenAIOSeriesResponsesAPIConfig(), "gpt-6.1-sol"),
(AzureAIResponsesAPIConfig(), "gpt-6.1-sol"),
(AzureAIAgentResponsesAPIConfig(), "agents/replay-test"),
],
ids=["azure", "o_series", "azure_ai", "azure_ai_agent"],
)
@pytest.mark.parametrize("as_model", [False, True], ids=["dict", "pydantic"])
@pytest.mark.parametrize("drop_params", [True, False], ids=["drop-params", "no-drop-params"])
def test_azure_reasoning_replay_removes_output_content_only_under_drop_params_without_changing_history(
config: AzureOpenAIResponsesAPIConfig, model: str, as_model: bool, drop_params: bool
) -> None:
replayable_reasoning: Final = {
"id": "rs_saved_reasoning",
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "Check the file before changing it."}],
"encrypted_content": "opaque-saved-reasoning",
}
saved_reasoning: Final = {
**replayable_reasoning,
"content": [{"type": "reasoning_text", "text": "I should inspect the file first."}],
}
tool_call: Final = {
"type": "function_call",
"id": "fc_read",
"call_id": "call_read",
"name": "read_file",
"arguments": '{"path":"main.py"}',
}
tool_result: Final = {"type": "function_call_output", "call_id": "call_read", "output": "print(1)"}
answer: Final = {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "The file prints 1.", "annotations": []}],
}
prompt: Final = {"role": "user", "content": "Continue."}
history: Final = [
ResponseReasoningItem.model_validate(saved_reasoning) if as_model else saved_reasoning,
tool_call,
tool_result,
answer,
prompt,
]
original: Final = deepcopy(history)
request: Final = config.transform_responses_api_request(
model=model,
input=history,
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
headers={},
)
sent_reasoning: Final = replayable_reasoning if drop_params else saved_reasoning
assert request["input"] == [sent_reasoning, tool_call, tool_result, answer, prompt]
assert history == original
@pytest.mark.parametrize("as_model", [False, True], ids=["dict", "pydantic"])
def test_openai_replay_without_drop_params_keeps_reasoning_content_and_message_status(as_model: bool) -> None:
saved_reasoning: Final = {
"id": "rs_saved_reasoning",
"type": "reasoning",
"summary": [],
"content": [{"type": "reasoning_text", "text": "Inspect the file."}],
"encrypted_content": "opaque-saved-reasoning",
}
saved_message: Final = {
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "The file is ready.", "annotations": []}],
}
history: Final = [
ResponseReasoningItem.model_validate(saved_reasoning) if as_model else saved_reasoning,
saved_message,
]
original: Final = deepcopy(history)
request: Final = OpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-6.1-sol",
input=history,
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert request["input"] == [saved_reasoning, saved_message]
assert history == original
@pytest.mark.parametrize(
"config",
[AzureOpenAIResponsesAPIConfig(), AzureOpenAIOSeriesResponsesAPIConfig(), AzureAIResponsesAPIConfig()],
ids=["azure", "o_series", "azure_ai"],
)
def test_azure_family_forwards_input_items_that_are_not_objects_for_the_provider_to_judge(
config: AzureOpenAIResponsesAPIConfig,
) -> None:
saved_reasoning: Final = {"type": "reasoning", "summary": [], "status": "completed", "encrypted_content": "opaque"}
request: Final = config.transform_responses_api_request(
model="gpt-6.1-sol",
input=["Continue.", saved_reasoning, ["nested"]],
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert request["input"] == [
"Continue.",
{"type": "reasoning", "summary": [], "encrypted_content": "opaque"},
["nested"],
]
@pytest.mark.asyncio
@pytest.mark.usefixtures(httpx_transport.__name__)
@pytest.mark.parametrize("async_call", [False, True], ids=["sync", "async"])
@pytest.mark.parametrize("stream", [False, True])
@pytest.mark.parametrize(("reasoning_index", "include_id"), [(51, True), (10, False)], ids=["with-id", "without-id"])
async def test_azure_responses_replays_reasoning_history_without_fields_azure_rejects(
respx_mock: respx.MockRouter, stream: bool, async_call: bool, reasoning_index: int, include_id: bool
) -> None:
prefix: Final = [{"role": "user", "content": f"Earlier context {index}."} for index in range(reasoning_index)]
replayable_reasoning: Final = {
"type": "reasoning",
"summary": [],
"encrypted_content": "opaque-saved-reasoning",
**({"id": "rs_saved_reasoning"} if include_id else {}),
}
saved_reasoning: Final = {
**replayable_reasoning,
"status": "completed",
"content": [{"type": "reasoning_text", "text": "Inspect the file before editing."}],
}
answer: Final = {
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": "Ready to continue.", "annotations": []}],
}
prompt: Final = {"role": "user", "content": "Continue."}
replayable_followup: Final = {
"type": "reasoning",
"summary": [],
"encrypted_content": "opaque-followup-reasoning",
**({"id": "rs_followup_reasoning"} if include_id else {}),
}
saved_followup: Final = {
**replayable_followup,
"status": "completed",
"content": [{"type": "reasoning_text", "text": "Check the next file."}],
}
history: Final = [*prefix, saved_reasoning, answer, saved_followup, prompt]
original: Final = deepcopy(history)
def respond(request: httpx.Request) -> httpx.Response:
body: Final = TypeAdapter(dict[str, JsonValue]).validate_json(request.content)
items: Final = body["input"]
assert isinstance(items, list)
reasoning: Final = items[reasoning_index]
assert isinstance(reasoning, dict)
if "status" in reasoning:
return httpx.Response(
400,
json={
"error": {
"message": f"Unknown parameter: 'input[{reasoning_index}].status'.",
"type": "invalid_request_error",
"param": f"input[{reasoning_index}].status",
"code": "unknown_parameter",
}
},
)
if reasoning.get("content"):
return httpx.Response(
400,
json={
"error": {
"message": f"Invalid 'input[{reasoning_index}].content': array too long. "
"Expected an array with maximum length 0, "
"but got an array with length 1 instead.",
"type": "invalid_request_error",
"param": f"input[{reasoning_index}].content",
"code": "array_above_max_length",
}
},
)
return httpx.Response(
200,
text=_COMPLETED_RESPONSE_SSE if stream else json.dumps(_COMPLETED_RESPONSE),
headers={"content-type": "text/event-stream" if stream else "application/json"},
)
route: Final = respx_mock.post(host="example-resource.openai.azure.com").mock(side_effect=respond)
request_params: Final = {
"model": "azure/gpt-6.1-sol",
"input": history,
"api_base": _AZURE_RESOURCE,
"api_key": "azure-test-key",
"stream": stream,
"store": False,
"include": ["reasoning.encrypted_content"],
"drop_params": True,
}
response: Final = await litellm.aresponses(**request_params) if async_call else litellm.responses(**request_params)
deltas: Final = (
(
[event.delta async for event in response if event.type == "response.output_text.delta"]
if async_call
else [event.delta for event in response if event.type == "response.output_text.delta"]
)
if stream
else []
)
text: Final = "".join(deltas) if stream else response.output[0].content[0].text
assert text == "Done."
assert route.call_count == 1
assert TypeAdapter(dict[str, JsonValue]).validate_json(route.calls[0].request.content)["input"] == [
*prefix,
replayable_reasoning,
answer,
replayable_followup,
prompt,
]
assert history == original
@pytest.mark.parametrize(
("as_model", "include_id"),
[(False, True), (False, False), (True, True)],
ids=["dictionary-with-id", "dictionary-without-id", "pydantic"],
)
@pytest.mark.parametrize("drop_params", [True, False], ids=["drop-params", "no-drop-params"])
def test_azure_reasoning_replay_drops_status_always_and_content_under_drop_params(
as_model: bool, include_id: bool, drop_params: bool
) -> None:
replayable: Final = {
"type": "reasoning",
"summary": [],
"encrypted_content": "opaque-saved-reasoning",
**({"id": "rs_saved_reasoning"} if include_id else {}),
}
saved_content: Final = [{"type": "reasoning_text", "text": "Inspect the file first."}]
saved_reasoning: Final = {**replayable, "status": "completed", "content": saved_content}
history: Final = [ResponseReasoningItem.model_validate(saved_reasoning) if as_model else saved_reasoning]
original: Final = deepcopy(history)
request: Final = AzureOpenAIResponsesAPIConfig().transform_responses_api_request(
model="gpt-6.1-sol",
input=history,
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
headers={},
)
assert request["input"] == [replayable if drop_params else {**replayable, "content": saved_content}]
assert history == original