Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions openhands-sdk/openhands/sdk/llm/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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),
}
)
Expand All @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions openhands-sdk/openhands/sdk/llm/utils/tool_call_id.py
Original file line number Diff line number Diff line change
@@ -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_-]+$"),
)
41 changes: 41 additions & 0 deletions tests/sdk/llm/test_responses_serialization.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import re

from openhands.sdk.llm.llm import LLM
from openhands.sdk.llm.message import (
ImageContent,
Expand Down Expand Up @@ -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")])
Expand Down
45 changes: 45 additions & 0 deletions tests/sdk/llm/test_tool_call_id.py
Original file line number Diff line number Diff line change
@@ -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")