Skip to content

Commit bb56aa5

Browse files
vishwa180copybara-github
authored andcommitted
fix(models): capture Anthropic thinking-block signatures during streaming
When streaming a Claude response with extended thinking enabled, `AnthropicLlm.generate_content_async` consumed `thinking_delta` and `text_delta` content-block deltas but ignored `signature_delta`. The aggregated thinking `Part` was therefore assembled without its `thought_signature`. On a follow-up request within the same turn (for example, after a tool call), that signature-less thinking block cannot be serialized back into a valid Anthropic `thinking` block, breaking multi-step (tool-calling) turns when streaming is enabled. The non-streaming path is unaffected because it already captures the signature via `content_block_to_part`. Handle `SignatureDelta` in the streaming content-block-delta loop so the signature is accumulated and attached to the aggregated thinking `Part`, matching the non-streaming path. Extended-thinking + tool-use turns now round-trip correctly under streaming. Adds a regression test asserting that a streamed `signature_delta` lands on the final thinking part and round-trips to a valid Anthropic thinking block. Co-authored-by: Vishwa Murugan <vishwamurugan@google.com> PiperOrigin-RevId: 952256615
1 parent d4f157d commit bb56aa5

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

src/google/adk/models/anthropic_llm.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,6 +829,20 @@ async def _generate_content_streaming(
829829
),
830830
partial=True,
831831
)
832+
elif isinstance(delta, anthropic_types.SignatureDelta):
833+
# Claude streams the thinking block's cryptographic signature as a
834+
# separate delta near the end of the block. Accumulate it so the
835+
# aggregated thinking Part below carries ``thought_signature``.
836+
# Without it the reasoning block cannot round-trip back to Claude on
837+
# the next request -- extended thinking + tool use requires echoing
838+
# the signed thinking blocks, and re-serializing history for the
839+
# follow-up call would otherwise fail. Not surfaced as a partial (the
840+
# signature is opaque, not user-visible text).
841+
thinking_blocks.setdefault(
842+
event.index,
843+
_ThinkingAccumulator(thinking="", signature=""),
844+
)
845+
thinking_blocks[event.index].signature += delta.signature
832846
elif isinstance(delta, anthropic_types.TextDelta):
833847
text_blocks.setdefault(event.index, "")
834848
text_blocks[event.index] += delta.text

tests/unittests/models/test_anthropic_llm.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,6 +1919,83 @@ async def test_streaming_thinking_yields_partial_and_final():
19191919
assert final.usage_metadata.candidates_token_count == 10
19201920

19211921

1922+
@pytest.mark.asyncio
1923+
async def test_streaming_thinking_captures_signature_delta():
1924+
"""A streamed signature_delta must land on the final thinking Part.
1925+
1926+
Without this the aggregated thinking Part has no ``thought_signature`` and
1927+
cannot round-trip back to Claude on the follow-up request after a tool call
1928+
(extended thinking + tool use), which raises when re-serializing history.
1929+
"""
1930+
llm = AnthropicLlm(model="claude-sonnet-4-20250514")
1931+
1932+
events = [
1933+
MagicMock(
1934+
type="message_start",
1935+
message=MagicMock(usage=MagicMock(input_tokens=15, output_tokens=0)),
1936+
),
1937+
MagicMock(
1938+
type="content_block_start",
1939+
index=0,
1940+
content_block=anthropic_types.ThinkingBlock(
1941+
thinking="", signature="", type="thinking"
1942+
),
1943+
),
1944+
MagicMock(
1945+
type="content_block_delta",
1946+
index=0,
1947+
delta=anthropic_types.ThinkingDelta(
1948+
thinking="Reason.", type="thinking_delta"
1949+
),
1950+
),
1951+
MagicMock(
1952+
type="content_block_delta",
1953+
index=0,
1954+
delta=anthropic_types.SignatureDelta(
1955+
signature="sig_stream_123", type="signature_delta"
1956+
),
1957+
),
1958+
MagicMock(type="content_block_stop", index=0),
1959+
MagicMock(
1960+
type="message_delta",
1961+
delta=MagicMock(stop_reason="end_turn"),
1962+
usage=MagicMock(output_tokens=5),
1963+
),
1964+
MagicMock(type="message_stop"),
1965+
]
1966+
1967+
mock_client = MagicMock()
1968+
mock_client.messages.create = AsyncMock(
1969+
return_value=_make_mock_stream_events(events)
1970+
)
1971+
request = LlmRequest(
1972+
model="claude-sonnet-4-20250514",
1973+
contents=[Content(role="user", parts=[Part.from_text(text="What?")])],
1974+
config=types.GenerateContentConfig(
1975+
thinking_config=types.ThinkingConfig(thinking_budget=5000),
1976+
),
1977+
)
1978+
1979+
with mock.patch.object(llm, "_anthropic_client", mock_client):
1980+
responses = [
1981+
r async for r in llm.generate_content_async(request, stream=True)
1982+
]
1983+
1984+
final = responses[-1]
1985+
assert not final.partial
1986+
thinking_part = final.content.parts[0]
1987+
assert thinking_part.thought
1988+
assert thinking_part.text == "Reason."
1989+
assert thinking_part.thought_signature == b"sig_stream_123"
1990+
1991+
# The aggregated Part must round-trip back to a valid Anthropic thinking
1992+
# block -- this is exactly what fails today (missing signature) on a
1993+
# tool-call turn.
1994+
block = part_to_message_block(thinking_part)
1995+
assert block["type"] == "thinking"
1996+
assert block["signature"] == "sig_stream_123"
1997+
1998+
19221999
@pytest.mark.asyncio
19232000
async def test_streaming_passes_thinking_param():
19242001
"""When thinking_config is set and stream=True, thinking kwarg is passed."""

0 commit comments

Comments
 (0)