This commit is contained in:
Shikhar Goel 2026-11-06 09:33:32 +00:00 • committed by GitHub
commit de2bd88ee1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 194 additions and 20 deletions

View file

@ -86,6 +86,7 @@ from litellm.responses.litellm_completion_transformation.tool_search import is_s
from litellm.responses.litellm_completion_transformation.transformation import (
TOOL_CALL_OUTPUT_ITEM_TYPES,
LiteLLMCompletionResponsesConfig,
ResponsesInput,
tool_output_to_chat_content,
)
from litellm.types.llms.base import LiteLLMBaseModel
@ -588,13 +589,14 @@ def _grouped_by_call_id(pairs: Iterable[tuple[str, _Grouped]]) -> Mapping[str, t
def _texts_by_tool_row(
messages: Sequence[Mapping[str, object]], input_data: "str | ResponseInputParam"
messages: Sequence[Mapping[str, object]], input_data: ResponsesInput
) -> Mapping[int, tuple[str, ...]]:
if isinstance(input_data, str):
return MappingProxyType({})
input_items: Final = (input_data,) if isinstance(input_data, dict) else input_data
output_texts_by_call_id: Final = _grouped_by_call_id(
(str(stream_item_field(item, "call_id")), tuple(text for _, text in tool_output_texts(item)))
for item in input_data
for item in input_items
if stream_item_field(item, "type") in TOOL_CALL_OUTPUT_ITEM_TYPES
)
row_indices_by_call_id: Final = _grouped_by_call_id(
@ -748,10 +750,12 @@ class OpenAIResponsesHandler(BaseTranslation):
`instructions` into chat completion messages.
"""
input_data: Final[object] = data.get("input")
if not _is_responses_input(input_data):
if not isinstance(input_data, (dict, tuple)) and not _is_responses_input(input_data):
return None
messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=input_data,
input=cast( # cast-ok: runtime container shape is validated immediately above before Responses-specific parsing enforces each nested item type.
"ResponsesInput", input_data
),
responses_api_request=data,
)
return cast(list[AllMessageValues], messages) if messages else None
@ -767,10 +771,12 @@ class OpenAIResponsesHandler(BaseTranslation):
Handles both string input and list of message objects.
"""
raw_input: Final[str | ResponseInputParam | None] = data.get("input")
if not isinstance(raw_input, (str, list)):
raw_input: Final = data.get("input")
if not isinstance(raw_input, (str, dict, list, tuple)):
return data
input_data: Final = cast("str | ResponseInputParam", raw_input) # cast-ok: fields read via stream_item_field
input_data: Final = cast( # cast-ok: runtime container shape is validated immediately above before Responses-specific parsing enforces each nested item type.
"ResponsesInput", raw_input
)
skip_system: Final = effective_skip_system_message_for_guardrail(guardrail_to_apply)
skip_tool: Final = effective_skip_tool_message_for_guardrail(guardrail_to_apply)
scan_only: Final = effective_scan_only_tool_results_for_guardrail(guardrail_to_apply)
@ -846,7 +852,7 @@ class OpenAIResponsesHandler(BaseTranslation):
async def _apply_guardrailed_texts(
self,
data: dict[str, object],
input_data: "str | ResponseInputParam",
input_data: ResponsesInput,
extracted: _ExtractedInputs,
guardrail_to_apply: "CustomGuardrail",
guardrailed_inputs: GenericGuardrailAPIInputs,
@ -866,7 +872,7 @@ class OpenAIResponsesHandler(BaseTranslation):
data["input"] = input_texts[0] # rebind-ok: data is an out-param
return
await self._apply_guardrail_responses_to_input(
messages=input_data,
messages=(input_data,) if isinstance(input_data, dict) else input_data,
responses=input_texts,
task_mappings=extracted.task_mappings,
)
@ -874,7 +880,7 @@ class OpenAIResponsesHandler(BaseTranslation):
def _extract_guardrail_inputs(
self,
data: Mapping[str, object],
input_data: "str | ResponseInputParam",
input_data: ResponsesInput,
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
*,
skip_system: bool = False,
@ -898,7 +904,8 @@ class OpenAIResponsesHandler(BaseTranslation):
if not scan_only:
texts_to_check.append(input_data)
else:
for msg_idx, message in enumerate(input_data):
input_messages: Final = (input_data,) if isinstance(input_data, dict) else input_data
for msg_idx, message in enumerate(input_messages):
if role_out_of_guardrail_scope(
_input_item_role(message),
skip_system_message=skip_system,

View file

@ -15,6 +15,7 @@ from typing import (
Any,
Final,
Literal,
NoReturn,
Protocol,
TypeAlias,
TypeVar,
@ -28,14 +29,19 @@ from openai.types.chat.chat_completion_named_tool_choice_param import (
from openai.types.chat.chat_completion_named_tool_choice_param import (
Function as NamedToolChoiceFunction,
)
from openai.types.responses import ResponseFunctionToolCall, ResponseFunctionWebSearch, ResponseOutputRefusal
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseFunctionWebSearch,
ResponseInputItemParam,
ResponseOutputRefusal,
)
from openai.types.responses.response_compaction_item import ResponseCompactionItem
from openai.types.responses.response_create_params import ResponseInputParam
from openai.types.responses.tool_choice_custom_param import ToolChoiceCustomParam
from openai.types.responses.tool_choice_function_param import ToolChoiceFunctionParam
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
from typing_extensions import ReadOnly, TypedDict, assert_never
import litellm
from litellm._logging import verbose_logger
@ -190,6 +196,7 @@ NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]]
NamespaceTool: TypeAlias = Mapping[str, object]
ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool | AnthropicToolSearchToolRegex
ResponsesInput: TypeAlias = str | ResponseInputItemParam | ResponseInputParam | tuple[ResponseInputItemParam, ...]
NAMESPACE_DESCRIPTION_SEPARATOR: Final = "\n\n"
NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS: Final = frozenset({"function", "custom"})
_INCOMPLETE_REASON_BY_FINISH_REASON: Final[Mapping[str, Literal["max_output_tokens", "content_filter"]]] = (
@ -249,6 +256,14 @@ class ResponsesReasoningChatForm:
summary: str | None
@dataclass(frozen=True, slots=True)
class _InvalidResponseInputType:
input_type: str
_ResponseInputTransformError: TypeAlias = _InvalidResponseInputType
class _ResponsesFilePart(ChatCompletionFileObject, total=False):
cache_control: ReadOnly[object]
@ -735,6 +750,20 @@ def _without_keys(item: object, keys: frozenset[str]) -> object:
return {key: value for key, value in _STR_KEY_DICT_ADAPTER.validate_python(item).items() if key not in keys}
def _as_input_item_list(input: ResponsesInput | None) -> str | ResponseInputParam:
# A single input item, or a tuple of them, is read as the list that the compaction and
# cache_control steps and the per-item transform expect. A request without input has no
# input items (streamed usage estimates read such requests). Any other type is left for
# the transform to reject.
if input is None:
return []
if isinstance(input, dict):
return [input]
if isinstance(input, tuple):
return list(input)
return input
def _without_item_keys(input: str | ResponseInputParam, keys: frozenset[str]) -> str | ResponseInputParam:
if not keys or not isinstance(input, list):
return input
@ -1186,7 +1215,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def transform_responses_api_request_to_chat_completion_request(
model: str,
input: str | ResponseInputParam,
input: ResponsesInput,
responses_api_request: ResponsesAPIOptionalRequestParams,
custom_llm_provider: str | None = None,
stream: bool | None = None,
@ -1242,7 +1271,7 @@ class LiteLLMCompletionResponsesConfig:
drops_unsupported_params(request_kwargs.get("drop_params")),
routed_model_info(request_kwargs.get("model_info")),
)
request_input: Final = with_compaction_items_as_params(input)
request_input: Final = with_compaction_items_as_params(_as_input_item_list(input))
native_compaction: Final = _replays_compaction_block(
request_input, context_management
) and provider_takes_compaction_blocks(model, custom_llm_provider)
@ -1333,7 +1362,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def transform_responses_api_input_to_messages(
input: str | ResponseInputParam,
input: ResponsesInput | None,
responses_api_request: ResponsesAPIOptionalRequestParams | dict,
replay_reasoning: bool = False,
item_cache_control: bool = False,
@ -1392,7 +1421,7 @@ class LiteLLMCompletionResponsesConfig:
)
)
dumped_input: Final = with_compaction_items_as_params(input)
dumped_input: Final = with_compaction_items_as_params(_as_input_item_list(input))
read_input: Final = _input_to_read(dumped_input, only_since_compaction, native_compaction)
sent_input: Final = _without_item_keys(
read_input, _dropped_item_keys(item_cache_control, tool_call_provider_fields)
@ -1414,6 +1443,8 @@ class LiteLLMCompletionResponsesConfig:
if call_id_error is None:
raise
raise call_id_error from error
if isinstance(converted, _InvalidResponseInputType):
LiteLLMCompletionResponsesConfig._raise_response_input_transform_error(converted)
messages.extend(converted)
return messages
@ -1578,9 +1609,15 @@ class LiteLLMCompletionResponsesConfig:
input: str | ResponseInputParam,
replay_reasoning: bool = False,
native_compaction: bool = False,
) -> list[
AllMessageValues | GenericChatCompletionMessage | ChatCompletionMessageToolCall | ChatCompletionResponseMessage
]:
) -> (
list[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionMessageToolCall
| ChatCompletionResponseMessage
]
| _ResponseInputTransformError
):
"""
Transform a ResponseInputParam into a Chat Completion message
@ -1700,10 +1737,26 @@ class LiteLLMCompletionResponsesConfig:
continue
messages.extend(chat_completion_messages)
else:
return _InvalidResponseInputType(input_type=type(input).__name__)
if not replay_reasoning:
return messages
return LiteLLMCompletionResponsesConfig._merge_reasoning_only_assistant_messages(messages)
@staticmethod
def _raise_response_input_transform_error(error: _ResponseInputTransformError) -> NoReturn:
import litellm
match error:
case _InvalidResponseInputType(input_type=input_type):
raise litellm.BadRequestError(
message=f"Invalid input type: {input_type}. Expected str, dict, list, or tuple of message items.",
model="",
llm_provider="",
)
case _:
assert_never(error)
@staticmethod
def _reasoning_only_assistant_message(
reasoning_text: str | None,

View file

@ -321,6 +321,40 @@ class TestOpenAIResponsesHandlerInputProcessing:
assert result["input"][1]["content"] == "World [GUARDRAILED]"
assert result["model"] == "gpt-4"
@pytest.mark.asyncio
async def test_process_input_dict_with_string_content(self):
handler = OpenAIResponsesHandler()
guardrail = MockGuardrail(guardrail_name="test")
data = {
"input": {"role": "user", "content": "Hello", "type": "message"},
"model": "gpt-4",
}
result = await handler.process_input_messages(data, guardrail)
result_input = result["input"]
assert isinstance(result_input, dict)
assert result_input["content"] == "Hello [GUARDRAILED]"
@pytest.mark.asyncio
async def test_process_input_dict_tool_output_lists_its_parts_in_structured_messages(self) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
request: Final = {
"input": {
"type": "function_call_output",
"call_id": "call_1",
"output": [{"type": "input_text", "text": "first "}, {"type": "input_text", "text": "output"}],
},
"model": "gpt-4",
}
await OpenAIResponsesHandler().process_input_messages(request, guardrail)
assert guardrail.seen_texts == [["first ", "output"]]
assert guardrail.seen_message_contents == [
[[{"type": "text", "text": "first "}, {"type": "text", "text": "output"}]]
]
@pytest.mark.asyncio
async def test_compaction_summary_is_scanned_and_rewritten_inside_its_item(self) -> None:
affinity_prefix: Final = "litellm_enc:bW9kZWxfaWQ6ZGVwLWE=;"

View file

@ -6591,6 +6591,86 @@ def test_transform_request_reads_service_tier_from_responses_api_request() -> No
assert result["service_tier"] == "priority"
def test_transform_response_input_param_dict_input():
"""Verify that a single dictionary input item is correctly converted to a chat completion message."""
dict_input = {"type": "message", "role": "user", "content": "hello from dict"}
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=dict_input,
responses_api_request={},
)
assert len(messages) == 1
assert messages[0]["role"] == "user"
assert messages[0]["content"] == "hello from dict"
def test_transform_response_input_param_tuple_input():
"""Verify that tuple input items are accepted like list input items."""
tuple_input = (
{"type": "message", "role": "user", "content": "first"},
{"type": "message", "role": "user", "content": "second"},
)
messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=tuple_input,
responses_api_request={},
)
assert [message["content"] for message in messages] == ["first", "second"]
def test_transform_response_input_param_invalid_type_raises_bad_request():
"""Verify that passing invalid input types like integer raises BadRequestError."""
import litellm
with pytest.raises(litellm.BadRequestError) as exc_info:
LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=123,
responses_api_request={},
)
assert "Invalid input type" in str(exc_info.value)
assert "tuple" in str(exc_info.value)
def test_transform_response_input_param_single_item_drops_cache_control_like_list_input() -> None:
item: Final = {"type": "message", "role": "user", "content": "hi", "cache_control": {"type": "ephemeral"}}
as_item: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=item, responses_api_request={}
)
assert as_item == LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=[item], responses_api_request={}
)
assert as_item == [{"role": "user", "content": "hi"}]
def test_transform_response_input_param_tuple_drops_items_before_compaction_like_list_input() -> None:
summary: Final = {"type": "compaction", "content": "Earlier turns covered the Tokyo trip."}
items: Final = (
{"type": "message", "role": "user", "content": "Plan the Tokyo trip."},
{"type": "compaction", "id": "cmp_litellm", "encrypted_content": json.dumps(summary)},
{"type": "message", "role": "user", "content": "And Osaka?"},
)
as_tuple: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=items, responses_api_request={}, only_since_compaction=True
)
assert as_tuple == LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=list(items), responses_api_request={}, only_since_compaction=True
)
assert as_tuple == [
{"role": "user", "content": "Earlier turns covered the Tokyo trip."},
{"role": "user", "content": "And Osaka?"},
]
def test_transform_response_input_param_without_input_returns_only_instructions() -> None:
messages: Final = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages(
input=None, responses_api_request={"instructions": "Be brief."}
)
assert messages == [{"role": "system", "content": "Be brief."}]
_COMPACTION_BLOCK: Final = {"type": "compaction", "content": "Earlier turns covered the Tokyo trip."}