From 8149e51d629eae81e4b63241ef85db36adc2d2ee Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 13 Aug 2026 08:44:44 +0800 Subject: [PATCH 1/4] fix(gemini): accumulate non-streaming reasoning parts --- src/any_llm/providers/gemini/utils.py | 2 +- tests/unit/providers/test_gemini_provider.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index af2dcd63f..d774b471f 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -363,7 +363,7 @@ def _convert_response_to_response_dict(response: types.GenerateContentResponse) for part in parts or []: if getattr(part, "thought", None): - reasoning = part.text + reasoning = (reasoning or "") + (part.text or "") elif function_call := getattr(part, "function_call", None): args_dict = {} if args := getattr(function_call, "args", None): diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 903016e8b..f438dace3 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -813,6 +813,23 @@ def test_convert_response_emits_choice_for_reasoning_only_truncation() -> None: assert choice["message"]["reasoning"] == "internal reasoning" +def test_convert_response_accumulates_multiple_reasoning_parts() -> None: + response = _make_gemini_response( + [ + types.Part(text="First thought. ", thought=True), + types.Part(text="Second thought.", thought=True), + types.Part(text="The answer."), + ], + types.FinishReason.STOP, + ) + + response_dict = _convert_response_to_response_dict(response) + + message = response_dict["choices"][0]["message"] + assert message["reasoning"] == "First thought. Second thought." + assert message["content"] == "The answer." + + def test_convert_response_emits_choice_for_filtered_response_without_content() -> None: response_dict = _convert_response_to_response_dict(_make_gemini_response(None, types.FinishReason.SAFETY)) From fdc5a60ba45b23e5cc38512b7d4d8132125e4cfc Mon Sep 17 00:00:00 2001 From: njbrake Date: Thu, 13 Aug 2026 13:00:00 +0000 Subject: [PATCH 2/4] fix(gemini): keep reasoning None when a thought part carries no text Accumulating with `(reasoning or "") + (part.text or "")` turns a textless thought part, e.g. one carrying only a thought_signature, into an empty string, which Reasoning coerces into a truthy Reasoning(content=""). The streaming converter emits None in that case; match it. Co-Authored-By: Claude Opus 5 (1M context) --- src/any_llm/providers/gemini/utils.py | 2 +- tests/unit/providers/test_gemini_provider.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index d774b471f..812c483d1 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -396,7 +396,7 @@ def _convert_response_to_response_dict(response: types.GenerateContentResponse) "message": { "role": "assistant", "content": None if tool_calls_list else text_content, - "reasoning": reasoning, + "reasoning": reasoning or None, "tool_calls": tool_calls_list or None, }, "finish_reason": _resolve_finish_reason(mapped_finish_reason, bool(tool_calls_list)) or "stop", diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index f438dace3..f0a0c77e0 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -830,6 +830,24 @@ def test_convert_response_accumulates_multiple_reasoning_parts() -> None: assert message["content"] == "The answer." +def test_convert_response_keeps_reasoning_none_for_textless_thought_part() -> None: + """A thought part can carry only a thought_signature; that must not turn reasoning into an + empty Reasoning object, matching the streaming converter.""" + response = _make_gemini_response( + [ + types.Part(thought=True, thought_signature=b"sig"), + types.Part(text="The answer."), + ], + types.FinishReason.STOP, + ) + + response_dict = _convert_response_to_response_dict(response) + + message = response_dict["choices"][0]["message"] + assert message["reasoning"] is None + assert message["content"] == "The answer." + + def test_convert_response_emits_choice_for_filtered_response_without_content() -> None: response_dict = _convert_response_to_response_dict(_make_gemini_response(None, types.FinishReason.SAFETY)) From 5abf339814a1ae7d433fe852e92f404f55497025 Mon Sep 17 00:00:00 2001 From: njbrake Date: Thu, 13 Aug 2026 13:00:33 +0000 Subject: [PATCH 3/4] fix(gemini): accumulate non-streaming text parts A candidate can carry more than one non-thought text part; google-genai's own GenerateContentResponse.text concatenates them. The non-streaming converter kept only the last one, dropping earlier text. Match the streaming converter, which already accumulates. Co-Authored-By: Claude Opus 5 (1M context) --- src/any_llm/providers/gemini/utils.py | 4 ++-- tests/unit/providers/test_gemini_provider.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index 812c483d1..6403cc264 100644 --- a/src/any_llm/providers/gemini/utils.py +++ b/src/any_llm/providers/gemini/utils.py @@ -384,8 +384,8 @@ def _convert_response_to_response_dict(response: types.GenerateContentResponse) tool_call_dict["extra_content"] = extra_content tool_calls_list.append(tool_call_dict) - elif getattr(part, "text", None): - text_content = part.text + elif part_text := getattr(part, "text", None): + text_content = (text_content or "") + part_text # Truncated or filtered responses produce a choice even without content or tool # calls, e.g. a thinking model that spent the whole max_output_tokens budget on diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index f0a0c77e0..19273b3c5 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -848,6 +848,19 @@ def test_convert_response_keeps_reasoning_none_for_textless_thought_part() -> No assert message["content"] == "The answer." +def test_convert_response_accumulates_multiple_text_parts() -> None: + response = _make_gemini_response( + [types.Part(text="Hello "), types.Part(text="world.")], + types.FinishReason.STOP, + ) + + response_dict = _convert_response_to_response_dict(response) + + message = response_dict["choices"][0]["message"] + assert message["content"] == "Hello world." + assert message["reasoning"] is None + + def test_convert_response_emits_choice_for_filtered_response_without_content() -> None: response_dict = _convert_response_to_response_dict(_make_gemini_response(None, types.FinishReason.SAFETY)) From d88556f7e22315232c5631b2dfeaab749ea85424 Mon Sep 17 00:00:00 2001 From: njbrake Date: Thu, 13 Aug 2026 13:04:12 +0000 Subject: [PATCH 4/4] tests(gemini): cover parts carrying neither text nor a function call The changed elif in the non-streaming converter had an uncovered false arm, so Codecov flagged the patch as partially covered. A part with inline data and no text exercises it. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/providers/test_gemini_provider.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/unit/providers/test_gemini_provider.py b/tests/unit/providers/test_gemini_provider.py index 19273b3c5..35c7a9e00 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -861,6 +861,24 @@ def test_convert_response_accumulates_multiple_text_parts() -> None: assert message["reasoning"] is None +def test_convert_response_skips_parts_without_text_or_function_call() -> None: + """A candidate can mix in parts that carry neither text nor a tool call, e.g. inline image + data; those must not disturb the accumulated text.""" + response = _make_gemini_response( + [ + types.Part(inline_data=types.Blob(mime_type="image/png", data=b"\x89PNG")), + types.Part(text="Described."), + ], + types.FinishReason.STOP, + ) + + response_dict = _convert_response_to_response_dict(response) + + message = response_dict["choices"][0]["message"] + assert message["content"] == "Described." + assert message["tool_calls"] is None + + def test_convert_response_emits_choice_for_filtered_response_without_content() -> None: response_dict = _convert_response_to_response_dict(_make_gemini_response(None, types.FinishReason.SAFETY))