diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 13e2238fdf6..f175bb5acdf 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -882,8 +882,7 @@ def _gemini_convert_messages_with_history( _message_content = assistant_msg.get("content", None) reasoning_content = assistant_msg.get("reasoning_content", None) thinking_blocks = assistant_msg.get("thinking_blocks") - if reasoning_content is not None: - assistant_content.append(PartType(thought=True, text=reasoning_content)) + emitted_thinking = False if thinking_blocks is not None: for block in thinking_blocks: if block["type"] == "thinking": @@ -904,6 +903,9 @@ def _gemini_convert_messages_with_history( text=block_thinking_str, ) ) + emitted_thinking = True + if not emitted_thinking and reasoning_content is not None: + assistant_content.append(PartType(thought=True, text=reasoning_content)) if _message_content is not None and isinstance(_message_content, list): _parts = [] for element in _message_content: diff --git a/tests/llm_translation/test_prompt_factory.py b/tests/llm_translation/test_prompt_factory.py index 7b03736920b..e5b207e9137 100644 --- a/tests/llm_translation/test_prompt_factory.py +++ b/tests/llm_translation/test_prompt_factory.py @@ -2490,3 +2490,109 @@ def test_has_tool_with_name_anthropic_shape_without_type_field(): def test_has_tool_with_name_not_a_list(): assert not has_tool_with_name(None, "my_tool") assert not has_tool_with_name("not a list", "my_tool") + + +def test_gemini_no_duplicate_reasoning_parts_when_both_fields_present(): + """ + Regression (#37973): when an assistant message has both reasoning_content and + thinking_blocks, only one reasoning part must be emitted. Signed thinking_blocks + take priority; reasoning_content is a fallback used only when thinking_blocks is absent. + """ + reasoning_text = "Two plus two is four." + signature = "test-signature-abc123" + + messages = [ + {"role": "user", "content": "What is 2+2?"}, + { + "role": "assistant", + "content": "4", + "reasoning_content": reasoning_text, + "thinking_blocks": [ + { + "type": "thinking", + "thinking": reasoning_text, + "signature": signature, + } + ], + }, + {"role": "user", "content": "Multiply by 3."}, + ] + + contents = _gemini_convert_messages_with_history(messages=messages) + assistant_turn = contents[1] + assert assistant_turn["role"] == "model" + + parts = assistant_turn["parts"] + + # Only the signed block should appear — no extra plain thought part + signed_parts = [p for p in parts if p.get("thoughtSignature") is not None] + plain_thought_parts = [p for p in parts if p.get("thought") is True] + + assert len(signed_parts) == 1, ( + f"Expected 1 signed reasoning part, got {len(signed_parts)}: {signed_parts}" + ) + assert len(plain_thought_parts) == 0, ( + f"reasoning_content must not be emitted when thinking_blocks is present; " + f"got {len(plain_thought_parts)} extra plain thought part(s)" + ) + assert signed_parts[0]["thoughtSignature"] == signature + + +def test_gemini_reasoning_content_fallback_when_no_thinking_blocks(): + """ + When an assistant message has reasoning_content but no thinking_blocks, + a plain thought part should still be emitted (the fallback path still works). + """ + reasoning_text = "I reasoned about this carefully." + + messages = [ + {"role": "user", "content": "Hello."}, + { + "role": "assistant", + "content": "Hi there.", + "reasoning_content": reasoning_text, + }, + {"role": "user", "content": "How are you?"}, + ] + + contents = _gemini_convert_messages_with_history(messages=messages) + assistant_turn = contents[1] + assert assistant_turn["role"] == "model" + + parts = assistant_turn["parts"] + + plain_thought_parts = [p for p in parts if p.get("thought") is True] + assert len(plain_thought_parts) == 1, ( + f"Expected 1 plain thought part from reasoning_content fallback, got {len(plain_thought_parts)}" + ) + assert plain_thought_parts[0]["text"] == reasoning_text + + +def test_gemini_reasoning_content_fallback_when_thinking_blocks_empty(): + """ + Greptile review: when thinking_blocks is non-null but empty (or has no + usable signed blocks), reasoning_content must still be emitted as fallback. + """ + reasoning_text = "I reasoned carefully." + + messages = [ + {"role": "user", "content": "Hello."}, + { + "role": "assistant", + "content": "Hi.", + "reasoning_content": reasoning_text, + "thinking_blocks": [], # non-null but empty — no usable blocks + }, + {"role": "user", "content": "How are you?"}, + ] + + contents = _gemini_convert_messages_with_history(messages=messages) + assistant_turn = contents[1] + parts = assistant_turn["parts"] + + plain_thought_parts = [p for p in parts if p.get("thought") is True] + assert len(plain_thought_parts) == 1, ( + f"reasoning_content fallback must fire when thinking_blocks is empty; " + f"got {len(plain_thought_parts)} plain thought part(s)" + ) + assert plain_thought_parts[0]["text"] == reasoning_text \ No newline at end of file diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index 4679b978f78..34280872ad6 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -2781,3 +2781,95 @@ def test_gemini_server_side_tool_signature_not_duplicated_on_text(): assert "thoughtSignature" not in text_part tool_call_part = next(p for p in parts if "toolCall" in p) assert tool_call_part["thoughtSignature"] == "server_side_signature" + + +def test_gemini_no_duplicate_reasoning_parts_when_both_fields_present(): + """ + Regression (#37973): when an assistant message has both reasoning_content and + thinking_blocks, only one reasoning part must be emitted. Signed thinking_blocks + take priority; reasoning_content is a fallback used only when thinking_blocks is absent. + """ + reasoning_text = "Two plus two is four." + signature = "test-signature-abc123" + + messages = [ + {"role": "user", "content": "What is 2+2?"}, + { + "role": "assistant", + "content": "4", + "reasoning_content": reasoning_text, + "thinking_blocks": [ + { + "type": "thinking", + "thinking": reasoning_text, + "signature": signature, + } + ], + }, + {"role": "user", "content": "Multiply by 3."}, + ] + + contents = _gemini_convert_messages_with_history(messages=messages) + parts = contents[1]["parts"] + + signed_parts = [p for p in parts if p.get("thoughtSignature") is not None] + plain_thought_parts = [p for p in parts if p.get("thought") is True] + + assert len(signed_parts) == 1, ( + f"Expected 1 signed reasoning part, got {len(signed_parts)}: {signed_parts}" + ) + assert len(plain_thought_parts) == 0, ( + f"reasoning_content must not be emitted when thinking_blocks is present; " + f"got {len(plain_thought_parts)} extra plain thought part(s)" + ) + assert signed_parts[0]["thoughtSignature"] == signature + + +def test_gemini_reasoning_content_fallback_when_no_thinking_blocks(): + """ + When an assistant message has reasoning_content but no thinking_blocks, + a plain thought part should be emitted as fallback. + """ + reasoning_text = "I reasoned about this carefully." + + messages = [ + {"role": "user", "content": "Hello."}, + { + "role": "assistant", + "content": "Hi there.", + "reasoning_content": reasoning_text, + }, + {"role": "user", "content": "How are you?"}, + ] + + contents = _gemini_convert_messages_with_history(messages=messages) + parts = contents[1]["parts"] + + plain_thought_parts = [p for p in parts if p.get("thought") is True] + assert len(plain_thought_parts) == 1 + assert plain_thought_parts[0]["text"] == reasoning_text + + +def test_gemini_reasoning_content_fallback_when_thinking_blocks_empty(): + """ + When thinking_blocks is non-null but empty, reasoning_content must still be emitted. + """ + reasoning_text = "I reasoned carefully." + + messages = [ + {"role": "user", "content": "Hello."}, + { + "role": "assistant", + "content": "Hi.", + "reasoning_content": reasoning_text, + "thinking_blocks": [], + }, + {"role": "user", "content": "How are you?"}, + ] + + contents = _gemini_convert_messages_with_history(messages=messages) + parts = contents[1]["parts"] + + plain_thought_parts = [p for p in parts if p.get("thought") is True] + assert len(plain_thought_parts) == 1 + assert plain_thought_parts[0]["text"] == reasoning_text