Skip to content

Commit 6a37210

Browse files
gurjot-05i-yliu
authored andcommitted
fix(LiteLlm): preserve signature-only blocks for streaming thinking aggregation
Merge #5437 Original PR by Gurjot Singh <gurjot14143132@gmail.com> Co-authored-by: Yi Liu <yiliuly@google.com> PiperOrigin-RevId: 933780190
1 parent e6df097 commit 6a37210

2 files changed

Lines changed: 192 additions & 10 deletions

File tree

src/google/adk/models/lite_llm.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -543,9 +543,11 @@ def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]:
543543
continue
544544
if block_type == "thinking":
545545
thinking_text = block.get("thinking", "")
546-
if thinking_text:
546+
signature = block.get("signature")
547+
# Anthropic streams a signature in a final chunk with empty text.
548+
# Preserve signature-only blocks so the signature survives aggregation.
549+
if thinking_text or signature:
547550
part = types.Part(text=thinking_text, thought=True)
548-
signature = block.get("signature")
549551
if signature:
550552
decoded_signature = _decode_thought_signature(signature)
551553
part.thought_signature = decoded_signature or str(
@@ -565,6 +567,43 @@ def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]:
565567
]
566568

567569

570+
def _aggregate_streaming_thought_parts(
571+
thought_parts: Iterable[types.Part],
572+
) -> List[types.Part]:
573+
"""Aggregates fragmented streaming thought parts into clean individual parts.
574+
575+
During streaming, Anthropic splits a thinking block across many deltas:
576+
text-only chunks followed by a signature-only chunk at block_stop. This helper
577+
joins the text chunks and attaches the signature, producing clean individual
578+
thought parts for session history and outbound requests.
579+
"""
580+
parts_list = list(thought_parts)
581+
if not parts_list:
582+
return []
583+
aggregated: List[types.Part] = []
584+
current_texts: List[str] = []
585+
for part in parts_list:
586+
if part.text:
587+
current_texts.append(part.text)
588+
if part.thought_signature:
589+
aggregated.append(
590+
types.Part(
591+
text="".join(current_texts),
592+
thought=True,
593+
thought_signature=part.thought_signature,
594+
)
595+
)
596+
current_texts = []
597+
if current_texts:
598+
aggregated.append(
599+
types.Part(
600+
text="".join(current_texts),
601+
thought=True,
602+
)
603+
)
604+
return aggregated
605+
606+
568607
def _extract_reasoning_value(message: Message | Delta | None) -> Any:
569608
"""Fetches the reasoning payload from a LiteLLM message.
570609
@@ -1023,17 +1062,24 @@ async def _content_to_message_param(
10231062
# For Anthropic models, rebuild thinking_blocks with signatures so that
10241063
# thinking is preserved across tool call boundaries. Without this,
10251064
# Anthropic silently drops thinking after the first turn.
1065+
#
1066+
# Streaming splits one Anthropic thinking block across many deltas:
1067+
# text-only chunks followed by a signature-only chunk at block_stop.
1068+
# Aggregate them back into one thinking block for outbound.
10261069
if model and _is_anthropic_model(model) and reasoning_parts:
1070+
aggregated_parts = _aggregate_streaming_thought_parts(reasoning_parts)
10271071
thinking_blocks = []
1028-
for part in reasoning_parts:
1072+
for part in aggregated_parts:
10291073
if part.text and part.thought_signature:
10301074
sig = part.thought_signature
10311075
if isinstance(sig, bytes):
1032-
sig = base64.b64encode(sig).decode("utf-8")
1076+
sig_str = base64.b64encode(sig).decode("utf-8")
1077+
else:
1078+
sig_str = str(sig)
10331079
thinking_blocks.append({
10341080
"type": "thinking",
10351081
"thinking": part.text,
1036-
"signature": sig,
1082+
"signature": sig_str,
10371083
})
10381084
if thinking_blocks:
10391085
msg = ChatCompletionAssistantMessage(
@@ -1877,6 +1923,11 @@ def _has_meaningful_signal(message: Message | Delta | None) -> bool:
18771923
or message.get("function_call")
18781924
or message.get("reasoning_content")
18791925
or message.get("reasoning")
1926+
# Anthropic streams the thinking block's signature in a final delta
1927+
# where content/reasoning_content are empty and only thinking_blocks
1928+
# carries the signature. Without this, the delta is discarded before
1929+
# _extract_reasoning_value can preserve the signature.
1930+
or message.get("thinking_blocks")
18801931
)
18811932

18821933
if isinstance(response, ModelResponseStream):

tests/unittests/models/test_litellm.py

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from unittest.mock import patch
2828
import warnings
2929

30+
from google.adk.models.lite_llm import _aggregate_streaming_thought_parts
3031
from google.adk.models.lite_llm import _append_fallback_user_content_if_missing
3132
from google.adk.models.lite_llm import _content_to_message_param
3233
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():
53965397
assert parts[0].text == "visible"
53975398

53985399

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+
"""
54015407
thinking_blocks = [
54025408
{"type": "thinking", "thinking": "", "signature": "c2lnMQ=="},
54035409
{"type": "thinking", "thinking": "real thought", "signature": "c2lnMg=="},
5410+
{
5411+
"type": "thinking",
5412+
"thinking": "",
5413+
"signature": "",
5414+
}, # fully empty: drop
54045415
]
54055416
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
54085442

54095443

54105444
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
54785512
assert result.get("reasoning_content") is None
54795513

54805514

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+
54815542
@pytest.mark.asyncio
54825543
async def test_content_to_message_param_non_anthropic_uses_reasoning_content():
54835544
"""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()
55775638
"type": "thinking",
55785639
"thinking": "",
55795640
"text": "leaked",
5580-
"signature": "c2ln",
5641+
"signature": "",
55815642
},
55825643
]
55835644
parts = _convert_reasoning_value_to_parts(thinking_blocks)
@@ -5678,6 +5739,76 @@ async def test_content_to_message_param_anthropic_provider_embeds_thinking_block
56785739
assert result.get("reasoning_content") is None
56795740

56805741

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+
56815812
@pytest.mark.asyncio
56825813
@pytest.mark.parametrize(
56835814
"log_level,should_call",

0 commit comments

Comments
 (0)