diff --git a/litellm/llms/anthropic/pass_through/responses_adapters/streaming_iterator.py b/litellm/llms/anthropic/pass_through/responses_adapters/streaming_iterator.py index 01f6475768e..b82315eba03 100644 --- a/litellm/llms/anthropic/pass_through/responses_adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/pass_through/responses_adapters/streaming_iterator.py @@ -137,7 +137,7 @@ class AnthropicResponsesStreamWrapper: self._stream_failed = False self._chunk_queue: deque[dict[str, object]] = deque() self._refusal_text: str = "" - self._reasoning_text_parts_seen: frozenset[tuple[str | None, int]] = frozenset() + self._reasoning_text_parts_seen: frozenset[tuple[str | None, bool, int]] = frozenset() self._sync_responses_iterator: Iterator[object] | None = None def _make_message_start(self) -> dict[str, object]: @@ -303,6 +303,10 @@ class AnthropicResponsesStreamWrapper: if event_type == "response.reasoning_summary_part.added": part_item_id: Final = self._field(event, "item_id") + if any( + item_id == part_item_id and not is_summary for item_id, is_summary, _ in self._reasoning_text_parts_seen + ): + return summary_index: Final = self._field(event, "summary_index") part_block_idx: Final = ( self._item_id_to_block_index.get(part_item_id, -1) if isinstance(part_item_id, str) else -1 @@ -328,8 +332,14 @@ class AnthropicResponsesStreamWrapper: ): reasoning_item_id: Final = self._field(event, "item_id") reasoning_id: Final = reasoning_item_id if isinstance(reasoning_item_id, str) else None - content_index: Final = self._field(event, "content_index") - part_key: Final = (reasoning_id, content_index if isinstance(content_index, int) else 0) + is_summary: Final = event_type == "response.reasoning_summary_text.delta" + if any( + item_id == reasoning_id and summary != is_summary + for item_id, summary, _ in self._reasoning_text_parts_seen + ): + return + content_index: Final = self._field(event, "summary_index" if is_summary else "content_index") + part_key: Final = (reasoning_id, is_summary, content_index if isinstance(content_index, int) else 0) if event_type == "response.reasoning_text.done" and part_key in self._reasoning_text_parts_seen: return reasoning_text: Final = self._field( @@ -337,8 +347,13 @@ class AnthropicResponsesStreamWrapper: ) if not isinstance(reasoning_text, str) or not reasoning_text: return - if event_type != "response.reasoning_summary_text.delta": - self._reasoning_text_parts_seen = self._reasoning_text_parts_seen | frozenset((part_key,)) + if ( + not is_summary + and part_key not in self._reasoning_text_parts_seen + and any(item_id == reasoning_id for item_id, _, _ in self._reasoning_text_parts_seen) + ): + self._emit_thinking_text(reasoning_id, REASONING_SUMMARY_PART_SEPARATOR) + self._reasoning_text_parts_seen = self._reasoning_text_parts_seen | frozenset((part_key,)) self._emit_thinking_text(reasoning_id, reasoning_text) return diff --git a/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py b/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py index e5c19a2d919..ea9ee98e2b8 100644 --- a/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py +++ b/tests/unit/llms/anthropic/pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py @@ -31,8 +31,11 @@ from litellm.types.llms.openai import ResponseFailedEvent, ResponsesAPIResponse @pytest.mark.asyncio @pytest.mark.parametrize("delivery", ["delta", "done", "item"]) @pytest.mark.parametrize("encrypted_content", [None, "opaque-reasoning"]) +@pytest.mark.parametrize("summary_order", ["absent", "first", "last"]) async def test_reasoning_text_reaches_client_and_survives_replay( - delivery: Literal["delta", "done", "item"], encrypted_content: str | None + delivery: Literal["delta", "done", "item"], + encrypted_content: str | None, + summary_order: Literal["absent", "first", "last"], ) -> None: from litellm.llms.anthropic.pass_through.responses_adapters.transformation import ( LiteLLMAnthropicToResponsesAPIAdapter, @@ -42,13 +45,24 @@ async def test_reasoning_text_reaches_client_and_survives_replay( reasoning: Final = { "type": "reasoning", "id": "rs_text", - "summary": [], - "content": [{"type": "reasoning_text", "text": "Use a tool"}], + "summary": [] + if summary_order == "absent" + else [{"type": "summary_text", "text": "Tool"}, {"type": "summary_text", "text": "needed"}], + "content": [ + {"type": "reasoning_text", "text": "Use a tool"}, + {"type": "reasoning_text", "text": "Then answer"}, + ], **({"encrypted_content": encrypted_content} if encrypted_content is not None else {}), } + summary_events: Final = ( + {"type": "response.reasoning_summary_text.delta", "item_id": "rs_text", "summary_index": 0, "delta": "Tool"}, + {"type": "response.reasoning_summary_part.added", "item_id": "rs_text", "summary_index": 1}, + {"type": "response.reasoning_summary_text.delta", "item_id": "rs_text", "summary_index": 1, "delta": "needed"}, + ) events: Final = ( {"type": "response.created"}, {"type": "response.output_item.added", "item": {"type": "reasoning", "id": "rs_text"}}, + *(summary_events if summary_order == "first" and delivery != "item" else ()), *( ( {"type": "response.reasoning_text.delta", "item_id": "rs_text", "content_index": 0, "delta": "Use "}, @@ -58,10 +72,14 @@ async def test_reasoning_text_reaches_client_and_survives_replay( else () ), *( - ({"type": "response.reasoning_text.done", "item_id": "rs_text", "content_index": 0, "text": "Use a tool"},) + ( + {"type": "response.reasoning_text.done", "item_id": "rs_text", "content_index": 0, "text": "Use a tool"}, + {"type": "response.reasoning_text.done", "item_id": "rs_text", "content_index": 1, "text": "Then answer"}, + ) if delivery != "item" else () ), + *(summary_events if summary_order == "last" and delivery != "item" else ()), {"type": "response.output_item.done", "item": reasoning}, {"type": "response.completed"}, ) @@ -85,7 +103,9 @@ async def test_reasoning_text_reaches_client_and_survives_replay( ], ) - assert thinking == "Use a tool" + assert thinking == ( + "Tool\n\nneeded" if summary_order == "first" and delivery != "item" else "Use a tool\n\nThen answer" + ) assert LiteLLMAnthropicToResponsesAPIAdapter().translate_messages_to_responses_input(request["messages"]) == [ {key: value for key, value in reasoning.items() if key != "id"} ] diff --git a/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py b/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py index 0a5039791ee..3d65bd09d93 100644 --- a/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py +++ b/tests/unit/llms/openai/responses/test_openai_count_tokens_transformation.py @@ -573,12 +573,12 @@ def test_anthropic_messages_keep_every_block_the_chat_converter_drops() -> None: {"type": "input_image", "image_url": f"data:image/png;base64,{PNG_BASE64}", "detail": "auto"}, ], }, - {"type": "function_call", "call_id": "toolu_01", "name": "lookup_clause", "arguments": '{"id": "14"}'}, { "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Let me pull clause 14."}], }, + {"type": "function_call", "call_id": "toolu_01", "name": "lookup_clause", "arguments": '{"id": "14"}'}, {"type": "function_call_output", "call_id": "toolu_01", "output": "14. Indemnification."}, )