Python: surface Gemini thought summaries as reasoning content (#7488)

Gemini thought-summary parts (part.thought=True) were dropped in _parse_parts, so reasoning never reached ChatResponse.contents. Emit them as text_reasoning content instead, matching OpenAIResponsesClient. Round-trip is safe: _convert_message_contents never re-emits reasoning text as a Part.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: b8aa906e-1408-40c1-9a45-6deb40dc36f8
This commit is contained in:
Giles Odigwe
2026-08-06 20:08:25 -07:00
committed by GitHub
parent 45c515b8a7
commit 4b1afd9052
2 changed files with 28 additions and 7 deletions
@@ -78,8 +78,8 @@ class ThinkingConfig(TypedDict, total=False):
Attributes:
include_thoughts: Whether to include thought summaries in the response. Thought summaries
are condensed representations of the model's internal reasoning and appear as response
parts where ``part.thought`` is ``True``. Note: the framework currently excludes
thought parts from ``ChatResponse.contents`` and does not surface them as output.
parts where ``part.thought`` is ``True``. When set, the framework surfaces these parts
as ``text_reasoning`` content in ``ChatResponse.contents``.
thinking_budget: Token budget for Gemini 2.5 models. Set to ``0`` to disable
thinking or ``-1`` to enable a dynamic budget.
thinking_level: Thinking level for Gemini 2.5 models and later. One of
@@ -1120,17 +1120,20 @@ class RawGeminiChatClient(
)
def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]:
"""Convert Gemini response parts to framework Content objects, skipping thought/reasoning parts.
"""Convert Gemini response parts to framework Content objects.
Args:
parts: Sequence of ``types.Part`` objects from a Gemini response candidate.
Returns:
A list of framework ``Content`` objects (text, function_call, or function_result).
A list of framework ``Content`` objects (text_reasoning, text, function_call, or
function_result).
"""
contents: list[Content] = []
for part in parts:
if part.thought:
if part.text:
contents.append(Content.from_text_reasoning(text=part.text, raw_representation=part))
continue
if part.text is not None:
contents.append(Content.from_text(text=part.text, raw_representation=part))
@@ -714,8 +714,8 @@ async def test_non_function_result_content_in_tool_message_is_skipped() -> None:
# thinking parts
async def test_thinking_parts_are_silently_skipped() -> None:
"""Excludes thought-summary parts from ChatResponse.contents, returning only the final answer."""
async def test_thinking_parts_are_surfaced_as_reasoning() -> None:
"""Surfaces thought-summary parts as text_reasoning content alongside the final answer."""
client, mock = _make_gemini_client()
mock.aio.models.generate_content = AsyncMock(
return_value=_make_response([
@@ -728,10 +728,28 @@ async def test_thinking_parts_are_silently_skipped() -> None:
messages=[Message(role="user", contents=[Content.from_text("What is the answer?")])]
)
assert len(response.messages[0].contents) == 1
contents = response.messages[0].contents
assert len(contents) == 2
assert contents[0].type == "text_reasoning"
assert contents[0].text == "I should think first..."
assert contents[1].type == "text"
assert response.messages[0].text == "The answer is 42."
async def test_empty_thinking_part_produces_no_reasoning_content() -> None:
"""A thought part with no text yields no content rather than empty reasoning."""
client, _ = _make_gemini_client()
contents = client._parse_parts([
_make_part(text=None, thought=True),
_make_part(text="The answer is 42."),
])
assert len(contents) == 1
assert contents[0].type == "text"
assert contents[0].text == "The answer is 42."
def test_function_call_part_preserves_thought_signature_from_raw_part() -> None:
"""Reuses the original Gemini Part so tool loops retain thought_signature metadata."""
client, _ = _make_gemini_client()