diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 93d79bb3ac8..b63ef78192c 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -2,6 +2,7 @@ Handler for transforming /chat/completions api requests to litellm.responses requests """ +import hashlib import json import os from collections.abc import AsyncIterator, Callable, Iterable, Iterator, Mapping, Sequence @@ -361,6 +362,17 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): # Unknown or unsupported type return None, index + @staticmethod + def _normalize_tool_call_id(tool_call_id: object) -> str | None: + if tool_call_id is None: + return None + tool_call_id_str: Final = str(tool_call_id) + if len(tool_call_id_str) <= 64: + return tool_call_id_str + prefix: Final = tool_call_id_str[:31] + digest: Final = hashlib.sha256(tool_call_id_str.encode("utf-8")).hexdigest()[:32] + return f"{prefix}_{digest}" + def convert_chat_completion_messages_to_responses_api( self, messages: list["AllMessageValues"] ) -> tuple[list[object], str | None]: @@ -374,6 +386,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if isinstance(tool_call, dict) and not tool_call.get("function") and isinstance(tool_call.get("custom"), dict) + and "id" in tool_call ) leading_system_count: Final = next( @@ -429,7 +442,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( ResponseCustomToolCallOutputParam( type="custom_tool_call_output", - call_id=tool_call_id, + call_id=self._normalize_tool_call_id(tool_call_id) or "", output=content if isinstance(content, str) else tool_output, ) ) @@ -437,7 +450,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( FunctionCallOutput( type="function_call_output", - call_id=tool_call_id, + call_id=self._normalize_tool_call_id(tool_call_id), output=tool_output, ) ) @@ -457,7 +470,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): if function: input_tool_call: dict[str, object] = { "type": "function_call", - "call_id": tool_call["id"], + "call_id": self._normalize_tool_call_id(tool_call.get("id")), } if "name" in function: input_tool_call["name"] = function["name"] @@ -468,7 +481,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( ResponseCustomToolCallParam( type="custom_tool_call", - call_id=tool_call["id"], + call_id=self._normalize_tool_call_id(tool_call.get("id")) or "", name=custom.get("name", ""), input=custom.get("input", ""), ) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index 25a3220792f..1fcb9c73ab0 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -4526,6 +4526,182 @@ def test_map_optional_params_verbosity_merges_into_text(): assert verbosity_only_request["text"] == {"verbosity": "low"} +@pytest.mark.parametrize( + "tool_call_id,is_custom", + [ + (None, False), + ("call_short_123", False), + ("call_" + "a" * 59, False), + ("call_" + "a" * 60, False), + ( + "call_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + False, + ), + ( + "custom_tool_call_id_exceeding_the_standard_responses_api_sixty_four_character_length_limit", + True, + ), + ], +) +def test_convert_chat_completion_messages_to_responses_api_normalizes_overlong_tool_call_ids( + tool_call_id: str | None, + is_custom: bool, +): + import hashlib + + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + expected_id: Final = ( + None + if tool_call_id is None + else tool_call_id + if len(tool_call_id) <= 64 + else f"{tool_call_id[:31]}_{hashlib.sha256(tool_call_id.encode('utf-8')).hexdigest()[:32]}" + ) + + handler: Final = LiteLLMResponsesTransformationHandler() + assistant_tool_call: Final[dict[str, object]] = ( + { + "id": tool_call_id, + "type": "custom", + "custom": {"name": "example_custom_tool", "input": "{}"}, + } + if is_custom + else { + "id": tool_call_id, + "type": "function", + "function": {"name": "example_tool", "arguments": "{}"}, + } + ) + + messages: Final[list[dict[str, object]]] = [ + {"role": "user", "content": "Run tool"}, + { + "role": "assistant", + "tool_calls": [assistant_tool_call], + }, + { + "role": "tool", + "tool_call_id": tool_call_id, + "content": "tool execution result", + }, + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + + if is_custom: + tool_call_item: Final = next( + item for item in input_items if isinstance(item, dict) and item.get("type") == "custom_tool_call" + ) + call_id: Final = tool_call_item.get("call_id") + else: + func_tool_call_item: Final = next( + item for item in input_items if isinstance(item, dict) and item.get("type") == "function_call" + ) + call_id = func_tool_call_item.get("call_id") + + assert call_id == expected_id + if call_id is not None: + assert len(str(call_id)) <= 64 + + if is_custom: + custom_output_item: Final = next( + item for item in input_items if isinstance(item, dict) and item.get("type") == "custom_tool_call_output" + ) + output_call_id: Final = custom_output_item.get("call_id") + else: + func_output_item: Final = next( + item for item in input_items if isinstance(item, dict) and item.get("type") == "function_call_output" + ) + output_call_id = func_output_item.get("call_id") + + assert output_call_id == expected_id + assert output_call_id == call_id + + +def test_convert_chat_completion_messages_to_responses_api_overlong_collision_resistance(): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler: Final = LiteLLMResponsesTransformationHandler() + id_1: Final = "call_" + "x" * 60 + "_1" + id_2: Final = "call_" + "x" * 60 + "_2" + + messages: Final[list[dict[str, object]]] = [ + { + "role": "assistant", + "tool_calls": [ + {"id": id_1, "type": "function", "function": {"name": "f1", "arguments": "{}"}}, + {"id": id_2, "type": "function", "function": {"name": "f2", "arguments": "{}"}}, + ], + }, + {"role": "tool", "tool_call_id": id_1, "content": "res1"}, + {"role": "tool", "tool_call_id": id_2, "content": "res2"}, + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + calls: Final = [item for item in input_items if isinstance(item, dict) and item.get("type") == "function_call"] + outputs: Final = [ + item for item in input_items if isinstance(item, dict) and item.get("type") == "function_call_output" + ] + + assert len(calls) == 2 + assert len(outputs) == 2 + assert calls[0]["call_id"] != calls[1]["call_id"] + assert calls[0]["call_id"] == outputs[0].get("call_id") + assert calls[1]["call_id"] == outputs[1].get("call_id") + assert len(str(calls[0]["call_id"])) <= 64 + assert len(str(calls[1]["call_id"])) <= 64 + + +def test_convert_chat_completion_messages_to_responses_api_mixed_custom_and_function_output_types(): + import hashlib + + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + handler: Final = LiteLLMResponsesTransformationHandler() + custom_raw_id: Final = "custom_call_" + "y" * 60 + function_raw_id: Final = f"{custom_raw_id[:31]}_{hashlib.sha256(custom_raw_id.encode('utf-8')).hexdigest()[:32]}" + + messages: Final[list[dict[str, object]]] = [ + { + "role": "assistant", + "tool_calls": [ + { + "id": custom_raw_id, + "type": "custom", + "custom": {"name": "c_tool", "input": "{}"}, + }, + { + "id": function_raw_id, + "type": "function", + "function": {"name": "f_tool", "arguments": "{}"}, + }, + ], + }, + {"role": "tool", "tool_call_id": custom_raw_id, "content": "custom_res"}, + {"role": "tool", "tool_call_id": function_raw_id, "content": "func_res"}, + ] + + input_items, _ = handler.convert_chat_completion_messages_to_responses_api(messages) + custom_outputs: Final = [ + item for item in input_items if isinstance(item, dict) and item.get("type") == "custom_tool_call_output" + ] + function_outputs: Final = [ + item for item in input_items if isinstance(item, dict) and item.get("type") == "function_call_output" + ] + + assert len(custom_outputs) == 1 + assert len(function_outputs) == 1 + assert custom_outputs[0].get("call_id") == function_raw_id + assert function_outputs[0].get("call_id") == function_raw_id + + def test_response_completed_carries_the_served_service_tier(): from litellm.completion_extras.litellm_responses_transformation.transformation import ( OpenAiResponsesToChatCompletionStreamIterator, @@ -4560,3 +4736,4 @@ def test_every_bridged_chunk_after_response_created_carries_the_served_service_t relayed = [iterator.chunk_parser(event).model_dump().get("service_tier") for event in events] assert relayed == ["default"] * len(events), relayed +