From 038b826d95b215cf8db2ccef68e97fb0a3c5cd32 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Sun, 18 Oct 2026 01:48:14 +0000 Subject: [PATCH] fix(responses): normalize overlong tool call IDs in chat-to-responses bridge --- .../transformation.py | 34 +++- ...responses_transformation_transformation.py | 177 ++++++++++++++++++ 2 files changed, 207 insertions(+), 4 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 98322aecf6a..fb28ffe95b5 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 @@ -796,6 +797,16 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): # Unknown or unsupported type return None, index + @staticmethod + def _normalize_tool_call_id(tool_call_id: str | None) -> str | None: + if tool_call_id is None: + return None + if len(tool_call_id) <= 64: + return tool_call_id + prefix: Final = tool_call_id[:31] + digest: Final = hashlib.sha256(tool_call_id.encode("utf-8")).hexdigest()[:32] + return f"{prefix}_{digest}" + def convert_chat_completion_messages_to_responses_api( self, messages: list["AllMessageValues"], @@ -836,6 +847,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( @@ -893,7 +905,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( ResponseCustomToolCallOutputParam( type="custom_tool_call_output", - call_id=tool_call_id, + call_id=( + self._normalize_tool_call_id(f"{tool_call_id}") or "" + if tool_call_id is not None + else "" + ), output=content if isinstance(content, str) else tool_output, ) ) @@ -901,7 +917,9 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( FunctionCallOutput( type="function_call_output", - call_id=tool_call_id, + call_id=( + self._normalize_tool_call_id(f"{tool_call_id}") if tool_call_id is not None else None + ), output=tool_output, ) ) @@ -923,7 +941,11 @@ 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(f"{tool_call['id']}") + if "id" in tool_call and tool_call["id"] is not None + else None + ), } if "name" in function: input_tool_call["name"] = function["name"] @@ -938,7 +960,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( ResponseCustomToolCallParam( type="custom_tool_call", - call_id=tool_call["id"], + call_id=( + self._normalize_tool_call_id(f"{tool_call['id']}") or "" + if "id" in tool_call and tool_call["id"] is not None + else "" + ), 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 9aa494c1aab..637d5d3825a 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 @@ -6060,3 +6060,180 @@ def test_stream_structured_output_drops_text_after_first_message_item( content: Final = "".join(chunk.choices[0].delta.content or "" for chunk in chunks) assert content == expected_content assert chunks[-1].choices[0].finish_reason == "stop" + + +@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 +