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
64 changes: 59 additions & 5 deletions src/openharness/api/openai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,17 +202,28 @@ def _convert_assistant_message(msg: ConversationMessage) -> dict[str, Any]:
openai_msg["reasoning_content"] = ""

if tool_uses:
openai_msg["tool_calls"] = [
{
# Gemini 3 thinking models return a ``thought_signature`` on each tool
# call (surfaced as ``extra_content`` in the OpenAI-compat protocol) that
# MUST be sent back on the follow-up request, or the API rejects it with
# a 400 "Function call is missing a thought_signature". The streaming/
# response parser stashes it on ``msg._tool_extra_content`` keyed by
# tool-call id; replay it here. Providers that don't set it are unaffected.
extra_by_id = getattr(msg, "_tool_extra_content", None) or {}
tool_calls: list[dict[str, Any]] = []
for tu in tool_uses:
call: dict[str, Any] = {
"id": tu.id,
"type": "function",
"function": {
"name": tu.name,
"arguments": json.dumps(tu.input),
},
}
for tu in tool_uses
]
extra = extra_by_id.get(tu.id)
if extra:
call["extra_content"] = extra
tool_calls.append(call)
openai_msg["tool_calls"] = tool_calls

return openai_msg

Expand All @@ -226,6 +237,7 @@ def _parse_assistant_response(response: Any) -> ConversationMessage:
if message.content:
content.append(TextBlock(text=message.content))

tool_extra: dict[str, Any] = {}
if message.tool_calls:
for tc in message.tool_calls:
try:
Expand All @@ -237,8 +249,30 @@ def _parse_assistant_response(response: Any) -> ConversationMessage:
name=tc.function.name,
input=args,
))
extra = _extract_tool_extra_content(tc)
if extra:
tool_extra[tc.id] = extra

result = ConversationMessage(role="assistant", content=content)
# Preserve Gemini 3 thought_signature (extra_content) for the round-trip.
if tool_extra:
result._tool_extra_content = tool_extra # type: ignore[attr-defined]
return result


return ConversationMessage(role="assistant", content=content)
def _extract_tool_extra_content(tc: Any) -> Any:
"""Pull the OpenAI-compat ``extra_content`` off a tool call, if present.

Gemini surfaces its ``thought_signature`` here (``extra_content.google.
thought_signature``). The openai SDK exposes non-standard fields either as a
direct attribute or via ``model_extra``. Returns None when absent.
"""
extra = getattr(tc, "extra_content", None)
if extra is None:
model_extra = getattr(tc, "model_extra", None)
if model_extra:
extra = model_extra.get("extra_content")
return extra


def _normalize_openai_base_url(base_url: str | None) -> str | None:
Expand Down Expand Up @@ -336,6 +370,7 @@ async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStr
collected_content = ""
collected_reasoning = ""
collected_tool_calls: dict[int, dict[str, Any]] = {}
last_tool_idx: int | None = None
finish_reason: str | None = None
usage_data: dict[str, int] = {}
# Buffer to strip inline <think>…</think> blocks across streaming chunks.
Expand Down Expand Up @@ -375,6 +410,15 @@ async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStr
if delta.tool_calls:
for tc_delta in delta.tool_calls:
idx = tc_delta.index
extra = _extract_tool_extra_content(tc_delta)
if idx is None:
# Gemini streams the thought_signature (extra_content) on
# a trailing, index-less delta. Attach it to the current
# tool call instead of creating a phantom entry.
if extra and last_tool_idx is not None:
collected_tool_calls[last_tool_idx]["extra_content"] = extra
continue
last_tool_idx = idx
if idx not in collected_tool_calls:
collected_tool_calls[idx] = {
"id": tc_delta.id or "",
Expand All @@ -389,6 +433,8 @@ async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStr
entry["name"] = tc_delta.function.name
if tc_delta.function.arguments:
entry["arguments"] += tc_delta.function.arguments
if extra:
entry["extra_content"] = extra

# Usage in chunk (if provider sends it)
if chunk.usage:
Expand All @@ -402,6 +448,7 @@ async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStr
if collected_content:
content.append(TextBlock(text=collected_content))

tool_extra: dict[str, Any] = {}
for _idx in sorted(collected_tool_calls.keys()):
tc = collected_tool_calls[_idx]
# Skip phantom/empty tool calls that some providers send
Expand All @@ -416,6 +463,8 @@ async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStr
name=tc["name"],
input=args,
))
if tc.get("extra_content"):
tool_extra[tc["id"]] = tc["extra_content"]

final_message = ConversationMessage(role="assistant", content=content)

Expand All @@ -424,6 +473,11 @@ async def _stream_once(self, request: ApiMessageRequest) -> AsyncIterator[ApiStr
if collected_reasoning:
final_message._reasoning = collected_reasoning # type: ignore[attr-defined]

# Preserve Gemini 3 thought_signature (extra_content) so the follow-up
# request round-trips it — required for tool calls to keep working.
if tool_extra:
final_message._tool_extra_content = tool_extra # type: ignore[attr-defined]

yield ApiMessageCompleteEvent(
message=final_message,
usage=UsageSnapshot(
Expand Down
46 changes: 46 additions & 0 deletions tests/test_api/test_openai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
_convert_assistant_message,
_convert_messages_to_openai,
_convert_tools_to_openai,
_extract_tool_extra_content,
_normalize_openai_base_url,
_strip_think_blocks,
_token_limit_param_for_model,
Expand Down Expand Up @@ -497,3 +498,48 @@ def test_no_tool_calls_never_emits_empty(self, monkeypatch):
msg = ConversationMessage(role="assistant", content=[TextBlock(text="hi")])
out = _convert_assistant_message(msg)
assert "reasoning_content" not in out


class _FakeToolCall:
"""Minimal stand-in for an openai SDK tool-call object."""

def __init__(self, extra_content=None, model_extra=None):
if extra_content is not None:
self.extra_content = extra_content
self.model_extra = model_extra


class TestThoughtSignatureRoundTrip:
"""Gemini 3 returns a thought_signature (as ``extra_content``) on each tool
call that must be echoed back, or the follow-up request fails with a 400.
"""

_SIG = {"google": {"thought_signature": "EjQKMg=="}}

def test_extract_from_direct_attribute(self):
assert _extract_tool_extra_content(_FakeToolCall(extra_content=self._SIG)) == self._SIG

def test_extract_from_model_extra(self):
tc = _FakeToolCall(model_extra={"extra_content": self._SIG})
assert _extract_tool_extra_content(tc) == self._SIG

def test_extract_absent_returns_none(self):
assert _extract_tool_extra_content(_FakeToolCall()) is None

def test_convert_replays_extra_content(self):
msg = ConversationMessage(
role="assistant",
content=[ToolUseBlock(id="call_1", name="bash", input={"cmd": "ls"})],
)
msg._tool_extra_content = {"call_1": self._SIG} # type: ignore[attr-defined]
out = _convert_assistant_message(msg)
assert out["tool_calls"][0]["extra_content"] == self._SIG

def test_convert_omits_when_absent(self):
# Providers that don't set a thought_signature must be unaffected.
msg = ConversationMessage(
role="assistant",
content=[ToolUseBlock(id="call_1", name="bash", input={"cmd": "ls"})],
)
out = _convert_assistant_message(msg)
assert "extra_content" not in out["tool_calls"][0]