|
27 | 27 | from unittest.mock import patch |
28 | 28 | import warnings |
29 | 29 |
|
| 30 | +from google.adk.models.lite_llm import _aggregate_streaming_thought_parts |
30 | 31 | from google.adk.models.lite_llm import _append_fallback_user_content_if_missing |
31 | 32 | from google.adk.models.lite_llm import _content_to_message_param |
32 | 33 | from google.adk.models.lite_llm import _convert_reasoning_value_to_parts |
@@ -5396,15 +5397,48 @@ def test_convert_reasoning_value_to_parts_skips_redacted_blocks(): |
5396 | 5397 | assert parts[0].text == "visible" |
5397 | 5398 |
|
5398 | 5399 |
|
5399 | | -def test_convert_reasoning_value_to_parts_skips_empty_thinking(): |
5400 | | - """Blocks with empty thinking text are excluded.""" |
| 5400 | +def test_convert_reasoning_value_to_parts_preserves_signature_only_blocks(): |
| 5401 | + """Signature-only blocks (empty text) are preserved for streaming aggregation. |
| 5402 | +
|
| 5403 | + Anthropic emits the block_stop signature as a delta with empty thinking text. |
| 5404 | + Dropping it would lose the signature, breaking multi-turn thinking continuity. |
| 5405 | + Blocks with neither text nor signature are still skipped. |
| 5406 | + """ |
5401 | 5407 | thinking_blocks = [ |
5402 | 5408 | {"type": "thinking", "thinking": "", "signature": "c2lnMQ=="}, |
5403 | 5409 | {"type": "thinking", "thinking": "real thought", "signature": "c2lnMg=="}, |
| 5410 | + { |
| 5411 | + "type": "thinking", |
| 5412 | + "thinking": "", |
| 5413 | + "signature": "", |
| 5414 | + }, # fully empty: drop |
5404 | 5415 | ] |
5405 | 5416 | parts = _convert_reasoning_value_to_parts(thinking_blocks) |
5406 | | - assert len(parts) == 1 |
5407 | | - assert parts[0].text == "real thought" |
| 5417 | + assert len(parts) == 2 |
| 5418 | + assert parts[0].text == "" |
| 5419 | + assert parts[0].thought is True |
| 5420 | + assert parts[0].thought_signature == b"sig1" |
| 5421 | + assert parts[1].text == "real thought" |
| 5422 | + assert parts[1].thought_signature == b"sig2" |
| 5423 | + |
| 5424 | + |
| 5425 | +def test_aggregate_streaming_thought_parts(): |
| 5426 | + """Tests aggregating fragmented streaming thought parts and multiple blocks.""" |
| 5427 | + parts = [ |
| 5428 | + types.Part(text="First block ", thought=True), |
| 5429 | + types.Part(text="text.", thought=True), |
| 5430 | + types.Part(text="", thought=True, thought_signature=b"sig1"), |
| 5431 | + types.Part(text="Second block", thought=True, thought_signature=b"sig2"), |
| 5432 | + types.Part(text="Trailing without sig", thought=True), |
| 5433 | + ] |
| 5434 | + aggregated = _aggregate_streaming_thought_parts(parts) |
| 5435 | + assert len(aggregated) == 3 |
| 5436 | + assert aggregated[0].text == "First block text." |
| 5437 | + assert aggregated[0].thought_signature == b"sig1" |
| 5438 | + assert aggregated[1].text == "Second block" |
| 5439 | + assert aggregated[1].thought_signature == b"sig2" |
| 5440 | + assert aggregated[2].text == "Trailing without sig" |
| 5441 | + assert aggregated[2].thought_signature is None |
5408 | 5442 |
|
5409 | 5443 |
|
5410 | 5444 | def test_convert_reasoning_value_to_parts_flat_string_unchanged(): |
@@ -5478,6 +5512,33 @@ async def test_content_to_message_param_anthropic_model_round_trip_preserves_sig |
5478 | 5512 | assert result.get("reasoning_content") is None |
5479 | 5513 |
|
5480 | 5514 |
|
| 5515 | +@pytest.mark.asyncio |
| 5516 | +async def test_content_to_message_param_anthropic_split_thinking_and_signature(): |
| 5517 | + """Combines separate thinking and signature parts into a single thinking_block.""" |
| 5518 | + content = types.Content( |
| 5519 | + role="model", |
| 5520 | + parts=[ |
| 5521 | + types.Part(text="deep thought", thought=True), |
| 5522 | + types.Part( |
| 5523 | + text="", thought=True, thought_signature=b"sig_round_trip" |
| 5524 | + ), |
| 5525 | + types.Part(text="Hello!"), |
| 5526 | + ], |
| 5527 | + ) |
| 5528 | + result = await _content_to_message_param( |
| 5529 | + content, model="anthropic/claude-4-sonnet" |
| 5530 | + ) |
| 5531 | + assert result["role"] == "assistant" |
| 5532 | + assert "thinking_blocks" in result |
| 5533 | + assert result.get("reasoning_content") is None |
| 5534 | + blocks = result["thinking_blocks"] |
| 5535 | + assert len(blocks) == 1 |
| 5536 | + assert blocks[0]["type"] == "thinking" |
| 5537 | + assert blocks[0]["thinking"] == "deep thought" |
| 5538 | + assert blocks[0]["signature"] == "c2lnX3JvdW5kX3RyaXA=" |
| 5539 | + assert result["content"] == "Hello!" |
| 5540 | + |
| 5541 | + |
5481 | 5542 | @pytest.mark.asyncio |
5482 | 5543 | async def test_content_to_message_param_non_anthropic_uses_reasoning_content(): |
5483 | 5544 | """For non-Anthropic models, reasoning_content is used as before.""" |
@@ -5577,7 +5638,7 @@ def test_convert_reasoning_value_to_parts_empty_thinking_does_not_fall_through() |
5577 | 5638 | "type": "thinking", |
5578 | 5639 | "thinking": "", |
5579 | 5640 | "text": "leaked", |
5580 | | - "signature": "c2ln", |
| 5641 | + "signature": "", |
5581 | 5642 | }, |
5582 | 5643 | ] |
5583 | 5644 | parts = _convert_reasoning_value_to_parts(thinking_blocks) |
@@ -5678,6 +5739,76 @@ async def test_content_to_message_param_anthropic_provider_embeds_thinking_block |
5678 | 5739 | assert result.get("reasoning_content") is None |
5679 | 5740 |
|
5680 | 5741 |
|
| 5742 | +@pytest.mark.asyncio |
| 5743 | +async def test_content_to_message_param_anthropic_aggregates_streaming_split_thinking(): |
| 5744 | + """Streaming splits one Anthropic thinking block across many parts: |
| 5745 | + text-only chunks followed by a signature-only chunk at block_stop. |
| 5746 | + _content_to_message_param must re-join them into one thinking_block. |
| 5747 | + """ |
| 5748 | + content = types.Content( |
| 5749 | + role="model", |
| 5750 | + parts=[ |
| 5751 | + # Text-only chunks from streaming deltas (no signature) |
| 5752 | + types.Part(text="The user wants ", thought=True), |
| 5753 | + types.Part(text="GST research ", thought=True), |
| 5754 | + types.Part(text="on secondment.", thought=True), |
| 5755 | + # Final signature-only chunk (empty text, signature carries the whole block) |
| 5756 | + types.Part( |
| 5757 | + text="", thought=True, thought_signature=b"ErEDClsIDBACGAIfull" |
| 5758 | + ), |
| 5759 | + # Non-thought response content |
| 5760 | + types.Part.from_function_call(name="create_plan", args={"q": "test"}), |
| 5761 | + ], |
| 5762 | + ) |
| 5763 | + result = await _content_to_message_param( |
| 5764 | + content, model="anthropic/claude-4-sonnet" |
| 5765 | + ) |
| 5766 | + # One aggregated thinking block with combined text and the block's signature |
| 5767 | + blocks = result["thinking_blocks"] |
| 5768 | + assert len(blocks) == 1 |
| 5769 | + assert blocks[0]["type"] == "thinking" |
| 5770 | + assert blocks[0]["thinking"] == "The user wants GST research on secondment." |
| 5771 | + assert blocks[0]["signature"] == "RXJFRENsc0lEQkFDR0FJZnVsbA==" |
| 5772 | + # Legacy reasoning_content is not set when the Anthropic branch takes |
| 5773 | + assert result.get("reasoning_content") is None |
| 5774 | + |
| 5775 | + |
| 5776 | +def test_model_response_to_chunk_preserves_signature_only_delta(): |
| 5777 | + """Anthropic streams a final thinking delta where content and |
| 5778 | + reasoning_content are empty but thinking_blocks carries the signature. |
| 5779 | + _has_meaningful_signal must recognize thinking_blocks as signal so the |
| 5780 | + signature survives into a ReasoningChunk. |
| 5781 | + """ |
| 5782 | + stream = ModelResponseStream( |
| 5783 | + id="x", |
| 5784 | + created=0, |
| 5785 | + model="claude", |
| 5786 | + choices=[ |
| 5787 | + StreamingChoices( |
| 5788 | + index=0, |
| 5789 | + delta=Delta( |
| 5790 | + role=None, |
| 5791 | + content="", |
| 5792 | + reasoning_content="", |
| 5793 | + thinking_blocks=[{ |
| 5794 | + "type": "thinking", |
| 5795 | + "thinking": "", |
| 5796 | + "signature": "SignatureOnlyChunk", |
| 5797 | + }], |
| 5798 | + ), |
| 5799 | + ) |
| 5800 | + ], |
| 5801 | + ) |
| 5802 | + chunks = list(_model_response_to_chunk(stream)) |
| 5803 | + reasoning_chunks = [c for c, _ in chunks if isinstance(c, ReasoningChunk)] |
| 5804 | + assert len(reasoning_chunks) == 1 |
| 5805 | + parts = reasoning_chunks[0].parts |
| 5806 | + assert len(parts) == 1 |
| 5807 | + assert parts[0].text == "" |
| 5808 | + assert parts[0].thought is True |
| 5809 | + assert parts[0].thought_signature == b"SignatureOnlyChunk" |
| 5810 | + |
| 5811 | + |
5681 | 5812 | @pytest.mark.asyncio |
5682 | 5813 | @pytest.mark.parametrize( |
5683 | 5814 | "log_level,should_call", |
|
0 commit comments