From 70875e0083956930f58b279f3491f575fc33b8be Mon Sep 17 00:00:00 2001 From: Mikko Kirvesoja Date: Tue, 25 Nov 2025 15:23:41 +0200 Subject: [PATCH 1/2] Support both reasoning_content and reasoning fields in LiteLLM adapter Fixes #3694 Extends _extract_reasoning_value() to check for both 'reasoning_content' (LiteLLM standard) and 'reasoning' (used by some providers) field names. This maximizes compatibility across the OpenAI-compatible ecosystem. The downstream processing in _iter_reasoning_texts() was already prepared to handle both field names, but the extraction step was missing support for the 'reasoning' field. Changes: - Updated _extract_reasoning_value() to check both field names - Prioritizes reasoning_content when both fields are present - Added 12 comprehensive unit tests All 113 tests passing. Fully backward compatible. --- src/google/adk/models/lite_llm.py | 12 +- tests/unittests/models/test_litellm.py | 165 +++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index aca230bc57..2d65c569ba 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -233,13 +233,21 @@ def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]: def _extract_reasoning_value(message: Message | Dict[str, Any]) -> Any: - """Fetches the reasoning payload from a LiteLLM message or dict.""" + """Fetches the reasoning payload from a LiteLLM message or dict. + Checks for both 'reasoning_content' (LiteLLM standard) and 'reasoning' (used by some providers). + """ if message is None: return None + if hasattr(message, "reasoning_content"): return getattr(message, "reasoning_content") + + if hasattr(message, "reasoning"): + return getattr(message, "reasoning") + if isinstance(message, dict): - return message.get("reasoning_content") + return message.get("reasoning_content") or message.get("reasoning") + return None diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 54b0f176f6..9854d390de 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -25,6 +25,7 @@ import warnings from google.adk.models.lite_llm import _content_to_message_param +from google.adk.models.lite_llm import _extract_reasoning_value from google.adk.models.lite_llm import _FILE_ID_REQUIRED_PROVIDERS from google.adk.models.lite_llm import _FINISH_REASON_MAPPING from google.adk.models.lite_llm import _function_declaration_to_tool_param @@ -1926,6 +1927,170 @@ def test_model_response_to_generate_content_response_reasoning_content(): assert response.content.parts[1].text == "Answer" +def test_message_to_generate_content_response_reasoning_field(): + """Test that 'reasoning' field is supported (alternative field name).""" + message = { + "role": "assistant", + "content": "Final answer", + "reasoning": "Thinking process", + } + response = _message_to_generate_content_response(message) + + assert len(response.content.parts) == 2 + thought_part = response.content.parts[0] + text_part = response.content.parts[1] + assert thought_part.text == "Thinking process" + assert thought_part.thought is True + assert text_part.text == "Final answer" + + +def test_model_response_to_generate_content_response_reasoning_field(): + """Test that 'reasoning' field is supported in ModelResponse.""" + model_response = ModelResponse( + model="test-model", + choices=[{ + "message": { + "role": "assistant", + "content": "Result", + "reasoning": "Chain of thought", + }, + "finish_reason": "stop", + }], + ) + + response = _model_response_to_generate_content_response(model_response) + + assert response.content.parts[0].text == "Chain of thought" + assert response.content.parts[0].thought is True + assert response.content.parts[1].text == "Result" + + +def test_reasoning_content_takes_precedence_over_reasoning(): + """Test that 'reasoning_content' is prioritized over 'reasoning'.""" + message = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "LiteLLM standard reasoning", + "reasoning": "Alternative reasoning", + } + response = _message_to_generate_content_response(message) + + assert len(response.content.parts) == 2 + thought_part = response.content.parts[0] + # Should use reasoning_content, not reasoning + assert thought_part.text == "LiteLLM standard reasoning" + assert thought_part.thought is True + + +def test_extract_reasoning_value_from_reasoning_content_attribute(): + """Test extraction from reasoning_content attribute (LiteLLM standard).""" + message = ChatCompletionAssistantMessage( + role="assistant", + content="Answer", + reasoning_content="LiteLLM reasoning", + ) + + result = _extract_reasoning_value(message) + assert result == "LiteLLM reasoning" + + +def test_extract_reasoning_value_from_reasoning_attribute(): + """Test extraction from reasoning attribute (alternative field name).""" + + # Create a mock object with reasoning attribute + class MockMessage: + + def __init__(self): + self.role = "assistant" + self.content = "Answer" + self.reasoning = "Alternative reasoning" + + message = MockMessage() + result = _extract_reasoning_value(message) + assert result == "Alternative reasoning" + + +def test_extract_reasoning_value_from_dict_reasoning_content(): + """Test extraction from dict with reasoning_content field.""" + message = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "Dict reasoning content", + } + + result = _extract_reasoning_value(message) + assert result == "Dict reasoning content" + + +def test_extract_reasoning_value_from_dict_reasoning(): + """Test extraction from dict with reasoning field.""" + message = { + "role": "assistant", + "content": "Answer", + "reasoning": "Dict reasoning", + } + + result = _extract_reasoning_value(message) + assert result == "Dict reasoning" + + +def test_extract_reasoning_value_prioritizes_reasoning_content(): + """Test that reasoning_content takes precedence over reasoning.""" + message = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "Primary reasoning", + "reasoning": "Secondary reasoning", + } + + result = _extract_reasoning_value(message) + assert result == "Primary reasoning" + + +def test_extract_reasoning_value_returns_none_when_missing(): + """Test that None is returned when no reasoning fields exist.""" + message = { + "role": "assistant", + "content": "Answer only", + } + + result = _extract_reasoning_value(message) + assert result is None + + +def test_extract_reasoning_value_handles_none_message(): + """Test that None message returns None.""" + result = _extract_reasoning_value(None) + assert result is None + + +def test_extract_reasoning_value_with_empty_reasoning(): + """Test handling of empty reasoning strings.""" + message = { + "role": "assistant", + "content": "Answer", + "reasoning": "", + } + + result = _extract_reasoning_value(message) + # Empty string is returned as-is (dict.get returns empty string) + assert result == "" + + +def test_extract_reasoning_value_with_empty_reasoning_content(): + """Test handling of empty reasoning_content strings.""" + message = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "", + } + + result = _extract_reasoning_value(message) + # Empty string from reasoning_content is returned, but 'or' makes it None + # because empty string is falsy and reasoning is not present + assert result is None + + def test_parse_tool_calls_from_text_multiple_calls(): text = ( '{"name":"alpha","arguments":{"value":1}}\n' From 20cecc64ce4f2a447109e043e636747a39e33467 Mon Sep 17 00:00:00 2001 From: Mikko Kirvesoja Date: Tue, 25 Nov 2025 15:50:19 +0200 Subject: [PATCH 2/2] Fix precedence bug in dict-based reasoning extraction Address gemini-code-assist review feedback: - Use two-argument dict.get() to check key presence instead of truthiness - This ensures reasoning_content takes precedence even with falsy values - Update test to expect empty string instead of None for consistency The previous 'or' operator would incorrectly fall back to 'reasoning' when 'reasoning_content' was present but had a falsy value like ''. Now using dict.get(key, default) properly maintains precedence. --- src/google/adk/models/lite_llm.py | 2 +- tests/unittests/models/test_litellm.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 2d65c569ba..a4a776d8ce 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -246,7 +246,7 @@ def _extract_reasoning_value(message: Message | Dict[str, Any]) -> Any: return getattr(message, "reasoning") if isinstance(message, dict): - return message.get("reasoning_content") or message.get("reasoning") + return message.get("reasoning_content", message.get("reasoning")) return None diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 9854d390de..8fb5c852b5 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -2086,9 +2086,8 @@ def test_extract_reasoning_value_with_empty_reasoning_content(): } result = _extract_reasoning_value(message) - # Empty string from reasoning_content is returned, but 'or' makes it None - # because empty string is falsy and reasoning is not present - assert result is None + # Empty string should be returned to maintain precedence + assert result == "" def test_parse_tool_calls_from_text_multiple_calls():