Skip to content

Commit 03c8681

Browse files
committed
test(lite_llm): cover Anthropic streaming thinking-block signature preservation
Updates the existing _convert_reasoning_value_to_parts test to reflect the new contract: signature-only blocks (empty thinking text) are preserved so the signature survives streaming aggregation. Adds two new tests: - test_content_to_message_param_anthropic_aggregates_streaming_split_thinking covers the outbound aggregation: multiple streaming-split thought parts (text chunks plus a final signature-only chunk) are rejoined into one thinking_block for Anthropic models. - test_model_response_to_chunk_preserves_signature_only_delta covers the streaming-path fix: _has_meaningful_signal recognizes thinking_blocks as signal, so a delta with empty content/reasoning_content but a signature survives into a ReasoningChunk.
1 parent 66e6640 commit 03c8681

1 file changed

Lines changed: 82 additions & 4 deletions

File tree

tests/unittests/models/test_litellm.py

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4774,15 +4774,25 @@ def test_convert_reasoning_value_to_parts_skips_redacted_blocks():
47744774
assert parts[0].text == "visible"
47754775

47764776

4777-
def test_convert_reasoning_value_to_parts_skips_empty_thinking():
4778-
"""Blocks with empty thinking text are excluded."""
4777+
def test_convert_reasoning_value_to_parts_preserves_signature_only_blocks():
4778+
"""Signature-only blocks (empty text) are preserved for streaming aggregation.
4779+
4780+
Anthropic emits the block_stop signature as a delta with empty thinking text.
4781+
Dropping it would lose the signature, breaking multi-turn thinking continuity.
4782+
Blocks with neither text nor signature are still skipped.
4783+
"""
47794784
thinking_blocks = [
47804785
{"type": "thinking", "thinking": "", "signature": "sig1"},
47814786
{"type": "thinking", "thinking": "real thought", "signature": "sig2"},
4787+
{"type": "thinking", "thinking": "", "signature": ""}, # fully empty: drop
47824788
]
47834789
parts = _convert_reasoning_value_to_parts(thinking_blocks)
4784-
assert len(parts) == 1
4785-
assert parts[0].text == "real thought"
4790+
assert len(parts) == 2
4791+
assert parts[0].text == ""
4792+
assert parts[0].thought is True
4793+
assert parts[0].thought_signature == b"sig1"
4794+
assert parts[1].text == "real thought"
4795+
assert parts[1].thought_signature == b"sig2"
47864796

47874797

47884798
def test_convert_reasoning_value_to_parts_flat_string_unchanged():
@@ -4894,3 +4904,71 @@ async def test_content_to_message_param_anthropic_no_signature_falls_back():
48944904
# Falls back to reasoning_content when no signatures present
48954905
assert result.get("reasoning_content") == "thinking without sig"
48964906
assert "thinking_blocks" not in result
4907+
4908+
4909+
@pytest.mark.asyncio
4910+
async def test_content_to_message_param_anthropic_aggregates_streaming_split_thinking():
4911+
"""Streaming splits one Anthropic thinking block across many parts:
4912+
text-only chunks followed by a signature-only chunk at block_stop.
4913+
_content_to_message_param must re-join them into one thinking_block.
4914+
"""
4915+
content = types.Content(
4916+
role="model",
4917+
parts=[
4918+
# Text-only chunks from streaming deltas (no signature)
4919+
types.Part(text="The user wants ", thought=True),
4920+
types.Part(text="GST research ", thought=True),
4921+
types.Part(text="on secondment.", thought=True),
4922+
# Final signature-only chunk (empty text, signature carries the whole block)
4923+
types.Part(text="", thought=True, thought_signature=b"ErEDClsIDBACGAIfull"),
4924+
# Non-thought response content
4925+
types.Part.from_function_call(name="create_plan", args={"q": "test"}),
4926+
],
4927+
)
4928+
result = await _content_to_message_param(
4929+
content, model="anthropic/claude-4-sonnet"
4930+
)
4931+
# One aggregated thinking block with combined text and the block's signature
4932+
blocks = result["thinking_blocks"]
4933+
assert len(blocks) == 1
4934+
assert blocks[0]["type"] == "thinking"
4935+
assert blocks[0]["thinking"] == "The user wants GST research on secondment."
4936+
assert blocks[0]["signature"] == "ErEDClsIDBACGAIfull"
4937+
# Legacy reasoning_content is not set when the Anthropic branch takes
4938+
assert result.get("reasoning_content") is None
4939+
4940+
4941+
def test_model_response_to_chunk_preserves_signature_only_delta():
4942+
"""Anthropic streams a final thinking delta where content and
4943+
reasoning_content are empty but thinking_blocks carries the signature.
4944+
_has_meaningful_signal must recognize thinking_blocks as signal so the
4945+
signature survives into a ReasoningChunk.
4946+
"""
4947+
stream = ModelResponseStream(
4948+
id="x",
4949+
created=0,
4950+
model="claude",
4951+
choices=[
4952+
StreamingChoices(
4953+
index=0,
4954+
delta=Delta(
4955+
role=None,
4956+
content="",
4957+
reasoning_content="",
4958+
thinking_blocks=[{
4959+
"type": "thinking",
4960+
"thinking": "",
4961+
"signature": "SignatureOnlyChunk",
4962+
}],
4963+
),
4964+
)
4965+
],
4966+
)
4967+
chunks = list(_model_response_to_chunk(stream))
4968+
reasoning_chunks = [c for c, _ in chunks if isinstance(c, ReasoningChunk)]
4969+
assert len(reasoning_chunks) == 1
4970+
parts = reasoning_chunks[0].parts
4971+
assert len(parts) == 1
4972+
assert parts[0].text == ""
4973+
assert parts[0].thought is True
4974+
assert parts[0].thought_signature == b"SignatureOnlyChunk"

0 commit comments

Comments
 (0)