From b2aa533198f24800f092c0c8ffe0b202db1a4e0a Mon Sep 17 00:00:00 2001 From: zora-zi <2468606005@qq.com> Date: Sat, 22 Aug 2026 18:57:56 +0800 Subject: [PATCH] fix(sdk): normalize Responses tool call IDs Co-authored-by: openhands --- openhands-sdk/openhands/sdk/llm/message.py | 15 ++++--- .../sdk/llm/utils/responses_serialization.py | 8 +++- .../openhands/sdk/llm/utils/tool_call_id.py | 36 +++++++++++++++ tests/sdk/llm/test_responses_serialization.py | 41 +++++++++++++++++ tests/sdk/llm/test_tool_call_id.py | 45 +++++++++++++++++++ 5 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 openhands-sdk/openhands/sdk/llm/utils/tool_call_id.py create mode 100644 tests/sdk/llm/test_tool_call_id.py diff --git a/openhands-sdk/openhands/sdk/llm/message.py b/openhands-sdk/openhands/sdk/llm/message.py index 8b5ba0d8a2..1f54b1880f 100644 --- a/openhands-sdk/openhands/sdk/llm/message.py +++ b/openhands-sdk/openhands/sdk/llm/message.py @@ -13,6 +13,9 @@ from openai.types.responses.response_reasoning_item import ResponseReasoningItem from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from openhands.sdk.llm.utils.tool_call_id import ( + OPENAI_RESPONSES_TOOL_CALL_ID_POLICY, +) from openhands.sdk.logger import get_logger from openhands.sdk.utils import DEFAULT_TEXT_CONTENT_LIMIT, maybe_truncate from openhands.sdk.utils.deprecation import handle_deprecated_model_fields @@ -99,11 +102,13 @@ def to_chat_dict(self) -> dict[str, Any]: def to_responses_dict(self) -> dict[str, Any]: """Serialize to OpenAI Responses 'function_call' input item format.""" - # Echo the original function_call.id verbatim when we have it, so - # replays stay byte-identical and OpenAI's prefix cache keeps matching. - item_id = self.responses_item_id or ( - self.id if str(self.id).startswith("fc") else f"fc_{self.id}" + # Echo a valid original function_call.id verbatim when we have it, so + # Responses replays stay byte-identical and prefix caches keep matching. + call_id = OPENAI_RESPONSES_TOOL_CALL_ID_POLICY.encode(self.id) + raw_item_id = self.responses_item_id or ( + call_id if call_id.startswith("fc") else f"fc_{call_id}" ) + item_id = OPENAI_RESPONSES_TOOL_CALL_ID_POLICY.encode(raw_item_id) # Responses requires arguments to be a JSON string args_str = ( self.arguments @@ -113,7 +118,7 @@ def to_responses_dict(self) -> dict[str, Any]: return { "type": "function_call", "id": item_id, - "call_id": self.id, + "call_id": call_id, "name": self.name, "arguments": args_str, } diff --git a/openhands-sdk/openhands/sdk/llm/utils/responses_serialization.py b/openhands-sdk/openhands/sdk/llm/utils/responses_serialization.py index bd124871ef..75cad34e81 100644 --- a/openhands-sdk/openhands/sdk/llm/utils/responses_serialization.py +++ b/openhands-sdk/openhands/sdk/llm/utils/responses_serialization.py @@ -11,6 +11,9 @@ ReasoningItemModel, TextContent, ) +from openhands.sdk.llm.utils.tool_call_id import ( + OPENAI_RESPONSES_TOOL_CALL_ID_POLICY, +) def message_to_responses_dict( @@ -135,13 +138,14 @@ def _tool_to_responses_items( if message.tool_call_id is None: return [] + call_id = OPENAI_RESPONSES_TOOL_CALL_ID_POLICY.encode(message.tool_call_id) items: list[dict[str, Any]] = [] for c in message.content: if isinstance(c, TextContent): items.append( { "type": "function_call_output", - "call_id": message.tool_call_id, + "call_id": call_id, "output": message._maybe_truncate_tool_text(c.text), } ) @@ -150,7 +154,7 @@ def _tool_to_responses_items( items.append( { "type": "function_call_output", - "call_id": message.tool_call_id, + "call_id": call_id, "output": [ { "type": "input_image", diff --git a/openhands-sdk/openhands/sdk/llm/utils/tool_call_id.py b/openhands-sdk/openhands/sdk/llm/utils/tool_call_id.py new file mode 100644 index 0000000000..8e1e764bb3 --- /dev/null +++ b/openhands-sdk/openhands/sdk/llm/utils/tool_call_id.py @@ -0,0 +1,36 @@ +import hashlib +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ToolCallIdPolicy: + """Constraints for rendering a tool-call ID on a provider wire protocol.""" + + allowed_pattern: re.Pattern[str] + generated_prefix: str = "id_" + max_length: int | None = None + + def encode(self, value: str) -> str: + """Preserve accepted IDs and deterministically encode rejected IDs.""" + if self._accepts(value): + return value + + digest = hashlib.sha256(value.encode("utf-8")).hexdigest() + if self.max_length is not None: + digest = digest[: self.max_length - len(self.generated_prefix)] + + encoded = f"{self.generated_prefix}{digest}" + if not self._accepts(encoded): + raise ValueError("Tool-call ID policy rejects its generated IDs") + return encoded + + def _accepts(self, value: str) -> bool: + if self.max_length is not None and len(value) > self.max_length: + return False + return self.allowed_pattern.fullmatch(value) is not None + + +OPENAI_RESPONSES_TOOL_CALL_ID_POLICY = ToolCallIdPolicy( + allowed_pattern=re.compile(r"^[A-Za-z0-9_-]+$"), +) diff --git a/tests/sdk/llm/test_responses_serialization.py b/tests/sdk/llm/test_responses_serialization.py index 9e20ae3e76..117d145e70 100644 --- a/tests/sdk/llm/test_responses_serialization.py +++ b/tests/sdk/llm/test_responses_serialization.py @@ -1,3 +1,5 @@ +import re + from openhands.sdk.llm.llm import LLM from openhands.sdk.llm.message import ( ImageContent, @@ -39,6 +41,45 @@ def test_function_call_and_output_paired(): assert outs[0]["call_id"] == fcs[0]["call_id"] +def test_parallel_cross_provider_tool_call_ids_are_responses_compatible(): + tool_calls = [ + MessageToolCall( + id=f"github_get_file_contents:{index}", + name="github_get_file_contents", + arguments="{}", + origin="completion", + ) + for index in (1, 2) + ] + messages = [ + Message(role="assistant", content=[], tool_calls=tool_calls), + *[ + Message( + role="tool", + tool_call_id=tool_call.id, + name=tool_call.name, + content=[TextContent(text="done")], + ) + for tool_call in tool_calls + ], + Message(role="user", content=[TextContent(text="switched")]), + ] + + _, inputs = LLM(model="gpt-5.6").format_messages_for_responses(messages) + + function_calls = [item for item in inputs if item["type"] == "function_call"] + outputs = [item for item in inputs if item["type"] == "function_call_output"] + allowed_id = re.compile(r"^[A-Za-z0-9_-]+$") + + assert len(function_calls) == len(outputs) == 2 + assert all(allowed_id.fullmatch(item["id"]) for item in function_calls) + assert all(allowed_id.fullmatch(item["call_id"]) for item in function_calls) + assert [item["call_id"] for item in outputs] == [ + item["call_id"] for item in function_calls + ] + assert len({item["call_id"] for item in function_calls}) == 2 + + def test_system_to_responses_value_instructions_concat(): m1 = Message(role="system", content=[TextContent(text="A"), TextContent(text="B")]) m2 = Message(role="system", content=[TextContent(text="C")]) diff --git a/tests/sdk/llm/test_tool_call_id.py b/tests/sdk/llm/test_tool_call_id.py new file mode 100644 index 0000000000..6e8646c2b9 --- /dev/null +++ b/tests/sdk/llm/test_tool_call_id.py @@ -0,0 +1,45 @@ +import re + +import pytest + +from openhands.sdk.llm.utils.tool_call_id import ToolCallIdPolicy + + +def test_policy_preserves_accepted_id(): + policy = ToolCallIdPolicy(re.compile(r"^[A-Za-z0-9_-]+$")) + + assert policy.encode("github_get_file_contents_1") == "github_get_file_contents_1" + + +def test_policy_deterministically_encodes_rejected_ids(): + policy = ToolCallIdPolicy(re.compile(r"^[A-Za-z0-9_-]+$")) + + first = policy.encode("github_get_file_contents:1") + second = policy.encode("github_get_file_contents:2") + + assert first == policy.encode("github_get_file_contents:1") + assert first != second + assert re.fullmatch(r"^[A-Za-z0-9_-]+$", first) + + +def test_policy_enforces_max_length(): + policy = ToolCallIdPolicy( + re.compile(r"^[A-Za-z0-9_-]+$"), + generated_prefix="tc_", + max_length=16, + ) + + encoded = policy.encode("a" * 17) + + assert len(encoded) == 16 + assert encoded.startswith("tc_") + + +def test_policy_rejects_incompatible_generated_ids(): + policy = ToolCallIdPolicy( + re.compile(r"^[A-Za-z0-9_-]+$"), + generated_prefix="invalid:", + ) + + with pytest.raises(ValueError, match="rejects its generated IDs"): + policy.encode("original:invalid")