fix: map LiteLLM thought parts to reasoning_content

- Treat Part(thought=True) as reasoning_content when building assistant messages.
- Add unit tests for thought-only and thought+text cases.

Close #4069

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 853790274
This commit is contained in:
George Weale
2026-01-08 10:10:13 -08:00
committed by Copybara-Service
parent 9b2bd411dd
commit 6c67b6c0f4
2 changed files with 102 additions and 37 deletions
+33 -14
View File
@@ -482,13 +482,15 @@ async def _content_to_message_param(
# Handle user or assistant messages # Handle user or assistant messages
role = _to_litellm_role(content.role) role = _to_litellm_role(content.role)
message_content = await _get_content(content.parts, provider=provider) or None
if role == "user": if role == "user":
user_parts = [part for part in content.parts if not part.thought]
message_content = await _get_content(user_parts, provider=provider) or None
return ChatCompletionUserMessage(role="user", content=message_content) return ChatCompletionUserMessage(role="user", content=message_content)
else: # assistant/model else: # assistant/model
tool_calls = [] tool_calls = []
content_present = False content_parts: list[types.Part] = []
reasoning_parts: list[types.Part] = []
for part in content.parts: for part in content.parts:
if part.function_call: if part.function_call:
tool_calls.append( tool_calls.append(
@@ -501,10 +503,16 @@ async def _content_to_message_param(
), ),
) )
) )
elif part.text or part.inline_data: elif part.thought:
content_present = True reasoning_parts.append(part)
else:
content_parts.append(part)
final_content = message_content if content_present else None final_content = (
await _get_content(content_parts, provider=provider)
if content_parts
else None
)
if final_content and isinstance(final_content, list): if final_content and isinstance(final_content, list):
# when the content is a single text object, we can use it directly. # when the content is a single text object, we can use it directly.
# this is needed for ollama_chat provider which fails if content is a list # this is needed for ollama_chat provider which fails if content is a list
@@ -514,10 +522,24 @@ async def _content_to_message_param(
else final_content else final_content
) )
reasoning_texts = []
for part in reasoning_parts:
if part.text:
reasoning_texts.append(part.text)
elif (
part.inline_data
and part.inline_data.data
and part.inline_data.mime_type
and part.inline_data.mime_type.startswith("text/")
):
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
reasoning_content = _NEW_LINE.join(text for text in reasoning_texts if text)
return ChatCompletionAssistantMessage( return ChatCompletionAssistantMessage(
role=role, role=role,
content=final_content, content=final_content,
tool_calls=tool_calls or None, tool_calls=tool_calls or None,
reasoning_content=reasoning_content or None,
) )
@@ -587,8 +609,8 @@ async def _get_content(
) -> OpenAIMessageContent: ) -> OpenAIMessageContent:
"""Converts a list of parts to litellm content. """Converts a list of parts to litellm content.
Thought parts represent internal model reasoning and are always dropped so Callers may need to filter out thought parts before calling this helper if
they are not replayed back to the model in subsequent turns. thought parts are not needed.
Args: Args:
parts: The parts to convert. parts: The parts to convert.
@@ -598,9 +620,9 @@ async def _get_content(
The litellm content. The litellm content.
""" """
parts_without_thought = [part for part in parts if not part.thought] parts_list = list(parts)
if len(parts_without_thought) == 1: if len(parts_list) == 1:
part = parts_without_thought[0] part = parts_list[0]
if part.text: if part.text:
return part.text return part.text
if ( if (
@@ -612,10 +634,7 @@ async def _get_content(
return _decode_inline_text_data(part.inline_data.data) return _decode_inline_text_data(part.inline_data.data)
content_objects = [] content_objects = []
for part in parts_without_thought: for part in parts_list:
# Skip thought parts to prevent reasoning from being replayed in subsequent
# turns. Thought parts are internal model reasoning and should not be sent
# back to the model.
if part.text: if part.text:
content_objects.append({ content_objects.append({
"type": "text", "type": "text",
+69 -23
View File
@@ -1860,6 +1860,59 @@ async def test_content_to_message_param_assistant_message():
assert message["content"] == "Test response" assert message["content"] == "Test response"
@pytest.mark.asyncio
async def test_content_to_message_param_user_filters_thought_parts():
thought_part = types.Part.from_text(text="internal reasoning")
thought_part.thought = True
content_part = types.Part.from_text(text="visible content")
content = types.Content(role="user", parts=[thought_part, content_part])
message = await _content_to_message_param(content)
assert message["role"] == "user"
assert message["content"] == "visible content"
@pytest.mark.asyncio
async def test_content_to_message_param_assistant_thought_message():
part = types.Part.from_text(text="internal reasoning")
part.thought = True
content = types.Content(role="assistant", parts=[part])
message = await _content_to_message_param(content)
assert message["role"] == "assistant"
assert message["content"] is None
assert message["reasoning_content"] == "internal reasoning"
@pytest.mark.asyncio
async def test_content_to_message_param_model_thought_message():
part = types.Part.from_text(text="internal reasoning")
part.thought = True
content = types.Content(role="model", parts=[part])
message = await _content_to_message_param(content)
assert message["role"] == "assistant"
assert message["content"] is None
assert message["reasoning_content"] == "internal reasoning"
@pytest.mark.asyncio
async def test_content_to_message_param_assistant_thought_and_content_message():
thought_part = types.Part.from_text(text="internal reasoning")
thought_part.thought = True
content_part = types.Part.from_text(text="visible content")
content = types.Content(role="assistant", parts=[thought_part, content_part])
message = await _content_to_message_param(content)
assert message["role"] == "assistant"
assert message["content"] == "visible content"
assert message["reasoning_content"] == "internal reasoning"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_content_to_message_param_function_call(): async def test_content_to_message_param_function_call():
content = types.Content( content = types.Content(
@@ -2087,42 +2140,35 @@ def test_split_message_content_prefers_existing_structured_calls():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_content_filters_thought_parts(): async def test_get_content_does_not_filter_thought_parts():
"""Test that thought parts are filtered from content. """Test that _get_content does not drop thought parts.
Thought parts contain model reasoning that should not be sent back to Thought filtering is handled by the caller (e.g., _content_to_message_param)
the model in subsequent turns. This test verifies that _get_content to avoid duplicating logic across helpers.
skips parts with thought=True.
See: https://github.com/google/adk-python/issues/3948
""" """
# Create a thought part (reasoning) and a regular text part
thought_part = types.Part(text="Internal reasoning...", thought=True) thought_part = types.Part(text="Internal reasoning...", thought=True)
regular_part = types.Part.from_text(text="Visible response") regular_part = types.Part.from_text(text="Visible response")
parts = [thought_part, regular_part]
content = await _get_content(parts) content = await _get_content([thought_part, regular_part])
# The thought part should be filtered out, leaving only the regular text assert content == [
assert content == "Visible response" {"type": "text", "text": "Internal reasoning..."},
{"type": "text", "text": "Visible response"},
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_content_filters_all_thought_parts(): async def test_get_content_all_thought_parts():
"""Test that all thought parts are filtered when only thoughts present. """Test that thought parts convert like regular text parts."""
When all parts are thought parts, _get_content should return an empty list.
See: https://github.com/google/adk-python/issues/3948
"""
thought_part1 = types.Part(text="First reasoning...", thought=True) thought_part1 = types.Part(text="First reasoning...", thought=True)
thought_part2 = types.Part(text="Second reasoning...", thought=True) thought_part2 = types.Part(text="Second reasoning...", thought=True)
parts = [thought_part1, thought_part2]
content = await _get_content(parts) content = await _get_content([thought_part1, thought_part2])
# All thought parts should be filtered out assert content == [
assert content == [] {"type": "text", "text": "First reasoning..."},
{"type": "text", "text": "Second reasoning..."},
]
@pytest.mark.asyncio @pytest.mark.asyncio