Fix OpenAI chat reasoning and tool history replay (#1002)
Some checks are pending
CI / Ban suppressions and legacy annotations (push) Waiting to run
CI / pytest (push) Waiting to run
CI / ruff-check (push) Waiting to run
CI / ruff-format (push) Waiting to run
CI / ty (push) Waiting to run

## Problem

OpenAI-chat providers lost explicit empty reasoning state and could
replay invalid tool-call history when unrelated messages appeared before
matching tool results.

## Changes

| Before | After |
| --- | --- |
| Empty `reasoning_content` and empty thinking blocks were treated as
absent. | Empty reasoning is preserved as explicit replay state. |
| OpenAI-chat conversion only deferred post-tool assistant text. |
OpenAI-chat conversion buffers later transcript messages until required
tool results are emitted. |
| Responses prior tool calls and outputs were emitted one item per
message. | Responses prior tool calls and outputs are grouped into valid
Anthropic tool-use/result messages. |
| Empty streamed `reasoning_content` produced no thinking block. | Empty
streamed `reasoning_content` starts thinking state without visible delta
text. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR fixes OpenAI chat reasoning replay and tool-history ordering.
The main changes are:

- Preserves explicit empty `reasoning_content` and empty thinking
blocks.
- Reworks OpenAI chat conversion around a ledger that waits for required
tool results before replaying buffered transcript messages.
- Groups prior Responses tool calls and outputs into valid Anthropic
tool-use and tool-result messages.
- Starts streamed thinking state when empty `reasoning_content` is
received.
- Adds focused tests for nested tool turns, out-of-order results,
multi-tool replay, and empty reasoning.
</details>

<h3>Confidence Score: 5/5</h3>

Safe to merge with low risk.

No blocking issues were found in the changed conversion paths. The
updated ledger covers the prior invalid replay cases and the tests
include nested, out-of-order, multi-tool, and empty reasoning scenarios.
The required patch version and lockfile updates are present.

No files require special attention.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Ran the focused OpenAI conversion regression suite with Pytest,
capturing the command, working directory, pass count, exit code, and
elapsed time.
- Encountered an external timeout during the initial Pytest run at 98%
progress, then re-ran the same focused suite to completion for
definitive proof.
- Validated code quality with Ruff by executing the lint command and
obtaining a successful output.

<a
href="https://app.greptile.com/trex/runs/13503694/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<details open><summary><h3>Important Files Changed</h3></summary>

| Filename | Overview |
|----------|----------|
| core/anthropic/conversion.py | Replaces single pending-tool state with
a ledger that buffers transcript segments until required OpenAI chat
tool results can be emitted in valid order. |
| core/openai_responses/input.py | Groups consecutive prior Responses
tool calls/results into Anthropic tool-use/result turns and preserves
explicit empty reasoning. |
| core/openai_responses/reasoning.py | Updates reasoning extraction and
combination helpers so empty strings remain explicit replay state
without adding spurious separators. |
| providers/deepseek/compat.py | Treats empty top-level or block-level
thinking as replayable when detecting DeepSeek tool-history
compatibility. |
| providers/transports/openai_chat/stream.py | Starts an Anthropic
thinking block for empty streamed `reasoning_content` while only
emitting deltas for non-empty text. |
| tests/providers/test_converter.py | Adds OpenAI chat conversion
coverage for buffered tool history, nested pending tool turns, and
explicit empty reasoning. |
| tests/core/openai_responses/test_conversion.py | Adds Responses
conversion tests for grouped prior tool calls/results and empty
reasoning attachment. |
| pyproject.toml | Bumps the package patch version for the production
conversion fixes. |
| uv.lock | Keeps the lockfile package version in sync with
`pyproject.toml`. |

</details>

<details open><summary><h3>Sequence Diagram</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant A as Anthropic transcript
participant L as OpenAI chat ledger
participant O as OpenAI chat history
A->>L: Assistant tool_use segment
L->>O: Emit assistant tool_calls
A->>L: Later plain user/assistant messages
L-->>L: Buffer until required tool_result ids arrive
A->>L: User tool_result blocks
L->>O: Emit matching role: tool results in tool_call order
L->>O: Emit deferred assistant post-tool content
L->>O: Drain buffered plain transcript messages
```

</a>
<a href="#gh-dark-mode-only">

```mermaid
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant A as Anthropic transcript
participant L as OpenAI chat ledger
participant O as OpenAI chat history
A->>L: Assistant tool_use segment
L->>O: Emit assistant tool_calls
A->>L: Later plain user/assistant messages
L-->>L: Buffer until required tool_result ids arrive
A->>L: User tool_result blocks
L->>O: Emit matching role: tool results in tool_call order
L->>O: Emit deferred assistant post-tool content
L->>O: Drain buffered plain transcript messages
```

</a>
</details>

<sub>Reviews (3): Last reviewed commit: ["Refactor OpenAI chat tool
history
replay"](ae1635d2ce)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=42274270)</sub>

<!-- /greptile_comment -->
This commit is contained in:
Ali Khokhar 2026-07-06 23:00:06 -07:00 committed by GitHub
parent 950aba393d
commit bd85deb736
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1065 additions and 264 deletions

View file

@ -57,7 +57,7 @@ def _tool_input_schema(tool: Any) -> dict[str, Any]:
def _clean_reasoning_content(value: Any) -> str | None:
if not isinstance(value, str):
return None
return value if value else None
return value
def _think_tag_content(reasoning: str) -> str:
@ -83,24 +83,30 @@ def _tool_call_from_tool_use(block: Any) -> dict[str, Any]:
@dataclass
class _PendingAfterTools:
"""Assistant content that appears after ``tool_use`` in an Anthropic message.
class _PlainSegment:
messages: list[dict[str, Any]]
OpenAI ``chat.completions`` cannot place assistant text after ``tool_calls`` in the
same message, so it is deferred until the corresponding ``role: tool`` results have
been replayed in order.
"""
# Tool use IDs still missing a ``role: tool`` result before post-tool text may be replayed.
remaining_tool_ids: set[str] = field(default_factory=set)
@dataclass
class _ToolTurnSegment:
assistant_message: dict[str, Any]
required_tool_ids: list[str]
deferred_blocks: list[Any] = field(default_factory=list)
top_level_reasoning: str | None = None
reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS
# True after deferred assistant text has been added to the OpenAI transcript.
deferred_emitted: bool = False
assistant_emitted: bool = False
def needs_deferred(self) -> bool:
return bool(self.deferred_blocks) and not self.deferred_emitted
_TranscriptSegment = _PlainSegment | _ToolTurnSegment
def _tool_call_ids(tool_calls: list[dict[str, Any]]) -> list[str]:
ids: list[str] = []
for tool_call in tool_calls:
tool_id = tool_call.get("id")
if tool_id is not None and str(tool_id).strip() != "":
ids.append(str(tool_id))
return ids
def _index_first_tool_use(blocks: list[Any]) -> int | None:
@ -145,6 +151,135 @@ def _assert_no_forbidden_assistant_block(block: Any) -> None:
)
class _OpenAIChatHistoryLedger:
"""Assemble OpenAI chat history while respecting tool-result dependencies."""
def __init__(self) -> None:
self._output: list[dict[str, Any]] = []
self._segments: list[_TranscriptSegment] = []
self._tool_results: dict[str, dict[str, Any]] = {}
def add_plain(self, messages: list[dict[str, Any]]) -> None:
if messages:
self._segments.append(_PlainSegment(messages))
self._drain_ready_segments()
def add_tool_turn(self, segment: _ToolTurnSegment) -> None:
self._segments.append(segment)
self._drain_ready_segments()
def add_user_blocks(self, blocks: list[Any]) -> None:
text_blocks: list[Any] = []
for block in blocks:
block_type = get_block_type(block)
if block_type == "tool_result":
self._add_text_blocks(text_blocks)
self._record_tool_result(block)
else:
text_blocks.append(block)
self._add_text_blocks(text_blocks)
self._drain_ready_segments()
def finish(self) -> list[dict[str, Any]]:
self._drain_ready_segments()
missing = self._missing_required_tool_ids()
if missing:
raise OpenAIConversionError(
"OpenAI chat conversion cannot replay incomplete tool history; "
f"missing tool_result blocks for tool_use ids: {missing}"
)
while self._segments:
segment = self._segments.pop(0)
if isinstance(segment, _PlainSegment):
self._output.extend(segment.messages)
continue
self._emit_tool_turn(segment)
return self._output
def _add_text_blocks(self, blocks: list[Any]) -> None:
if not blocks:
return
self.add_plain(AnthropicToOpenAIConverter._convert_user_message(blocks))
blocks.clear()
def _record_tool_result(self, block: Any) -> None:
tuid = get_block_attr(block, "tool_use_id")
tuid_s = str(tuid) if tuid is not None else ""
if not tuid_s:
self.add_plain(AnthropicToOpenAIConverter._convert_user_message([block]))
return
tool_content = get_block_attr(block, "content", "")
serialized = serialize_tool_result_content(tool_content)
tool_message = {
"role": "tool",
"tool_call_id": tuid,
"content": serialized if serialized else "",
}
if self._has_pending_tool_id(tuid_s):
self._tool_results[tuid_s] = tool_message
else:
self.add_plain([tool_message])
def _drain_ready_segments(self) -> None:
while self._segments:
segment = self._segments[0]
if isinstance(segment, _PlainSegment):
self._output.extend(segment.messages)
self._segments.pop(0)
continue
if not segment.assistant_emitted:
self._output.append(segment.assistant_message)
segment.assistant_emitted = True
missing = [
tool_id
for tool_id in segment.required_tool_ids
if tool_id not in self._tool_results
]
if missing:
break
self._segments.pop(0)
for tool_id in segment.required_tool_ids:
self._output.append(self._tool_results.pop(tool_id))
deferred_messages = (
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(segment)
)
self._output.extend(deferred_messages)
def _emit_tool_turn(self, segment: _ToolTurnSegment) -> None:
if not segment.assistant_emitted:
self._output.append(segment.assistant_message)
segment.assistant_emitted = True
for tool_id in segment.required_tool_ids:
tool_result = self._tool_results.pop(tool_id, None)
if tool_result is not None:
self._output.append(tool_result)
self._output.extend(
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(segment)
)
def _missing_required_tool_ids(self) -> list[str]:
missing: list[str] = []
for segment in self._segments:
if not isinstance(segment, _ToolTurnSegment):
continue
missing.extend(
tool_id
for tool_id in segment.required_tool_ids
if tool_id not in self._tool_results
)
return missing
def _has_pending_tool_id(self, tool_id: str) -> bool:
return any(
isinstance(segment, _ToolTurnSegment)
and tool_id in segment.required_tool_ids
for segment in self._segments
)
class AnthropicToOpenAIConverter:
"""Convert Anthropic message format to OpenAI-compatible format."""
@ -154,8 +289,7 @@ class AnthropicToOpenAIConverter:
*,
reasoning_replay: ReasoningReplayMode = ReasoningReplayMode.THINK_TAGS,
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
pending: _PendingAfterTools | None = None
ledger = _OpenAIChatHistoryLedger()
for msg in messages:
role = msg.role
@ -164,106 +298,78 @@ class AnthropicToOpenAIConverter:
getattr(msg, "reasoning_content", None)
)
if role == "assistant" and isinstance(content, list):
if pending is not None and pending.needs_deferred():
# Orphan: expected tool result; emit deferred to avoid a stuck session.
result.extend(
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
pending,
)
)
pending.deferred_emitted = True
pending = None
if role == "user" and isinstance(content, list):
ledger.add_user_blocks(content)
continue
if (first_i := _index_first_tool_use(content)) is not None:
for block in content:
if get_block_type(block) == "tool_use":
continue
_assert_no_forbidden_assistant_block(block)
out, new_pending = (
AnthropicToOpenAIConverter._convert_assistant_message_with_split(
content,
first_tool_index=first_i,
reasoning_content=reasoning_content,
reasoning_replay=reasoning_replay,
)
)
result.extend(out)
if new_pending is not None:
pending = new_pending
else:
for block in content:
_assert_no_forbidden_assistant_block(block)
result.extend(
AnthropicToOpenAIConverter._convert_assistant_message(
content,
reasoning_content=reasoning_content,
reasoning_replay=reasoning_replay,
)
)
elif isinstance(content, str):
if role == "user" and pending is not None and pending.needs_deferred():
result.extend(
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
pending
)
)
pending.deferred_emitted = True
pending = None
converted = {"role": role, "content": content}
if role == "assistant" and reasoning_content:
if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT:
converted["reasoning_content"] = reasoning_content
elif reasoning_replay == ReasoningReplayMode.THINK_TAGS:
content_parts = [_think_tag_content(reasoning_content)]
if content:
content_parts.append(content)
converted["content"] = "\n\n".join(content_parts)
result.append(converted)
elif isinstance(content, list):
if role == "user":
if pending is not None and pending.needs_deferred():
if not pending.remaining_tool_ids:
result.extend(
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
pending
)
)
pending.deferred_emitted = True
pending = None
result.extend(
AnthropicToOpenAIConverter._convert_user_message(
content
)
)
else:
pieces = AnthropicToOpenAIConverter._convert_user_message_with_injection(
content, pending
)
result.extend(pieces["messages"])
if pieces["cleared_pending"]:
pending = None
else:
result.extend(
AnthropicToOpenAIConverter._convert_user_message(content)
)
else:
if role == "user" and pending is not None and pending.needs_deferred():
result.extend(
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
pending
)
)
pending.deferred_emitted = True
pending = None
result.append({"role": role, "content": str(content)})
if pending is not None and pending.needs_deferred():
result.extend(
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(pending)
segments = AnthropicToOpenAIConverter._convert_message_to_segments(
role,
content,
reasoning_content=reasoning_content,
reasoning_replay=reasoning_replay,
)
for segment in segments:
if isinstance(segment, _PlainSegment):
ledger.add_plain(segment.messages)
else:
ledger.add_tool_turn(segment)
return result
return ledger.finish()
@staticmethod
def _convert_message_to_segments(
role: str,
content: Any,
*,
reasoning_content: str | None,
reasoning_replay: ReasoningReplayMode,
) -> list[_TranscriptSegment]:
if role == "assistant" and isinstance(content, list):
if (first_i := _index_first_tool_use(content)) is not None:
for block in content:
if get_block_type(block) == "tool_use":
continue
_assert_no_forbidden_assistant_block(block)
return [
AnthropicToOpenAIConverter._convert_assistant_message_with_split(
content,
first_tool_index=first_i,
reasoning_content=reasoning_content,
reasoning_replay=reasoning_replay,
)
]
for block in content:
_assert_no_forbidden_assistant_block(block)
return [
_PlainSegment(
AnthropicToOpenAIConverter._convert_assistant_message(
content,
reasoning_content=reasoning_content,
reasoning_replay=reasoning_replay,
)
)
]
if role == "user" and isinstance(content, list):
return [
_PlainSegment(AnthropicToOpenAIConverter._convert_user_message(content))
]
if isinstance(content, str):
converted = {"role": role, "content": content}
if role == "assistant" and reasoning_content is not None:
if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT:
converted["reasoning_content"] = reasoning_content
elif (
reasoning_replay == ReasoningReplayMode.THINK_TAGS
and reasoning_content
):
content_parts = [_think_tag_content(reasoning_content)]
if content:
content_parts.append(content)
converted["content"] = "\n\n".join(content_parts)
return [_PlainSegment([converted])]
if isinstance(content, list):
return []
return [_PlainSegment([{"role": role, "content": str(content)}])]
@staticmethod
def _convert_assistant_message_with_split(
@ -272,17 +378,17 @@ class AnthropicToOpenAIConverter:
first_tool_index: int,
reasoning_content: str | None,
reasoning_replay: ReasoningReplayMode,
) -> tuple[list[dict[str, Any]], _PendingAfterTools | None]:
) -> _ToolTurnSegment:
pre = content[:first_tool_index]
tool_calls = _iter_tool_uses_in_order(content)
if not tool_calls:
return (
AnthropicToOpenAIConverter._convert_assistant_message(
return _ToolTurnSegment(
assistant_message=AnthropicToOpenAIConverter._convert_assistant_message(
content,
reasoning_content=reasoning_content,
reasoning_replay=reasoning_replay,
),
None,
)[0],
required_tool_ids=[],
)
deferred_blocks = _deferred_post_tool_blocks(
content, first_tool_index=first_tool_index
@ -296,7 +402,7 @@ class AnthropicToOpenAIConverter:
}
if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT:
replay = reasoning_content
if replay:
if replay is not None:
pre_msg["reasoning_content"] = replay
else:
pre_msg = AnthropicToOpenAIConverter._convert_assistant_message(
@ -307,20 +413,13 @@ class AnthropicToOpenAIConverter:
pre_msg["tool_calls"] = tool_calls
if tool_calls and pre_msg.get("content") == " ":
pre_msg["content"] = ""
pnd: _PendingAfterTools | None = None
if deferred_blocks:
res_ids: set[str] = set()
for tc in tool_calls:
tid = tc.get("id")
if tid is not None and str(tid).strip() != "":
res_ids.add(str(tid))
pnd = _PendingAfterTools(
remaining_tool_ids=res_ids,
deferred_blocks=deferred_blocks,
top_level_reasoning=reasoning_content,
reasoning_replay=reasoning_replay,
)
return [pre_msg], pnd
return _ToolTurnSegment(
assistant_message=pre_msg,
required_tool_ids=_tool_call_ids(tool_calls),
deferred_blocks=deferred_blocks,
top_level_reasoning=reasoning_content,
reasoning_replay=reasoning_replay,
)
@staticmethod
def _convert_assistant_message(
@ -331,6 +430,7 @@ class AnthropicToOpenAIConverter:
) -> list[dict[str, Any]]:
content_parts: list[str] = []
thinking_parts: list[str] = []
thinking_seen = False
tool_calls: list[dict[str, Any]] = []
for block in content:
block_type = get_block_type(block)
@ -343,6 +443,7 @@ class AnthropicToOpenAIConverter:
if reasoning_replay == ReasoningReplayMode.THINK_TAGS:
content_parts.append(_think_tag_content(thinking))
elif reasoning_content is None:
thinking_seen = True
thinking_parts.append(thinking)
elif block_type == "redacted_thinking":
# Opaque provider continuation data; do not materialize as model-visible text
@ -364,15 +465,16 @@ class AnthropicToOpenAIConverter:
if tool_calls:
msg["tool_calls"] = tool_calls
if reasoning_replay == ReasoningReplayMode.REASONING_CONTENT:
replay_reasoning = reasoning_content or "\n".join(thinking_parts)
if replay_reasoning:
msg["reasoning_content"] = replay_reasoning
if reasoning_content is not None:
msg["reasoning_content"] = reasoning_content
elif thinking_seen:
msg["reasoning_content"] = "\n".join(thinking_parts)
return [msg]
@staticmethod
def _deferred_post_tool_to_messages(
pending: _PendingAfterTools,
pending: _ToolTurnSegment,
) -> list[dict[str, Any]]:
if not pending.deferred_blocks:
return []
@ -382,65 +484,6 @@ class AnthropicToOpenAIConverter:
reasoning_replay=pending.reasoning_replay,
)
@staticmethod
def _convert_user_message_with_injection(
content: list[Any], pending: _PendingAfterTools
) -> dict[str, Any]:
"""Convert user list blocks, emitting deferred assistant after all tool results."""
if not pending.needs_deferred() or not pending.remaining_tool_ids:
return {
"messages": AnthropicToOpenAIConverter._convert_user_message(content),
"cleared_pending": False,
}
result: list[dict[str, Any]] = []
text_parts: list[str] = []
cleared = False
def flush_text() -> None:
if text_parts:
result.append({"role": "user", "content": "\n".join(text_parts)})
text_parts.clear()
for block in content:
block_type = get_block_type(block)
if block_type == "text":
text_parts.append(get_block_attr(block, "text", ""))
elif block_type == "image":
raise OpenAIConversionError(
"User message image blocks are not supported for OpenAI chat "
"conversion; use a vision-capable native Anthropic provider or "
"extend the converter."
)
elif block_type == "tool_result":
flush_text()
tool_content = get_block_attr(block, "content", "")
serialized = serialize_tool_result_content(tool_content)
tuid = get_block_attr(block, "tool_use_id")
tuid_s = str(tuid) if tuid is not None else ""
result.append(
{
"role": "tool",
"tool_call_id": tuid,
"content": serialized if serialized else "",
}
)
if tuid_s in pending.remaining_tool_ids:
pending.remaining_tool_ids.discard(tuid_s)
if not pending.remaining_tool_ids:
result.extend(
AnthropicToOpenAIConverter._deferred_post_tool_to_messages(
pending
)
)
pending.deferred_emitted = True
cleared = True
else:
pass
flush_text()
return {"messages": result, "cleared_pending": cleared}
@staticmethod
def _convert_user_message(content: list[Any]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []

View file

@ -122,22 +122,17 @@ def _append_input_item(
quarantined_function_call_ids.add(call_id)
_trace_quarantined_function_call(call_id, exc)
return pending_reasoning
message = {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": call_id,
"name": responses_tool_name_to_anthropic_name(
name, namespace=namespace
),
"input": tool_input,
}
],
tool_use = {
"type": "tool_use",
"id": call_id,
"name": responses_tool_name_to_anthropic_name(name, namespace=namespace),
"input": tool_input,
}
if pending_reasoning:
message["reasoning_content"] = pending_reasoning
messages.append(message)
_append_tool_use_message(
messages,
tool_use,
reasoning_content=pending_reasoning,
)
return None
if item_type in {"function_call_output", "custom_tool_call_output"}:
call_id = call_id_from_item(item)
@ -146,18 +141,14 @@ def _append_input_item(
and call_id in quarantined_function_call_ids
):
return pending_reasoning
_append_pending_reasoning(messages, pending_reasoning)
messages.append(
_append_pending_reasoning_before_tool_output(messages, pending_reasoning)
_append_tool_result_message(
messages,
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": call_id,
"content": item.get("output", ""),
}
],
}
"type": "tool_result",
"tool_use_id": call_id,
"content": item.get("output", ""),
},
)
return None
if item_type == "reasoning":
@ -204,7 +195,7 @@ def _append_message_item(
"role": normalized_role,
"content": _convert_message_content(content),
}
if normalized_role == "assistant" and reasoning_content:
if normalized_role == "assistant" and reasoning_content is not None:
message["reasoning_content"] = reasoning_content
messages.append(message)
@ -212,7 +203,7 @@ def _append_message_item(
def _append_pending_reasoning(
messages: list[dict[str, Any]], pending_reasoning: str | None
) -> None:
if pending_reasoning:
if pending_reasoning is not None:
messages.append(
{
"role": "assistant",
@ -222,6 +213,91 @@ def _append_pending_reasoning(
)
def _append_pending_reasoning_before_tool_output(
messages: list[dict[str, Any]], pending_reasoning: str | None
) -> None:
if pending_reasoning is None:
return
message = _last_assistant_tool_use_message(messages)
if message is None:
_append_pending_reasoning(messages, pending_reasoning)
return
_merge_message_reasoning(message, pending_reasoning)
def _append_tool_use_message(
messages: list[dict[str, Any]],
tool_use: dict[str, Any],
*,
reasoning_content: str | None,
) -> None:
message = _last_assistant_tool_use_message(messages)
if message is None:
message = {"role": "assistant", "content": []}
messages.append(message)
if reasoning_content is not None:
_merge_message_reasoning(message, reasoning_content)
content = message["content"]
if isinstance(content, list):
content.append(tool_use)
def _append_tool_result_message(
messages: list[dict[str, Any]],
tool_result: dict[str, Any],
) -> None:
message = _last_user_tool_result_message(messages)
if message is None:
message = {"role": "user", "content": []}
messages.append(message)
content = message["content"]
if isinstance(content, list):
content.append(tool_result)
def _last_assistant_tool_use_message(
messages: list[dict[str, Any]],
) -> dict[str, Any] | None:
if not messages:
return None
message = messages[-1]
if message.get("role") != "assistant":
return None
content = message.get("content")
if not isinstance(content, list) or not content:
return None
if all(
isinstance(block, dict) and block.get("type") == "tool_use" for block in content
):
return message
return None
def _last_user_tool_result_message(
messages: list[dict[str, Any]],
) -> dict[str, Any] | None:
if not messages:
return None
message = messages[-1]
if message.get("role") != "user":
return None
content = message.get("content")
if not isinstance(content, list) or not content:
return None
if all(
isinstance(block, dict) and block.get("type") == "tool_result"
for block in content
):
return message
return None
def _merge_message_reasoning(message: dict[str, Any], reasoning: str) -> None:
existing = message.get("reasoning_content")
existing_reasoning = existing if isinstance(existing, str) else None
message["reasoning_content"] = combine_reasoning(existing_reasoning, reasoning)
def _iter_input_items(value: Any) -> list[Any]:
if value is None:
return []

View file

@ -21,10 +21,14 @@ def reasoning_text_from_item(item: Mapping[str, Any]) -> str | None:
def combine_reasoning(existing: str | None, addition: str | None) -> str | None:
if not addition:
if addition is None:
return existing
if not existing:
if existing is None:
return addition
if existing == "":
return addition
if addition == "":
return existing
return f"{existing}\n{addition}"
@ -45,6 +49,6 @@ def _text_parts_from_items(value: Any, *, item_type: str) -> list[str]:
for item in value:
if isinstance(item, dict) and item.get("type") == item_type:
text = optional_str(item.get("text"))
if text:
if text is not None:
parts.append(text)
return parts

View file

@ -297,13 +297,13 @@ def _has_replayable_thinking_before_tool_use(message: Mapping[str, Any]) -> bool
if not isinstance(content, list):
return False
has_thinking = False
has_thinking = isinstance(message.get("reasoning_content"), str)
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "thinking" and isinstance(block.get("thinking"), str):
has_thinking = bool(block["thinking"])
has_thinking = True
continue
if btype == "tool_use":
return has_thinking

View file

@ -129,13 +129,14 @@ class OpenAIChatStreamAdapter:
logger.debug("{} finish_reason: {}", tag, finish_reason)
reasoning = getattr(delta, "reasoning_content", None)
if thinking_enabled and reasoning:
if thinking_enabled and isinstance(reasoning, str):
for event in hold_events(ledger.ensure_thinking_block()):
yield event
for event in hold_event(
ledger.emit_thinking_delta(reasoning)
):
yield event
if reasoning:
for event in hold_event(
ledger.emit_thinking_delta(reasoning)
):
yield event
for event in self._transport._handle_extra_reasoning(
delta,

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "free-claude-code"
version = "3.4.6"
version = "3.4.7"
description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
readme = "README.md"
requires-python = ">=3.14.0"

View file

@ -376,6 +376,157 @@ def test_responses_prior_custom_tool_call_flattens_tool_use_name() -> None:
]
def test_responses_groups_consecutive_prior_tool_calls() -> None:
payload = _ADAPTER.to_anthropic_payload(
{
"model": "nvidia_nim/test-model",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "echo",
"arguments": '{"value":"FCC"}',
},
{
"type": "custom_tool_call",
"call_id": "call_2",
"name": "apply_patch",
"input": "*** Begin Patch",
},
],
}
)
assert payload["messages"] == [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_1",
"name": "echo",
"input": {"value": "FCC"},
},
{
"type": "tool_use",
"id": "call_2",
"name": "apply_patch",
"input": {"input": "*** Begin Patch"},
},
],
}
]
def test_responses_groups_consecutive_prior_tool_outputs() -> None:
payload = _ADAPTER.to_anthropic_payload(
{
"model": "nvidia_nim/test-model",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "echo",
"arguments": '{"value":"FCC"}',
},
{
"type": "function_call",
"call_id": "call_2",
"name": "echo",
"arguments": '{"value":"Codex"}',
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "FCC",
},
{
"type": "function_call_output",
"call_id": "call_2",
"output": "Codex",
},
],
}
)
assert len(payload["messages"]) == 2
assert payload["messages"][0]["role"] == "assistant"
assert len(payload["messages"][0]["content"]) == 2
assert payload["messages"][1] == {
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "call_1",
"content": "FCC",
},
{
"type": "tool_result",
"tool_use_id": "call_2",
"content": "Codex",
},
],
}
def test_responses_reasoning_between_tool_call_and_output_attaches_to_tool_message() -> (
None
):
payload = _ADAPTER.to_anthropic_payload(
{
"model": "nvidia_nim/test-model",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "echo",
"arguments": '{"value":"FCC"}',
},
{
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": "Need the result."}],
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "FCC",
},
],
}
)
assert payload["messages"][0]["reasoning_content"] == "Need the result."
assert payload["messages"][0]["content"][0]["id"] == "call_1"
assert payload["messages"][1]["content"][0]["tool_use_id"] == "call_1"
def test_responses_empty_reasoning_attaches_to_prior_tool_call() -> None:
payload = _ADAPTER.to_anthropic_payload(
{
"model": "nvidia_nim/test-model",
"input": [
{
"type": "function_call",
"call_id": "call_1",
"name": "echo",
"arguments": '{"value":"FCC"}',
},
{
"type": "reasoning",
"content": [{"type": "reasoning_text", "text": ""}],
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "FCC",
},
],
}
)
assert payload["messages"][0]["reasoning_content"] == ""
def test_responses_unsupported_tool_type_is_clear() -> None:
with pytest.raises(_CONVERSION_ERROR, match="Unsupported Responses tool type"):
_ADAPTER.to_anthropic_payload(

View file

@ -266,6 +266,22 @@ def test_convert_assistant_top_level_reasoning_content_is_preserved():
]
def test_convert_assistant_empty_top_level_reasoning_content_is_preserved():
messages = [MockMessage("assistant", "The answer is 4.", reasoning_content="")]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result == [
{
"role": "assistant",
"content": "The answer is 4.",
"reasoning_content": "",
}
]
def test_convert_assistant_thinking_tool_use_replays_top_level_reasoning():
content = [
MockBlock(type="thinking", thinking="I should call the tool."),
@ -276,18 +292,78 @@ def test_convert_assistant_thinking_tool_use_replays_top_level_reasoning():
input={"query": "python"},
),
]
messages = [MockMessage("assistant", content)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_reasoning",
content="result",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert len(result) == 1
assert len(result) == 2
assert result[0]["content"] == ""
assert result[0]["reasoning_content"] == "I should call the tool."
assert "<think>" not in result[0]["content"]
assert result[0]["tool_calls"][0]["id"] == "call_reasoning"
def test_convert_assistant_empty_thinking_replays_empty_reasoning_content():
content = [
MockBlock(type="thinking", thinking=""),
MockBlock(type="text", text="The answer is 4."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result == [
{
"role": "assistant",
"content": "The answer is 4.",
"reasoning_content": "",
}
]
def test_convert_assistant_tool_use_replays_empty_reasoning_content():
content = [
MockBlock(type="thinking", thinking=""),
MockBlock(type="tool_use", id="call_empty", name="Read", input={}),
]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_empty",
content="result",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(
messages, reasoning_replay=ReasoningReplayMode.REASONING_CONTENT
)
assert result[0]["content"] == ""
assert result[0]["reasoning_content"] == ""
assert result[0]["tool_calls"][0]["id"] == "call_empty"
def test_convert_assistant_message_thinking_removed_when_disabled():
content = [
MockBlock(type="thinking", thinking="I need to calculate this."),
@ -327,10 +403,16 @@ def test_convert_assistant_message_tool_use():
type="tool_use", id="call_1", name="search", input={"query": "python"}
),
]
messages = [MockMessage("assistant", content)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert len(result) == 2
msg = result[0]
assert msg["role"] == "assistant"
assert "I will call the tool." in msg["content"]
@ -352,7 +434,13 @@ def test_convert_assistant_tool_use_preserves_extra_content():
extra_content={"google": {"thought_signature": "sig"}},
),
]
messages = [MockMessage("assistant", content)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert result[0]["tool_calls"][0]["extra_content"] == {
@ -377,7 +465,13 @@ def test_convert_assistant_message_tool_use_no_text():
# So if tool_calls is present, content_str remains "" (empty).
content = [MockBlock(type="tool_use", id="call_2", name="test", input={})]
messages = [MockMessage("assistant", content)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_2", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert (
@ -400,10 +494,14 @@ def test_convert_mixed_blocks_and_types_and_roles():
MockMessage(
"assistant", [MockBlock(type="tool_use", id="t1", name="f", input={})]
),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="t1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 3
assert len(result) == 4
assert result[0]["role"] == "user"
assert "<think>" in result[1]["content"]
assert result[2]["tool_calls"][0]["id"] == "t1"
@ -423,7 +521,13 @@ def test_get_block_attr_defaults():
def test_input_not_dict():
# Tool input might not be a dict (e.g. malformed or string)
content = [MockBlock(type="tool_use", id="call_x", name="f", input="some_string")]
messages = [MockMessage("assistant", content)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_x", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
# The converter calls json.dumps(tool_input) if dict, else str(tool_input)
# So it should be "some_string"
@ -486,9 +590,15 @@ def test_convert_assistant_message_unknown_block_type():
def test_convert_tool_use_none_input():
"""Tool use with None input should not crash."""
content = [MockBlock(type="tool_use", id="call_n", name="test", input=None)]
messages = [MockMessage("assistant", content)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_n", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert len(result) == 2
assert "tool_calls" in result[0]
@ -506,10 +616,16 @@ def test_convert_assistant_interleaved_order_preserved():
MockBlock(type="thinking", thinking="Second thought."),
MockBlock(type="tool_use", id="call_1", name="search", input={"q": "x"}),
]
messages = [MockMessage("assistant", content)]
messages = [
MockMessage("assistant", content),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_1", content="result")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 1
assert len(result) == 2
msg = result[0]
# Expected: thinking1, text, thinking2 in that order within content; tool_calls at end
expected_content = "<think>\nFirst thought.\n</think>\n\nHere is the answer.\n\n<think>\nSecond thought.\n</think>"
@ -591,18 +707,15 @@ def test_convert_user_message_image_raises():
AnthropicToOpenAIConverter.convert_messages(messages)
def test_convert_assistant_text_after_tool_use_splits_for_openai_chat():
"""Post-tool_use assistant text is replayed as a second assistant turn (issue 206)."""
def test_convert_assistant_text_after_tool_use_requires_matching_tool_result():
"""Dangling post-tool assistant text cannot be replayed as valid OpenAI chat."""
content = [
MockBlock(type="tool_use", id="call_z", name="Read", input={}),
MockBlock(type="text", text="After tool"),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert len(result) == 2
assert result[0]["role"] == "assistant"
assert result[0]["tool_calls"][0]["id"] == "call_z"
assert result[1] == {"role": "assistant", "content": "After tool"}
with pytest.raises(OpenAIConversionError, match="missing tool_result"):
AnthropicToOpenAIConverter.convert_messages(messages)
def test_convert_assistant_text_after_tool_use_inserts_after_tool_results():
@ -631,6 +744,257 @@ def test_convert_assistant_text_after_tool_use_inserts_after_tool_results():
assert result[2] == {"role": "assistant", "content": "Post-tool commentary"}
def test_unrelated_user_text_before_tool_result_is_buffered_until_after_tool_result():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_z", name="Read", input={})],
),
MockMessage("user", "Please also summarize it."),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_z",
content="file contents",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == ["assistant", "tool", "user"]
assert result[0]["tool_calls"][0]["id"] == "call_z"
assert result[1]["tool_call_id"] == "call_z"
assert result[2]["content"] == "Please also summarize it."
def test_unrelated_assistant_text_before_tool_result_is_buffered_until_after_tool_result():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_z", name="Read", input={})],
),
MockMessage("assistant", "Waiting for the result."),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="call_z",
content="file contents",
)
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
]
assert result[0]["tool_calls"][0]["id"] == "call_z"
assert result[1]["tool_call_id"] == "call_z"
assert result[2]["content"] == "Waiting for the result."
def test_user_text_in_tool_result_message_is_replayed_after_tool_sequence():
messages = [
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_z", name="Read", input={}),
MockBlock(type="text", text="Post-tool commentary"),
],
),
MockMessage(
"user",
[
MockBlock(type="text", text="Use this result too."),
MockBlock(
type="tool_result",
tool_use_id="call_z",
content="file contents",
),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"user",
]
assert result[1]["tool_call_id"] == "call_z"
assert result[2]["content"] == "Post-tool commentary"
assert result[3]["content"] == "Use this result too."
def test_nested_pending_tool_use_waits_for_its_own_tool_result_before_deferred_text():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_a", name="ReadA", input={})],
),
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Post-call-b commentary"),
],
),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_a", content="result a")],
),
MockMessage(
"user",
[MockBlock(type="tool_result", tool_use_id="call_b", content="result b")],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"tool",
"assistant",
]
assert result[0]["tool_calls"][0]["id"] == "call_a"
assert result[1]["tool_call_id"] == "call_a"
assert result[2]["tool_calls"][0]["id"] == "call_b"
assert result[3]["tool_call_id"] == "call_b"
assert result[4]["content"] == "Post-call-b commentary"
def test_nested_pending_uses_early_nested_tool_result_after_outer_result():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_a", name="ReadA", input={})],
),
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Post-call-b commentary"),
],
),
MockMessage(
"user",
[
MockBlock(type="tool_result", tool_use_id="call_b", content="result b"),
MockBlock(type="tool_result", tool_use_id="call_a", content="result a"),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"tool",
"assistant",
]
assert result[0]["tool_calls"][0]["id"] == "call_a"
assert result[1]["tool_call_id"] == "call_a"
assert result[2]["tool_calls"][0]["id"] == "call_b"
assert result[3]["tool_call_id"] == "call_b"
assert result[4]["content"] == "Post-call-b commentary"
def test_multi_tool_turn_waits_for_all_results_before_deferred_text():
messages = [
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_a", name="ReadA", input={}),
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Both tools are done."),
],
),
MockMessage(
"user",
[
MockBlock(type="tool_result", tool_use_id="call_b", content="result b"),
MockBlock(type="tool_result", tool_use_id="call_a", content="result a"),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"tool",
"assistant",
]
assert [message["tool_call_id"] for message in result[1:3]] == [
"call_a",
"call_b",
]
assert result[3]["content"] == "Both tools are done."
def test_nested_pending_buffers_user_text_until_all_prior_tool_sequences_complete():
messages = [
MockMessage(
"assistant",
[MockBlock(type="tool_use", id="call_a", name="ReadA", input={})],
),
MockMessage(
"assistant",
[
MockBlock(type="tool_use", id="call_b", name="ReadB", input={}),
MockBlock(type="text", text="Post-call-b commentary"),
],
),
MockMessage(
"user",
[
MockBlock(type="text", text="Use both results."),
MockBlock(
type="tool_result",
tool_use_id="call_a",
content="result a",
),
MockBlock(
type="tool_result",
tool_use_id="call_b",
content="result b",
),
],
),
]
result = AnthropicToOpenAIConverter.convert_messages(messages)
assert [message["role"] for message in result] == [
"assistant",
"tool",
"assistant",
"tool",
"assistant",
"user",
]
assert result[1]["tool_call_id"] == "call_a"
assert result[3]["tool_call_id"] == "call_b"
assert result[4]["content"] == "Post-call-b commentary"
assert result[5]["content"] == "Use both results."
def test_openai_build_accepts_declared_native_top_level_hints() -> None:
"""OpenAI conversion ignores known non-OpenAI hints (e.g. context_management) without 400."""
req = MessagesRequest.model_validate(

View file

@ -388,7 +388,7 @@ def test_tool_history_without_thinking_disables_thinking_and_hints(deepseek_prov
assert body["messages"][1]["role"] == "tool"
def test_tool_history_with_empty_thinking_disables_thinking(deepseek_provider):
def test_tool_history_with_empty_thinking_preserves_reasoning_state(deepseek_provider):
request = MessagesRequest.model_validate(
{
"model": "m",
@ -422,8 +422,49 @@ def test_tool_history_with_empty_thinking_disables_thinking(deepseek_provider):
body = deepseek_provider._build_request_body(request)
assert "extra_body" not in body
assert "reasoning_content" not in body["messages"][0]
assert body["extra_body"]["thinking"] == {"type": "enabled"}
assert body["messages"][0]["reasoning_content"] == ""
assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read"
def test_tool_history_with_empty_top_level_reasoning_preserves_reasoning_state(
deepseek_provider,
):
request = MessagesRequest.model_validate(
{
"model": "m",
"messages": [
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "t1",
"name": "Read",
"input": {"file_path": "x"},
},
],
"reasoning_content": "",
},
{
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": "t1",
"content": "ok",
}
],
},
],
"thinking": {"type": "enabled"},
}
)
body = deepseek_provider._build_request_body(request)
assert body["extra_body"]["thinking"] == {"type": "enabled"}
assert body["messages"][0]["reasoning_content"] == ""
assert body["messages"][0]["tool_calls"][0]["function"]["name"] == "Read"

View file

@ -129,7 +129,17 @@ def test_build_request_body_replays_prior_thinking_as_mistral_chunks(
input={"value": "x"},
),
],
)
),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="toolu_1",
content="result",
)
],
),
],
)
@ -201,7 +211,7 @@ def test_build_request_body_thinking_disabled_strips_prior_mistral_thinking():
MockBlock(type="thinking", thinking="Hidden."),
MockBlock(type="text", text="Visible."),
],
)
),
],
)
@ -360,7 +370,7 @@ async def test_stream_response_preserves_native_thinking_and_string_text(
tool_calls=None,
),
finish_reason="stop",
)
),
],
usage=MagicMock(completion_tokens=2, prompt_tokens=10),
)
@ -521,7 +531,17 @@ async def test_stream_response_retries_without_mistral_reasoning_on_rejection(
input={"value": "FCC_TOOL"},
),
],
)
),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="toolu_reasoning",
content="result",
)
],
),
],
)
@ -579,7 +599,17 @@ async def test_stream_response_reasoning_retry_preserves_visible_text_and_tools(
input={"value": "FCC_TOOL"},
),
],
)
),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="toolu_reasoning",
content="result",
)
],
),
],
)

View file

@ -834,7 +834,17 @@ async def test_stream_response_retries_without_reasoning_content(nim_provider):
input={"value": "FCC_TOOL"},
),
],
)
),
MockMessage(
"user",
[
MockBlock(
type="tool_result",
tool_use_id="toolu_reasoning",
content="result",
)
],
),
],
)

View file

@ -0,0 +1,38 @@
"""Tests for the OpenCode OpenAI-compatible provider."""
from api.models.anthropic import MessagesRequest
from providers.base import ProviderConfig
from providers.opencode import OpenCodeProvider
def test_build_request_body_preserves_empty_reasoning_content() -> None:
provider = OpenCodeProvider(
ProviderConfig(
api_key="test_opencode_key",
base_url="https://example.invalid/v1",
rate_limit=1,
rate_window=1,
enable_thinking=True,
)
)
request = MessagesRequest.model_validate(
{
"model": "m",
"messages": [
{
"role": "assistant",
"content": "visible",
"reasoning_content": "",
}
],
"thinking": {"type": "enabled"},
}
)
body = provider._build_request_body(request)
assert body["messages"][0] == {
"role": "assistant",
"content": "visible",
"reasoning_content": "",
}

View file

@ -99,7 +99,7 @@ def _make_chunk(
delta = MagicMock()
delta.content = content
delta.tool_calls = tool_calls
delta.reasoning_content = reasoning_content if reasoning_content else None
delta.reasoning_content = reasoning_content
choice = MagicMock()
choice.delta = delta
@ -470,6 +470,49 @@ class TestStreamingExceptionHandling:
assert "I think..." in event_text
assert "The answer" in event_text
@pytest.mark.asyncio
async def test_stream_with_empty_reasoning_content_starts_thinking_block_only(self):
"""Empty reasoning_content is stateful but must not emit visible thinking text."""
provider = _make_provider()
request = _make_request()
chunk1 = _make_chunk(reasoning_content="")
chunk2 = _make_chunk(finish_reason="stop")
stream_mock = AsyncStreamMock([chunk1, chunk2])
with (
patch.object(
provider._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=stream_mock,
),
patch.object(
provider._global_rate_limiter,
"wait_if_blocked",
new_callable=AsyncMock,
return_value=False,
),
):
events = await _collect_stream(provider, request)
parsed = parse_sse_text("".join(events))
thinking_starts = [
event
for event in parsed
if event.event == "content_block_start"
and event.data["content_block"]["type"] == "thinking"
]
thinking_deltas = [
event
for event in parsed
if event.event == "content_block_delta"
and event.data["delta"]["type"] == "thinking_delta"
]
assert len(thinking_starts) == 1
assert thinking_deltas == []
assert parsed[-1].event == "message_stop"
@pytest.mark.asyncio
async def test_stream_with_reasoning_content_suppressed_when_disabled(self):
"""reasoning deltas are stripped while normal text still streams."""

2
uv.lock generated
View file

@ -561,7 +561,7 @@ wheels = [
[[package]]
name = "free-claude-code"
version = "3.4.6"
version = "3.4.7"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },