Skip to content

Commit fc45fa6

Browse files
GWealecopybara-github
authored andcommitted
feat: Add support for Anthropic's thinking_blocks format in LiteLLM integration
This change enables the LiteLLM adapter to correctly parse and generate Anthropic's structured "thinking_blocks" format, which includes a "signature" for each thought block. The "signature" is crucial for Anthropic models to maintain their reasoning state across multiple turns, particularly when tool calls are made Close #4801 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 885131757
1 parent b318eee commit fc45fa6

File tree

2 files changed

+306
-4
lines changed

2 files changed

+306
-4
lines changed

src/google/adk/models/lite_llm.py

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,8 +384,42 @@ def _iter_reasoning_texts(reasoning_value: Any) -> Iterable[str]:
384384
yield str(reasoning_value)
385385

386386

387+
def _is_thinking_blocks_format(reasoning_value: Any) -> bool:
388+
"""Returns True if reasoning_value is Anthropic thinking_blocks format.
389+
390+
Anthropic thinking_blocks is a list of dicts, each with 'type', 'thinking',
391+
and 'signature' keys.
392+
"""
393+
if not isinstance(reasoning_value, list) or not reasoning_value:
394+
return False
395+
first = reasoning_value[0]
396+
return isinstance(first, dict) and "signature" in first
397+
398+
387399
def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]:
388-
"""Converts provider reasoning payloads into Gemini thought parts."""
400+
"""Converts provider reasoning payloads into Gemini thought parts.
401+
402+
Handles Anthropic thinking_blocks (list of dicts with type/thinking/signature)
403+
by preserving the signature on each part's thought_signature field. This is
404+
required for Anthropic to maintain thinking across tool call boundaries.
405+
"""
406+
if _is_thinking_blocks_format(reasoning_value):
407+
parts: List[types.Part] = []
408+
for block in reasoning_value:
409+
if not isinstance(block, dict):
410+
continue
411+
block_type = block.get("type", "")
412+
if block_type == "redacted":
413+
continue
414+
thinking_text = block.get("thinking", "")
415+
signature = block.get("signature", "")
416+
if not thinking_text:
417+
continue
418+
part = types.Part(text=thinking_text, thought=True)
419+
if signature:
420+
part.thought_signature = signature.encode("utf-8")
421+
parts.append(part)
422+
return parts
389423
return [
390424
types.Part(text=text, thought=True)
391425
for text in _iter_reasoning_texts(reasoning_value)
@@ -396,12 +430,19 @@ def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]:
396430
def _extract_reasoning_value(message: Message | Delta | None) -> Any:
397431
"""Fetches the reasoning payload from a LiteLLM message.
398432
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.
433+
Checks for 'thinking_blocks' (Anthropic structured format with signatures),
434+
'reasoning_content' (LiteLLM standard, used by Azure/Foundry, Ollama via
435+
LiteLLM) and 'reasoning' (used by LM Studio, vLLM).
436+
Prioritizes 'thinking_blocks' when present (Anthropic models), then
437+
'reasoning_content', then 'reasoning'.
402438
"""
403439
if message is None:
404440
return None
441+
# Anthropic models return thinking_blocks with type/thinking/signature fields.
442+
# This must be preserved to maintain thinking across tool call boundaries.
443+
thinking_blocks = message.get("thinking_blocks")
444+
if thinking_blocks is not None:
445+
return thinking_blocks
405446
reasoning_content = message.get("reasoning_content")
406447
if reasoning_content is not None:
407448
return reasoning_content
@@ -835,6 +876,30 @@ async def _content_to_message_param(
835876
else final_content
836877
)
837878

879+
# For Anthropic models, rebuild thinking_blocks with signatures so that
880+
# thinking is preserved across tool call boundaries. Without this,
881+
# Anthropic silently drops thinking after the first turn.
882+
if model and _is_anthropic_model(model) and reasoning_parts:
883+
thinking_blocks = []
884+
for part in reasoning_parts:
885+
if part.text and part.thought_signature:
886+
sig = part.thought_signature
887+
if isinstance(sig, bytes):
888+
sig = sig.decode("utf-8")
889+
thinking_blocks.append({
890+
"type": "thinking",
891+
"thinking": part.text,
892+
"signature": sig,
893+
})
894+
if thinking_blocks:
895+
msg = ChatCompletionAssistantMessage(
896+
role=role,
897+
content=final_content,
898+
tool_calls=tool_calls or None,
899+
)
900+
msg["thinking_blocks"] = thinking_blocks # type: ignore[typeddict-unknown-key]
901+
return msg
902+
838903
reasoning_texts = []
839904
for part in reasoning_parts:
840905
if part.text:
@@ -1943,6 +2008,31 @@ def _build_request_log(req: LlmRequest) -> str:
19432008
"""
19442009

19452010

2011+
def _is_anthropic_model(model_string: str) -> bool:
2012+
"""Check if the model is an Anthropic Claude model accessed via LiteLLM.
2013+
2014+
Detects models using the anthropic/ provider prefix, bedrock/ models that
2015+
contain 'anthropic' or 'claude', and vertex_ai/ models that contain 'claude'.
2016+
2017+
Args:
2018+
model_string: A LiteLLM model string (e.g., "anthropic/claude-4-sonnet",
2019+
"bedrock/anthropic.claude-3-5-sonnet", "vertex_ai/claude-4-sonnet")
2020+
2021+
Returns:
2022+
True if it's an Anthropic Claude model, False otherwise.
2023+
"""
2024+
lower = model_string.lower()
2025+
if lower.startswith("anthropic/"):
2026+
return True
2027+
if lower.startswith("bedrock/"):
2028+
model_part = lower.split("/", 1)[1]
2029+
return "anthropic" in model_part or "claude" in model_part
2030+
if lower.startswith("vertex_ai/"):
2031+
model_part = lower.split("/", 1)[1]
2032+
return "claude" in model_part
2033+
return False
2034+
2035+
19462036
def _is_litellm_vertex_model(model_string: str) -> bool:
19472037
"""Check if the model is a Vertex AI model accessed via LiteLLM.
19482038

tests/unittests/models/test_litellm.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from google.adk.models.lite_llm import _append_fallback_user_content_if_missing
3030
from google.adk.models.lite_llm import _content_to_message_param
31+
from google.adk.models.lite_llm import _convert_reasoning_value_to_parts
3132
from google.adk.models.lite_llm import _enforce_strict_openai_schema
3233
from google.adk.models.lite_llm import _extract_reasoning_value
3334
from google.adk.models.lite_llm import _extract_thought_signature_from_tool_call
@@ -37,6 +38,7 @@
3738
from google.adk.models.lite_llm import _get_completion_inputs
3839
from google.adk.models.lite_llm import _get_content
3940
from google.adk.models.lite_llm import _get_provider_from_model
41+
from google.adk.models.lite_llm import _is_anthropic_model
4042
from google.adk.models.lite_llm import _message_to_generate_content_response
4143
from google.adk.models.lite_llm import _MISSING_TOOL_RESULT_MESSAGE
4244
from google.adk.models.lite_llm import _model_response_to_chunk
@@ -4682,3 +4684,213 @@ def test_handles_litellm_logger_names(logger_name):
46824684
finally:
46834685
# Clean up
46844686
test_logger.removeHandler(handler)
4687+
4688+
4689+
# ── Anthropic thinking_blocks tests ─────────────────────────────
4690+
4691+
4692+
@pytest.mark.parametrize(
4693+
"model_string,expected",
4694+
[
4695+
("anthropic/claude-4-sonnet", True),
4696+
("anthropic/claude-3-5-sonnet-20241022", True),
4697+
("Anthropic/Claude-4-Opus", True),
4698+
("bedrock/anthropic.claude-3-5-sonnet", True),
4699+
("bedrock/us.anthropic.claude-3-5-sonnet-20241022-v2:0", True),
4700+
("bedrock/claude-3-5-sonnet", True),
4701+
("vertex_ai/claude-3-5-sonnet@20241022", True),
4702+
("openai/gpt-4o", False),
4703+
("gemini/gemini-2.5-pro", False),
4704+
("vertex_ai/gemini-2.5-flash", False),
4705+
("bedrock/amazon.titan-text-express-v1", False),
4706+
],
4707+
ids=[
4708+
"anthropic-prefix",
4709+
"anthropic-versioned",
4710+
"anthropic-uppercase",
4711+
"bedrock-anthropic-dot",
4712+
"bedrock-us-anthropic",
4713+
"bedrock-claude",
4714+
"vertex-claude",
4715+
"openai-no-match",
4716+
"gemini-no-match",
4717+
"vertex-gemini-no-match",
4718+
"bedrock-non-anthropic",
4719+
],
4720+
)
4721+
def test_is_anthropic_model(model_string, expected):
4722+
assert _is_anthropic_model(model_string) is expected
4723+
4724+
4725+
def test_extract_reasoning_value_prefers_thinking_blocks():
4726+
"""thinking_blocks takes precedence over reasoning_content."""
4727+
thinking_blocks = [
4728+
{"type": "thinking", "thinking": "deep thought", "signature": "sig123"},
4729+
]
4730+
message = {
4731+
"role": "assistant",
4732+
"content": "Answer",
4733+
"thinking_blocks": thinking_blocks,
4734+
"reasoning_content": "flat reasoning",
4735+
}
4736+
result = _extract_reasoning_value(message)
4737+
assert result is thinking_blocks
4738+
4739+
4740+
def test_extract_reasoning_value_falls_back_without_thinking_blocks():
4741+
"""When thinking_blocks is absent, falls back to reasoning_content."""
4742+
message = {
4743+
"role": "assistant",
4744+
"content": "Answer",
4745+
"reasoning_content": "flat reasoning",
4746+
}
4747+
result = _extract_reasoning_value(message)
4748+
assert result == "flat reasoning"
4749+
4750+
4751+
def test_convert_reasoning_value_to_parts_thinking_blocks_preserves_signature():
4752+
"""thinking_blocks format produces parts with thought_signature."""
4753+
thinking_blocks = [
4754+
{"type": "thinking", "thinking": "step 1", "signature": "sig_abc"},
4755+
{"type": "thinking", "thinking": "step 2", "signature": "sig_def"},
4756+
]
4757+
parts = _convert_reasoning_value_to_parts(thinking_blocks)
4758+
assert len(parts) == 2
4759+
assert parts[0].text == "step 1"
4760+
assert parts[0].thought is True
4761+
assert parts[0].thought_signature == b"sig_abc"
4762+
assert parts[1].text == "step 2"
4763+
assert parts[1].thought_signature == b"sig_def"
4764+
4765+
4766+
def test_convert_reasoning_value_to_parts_skips_redacted_blocks():
4767+
"""Redacted thinking blocks are excluded from parts."""
4768+
thinking_blocks = [
4769+
{"type": "thinking", "thinking": "visible", "signature": "sig1"},
4770+
{"type": "redacted", "data": "hidden"},
4771+
]
4772+
parts = _convert_reasoning_value_to_parts(thinking_blocks)
4773+
assert len(parts) == 1
4774+
assert parts[0].text == "visible"
4775+
4776+
4777+
def test_convert_reasoning_value_to_parts_skips_empty_thinking():
4778+
"""Blocks with empty thinking text are excluded."""
4779+
thinking_blocks = [
4780+
{"type": "thinking", "thinking": "", "signature": "sig1"},
4781+
{"type": "thinking", "thinking": "real thought", "signature": "sig2"},
4782+
]
4783+
parts = _convert_reasoning_value_to_parts(thinking_blocks)
4784+
assert len(parts) == 1
4785+
assert parts[0].text == "real thought"
4786+
4787+
4788+
def test_convert_reasoning_value_to_parts_flat_string_unchanged():
4789+
"""Flat string reasoning still produces thought parts without signature."""
4790+
parts = _convert_reasoning_value_to_parts("simple reasoning text")
4791+
assert len(parts) == 1
4792+
assert parts[0].text == "simple reasoning text"
4793+
assert parts[0].thought is True
4794+
assert parts[0].thought_signature is None
4795+
4796+
4797+
@pytest.mark.asyncio
4798+
async def test_content_to_message_param_anthropic_outputs_thinking_blocks():
4799+
"""For Anthropic models, thinking_blocks are output instead of reasoning_content."""
4800+
content = types.Content(
4801+
role="model",
4802+
parts=[
4803+
types.Part(
4804+
text="deep thought",
4805+
thought=True,
4806+
thought_signature=b"sig_round_trip",
4807+
),
4808+
types.Part(text="Hello!"),
4809+
],
4810+
)
4811+
result = await _content_to_message_param(
4812+
content, model="anthropic/claude-4-sonnet"
4813+
)
4814+
assert result["role"] == "assistant"
4815+
assert "thinking_blocks" in result
4816+
assert result.get("reasoning_content") is None
4817+
blocks = result["thinking_blocks"]
4818+
assert len(blocks) == 1
4819+
assert blocks[0]["type"] == "thinking"
4820+
assert blocks[0]["thinking"] == "deep thought"
4821+
assert blocks[0]["signature"] == "sig_round_trip"
4822+
assert result["content"] == "Hello!"
4823+
4824+
4825+
@pytest.mark.asyncio
4826+
async def test_content_to_message_param_non_anthropic_uses_reasoning_content():
4827+
"""For non-Anthropic models, reasoning_content is used as before."""
4828+
content = types.Content(
4829+
role="model",
4830+
parts=[
4831+
types.Part(text="thinking text", thought=True),
4832+
types.Part(text="Answer"),
4833+
],
4834+
)
4835+
result = await _content_to_message_param(content, model="openai/gpt-4o")
4836+
assert result["role"] == "assistant"
4837+
assert result.get("reasoning_content") == "thinking text"
4838+
assert "thinking_blocks" not in result
4839+
4840+
4841+
@pytest.mark.asyncio
4842+
async def test_anthropic_thinking_blocks_round_trip():
4843+
"""End-to-end: thinking_blocks in response → Part → thinking_blocks out."""
4844+
# Simulate LiteLLM response with thinking_blocks
4845+
response_message = {
4846+
"role": "assistant",
4847+
"content": "Final answer",
4848+
"thinking_blocks": [
4849+
{
4850+
"type": "thinking",
4851+
"thinking": "Let me reason...",
4852+
"signature": "abc123signature",
4853+
},
4854+
],
4855+
}
4856+
4857+
# Step 1: Extract reasoning value
4858+
reasoning_value = _extract_reasoning_value(response_message)
4859+
assert isinstance(reasoning_value, list)
4860+
4861+
# Step 2: Convert to parts (preserves signature)
4862+
parts = _convert_reasoning_value_to_parts(reasoning_value)
4863+
assert len(parts) == 1
4864+
assert parts[0].thought_signature == b"abc123signature"
4865+
4866+
# Step 3: Build Content for history
4867+
all_parts = parts + [types.Part(text="Final answer")]
4868+
content = types.Content(role="model", parts=all_parts)
4869+
4870+
# Step 4: Convert back to message param for Anthropic
4871+
result = await _content_to_message_param(
4872+
content, model="anthropic/claude-4-sonnet"
4873+
)
4874+
blocks = result["thinking_blocks"]
4875+
assert len(blocks) == 1
4876+
assert blocks[0]["type"] == "thinking"
4877+
assert blocks[0]["thinking"] == "Let me reason..."
4878+
assert blocks[0]["signature"] == "abc123signature"
4879+
4880+
4881+
@pytest.mark.asyncio
4882+
async def test_content_to_message_param_anthropic_no_signature_falls_back():
4883+
"""Anthropic model with thought parts but no signatures uses reasoning_content."""
4884+
content = types.Content(
4885+
role="model",
4886+
parts=[
4887+
types.Part(text="thinking without sig", thought=True),
4888+
types.Part(text="Response"),
4889+
],
4890+
)
4891+
result = await _content_to_message_param(
4892+
content, model="anthropic/claude-4-sonnet"
4893+
)
4894+
# Falls back to reasoning_content when no signatures present
4895+
assert result.get("reasoning_content") == "thinking without sig"
4896+
assert "thinking_blocks" not in result

0 commit comments

Comments
 (0)