fix(responses): drop the null created_by OpenAI rejects when output items are replayed (#48783)

* fix(responses): drop the null created_by OpenAI rejects when output items are replayed

* fix(responses): drop a null created_by from replayed input items too

A conversation saved from an older proxy still carries created_by: null on its compaction, apply_patch and shell items, so OpenAI keeps rejecting the replay. Drop the null from input items on the HTTP request transform and the WebSocket response.create rewrite, keeping a created_by that is not null.

* fix(responses): drop a null created_by from counted input items too

* test(responses): cover saved reasoning nulls and re-compaction of a saved conversation

* fix(responses): drop a set created_by from input under drop_params

* fix(types): type the Responses output serializer's parameters

* fix(responses): drop a set created_by on reasoning items and Perplexity presets under drop_params

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
tessellark 2026-10-28 21:11:49 -07:00 • committed by GitHub
parent b1b5d16f0b
commit dbaae521f9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 416 additions and 76 deletions

View file

@ -142,7 +142,9 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
return filtered_item
return item
def _validate_input_param(self, input: str | ResponseInputParam) -> str | ResponseInputParam:
def _validate_input_param(
self, input: str | ResponseInputParam, *, drops_set_created_by: 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.
@ -150,7 +152,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
from typing import cast
# First call parent's validation
validated_input: Final = super()._validate_input_param(input)
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):

View file

@ -22,7 +22,7 @@ from litellm.llms.anthropic.messages.responses_adapters.transformation import (
)
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.llms.anthropic import AnthropicMessagesRequest, AnthropicOutputSchema
from litellm.types.llms.openai import ResponsesAPIRequestParams
from litellm.types.llms.openai import ResponsesAPIRequestParams, replayable_responses_input
from litellm.types.utils import all_litellm_params
@ -387,10 +387,13 @@ def _forwards(field: str, policy: DropPolicy) -> bool:
return field in _COUNT_REQUEST_FIELDS or (not policy.drop_params and field in _INFERENCE_REQUEST_FIELDS)
def _restored(field: str, value: object) -> object:
def _restored(field: str, value: object, policy: DropPolicy) -> object:
match field, value:
case "input", _:
return ResponsesAPIRequestUtils.restore_encrypted_content_item_ids_in_input(copy.deepcopy(value))
return replayable_responses_input(
ResponsesAPIRequestUtils.restore_encrypted_content_item_ids_in_input(copy.deepcopy(value)),
drops_set_created_by=policy.drop_params,
)
case "previous_response_id", str():
return ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(value)
case _:
@ -398,5 +401,7 @@ def _restored(field: str, value: object) -> object:
def native_count_request(request: Mapping[str, object], model: str, policy: DropPolicy) -> Mapping[str, object]:
forwarded: Final = {field: _restored(field, value) for field, value in request.items() if _forwards(field, policy)}
forwarded: Final = {
field: _restored(field, value, policy) for field, value in request.items() if _forwards(field, policy)
}
return {**forwarded, "model": model}

View file

@ -108,7 +108,7 @@ class _DropParamsSetting(BaseModel):
drop_params: object = None
def _drops_params(litellm_params: GenericLiteLLMParams) -> bool:
def drops_params(litellm_params: GenericLiteLLMParams) -> bool:
setting: Final = _DropParamsSetting.model_validate(litellm_params)
return litellm.drop_params is True or normalize_drop_params(setting.drop_params) is True
@ -541,7 +541,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
litellm_params: GenericLiteLLMParams,
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))
validated_input: Final = self._validate_input_param(
self._drop_bridge_minted_reasoning_items(input), drops_set_created_by=drops_params(litellm_params)
)
marked_input: Final = (
self._input_with_prompt_cache_breakpoints(validated_input)
if self._reads_prompt_cache_breakpoints(
@ -582,7 +584,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
if tools is None or not any(is_anthropic_tool_search_chat_tool(tool) for tool in tools):
return tools
callers_own_search: Final = any(is_openai_tool_search_tool(tool) for tool in tools)
if callers_own_search or not _drops_params(litellm_params) or _runs_tool_search(model, tools):
if callers_own_search or not drops_params(litellm_params) or _runs_tool_search(model, tools):
return tools_with_anthropic_tool_search_mapped(tools)
verbose_logger.warning(
"Dropping tool search under drop_params: OpenAI model '%s' cannot run it or no tool is deferred, so every "
@ -775,7 +777,9 @@ 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) -> str | ResponseInputParam:
def _validate_input_param(
self, input: str | ResponseInputParam, *, drops_set_created_by: bool = False
) -> str | ResponseInputParam:
"""
Ensure all input fields if pydantic are converted to dict
@ -787,18 +791,20 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
for item in input:
# if it's pydantic, convert to dict
if isinstance(item, BaseModel):
validated_input.append(item.model_dump(exclude_none=True))
validated_input.append(
replayable_responses_item(
item.model_dump(exclude_none=True), drops_set_created_by=drops_set_created_by
)
)
elif isinstance(item, dict):
# Handle reasoning items specifically to filter out status=None
if item.get("type") == "reasoning":
verbose_logger.debug("Handling reasoning item: %s", item)
# Type assertion since we know it's a dict at this point
dict_item = cast(dict[str, object], item)
filtered_item = self._handle_reasoning_item(dict_item)
else:
# For other dict items, just pass through
filtered_item = cast(dict[str, object], item)
validated_input.append(filtered_item)
validated_input.append(
replayable_responses_item(
self._handle_reasoning_item(cast(dict[str, object], item))
if item.get("type") == "reasoning"
else item,
drops_set_created_by=drops_set_created_by,
)
)
else:
validated_input.append(item)
return validated_input

View file

@ -17,7 +17,7 @@ from pydantic import TypeAdapter
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.litellm_core_utils.url_utils import join_api_base
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig, drops_params
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.openai import ResponseInputParam, ResponsesAPIResponse
from litellm.types.router import GenericLiteLLMParams
@ -84,7 +84,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)
input = self._validate_input_param(input, drops_set_created_by=drops_params(litellm_params))
data: Final[dict] = {
"preset": model[len("preset/") :],
"input": input,

View file

@ -71,6 +71,7 @@ from litellm.types.llms.openai import (
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
ResponsesAPIStreamingResponse,
replayable_responses_input,
)
from litellm.types.responses.main import GenericResponseOutputItem, OutputText
from litellm.types.utils import CallTypes, ModelResponse
@ -2365,15 +2366,17 @@ def _ws_event_error(event: Mapping[str, object]) -> object:
return response.get("error") if _is_json_object(response) else None
def _restore_input_item_ids(items: Sequence[object]) -> Sequence[object]:
return ResponsesAPIRequestUtils.restore_encrypted_content_item_ids_in_input(copy.deepcopy(list(items)))
def _replayable_input_items(items: Sequence[object]) -> object:
return replayable_responses_input(
ResponsesAPIRequestUtils.restore_encrypted_content_item_ids_in_input(copy.deepcopy(list(items)))
)
def _restored_container_fields(container: Mapping[str, object]) -> Mapping[str, object]:
input_items: Final = container.get("input")
previous_response_id: Final = container.get("previous_response_id")
restored: Final = {
"input": _restore_input_item_ids(input_items) if _is_json_array(input_items) else input_items,
"input": _replayable_input_items(input_items) if _is_json_array(input_items) else input_items,
"previous_response_id": (
ResponsesAPIRequestUtils.decode_previous_response_id_to_original_previous_response_id(previous_response_id)
if isinstance(previous_response_id, str)

View file

@ -1,5 +1,5 @@
import builtins
from collections.abc import Iterable, Mapping
from collections.abc import Iterable, Mapping, Sequence
from enum import Enum
from os import PathLike
from typing import IO, TYPE_CHECKING, Any, Final, Generic, Literal, Optional, TypeAlias, Union
@ -66,6 +66,7 @@ from pydantic import (
ConfigDict,
Discriminator,
Field,
FieldSerializationInfo,
NonNegativeInt,
PrivateAttr,
SerializerFunctionWrapHandler,
@ -1487,15 +1488,66 @@ One of: completed, failed, in_progress, cancelled, queued, or incomplete.
"""
_NULL_DROPPED_OUTPUT_ITEM_KEYS: Final = frozenset(
(
("reasoning", "status"),
("reasoning", "content"),
("reasoning", "encrypted_content"),
("tool_search_call", "created_by"),
("tool_search_output", "created_by"),
)
)
_NULL_DROPPED_REASONING_ITEM_KEYS: Final = frozenset(("status", "content", "encrypted_content"))
def _as_json_object(value: object) -> Mapping[str, object] | None:
return value if isinstance(value, dict) else None
def _as_json_array(value: object) -> Sequence[object] | None:
return value if isinstance(value, list) else None
def _drops_created_by(value: object, *, drops_set_created_by: bool) -> bool:
return value is None or drops_set_created_by
def _drops(item_type: object, key: object, value: object, *, drops_set_created_by: bool) -> bool:
if key == "created_by":
return _drops_created_by(value, drops_set_created_by=drops_set_created_by)
return value is None and item_type == "reasoning" and key in _NULL_DROPPED_REASONING_ITEM_KEYS
def _without_created_by(entry: object, *, drops_set_created_by: bool) -> object:
entry_object: Final = _as_json_object(entry)
if entry_object is None:
return entry
return {
k: v
for k, v in entry_object.items()
if k != "created_by" or not _drops_created_by(v, drops_set_created_by=drops_set_created_by)
}
def _replayable_output_value(item_type: object, key: object, value: object, *, drops_set_created_by: bool) -> object:
entries: Final = _as_json_array(value)
if item_type != "shell_call_output" or key != "output" or entries is None:
return value
return [_without_created_by(entry, drops_set_created_by=drops_set_created_by) for entry in entries]
def replayable_responses_item(
item: Mapping[str, object], *, drops_set_created_by: bool = False
) -> Mapping[str, object]:
item_type: Final = item.get("type")
return {
k: _replayable_output_value(item_type, k, v, drops_set_created_by=drops_set_created_by)
for k, v in item.items()
if not _drops(item_type, k, v, drops_set_created_by=drops_set_created_by)
}
def replayable_responses_input(value: object, *, drops_set_created_by: bool = False) -> object:
items: Final = _as_json_array(value)
if items is None:
return value
return [
replayable_responses_item(obj, drops_set_created_by=drops_set_created_by)
if (obj := _as_json_object(item)) is not None
else item
for item in items
]
class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
@ -1570,34 +1622,16 @@ class ResponsesAPIResponse(BaseLiteLLMOpenAIResponseObject):
@field_serializer("output", mode="wrap")
@classmethod
def _serialize_output_filter_reasoning_nulls(cls, value, handler, _info):
def _serialize_output_as_replayable(
cls, value: builtins.object, handler: SerializerFunctionWrapHandler, _info: FieldSerializationInfo
):
"""
Filter null status/content/encrypted_content from reasoning output items.
Mirrors the request-side filtering in
OpenAIResponsesAPIConfig._handle_reasoning_item() which filters these
same fields before sending requests to providers.
Without this, reasoning items include null fields that cause SDK errors
(e.g., the OpenAI C# SDK crashes on status=null).
Issue: https://github.com/BerriAI/litellm/issues/16824
Drop the nulls the SDK types default in but providers never send: created_by on every output item
(OpenAI rejects it when `output` is replayed as `input`), and status/content/encrypted_content on
reasoning items (the OpenAI C# SDK crashes on status=null, issue #16824).
"""
serialized: Final = handler(value)
if not isinstance(serialized, list):
return serialized
return [
(
{
k: v
for k, v in item.items()
if v is not None or (item.get("type"), k) not in _NULL_DROPPED_OUTPUT_ITEM_KEYS
}
if isinstance(item, dict)
else item
)
for item in serialized
]
serialized: Final[object] = handler(value)
return replayable_responses_input(serialized)
@property
def output_text(self) -> str:

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import json
import os
import uuid
from types import MappingProxyType
from typing import Final
import openai
@ -65,6 +66,11 @@ def test_responses_compact_routes_forward_complete_request_and_typed_items(gatew
"call_id": "call_previous",
"output": "The record is ready.",
}
saved_compaction_item: Final = {
"id": f"cmp_{uuid.uuid4().hex}",
"type": "compaction",
"encrypted_content": "synthetic-saved-state",
}
requests: Final = (
{
"input": "Compact this conversation.",
@ -75,6 +81,20 @@ def test_responses_compact_routes_forward_complete_request_and_typed_items(gatew
"tools": [_FUNCTION_TOOL],
},
)
saved_compaction_request: Final = {
"input": [{**saved_compaction_item, "created_by": None}, {**function_output_item, "created_by": None}],
"instructions": "Compact the saved conversation again.",
}
forwarded_bodies: Final = MappingProxyType(
{
None: requests[0],
requests[1]["instructions"]: requests[1],
saved_compaction_request["instructions"]: {
**saved_compaction_request,
"input": [saved_compaction_item, function_output_item],
},
}
)
output_items: Final = [
{
"id": f"rs_compact_{uuid.uuid4().hex}",
@ -98,18 +118,7 @@ def test_responses_compact_routes_forward_complete_request_and_typed_items(gatew
assert request.method == "POST"
assert request.headers["authorization"] == f"Bearer {_API_KEY}", dict(request.headers)
body: Final = _JSON_OBJECT.validate_json(request.body)
compact_params: Final = (
{
"model": _MODEL,
**requests[0],
}
if body.get("input") == requests[0]["input"]
else {
"model": _MODEL,
**requests[1],
}
)
assert body == compact_params, request.body
assert body == {"model": _MODEL, **forwarded_bodies[body.get("instructions")]}, request.body
return Reply(
body=json.dumps(
{
@ -128,16 +137,20 @@ def test_responses_compact_routes_forward_complete_request_and_typed_items(gatew
).encode()
)
sent_bodies: Final = (*requests, saved_compaction_request)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = _deployment(scenario, wire)
for alias in _ALIASES:
for request_body in requests:
for request_body in sent_bodies:
response: Final = gateway.request(
"POST",
alias,
{"model": model, **request_body},
)
assert response.status_code == 200, response.text
wire_output: Final = _JSON_OBJECT.validate_json(response.content)["output"]
assert isinstance(wire_output, list), response.text
assert [item for item in wire_output if not isinstance(item, dict) or "created_by" in item] == []
compacted: Final = CompactedResponse.model_validate_json(response.content)
assert compacted.id != completed_id, compacted.model_dump_json()
assert same_response(compacted.id, completed_id), compacted.model_dump_json()
@ -201,7 +214,7 @@ def test_responses_compact_routes_forward_complete_request_and_typed_items(gatew
"user": None,
"store": None,
}, response.text
assert [request.target for request in drain_contract_requests(wire)] == ["/v1/responses/compact"] * 6
assert [request.target for request in drain_contract_requests(wire)] == ["/v1/responses/compact"] * (len(_ALIASES) * len(sent_bodies))
@pytest.mark.parametrize("prefix", ("/v1", "", "/openai/v1"))

View file

@ -5,8 +5,10 @@ import pytest
from litellm.llms.anthropic.messages.responses_adapters.transformation import (
LiteLLMAnthropicToResponsesAPIAdapter,
)
from litellm.litellm_core_utils.tool_fields import DropPolicy
from litellm.llms.openai.responses.count_tokens.transformation import (
OpenAICountTokensConfig,
native_count_request,
)
from tests.unit.litellm_core_utils.attribute_forwarder import AttributeForwarder
@ -610,3 +612,56 @@ def test_anthropic_list_system_becomes_joined_instruction_text() -> None:
)
assert instructions == "First rule.\nSecond rule."
def test_a_saved_conversation_with_a_null_created_by_is_counted_without_it() -> None:
compaction: Final = {"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA"}
tool_output: Final = {"type": "function_call_output", "call_id": "call_1", "output": "42"}
sent_created_by: Final = {
"type": "function_call_output",
"call_id": "call_2",
"output": "7",
"created_by": "user_1",
}
request: Final = {
"model": "gpt-5.6",
"input": [{**compaction, "created_by": None}, {**tool_output, "created_by": None}, sent_created_by],
}
policy: Final = DropPolicy(drop_params=False, additional_drop_params=frozenset())
body: Final = native_count_request(request, "gpt-5.6", policy)
assert body["input"] == [compaction, tool_output, sent_created_by]
def test_a_set_created_by_is_counted_without_it_under_drop_params() -> None:
tool_output: Final = {"type": "function_call_output", "call_id": "call_1", "output": "42"}
request: Final = {"model": "gpt-5.6", "input": [{**tool_output, "created_by": "user_1"}]}
policy: Final = DropPolicy(drop_params=True, additional_drop_params=frozenset())
body: Final = native_count_request(request, "gpt-5.6", policy)
assert body["input"] == [tool_output]
def test_a_text_input_is_counted_as_sent() -> None:
policy: Final = DropPolicy(drop_params=False, additional_drop_params=frozenset())
body: Final = native_count_request({"model": "gpt-5.6", "input": "Hello"}, "gpt-5.6", policy)
assert body["input"] == "Hello"
def test_a_reasoning_item_is_counted_without_its_null_fields() -> None:
reasoning: Final = {"id": "rs_1", "type": "reasoning", "summary": []}
request: Final = {
"model": "gpt-5.6",
"input": [{**reasoning, "status": None, "content": None, "encrypted_content": None}],
}
policy: Final = DropPolicy(drop_params=False, additional_drop_params=frozenset())
body: Final = native_count_request(request, "gpt-5.6", policy)
assert body["input"] == [reasoning]

View file

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
import respx
from openai.types.responses.response_compaction_item import ResponseCompactionItem
from pydantic import JsonValue, TypeAdapter
import litellm
@ -1376,6 +1377,80 @@ class TestOpenAIResponsesAPIConfig:
assert request["input"] == [expected, {"role": "user", "content": "And Berlin?"}]
@pytest.mark.parametrize(
("item", "expected"),
[
(
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA", "created_by": None},
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA"},
),
(
{
"type": "shell_call_output",
"call_id": "call_1",
"created_by": None,
"output": [{"stdout": "a.py", "stderr": "", "created_by": None}],
},
{"type": "shell_call_output", "call_id": "call_1", "output": [{"stdout": "a.py", "stderr": ""}]},
),
(
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA", "created_by": "user_1"},
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA", "created_by": "user_1"},
),
],
ids=("compaction", "shell-call-output-entries", "sent-created-by"),
)
def test_an_input_item_saved_with_a_null_created_by_replays_without_it(
self, item: dict[str, object], expected: dict[str, object]
):
request: Final = self.config.transform_responses_api_request(
model="gpt-5.6",
input=[item, {"role": "user", "content": "And Berlin?"}],
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert request["input"] == [expected, {"role": "user", "content": "And Berlin?"}]
@pytest.mark.parametrize(
("item", "expected"),
[
(
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA", "created_by": "user_1"},
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA"},
),
(
ResponseCompactionItem(id="cmp_1", type="compaction", encrypted_content="gAAAA", created_by="user_1"),
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA"},
),
(
{
"type": "shell_call_output",
"call_id": "call_1",
"created_by": "user_1",
"output": [{"stdout": "a.py", "stderr": "", "created_by": "user_1"}],
},
{"type": "shell_call_output", "call_id": "call_1", "output": [{"stdout": "a.py", "stderr": ""}]},
),
(
{"id": "rs_1", "type": "reasoning", "summary": [], "created_by": "user_1"},
{"id": "rs_1", "type": "reasoning", "summary": []},
),
],
ids=("compaction", "sdk-compaction-item", "shell-call-output-entries", "reasoning"),
)
def test_a_set_created_by_is_dropped_under_drop_params(self, item: object, expected: dict[str, object]):
request: Final = self.config.transform_responses_api_request(
model="gpt-5.6",
input=cast("list[dict[str, object]]", [item]), # cast-ok: an SDK item stands in for its JSON shape
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(drop_params=True),
headers={},
)
assert request["input"] == [expected]
class TestAzureResponsesAPIConfig:
def setup_method(self):
@ -1383,6 +1458,19 @@ class TestAzureResponsesAPIConfig:
self.model = "gpt-4o"
self.logging_obj = MagicMock()
def test_azure_drops_a_set_created_by_under_drop_params(self):
compaction: Final = {"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA"}
request: Final = self.config.transform_responses_api_request(
model=self.model,
input=[{**compaction, "created_by": "user_1"}],
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(drop_params=True),
headers={},
)
assert request["input"] == [compaction]
def test_azure_decodes_json_string_tool_parameters(self):
"""Azure reaches the same wire through `super()`, after un-nesting a chat-shaped tool."""
result = self.config.transform_responses_api_request(

View file

@ -20,6 +20,7 @@ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.perplexity.responses.transformation import PerplexityResponsesConfig
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
@ -362,6 +363,19 @@ class TestPerplexityResponsesTransformation:
assert data["input"][0]["type"] == "message"
assert data["input"][0]["role"] == "user"
def test_preset_drops_a_set_created_by_under_drop_params(self):
tool_output: Final = {"type": "function_call_output", "call_id": "call_1", "output": "sunny"}
data: Final = PerplexityResponsesConfig().transform_responses_api_request(
model="preset/pro-search",
input=[{**tool_output, "created_by": "user_1"}],
response_api_optional_request_params={},
litellm_params=GenericLiteLLMParams(drop_params=True),
headers={},
)
assert data["input"] == [tool_output]
def test_non_preset_list_input(self):
"""Non-preset with list input preserves type field"""
config = PerplexityResponsesConfig()

View file

@ -2197,6 +2197,48 @@ class TestNativeWebSocketDeploymentDefaults:
assert await handler._mask_response_create(cancel_frame) is cancel_frame
assert await handler._mask_response_create(complete_frame) is complete_frame
@pytest.mark.asyncio
@pytest.mark.parametrize("nested", (False, True), ids=("flat", "nested"))
async def test_saved_output_item_with_a_null_created_by_is_forwarded_without_it(self, nested: bool):
handler: Final = _make_streaming(authorized_model="gpt-5-pro")
compaction: Final = {"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA"}
user_turn: Final = {"role": "user", "content": "hi"}
request: Final = {"model": "gpt-5-pro", "input": [{**compaction, "created_by": None}, user_turn]}
frame: Final = (
{"type": "response.create", "response": request} if nested else {"type": "response.create", **request}
)
forwarded: Final = json.loads(await handler._mask_response_create(json.dumps(frame)))
assert (forwarded["response"] if nested else forwarded)["input"] == [compaction, user_turn]
@pytest.mark.asyncio
async def test_saved_reasoning_item_is_forwarded_without_its_null_fields(self):
handler: Final = _make_streaming(authorized_model="gpt-5-pro")
reasoning: Final = {"id": "rs_1", "type": "reasoning", "summary": []}
frame: Final = {
"type": "response.create",
"model": "gpt-5-pro",
"input": [{**reasoning, "status": None, "content": None, "encrypted_content": None}],
}
forwarded: Final = json.loads(await handler._mask_response_create(json.dumps(frame)))
assert forwarded["input"] == [reasoning]
@pytest.mark.asyncio
async def test_input_items_with_nothing_to_drop_pass_through_untouched(self):
handler: Final = _make_streaming(authorized_model="gpt-5-pro")
frame: Final = json.dumps(
{
"type": "response.create",
"model": "gpt-5-pro",
"input": [{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA", "created_by": "user_1"}],
}
)
assert await handler._mask_response_create(frame) is frame
@pytest.mark.asyncio
async def test_handler_applies_defaults_to_the_first_frame_sent_upstream(self):
import asyncio

View file

@ -1,4 +1,6 @@
import asyncio
from collections.abc import Mapping
from itertools import chain
from typing import Final, Optional
from unittest.mock import AsyncMock, patch
@ -494,6 +496,82 @@ class TestResponsesAPIReasoningNullFields:
assert dumped["instructions"] is None
_OUTPUT_ITEMS_OPENAI_SENDS_WITHOUT_CREATED_BY: Final = (
{"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA"},
{
"id": "apc_1",
"type": "apply_patch_call",
"call_id": "call_1",
"status": "completed",
"operation": {"type": "delete_file", "path": "a.py"},
},
{"id": "apco_1", "type": "apply_patch_call_output", "call_id": "call_1", "status": "completed"},
{
"id": "sh_1",
"type": "shell_call",
"call_id": "call_2",
"status": "completed",
"action": {"commands": ["ls"], "max_output_length": None, "timeout_ms": None},
},
{
"id": "sho_1",
"type": "shell_call_output",
"call_id": "call_2",
"status": "completed",
"output": [{"stdout": "a.py", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}],
},
{"id": "tsc_1", "type": "tool_search_call", "arguments": {}, "execution": "server", "status": "completed"},
{"id": "fco_1", "type": "function_call_output", "call_id": "call_3", "output": "42", "status": "completed"},
{
"id": "cco_1",
"type": "computer_call_output",
"call_id": "call_4",
"output": {"type": "computer_screenshot", "image_url": "data:image/png;base64,AAAA"},
"status": "completed",
},
)
def _created_by_paths_under_key(key: object, child: object, path: str) -> tuple[str, ...]:
return (f"{path}.{key}",) if key == "created_by" else _created_by_paths(child, f"{path}.{key}")
def _created_by_paths(value: object, path: str = "") -> tuple[str, ...]:
if isinstance(value, dict):
return tuple(chain.from_iterable(_created_by_paths_under_key(k, v, path) for k, v in value.items()))
if isinstance(value, list):
return tuple(chain.from_iterable(_created_by_paths(v, f"{path}[{i}]") for i, v in enumerate(value)))
return ()
@pytest.mark.parametrize("item", _OUTPUT_ITEMS_OPENAI_SENDS_WITHOUT_CREATED_BY, ids=lambda item: item["type"])
def test_output_item_replays_without_a_created_by_the_provider_never_sent(item: Mapping[str, object]):
response: Final = ResponsesAPIResponse.model_validate(
{"id": "resp_1", "created_at": 1741476542, "object": "response", "status": "completed", "output": [item]}
)
assert _created_by_paths(json.loads(response.model_dump_json())["output"]) == ()
assert _created_by_paths(response.model_dump()["output"]) == ()
def test_output_item_keeps_a_created_by_the_provider_sent():
item: Final = {"id": "cmp_1", "type": "compaction", "encrypted_content": "gAAAA", "created_by": "user_1"}
response: Final = ResponsesAPIResponse.model_validate(
{"id": "resp_1", "created_at": 1741476542, "object": "response", "status": "completed", "output": [item]}
)
assert json.loads(response.model_dump_json())["output"] == [item]
def test_shell_call_output_the_sdk_cannot_type_serializes_as_sent():
item: Final = {"id": "sho_1", "type": "shell_call_output", "output": ["a.py"]}
response: Final = ResponsesAPIResponse.model_validate(
{"id": "resp_1", "created_at": 1741476542, "object": "response", "status": "completed", "output": [item]}
)
assert json.loads(response.model_dump_json())["output"] == [item]
def test_normalize_fine_tuning_job_dict_maps_azure_pending():
from litellm.llms.openai.fine_tuning.handler import _normalize_fine_tuning_job_dict