Skip to content

Commit 7c3dc17

Browse files
authored
fix(vertexai): preserve reasoning_content as thought parts in multi-turn conversations (#5677)
# What does this PR do? When thinking-enabled Gemini models (2.5/3) return `reasoning_content` in assistant messages, that content must be sent back as native `thought` parts in subsequent multi-turn requests. Without this, the model loses prior reasoning context across turns. This PR updates `_convert_assistant_message()` in the VertexAI converter to extract `reasoning_content` from assistant messages and emit it as a `{"thought": True, "text": ...}` part, placed before text and function_call parts. A truthiness guard skips the part when `reasoning_content` is missing or `None`, preserving backward compatibility. ## Test Plan Added parametrized unit tests covering all combinations of `reasoning_content` with text, tool_calls, both, neither, and `None`: ```bash uv run pytest tests/unit/providers/inference/vertexai/test_converters_requests.py -v -k "reasoning" ``` Output: ```text test_assistant_reasoning_content[reasoning_and_text] PASSED test_assistant_reasoning_content[reasoning_only] PASSED test_assistant_reasoning_content[no_reasoning] PASSED test_assistant_reasoning_content[reasoning_none] PASSED test_assistant_reasoning_content_with_tool_calls[reasoning_and_tool_calls] PASSED test_assistant_reasoning_content_with_tool_calls[reasoning_text_and_tool_calls] PASSED 6 passed in 0.04s ``` Full converter test suite (110 tests) passes with no regressions: ```bash uv run pytest tests/unit/providers/inference/vertexai/test_converters_requests.py tests/unit/providers/inference/vertexai/test_converters_responses.py -x --tb=short ``` ```text 110 passed in 0.05s ``` Signed-off-by: Major Hayden <major@redhat.com> Signed-off-by: Major Hayden <major@mhtx.net>
1 parent 6751d40 commit 7c3dc17

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

src/ogx/providers/remote/inference/vertexai/converters.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,16 @@ def _convert_assistant_message(msg: dict[str, Any]) -> dict[str, Any] | None:
253253
"""
254254
parts: list[dict[str, Any]] = []
255255

256+
# Preserve prior reasoning in multi-turn conversations with thinking-enabled
257+
# Gemini models by emitting reasoning_content as a native thought part.
258+
reasoning_content = msg.get("reasoning_content")
259+
if reasoning_content:
260+
if not isinstance(reasoning_content, str):
261+
raise TypeError(
262+
f"Failed to convert assistant message: reasoning_content must be a string, got {type(reasoning_content).__name__}"
263+
)
264+
parts.append({"thought": True, "text": reasoning_content})
265+
256266
text = _extract_text_content(msg.get("content"))
257267
if text:
258268
parts.append({"text": text})

tests/unit/providers/inference/vertexai/test_converters_requests.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,6 +607,80 @@ def test_tool_call_id_not_found(self):
607607
fr = contents[0]["parts"][0]["function_response"]
608608
assert fr["name"] == "unknown"
609609

610+
_SEARCH_TOOL_CALL = {
611+
"id": "call_1",
612+
"type": "function",
613+
"function": {"name": "search", "arguments": '{"q": "test"}'},
614+
}
615+
616+
@pytest.mark.parametrize(
617+
"message,expected_parts",
618+
[
619+
pytest.param(
620+
{"role": "assistant", "reasoning_content": "I think the answer is 42", "content": "Hello"},
621+
[{"thought": True, "text": "I think the answer is 42"}, {"text": "Hello"}],
622+
id="reasoning_and_text",
623+
),
624+
pytest.param(
625+
{"role": "assistant", "reasoning_content": "My reasoning", "content": None},
626+
[{"thought": True, "text": "My reasoning"}],
627+
id="reasoning_only",
628+
),
629+
pytest.param(
630+
{"role": "assistant", "content": "Hello"},
631+
[{"text": "Hello"}],
632+
id="no_reasoning",
633+
),
634+
pytest.param(
635+
{"role": "assistant", "reasoning_content": None, "content": "Hello"},
636+
[{"text": "Hello"}],
637+
id="reasoning_none",
638+
),
639+
],
640+
)
641+
def test_assistant_reasoning_content(self, message, expected_parts):
642+
"""Test reasoning_content is emitted as thought parts before text parts."""
643+
_, contents = convert_openai_messages_to_gemini([message])
644+
assert contents[0]["role"] == "model"
645+
assert contents[0]["parts"] == expected_parts
646+
647+
@pytest.mark.parametrize(
648+
"reasoning_content",
649+
[
650+
pytest.param({"type": "thinking", "thinking": "deep thought"}, id="dict_object"),
651+
pytest.param(["thinking", "items"], id="list_object"),
652+
pytest.param(42, id="integer"),
653+
],
654+
)
655+
def test_assistant_reasoning_content_must_be_string(self, reasoning_content):
656+
"""Test that non-string reasoning_content raises TypeError."""
657+
message = {"role": "assistant", "reasoning_content": reasoning_content, "content": "Hello"}
658+
with pytest.raises(TypeError, match="reasoning_content must be a string"):
659+
convert_openai_messages_to_gemini([message])
660+
661+
@pytest.mark.parametrize(
662+
"message,expected_non_fc_parts",
663+
[
664+
pytest.param(
665+
{"role": "assistant", "reasoning_content": "Need a tool", "content": None},
666+
[{"thought": True, "text": "Need a tool"}],
667+
id="reasoning_and_tool_calls",
668+
),
669+
pytest.param(
670+
{"role": "assistant", "reasoning_content": "Let me think", "content": "I will search"},
671+
[{"thought": True, "text": "Let me think"}, {"text": "I will search"}],
672+
id="reasoning_text_and_tool_calls",
673+
),
674+
],
675+
)
676+
def test_assistant_reasoning_content_with_tool_calls(self, message, expected_non_fc_parts):
677+
"""Test thought -> text -> function_call ordering when tool_calls are present."""
678+
message["tool_calls"] = [self._SEARCH_TOOL_CALL]
679+
_, contents = convert_openai_messages_to_gemini([message])
680+
parts = contents[0]["parts"]
681+
assert parts[: len(expected_non_fc_parts)] == expected_non_fc_parts
682+
assert "function_call" in parts[-1]
683+
610684

611685
class TestConvertOpenAIToolsToGemini:
612686
def test_single_function_tool(self):

0 commit comments

Comments
 (0)