|
28 | 28 |
|
29 | 29 | from google.adk.models.lite_llm import _append_fallback_user_content_if_missing |
30 | 30 | from google.adk.models.lite_llm import _content_to_message_param |
| 31 | +from google.adk.models.lite_llm import _convert_reasoning_value_to_parts |
31 | 32 | from google.adk.models.lite_llm import _enforce_strict_openai_schema |
32 | 33 | from google.adk.models.lite_llm import _extract_reasoning_value |
33 | 34 | from google.adk.models.lite_llm import _extract_thought_signature_from_tool_call |
|
37 | 38 | from google.adk.models.lite_llm import _get_completion_inputs |
38 | 39 | from google.adk.models.lite_llm import _get_content |
39 | 40 | from google.adk.models.lite_llm import _get_provider_from_model |
| 41 | +from google.adk.models.lite_llm import _is_anthropic_model |
40 | 42 | from google.adk.models.lite_llm import _message_to_generate_content_response |
41 | 43 | from google.adk.models.lite_llm import _MISSING_TOOL_RESULT_MESSAGE |
42 | 44 | from google.adk.models.lite_llm import _model_response_to_chunk |
@@ -4682,3 +4684,213 @@ def test_handles_litellm_logger_names(logger_name): |
4682 | 4684 | finally: |
4683 | 4685 | # Clean up |
4684 | 4686 | 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