diff --git a/src/any_llm/providers/gemini/utils.py b/src/any_llm/providers/gemini/utils.py index af2dcd63f..6403cc264 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): @@ -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 @@ -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 903016e8b..35c7a9e00 100644 --- a/tests/unit/providers/test_gemini_provider.py +++ b/tests/unit/providers/test_gemini_provider.py @@ -813,6 +813,72 @@ 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_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_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_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))