diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index dad5543f..8c1568cc 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -388,10 +388,18 @@ def _convert_reasoning_value_to_parts(reasoning_value: Any) -> List[types.Part]: def _extract_reasoning_value(message: Message | Delta | None) -> Any: - """Fetches the reasoning payload from a LiteLLM message.""" + """Fetches the reasoning payload from a LiteLLM message. + + Checks for both 'reasoning_content' (LiteLLM standard, used by Azure/Foundry, + Ollama via LiteLLM) and 'reasoning' (used by LM Studio, vLLM). + Prioritizes 'reasoning_content' when both are present. + """ if message is None: return None - return message.get("reasoning_content") + reasoning_content = message.get("reasoning_content") + if reasoning_content is not None: + return reasoning_content + return message.get("reasoning") class ChatCompletionFileUrlObject(TypedDict, total=False): @@ -1302,6 +1310,7 @@ def _model_response_to_chunk( or message.get("tool_calls") or message.get("function_call") or message.get("reasoning_content") + or message.get("reasoning") ) if isinstance(response, ModelResponseStream): diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index aa19bfa8..2bd5f7d2 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -27,6 +27,7 @@ import warnings from google.adk.models.lite_llm import _append_fallback_user_content_if_missing from google.adk.models.lite_llm import _content_to_message_param from google.adk.models.lite_llm import _enforce_strict_openai_schema +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 @@ -2285,6 +2286,139 @@ 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 the 'reasoning' field is supported (LM Studio, vLLM).""" + 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] + assert thought_part.text == "LiteLLM standard reasoning" + assert thought_part.thought is True + + +def test_extract_reasoning_value_from_reasoning_content(): + """Test extraction from reasoning_content (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(): + """Test extraction from reasoning (LM Studio, vLLM).""" + + class MockMessage: + + def __init__(self): + self.role = "assistant" + self.content = "Answer" + self.reasoning = "Alternative reasoning" + + def get(self, key, default=None): + return getattr(self, key, default) + + message = MockMessage() + result = _extract_reasoning_value(message) + assert result == "Alternative reasoning" + + +def test_extract_reasoning_value_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_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_dict_prefers_reasoning_content(): + """Test that reasoning_content takes precedence over reasoning in dicts.""" + message = { + "role": "assistant", + "content": "Answer", + "reasoning_content": "Primary", + "reasoning": "Secondary", + } + result = _extract_reasoning_value(message) + assert result == "Primary" + + +def test_extract_reasoning_value_none_message(): + """Test that None message returns None.""" + result = _extract_reasoning_value(None) + assert result is None + + +def test_extract_reasoning_value_no_reasoning_fields(): + """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_parse_tool_calls_from_text_multiple_calls(): text = ( '{"name":"alpha","arguments":{"value":1}}\n'