mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-30 06:43:27 +00:00
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:
parent
b1b5d16f0b
commit
dbaae521f9
12 changed files with 416 additions and 76 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue