Skip to content

Commit 59e6e04

Browse files
committed
fix: preserve Anthropic thinking blocks and signatures in LiteLLM round-trip
When using Claude models through LiteLLM, extended thinking blocks (with signatures) were lost after the first turn because: 1. _extract_reasoning_value() only read reasoning_content (flattened string without signatures), ignoring thinking_blocks 2. _content_to_message_param() set reasoning_content on the outgoing message, which LiteLLM's anthropic_messages_pt() template silently drops This fix: - Adds _is_anthropic_provider() helper to detect anthropic/bedrock/ vertex_ai providers - Updates _extract_reasoning_value() to prefer thinking_blocks (with per-block signatures) over reasoning_content - Updates _convert_reasoning_value_to_parts() to handle ChatCompletionThinkingBlock dicts, preserving thought_signature - Updates _content_to_message_param() to embed thinking blocks directly in the message content list for Anthropic providers, bypassing the broken reasoning_content path Fixes #4801
1 parent f8270c8 commit 59e6e04

File tree

2 files changed

+191
-4
lines changed

2 files changed

+191
-4
lines changed

src/google/adk/models/lite_llm.py

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ def _get_provider_from_model(model: str) -> str:
233233
return ""
234234

235235

236+
# Providers that route to Anthropic's API and require thinking blocks
237+
# embedded directly in the message content list.
238+
_ANTHROPIC_PROVIDERS = frozenset({"anthropic", "bedrock", "vertex_ai"})
239+
240+
241+
def _is_anthropic_provider(provider: str) -> bool:
242+
"""Returns True if the provider routes to an Anthropic model endpoint."""
243+
return provider.lower() in _ANTHROPIC_PROVIDERS if provider else False
244+
245+
236246
# Default MIME type when none can be inferred
237247
_DEFAULT_MIME_TYPE = "application/octet-stream"
238248

@@ -385,7 +395,34 @@ def _iter_reasoning_texts(reasoning_value: Any) -> Iterable[str]:
385395

386396

387397
def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]:
388-
"""Converts provider reasoning payloads into Gemini thought parts."""
398+
"""Converts provider reasoning payloads into Gemini thought parts.
399+
400+
Handles two formats:
401+
- A list of ChatCompletionThinkingBlock dicts (Anthropic) with
402+
'thinking' and 'signature' fields.
403+
- A plain string or nested structure (OpenAI/Azure/Ollama) via
404+
_iter_reasoning_texts.
405+
"""
406+
if isinstance(reasoning_value, list):
407+
parts = []
408+
for block in reasoning_value:
409+
if isinstance(block, dict) and block.get("type") == "thinking":
410+
text = block.get("thinking", "")
411+
signature = block.get("signature")
412+
if text:
413+
parts.append(
414+
types.Part(
415+
text=text,
416+
thought=True,
417+
thought_signature=signature,
418+
)
419+
)
420+
else:
421+
# Fall back to text extraction for non-thinking-block items
422+
for text in _iter_reasoning_texts(block):
423+
if text:
424+
parts.append(types.Part(text=text, thought=True))
425+
return parts
389426
return [
390427
types.Part(text=text, thought=True)
391428
for text in _iter_reasoning_texts(reasoning_value)
@@ -396,12 +433,19 @@ def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]:
396433
def _extract_reasoning_value(message: Message | Delta | None) -> Any:
397434
"""Fetches the reasoning payload from a LiteLLM message.
398435
399-
Checks for both 'reasoning_content' (LiteLLM standard, used by Azure/Foundry,
400-
Ollama via LiteLLM) and 'reasoning' (used by LM Studio, vLLM).
401-
Prioritizes 'reasoning_content' when both are present.
436+
Checks for 'thinking_blocks' (Anthropic thinking with signatures),
437+
'reasoning_content' (LiteLLM standard, used by Azure/Foundry,
438+
Ollama via LiteLLM), and 'reasoning' (used by LM Studio, vLLM).
439+
Prioritizes 'thinking_blocks' when present, as they contain
440+
the signature required for Anthropic's extended thinking API.
402441
"""
403442
if message is None:
404443
return None
444+
# Prefer thinking_blocks (Anthropic) — they carry per-block signatures
445+
# needed for multi-turn conversations with extended thinking.
446+
thinking_blocks = message.get("thinking_blocks")
447+
if thinking_blocks:
448+
return thinking_blocks
405449
reasoning_content = message.get("reasoning_content")
406450
if reasoning_content is not None:
407451
return reasoning_content
@@ -847,6 +891,33 @@ async def _content_to_message_param(
847891
):
848892
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
849893

894+
# Anthropic/Bedrock providers require thinking blocks to be embedded
895+
# directly in the message content list. LiteLLM's prompt template for
896+
# Anthropic drops the top-level reasoning_content field, so thinking
897+
# blocks disappear from multi-turn histories and the model stops
898+
# producing them after the first turn. Signatures are required by the
899+
# Anthropic API for thinking blocks in multi-turn conversations.
900+
if reasoning_parts and _is_anthropic_provider(provider):
901+
content_list = []
902+
for part in reasoning_parts:
903+
if part.text:
904+
block = {"type": "thinking", "thinking": part.text}
905+
if part.thought_signature:
906+
sig = part.thought_signature
907+
if isinstance(sig, bytes):
908+
sig = base64.b64encode(sig).decode("utf-8")
909+
block["signature"] = sig
910+
content_list.append(block)
911+
if isinstance(final_content, list):
912+
content_list.extend(final_content)
913+
elif final_content:
914+
content_list.append({"type": "text", "text": final_content})
915+
return ChatCompletionAssistantMessage(
916+
role=role,
917+
content=content_list or None,
918+
tool_calls=tool_calls or None,
919+
)
920+
850921
reasoning_content = _NEW_LINE.join(text for text in reasoning_texts if text)
851922
return ChatCompletionAssistantMessage(
852923
role=role,

tests/unittests/models/test_litellm.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4675,3 +4675,119 @@ def test_handles_litellm_logger_names(logger_name):
46754675
finally:
46764676
# Clean up
46774677
test_logger.removeHandler(handler)
4678+
4679+
4680+
# ---------- Anthropic thinking-block round-trip tests ----------
4681+
4682+
from google.adk.models.lite_llm import _is_anthropic_provider
4683+
from google.adk.models.lite_llm import _convert_reasoning_value_to_parts
4684+
4685+
4686+
def test_is_anthropic_provider():
4687+
"""Verify _is_anthropic_provider matches known Claude provider prefixes."""
4688+
assert _is_anthropic_provider("anthropic")
4689+
assert _is_anthropic_provider("bedrock")
4690+
assert _is_anthropic_provider("vertex_ai")
4691+
assert _is_anthropic_provider("ANTHROPIC") # case-insensitive
4692+
assert not _is_anthropic_provider("openai")
4693+
assert not _is_anthropic_provider("")
4694+
assert not _is_anthropic_provider(None)
4695+
4696+
4697+
def test_extract_reasoning_value_prefers_thinking_blocks():
4698+
"""thinking_blocks (Anthropic format with signatures) take priority."""
4699+
thinking_blocks = [
4700+
{"type": "thinking", "thinking": "step 1", "signature": "sig1"},
4701+
{"type": "thinking", "thinking": "step 2", "signature": "sig2"},
4702+
]
4703+
message = {
4704+
"role": "assistant",
4705+
"content": "Answer",
4706+
"reasoning_content": "flat string",
4707+
"thinking_blocks": thinking_blocks,
4708+
}
4709+
result = _extract_reasoning_value(message)
4710+
assert result is thinking_blocks
4711+
4712+
4713+
def test_convert_reasoning_value_preserves_signatures():
4714+
"""_convert_reasoning_value_to_parts keeps thought_signature from blocks."""
4715+
blocks = [
4716+
{"type": "thinking", "thinking": "I should greet", "signature": "c2lnX2E="},
4717+
{"type": "thinking", "thinking": "Let me respond", "signature": "c2lnX2I="},
4718+
]
4719+
parts = _convert_reasoning_value_to_parts(blocks)
4720+
assert len(parts) == 2
4721+
assert parts[0].text == "I should greet"
4722+
assert parts[0].thought is True
4723+
assert parts[0].thought_signature == b"sig_a"
4724+
assert parts[1].text == "Let me respond"
4725+
assert parts[1].thought_signature == b"sig_b"
4726+
4727+
4728+
def test_convert_reasoning_value_plain_string_no_signature():
4729+
"""Plain strings (non-Anthropic) produce thought=True with no signature."""
4730+
parts = _convert_reasoning_value_to_parts("Plain reasoning")
4731+
assert len(parts) == 1
4732+
assert parts[0].text == "Plain reasoning"
4733+
assert parts[0].thought is True
4734+
assert parts[0].thought_signature is None
4735+
4736+
4737+
@pytest.mark.asyncio
4738+
async def test_content_to_message_param_anthropic_embeds_thinking_blocks():
4739+
"""For anthropic provider, thinking parts become content-list blocks."""
4740+
content = types.Content(
4741+
role="model",
4742+
parts=[
4743+
types.Part(text="I need to think", thought=True, thought_signature="c2lnX3g="),
4744+
types.Part.from_text(text="Hello!"),
4745+
],
4746+
)
4747+
msg = await _content_to_message_param(content, provider="anthropic")
4748+
# Content should be a list with thinking + text blocks
4749+
assert isinstance(msg["content"], list)
4750+
assert msg["content"][0]["type"] == "thinking"
4751+
assert msg["content"][0]["thinking"] == "I need to think"
4752+
assert msg["content"][0]["signature"] == "c2lnX3g="
4753+
assert msg["content"][1]["type"] == "text"
4754+
assert msg["content"][1]["text"] == "Hello!"
4755+
# reasoning_content should NOT be set (blocks are in content)
4756+
assert msg.get("reasoning_content") is None
4757+
4758+
4759+
@pytest.mark.asyncio
4760+
async def test_content_to_message_param_openai_uses_reasoning_content():
4761+
"""For non-anthropic provider, thinking parts use reasoning_content field."""
4762+
content = types.Content(
4763+
role="model",
4764+
parts=[
4765+
types.Part(text="I need to think", thought=True),
4766+
types.Part.from_text(text="Hello!"),
4767+
],
4768+
)
4769+
msg = await _content_to_message_param(content, provider="openai")
4770+
# reasoning_content should be set as a string
4771+
assert msg.get("reasoning_content") == "I need to think"
4772+
# Content should be a simple string (not a list)
4773+
assert isinstance(msg["content"], str)
4774+
assert msg["content"] == "Hello!"
4775+
4776+
4777+
@pytest.mark.asyncio
4778+
async def test_content_to_message_param_anthropic_thinking_with_tool_calls():
4779+
"""Anthropic thinking + tool calls: thinking in content, tool_calls separate."""
4780+
content = types.Content(
4781+
role="model",
4782+
parts=[
4783+
types.Part(text="Let me calculate", thought=True, thought_signature="c2lnX2NhbGM="),
4784+
types.Part.from_function_call(name="add", args={"a": 1, "b": 2}),
4785+
],
4786+
)
4787+
msg = await _content_to_message_param(content, provider="anthropic")
4788+
assert isinstance(msg["content"], list)
4789+
assert msg["content"][0]["type"] == "thinking"
4790+
assert msg["content"][0]["signature"] == "c2lnX2NhbGM="
4791+
assert msg["tool_calls"] is not None
4792+
assert len(msg["tool_calls"]) == 1
4793+
assert msg["tool_calls"][0]["function"]["name"] == "add"

0 commit comments

Comments
 (0)