diff --git a/core/anthropic/conversion.py b/core/anthropic/conversion.py index 7410be37..de747169 100644 --- a/core/anthropic/conversion.py +++ b/core/anthropic/conversion.py @@ -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]] = [] diff --git a/core/openai_responses/input.py b/core/openai_responses/input.py index 5fefdd8d..a06162cf 100644 --- a/core/openai_responses/input.py +++ b/core/openai_responses/input.py @@ -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 [] diff --git a/core/openai_responses/reasoning.py b/core/openai_responses/reasoning.py index d867ddf6..12a732fa 100644 --- a/core/openai_responses/reasoning.py +++ b/core/openai_responses/reasoning.py @@ -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 diff --git a/providers/deepseek/compat.py b/providers/deepseek/compat.py index 4e400f26..5448744b 100644 --- a/providers/deepseek/compat.py +++ b/providers/deepseek/compat.py @@ -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 diff --git a/providers/transports/openai_chat/stream.py b/providers/transports/openai_chat/stream.py index 376e335d..e3c4d5c8 100644 --- a/providers/transports/openai_chat/stream.py +++ b/providers/transports/openai_chat/stream.py @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 5bd5a6c2..a27fc8ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/core/openai_responses/test_conversion.py b/tests/core/openai_responses/test_conversion.py index 00d8e8bf..fd525879 100644 --- a/tests/core/openai_responses/test_conversion.py +++ b/tests/core/openai_responses/test_conversion.py @@ -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( diff --git a/tests/providers/test_converter.py b/tests/providers/test_converter.py index 376fe436..46cbcc93 100644 --- a/tests/providers/test_converter.py +++ b/tests/providers/test_converter.py @@ -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 "" 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 "" 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 = "\nFirst thought.\n\n\nHere is the answer.\n\n\nSecond thought.\n" @@ -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( diff --git a/tests/providers/test_deepseek.py b/tests/providers/test_deepseek.py index b64d7922..7a976861 100644 --- a/tests/providers/test_deepseek.py +++ b/tests/providers/test_deepseek.py @@ -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" diff --git a/tests/providers/test_mistral.py b/tests/providers/test_mistral.py index ac6a8b17..01ee9e32 100644 --- a/tests/providers/test_mistral.py +++ b/tests/providers/test_mistral.py @@ -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", + ) + ], + ), ], ) diff --git a/tests/providers/test_nvidia_nim.py b/tests/providers/test_nvidia_nim.py index 82a19a74..6513cbd7 100644 --- a/tests/providers/test_nvidia_nim.py +++ b/tests/providers/test_nvidia_nim.py @@ -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", + ) + ], + ), ], ) diff --git a/tests/providers/test_opencode.py b/tests/providers/test_opencode.py new file mode 100644 index 00000000..ce072a87 --- /dev/null +++ b/tests/providers/test_opencode.py @@ -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": "", + } diff --git a/tests/providers/test_streaming_errors.py b/tests/providers/test_streaming_errors.py index 14fe24c0..b803391e 100644 --- a/tests/providers/test_streaming_errors.py +++ b/tests/providers/test_streaming_errors.py @@ -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.""" diff --git a/uv.lock b/uv.lock index a631ee71..792e7cf8 100644 --- a/uv.lock +++ b/uv.lock @@ -561,7 +561,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "3.4.6" +version = "3.4.7" source = { editable = "." } dependencies = [ { name = "aiohttp" },