Skip to content

Commit 6c67b6c

Browse files
GWealecopybara-github
authored andcommitted
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 <[email protected]> PiperOrigin-RevId: 853790274
1 parent 9b2bd41 commit 6c67b6c

File tree

2 files changed

+102
-37
lines changed

2 files changed

+102
-37
lines changed

src/google/adk/models/lite_llm.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -482,13 +482,15 @@ async def _content_to_message_param(
482482

483483
# Handle user or assistant messages
484484
role = _to_litellm_role(content.role)
485-
message_content = await _get_content(content.parts, provider=provider) or None
486485

487486
if role == "user":
487+
user_parts = [part for part in content.parts if not part.thought]
488+
message_content = await _get_content(user_parts, provider=provider) or None
488489
return ChatCompletionUserMessage(role="user", content=message_content)
489490
else: # assistant/model
490491
tool_calls = []
491-
content_present = False
492+
content_parts: list[types.Part] = []
493+
reasoning_parts: list[types.Part] = []
492494
for part in content.parts:
493495
if part.function_call:
494496
tool_calls.append(
@@ -501,10 +503,16 @@ async def _content_to_message_param(
501503
),
502504
)
503505
)
504-
elif part.text or part.inline_data:
505-
content_present = True
506+
elif part.thought:
507+
reasoning_parts.append(part)
508+
else:
509+
content_parts.append(part)
506510

507-
final_content = message_content if content_present else None
511+
final_content = (
512+
await _get_content(content_parts, provider=provider)
513+
if content_parts
514+
else None
515+
)
508516
if final_content and isinstance(final_content, list):
509517
# when the content is a single text object, we can use it directly.
510518
# this is needed for ollama_chat provider which fails if content is a list
@@ -514,10 +522,24 @@ async def _content_to_message_param(
514522
else final_content
515523
)
516524

525+
reasoning_texts = []
526+
for part in reasoning_parts:
527+
if part.text:
528+
reasoning_texts.append(part.text)
529+
elif (
530+
part.inline_data
531+
and part.inline_data.data
532+
and part.inline_data.mime_type
533+
and part.inline_data.mime_type.startswith("text/")
534+
):
535+
reasoning_texts.append(_decode_inline_text_data(part.inline_data.data))
536+
537+
reasoning_content = _NEW_LINE.join(text for text in reasoning_texts if text)
517538
return ChatCompletionAssistantMessage(
518539
role=role,
519540
content=final_content,
520541
tool_calls=tool_calls or None,
542+
reasoning_content=reasoning_content or None,
521543
)
522544

523545

@@ -587,8 +609,8 @@ async def _get_content(
587609
) -> OpenAIMessageContent:
588610
"""Converts a list of parts to litellm content.
589611
590-
Thought parts represent internal model reasoning and are always dropped so
591-
they are not replayed back to the model in subsequent turns.
612+
Callers may need to filter out thought parts before calling this helper if
613+
thought parts are not needed.
592614
593615
Args:
594616
parts: The parts to convert.
@@ -598,9 +620,9 @@ async def _get_content(
598620
The litellm content.
599621
"""
600622

601-
parts_without_thought = [part for part in parts if not part.thought]
602-
if len(parts_without_thought) == 1:
603-
part = parts_without_thought[0]
623+
parts_list = list(parts)
624+
if len(parts_list) == 1:
625+
part = parts_list[0]
604626
if part.text:
605627
return part.text
606628
if (
@@ -612,10 +634,7 @@ async def _get_content(
612634
return _decode_inline_text_data(part.inline_data.data)
613635

614636
content_objects = []
615-
for part in parts_without_thought:
616-
# Skip thought parts to prevent reasoning from being replayed in subsequent
617-
# turns. Thought parts are internal model reasoning and should not be sent
618-
# back to the model.
637+
for part in parts_list:
619638
if part.text:
620639
content_objects.append({
621640
"type": "text",

tests/unittests/models/test_litellm.py

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1860,6 +1860,59 @@ async def test_content_to_message_param_assistant_message():
18601860
assert message["content"] == "Test response"
18611861

18621862

1863+
@pytest.mark.asyncio
1864+
async def test_content_to_message_param_user_filters_thought_parts():
1865+
thought_part = types.Part.from_text(text="internal reasoning")
1866+
thought_part.thought = True
1867+
content_part = types.Part.from_text(text="visible content")
1868+
content = types.Content(role="user", parts=[thought_part, content_part])
1869+
1870+
message = await _content_to_message_param(content)
1871+
1872+
assert message["role"] == "user"
1873+
assert message["content"] == "visible content"
1874+
1875+
1876+
@pytest.mark.asyncio
1877+
async def test_content_to_message_param_assistant_thought_message():
1878+
part = types.Part.from_text(text="internal reasoning")
1879+
part.thought = True
1880+
content = types.Content(role="assistant", parts=[part])
1881+
1882+
message = await _content_to_message_param(content)
1883+
1884+
assert message["role"] == "assistant"
1885+
assert message["content"] is None
1886+
assert message["reasoning_content"] == "internal reasoning"
1887+
1888+
1889+
@pytest.mark.asyncio
1890+
async def test_content_to_message_param_model_thought_message():
1891+
part = types.Part.from_text(text="internal reasoning")
1892+
part.thought = True
1893+
content = types.Content(role="model", parts=[part])
1894+
1895+
message = await _content_to_message_param(content)
1896+
1897+
assert message["role"] == "assistant"
1898+
assert message["content"] is None
1899+
assert message["reasoning_content"] == "internal reasoning"
1900+
1901+
1902+
@pytest.mark.asyncio
1903+
async def test_content_to_message_param_assistant_thought_and_content_message():
1904+
thought_part = types.Part.from_text(text="internal reasoning")
1905+
thought_part.thought = True
1906+
content_part = types.Part.from_text(text="visible content")
1907+
content = types.Content(role="assistant", parts=[thought_part, content_part])
1908+
1909+
message = await _content_to_message_param(content)
1910+
1911+
assert message["role"] == "assistant"
1912+
assert message["content"] == "visible content"
1913+
assert message["reasoning_content"] == "internal reasoning"
1914+
1915+
18631916
@pytest.mark.asyncio
18641917
async def test_content_to_message_param_function_call():
18651918
content = types.Content(
@@ -2087,42 +2140,35 @@ def test_split_message_content_prefers_existing_structured_calls():
20872140

20882141

20892142
@pytest.mark.asyncio
2090-
async def test_get_content_filters_thought_parts():
2091-
"""Test that thought parts are filtered from content.
2092-
2093-
Thought parts contain model reasoning that should not be sent back to
2094-
the model in subsequent turns. This test verifies that _get_content
2095-
skips parts with thought=True.
2143+
async def test_get_content_does_not_filter_thought_parts():
2144+
"""Test that _get_content does not drop thought parts.
20962145
2097-
See: https://github.com/google/adk-python/issues/3948
2146+
Thought filtering is handled by the caller (e.g., _content_to_message_param)
2147+
to avoid duplicating logic across helpers.
20982148
"""
2099-
# Create a thought part (reasoning) and a regular text part
21002149
thought_part = types.Part(text="Internal reasoning...", thought=True)
21012150
regular_part = types.Part.from_text(text="Visible response")
2102-
parts = [thought_part, regular_part]
21032151

2104-
content = await _get_content(parts)
2152+
content = await _get_content([thought_part, regular_part])
21052153

2106-
# The thought part should be filtered out, leaving only the regular text
2107-
assert content == "Visible response"
2154+
assert content == [
2155+
{"type": "text", "text": "Internal reasoning..."},
2156+
{"type": "text", "text": "Visible response"},
2157+
]
21082158

21092159

21102160
@pytest.mark.asyncio
2111-
async def test_get_content_filters_all_thought_parts():
2112-
"""Test that all thought parts are filtered when only thoughts present.
2113-
2114-
When all parts are thought parts, _get_content should return an empty list.
2115-
2116-
See: https://github.com/google/adk-python/issues/3948
2117-
"""
2161+
async def test_get_content_all_thought_parts():
2162+
"""Test that thought parts convert like regular text parts."""
21182163
thought_part1 = types.Part(text="First reasoning...", thought=True)
21192164
thought_part2 = types.Part(text="Second reasoning...", thought=True)
2120-
parts = [thought_part1, thought_part2]
21212165

2122-
content = await _get_content(parts)
2166+
content = await _get_content([thought_part1, thought_part2])
21232167

2124-
# All thought parts should be filtered out
2125-
assert content == []
2168+
assert content == [
2169+
{"type": "text", "text": "First reasoning..."},
2170+
{"type": "text", "text": "Second reasoning..."},
2171+
]
21262172

21272173

21282174
@pytest.mark.asyncio

0 commit comments

Comments
 (0)