From cec0fc3e8e0b4ab2ef510ecce3a80f2ef08ba1d0 Mon Sep 17 00:00:00 2001 From: agustin18 Date: Thu, 24 Sep 2026 02:52:52 +0000 Subject: [PATCH] fix(responses): normalize overlong tool call IDs in chat-to-responses bridge --- .../transformation.py | 39 ++++- ...responses_transformation_transformation.py | 139 ++++++++++++++++++ 2 files changed, 172 insertions(+), 6 deletions(-) diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 31af5a144eb..05c0b796633 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 @@ -357,19 +358,42 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): # Unknown or unsupported type return None, index + @staticmethod + def _normalize_tool_call_id(tool_call_id: object) -> str | None: + """Normalize tool call ID to ensure it does not exceed 64 characters. + + Responses API downstream providers (e.g. OpenAI Responses API, AWS Bedrock) + enforce a 64-character limit on ``call_id``. IDs within the limit are preserved + verbatim. Overlong IDs are deterministically mapped to the first 31 characters + of the ID followed by '_' and a 32-character SHA-256 digest of the full ID + (31 + 1 + 32 = 64 characters), preserving readability and guaranteeing collision resistance. + """ + 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]: input_items: Final[list[object]] = [] instructions: str | None = None custom_tool_call_ids: Final = frozenset( - tool_call["id"] + ident for msg in messages if msg.get("role") == "assistant" and isinstance(msg.get("tool_calls"), list) for tool_call in msg.get("tool_calls") or () if isinstance(tool_call, dict) and not tool_call.get("function") and isinstance(tool_call.get("custom"), dict) + for raw_id in (tool_call.get("id"),) + if raw_id is not None + for ident in (raw_id, self._normalize_tool_call_id(raw_id)) + if ident is not None ) leading_system_count: Final = next( @@ -421,11 +445,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): else: # Fallback: convert unexpected types to input_text tool_output = [{"type": "input_text", "text": str(content)}] - if tool_call_id in custom_tool_call_ids: + normalized_tool_call_id: Final = self._normalize_tool_call_id(tool_call_id) + if tool_call_id in custom_tool_call_ids or normalized_tool_call_id in custom_tool_call_ids: input_items.append( ResponseCustomToolCallOutputParam( type="custom_tool_call_output", - call_id=tool_call_id, + call_id=normalized_tool_call_id or "", output=content if isinstance(content, str) else tool_output, ) ) @@ -433,7 +458,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( FunctionCallOutput( type="function_call_output", - call_id=tool_call_id, + call_id=normalized_tool_call_id, output=tool_output, ) ) @@ -450,10 +475,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): for tool_call in tool_calls: function = tool_call.get("function") custom = tool_call.get("custom") + raw_id: Final = tool_call.get("id") + normalized_call_id: Final = self._normalize_tool_call_id(raw_id) if function: input_tool_call: dict[str, object] = { "type": "function_call", - "call_id": tool_call["id"], + "call_id": normalized_call_id, } if "name" in function: input_tool_call["name"] = function["name"] @@ -464,7 +491,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): input_items.append( ResponseCustomToolCallParam( type="custom_tool_call", - call_id=tool_call["id"], + call_id=normalized_call_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 d0f9bad795d..4f428340926 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 @@ -4352,3 +4352,142 @@ def test_map_optional_params_verbosity_merges_into_text(): verbosity_only_request, ) assert verbosity_only_request["text"] == {"verbosity": "low"} + + +@pytest.mark.parametrize( + "tool_call_id,is_custom", + [ + # Short tool call ID: stays unchanged + ("call_short_123", False), + # Exactly 64 characters: boundary case, stays unchanged + ("call_" + "a" * 59, False), + # Overlong tool call ID (65 characters): normalized to <= 64 chars + ("call_" + "a" * 60, False), + # Overlong tool call ID from issue #42765 (85 characters): normalized to 64 chars + ( + "call_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + False, + ), + # Custom tool call with overlong ID: normalized to <= 64 chars + ( + "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, + is_custom: bool, +): + """ + Overlong tool call IDs (> 64 chars) must be deterministically normalized to <= 64 characters + consistently across assistant tool_calls and matching tool result messages, + while IDs <= 64 chars must be preserved unchanged. + """ + import hashlib + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + LiteLLMResponsesTransformationHandler, + ) + + expected_id: Final = ( + 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) + + # Validate tool call item + 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 + assert len(str(call_id)) <= 64 + + # Validate matching tool output item + 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(): + """Two distinct overlong IDs with the same prefix must not produce collision.""" + 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