mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-06 07:48:06 +00:00
fix(anthropic): keep streamed reasoning representations separate
This commit is contained in:
parent
362ada411d
commit
443a353a72
3 changed files with 46 additions and 11 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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."},
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue