diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index cdeeef1d96c..eb6d103efb9 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -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): diff --git a/litellm/llms/openai/responses/count_tokens/transformation.py b/litellm/llms/openai/responses/count_tokens/transformation.py index b4e152ff406..a45a0448326 100644 --- a/litellm/llms/openai/responses/count_tokens/transformation.py +++ b/litellm/llms/openai/responses/count_tokens/transformation.py @@ -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} diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 033326e653f..64b2e61130d 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -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 diff --git a/litellm/llms/perplexity/responses/transformation.py b/litellm/llms/perplexity/responses/transformation.py index 77817a5e5bb..58f71028451 100644 --- a/litellm/llms/perplexity/responses/transformation.py +++ b/litellm/llms/perplexity/responses/transformation.py @@ -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, diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 5060e03f040..6c57bfd2a3f 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -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) diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 8e1db87c16f..6ce51abcd8a 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -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: diff --git a/tests/integration/providers/test_responses_compact_wire.py b/tests/integration/providers/test_responses_compact_wire.py index 4da3f411cb5..88fbec27c07 100644 --- a/tests/integration/providers/test_responses_compact_wire.py +++ b/tests/integration/providers/test_responses_compact_wire.py @@ -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")) diff --git a/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py b/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py index d5e7bb4f93b..575183772f9 100644 --- a/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py +++ b/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py @@ -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] diff --git a/tests/unit/llms/openai/responses/test_openai_responses_transformation.py b/tests/unit/llms/openai/responses/test_openai_responses_transformation.py index 66e23115f3e..48bb0955f34 100644 --- a/tests/unit/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/unit/llms/openai/responses/test_openai_responses_transformation.py @@ -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( diff --git a/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py b/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py index b6d26aa1de6..0247ffc361d 100644 --- a/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py +++ b/tests/unit/llms/perplexity/responses/test_perplexity_responses_transformation.py @@ -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() diff --git a/tests/unit/responses/test_responses_websocket_all_providers.py b/tests/unit/responses/test_responses_websocket_all_providers.py index bfb2e16e1a7..ab52e6d29c3 100644 --- a/tests/unit/responses/test_responses_websocket_all_providers.py +++ b/tests/unit/responses/test_responses_websocket_all_providers.py @@ -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 diff --git a/tests/unit/types/llms/test_types_llms_openai.py b/tests/unit/types/llms/test_types_llms_openai.py index 6d5f8fabaed..1b7a9693f7b 100644 --- a/tests/unit/types/llms/test_types_llms_openai.py +++ b/tests/unit/types/llms/test_types_llms_openai.py @@ -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