mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
Merge 9abdd8894f into 4086d5ccaa
This commit is contained in:
commit
de2bd88ee1
4 changed files with 194 additions and 20 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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=;"
|
||||
|
|
|
|||
|
|
@ -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."}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue