From 83872c19ba0295081f81f8c91c4be37f9aff9a7f Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 9 Jul 2026 18:41:43 +0000 Subject: [PATCH 1/7] Python: Fix reasoning content parsing in OpenAIChatCompletionClient Fix two issues with reasoning content handling in the Chat Completions client: 1. (#6979) reasoning_details plaintext buried as encrypted data: The client dumped the entire reasoning_details array into Content.protected_data without setting Content.text, causing AG-UI to emit ReasoningEncryptedValueEvent instead of visible ReasoningMessageContentEvent for plaintext reasoning providers (e.g. OpenRouter). Now extracts readable text from reasoning_details entries into Content.text while preserving protected_data for round-trip fidelity. 2. (#6978) Mistral list content causes crash: Mistral reasoning models return content as a list of typed chunks ([{"type": "thinking", ...}, {"type": "text", ...}]) instead of a plain string. _parse_text_from_openai assumed content was always a string, causing a Pydantic ValidationError downstream. Now detects list content and parses thinking chunks as Content.from_text_reasoning and text chunks as Content.from_text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_chat_completion_client.py | 104 ++++++++- .../test_openai_chat_completion_client.py | 218 ++++++++++++++++++ 2 files changed, 311 insertions(+), 11 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 37c2e5b52dd..a6adcda98a8 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -99,6 +99,44 @@ ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) +def _extract_reasoning_text(reasoning_details: Any) -> str | None: + """Extract visible plaintext from a reasoning_details payload. + + OpenAI-compatible providers return reasoning_details in various forms: + - A list of dicts with ``"text"`` keys (e.g. OpenRouter: ``[{"type": "reasoning.text", "text": "..."}]``) + - A dict with a ``"content"`` list containing text entries + - A plain string + + Returns the concatenated plaintext if any is found, otherwise None. + """ + if isinstance(reasoning_details, str): + return reasoning_details or None + + if isinstance(reasoning_details, list): + parts: list[str] = [] + for entry in reasoning_details: + if isinstance(entry, dict): + if text := entry.get("text"): + parts.append(text) + elif isinstance(entry, str): + parts.append(entry) + return "".join(parts) or None + + if isinstance(reasoning_details, dict): + # Handle {"content": [...], "text": "..."} style + if text := reasoning_details.get("text"): + return text + if content_list := reasoning_details.get("content"): + if isinstance(content_list, list): + parts = [] + for entry in content_list: + if isinstance(entry, dict) and (text := entry.get("text")): + parts.append(text) + return "".join(parts) or None + + return None + + # region OpenAI Chat Options TypedDict @@ -709,12 +747,14 @@ def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping if choice.finish_reason: finish_reason = choice.finish_reason # type: ignore[assignment] contents: list[Content] = [] - if text_content := self._parse_text_from_openai(choice): - contents.append(text_content) + contents.extend(self._parse_text_from_openai(choice)) if parsed_tool_calls := [tool for tool in self._parse_tool_calls_from_openai(choice)]: contents.extend(parsed_tool_calls) if reasoning_details := getattr(choice.message, "reasoning_details", None): - contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details))) + contents.append(Content.from_text_reasoning( + text=_extract_reasoning_text(reasoning_details), + protected_data=json.dumps(reasoning_details), + )) messages.append(Message(role="assistant", contents=contents)) return ChatResponse( response_id=response.id, @@ -755,10 +795,12 @@ def _parse_response_update_from_openai( continue contents.extend(self._parse_tool_calls_from_openai(choice)) - if text_content := self._parse_text_from_openai(choice): - contents.append(text_content) + contents.extend(self._parse_text_from_openai(choice)) if reasoning_details := getattr(choice.delta, "reasoning_details", None): - contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details))) + contents.append(Content.from_text_reasoning( + text=_extract_reasoning_text(reasoning_details), + protected_data=json.dumps(reasoning_details), + )) return ChatResponseUpdate( created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), contents=contents, @@ -795,14 +837,54 @@ def _parse_usage_from_openai(self, usage: CompletionUsage) -> UsageDetails: details["cache_read_input_token_count"] = tokens return details - def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> Content | None: - """Parse the choice into a Content object with type='text'.""" + def _parse_text_from_openai(self, choice: Choice | ChunkChoice) -> list[Content]: + """Parse the choice into Content objects with type='text' and/or 'text_reasoning'. + + Handles both plain string content and structured list content (e.g. Mistral + reasoning models that return ``[{"type": "thinking", ...}, {"type": "text", ...}]``). + """ message = choice.message if isinstance(choice, Choice) else choice.delta if message.content: - return Content.from_text(text=message.content, raw_representation=choice) + content = message.content + # Some OpenAI-compatible providers (e.g. Mistral reasoning models) return + # content as a list of typed chunks rather than a plain string. + if isinstance(content, list): + return self._parse_chunked_content(content, choice) + return [Content.from_text(text=content, raw_representation=choice)] if hasattr(message, "refusal") and message.refusal: - return Content.from_text(text=message.refusal, raw_representation=choice) - return None + return [Content.from_text(text=message.refusal, raw_representation=choice)] + return [] + + @staticmethod + def _parse_chunked_content(chunks: list[Any], choice: Choice | ChunkChoice) -> list[Content]: + """Parse structured content chunks (e.g. Mistral thinking/text format). + + Handles chunk types: + - ``{"type": "thinking", "thinking": "..." | [{"type": "text", "text": "..."}]}`` + - ``{"type": "text", "text": "..."}`` + """ + results: list[Content] = [] + for chunk in chunks: + if not isinstance(chunk, dict): + continue + chunk_type = chunk.get("type") + if chunk_type == "thinking": + thinking = chunk.get("thinking") + if isinstance(thinking, str): + text = thinking + elif isinstance(thinking, list): + text = "".join( + part.get("text", "") for part in thinking if isinstance(part, dict) + ) + else: + text = "" + if text: + results.append(Content.from_text_reasoning(text=text, raw_representation=choice)) + elif chunk_type == "text": + text = chunk.get("text") + if text: + results.append(Content.from_text(text=text, raw_representation=choice)) + return results def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]: """Get metadata from a chat response.""" diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 81d9963688b..bfee35dc6bf 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2087,4 +2087,222 @@ def test_streaming_chunk_with_null_delta_no_tool_calls_parsed( assert not any(c.type == "function_call" for c in update.contents) +def test_parse_reasoning_details_extracts_plaintext( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that reasoning_details with plaintext entries surface text in Content.text (issue #6979).""" + from openai.types.chat.chat_completion import ChatCompletion, Choice + from openai.types.chat.chat_completion_message import ChatCompletionMessage + + client = OpenAIChatCompletionClient() + + # OpenRouter-style reasoning_details with plaintext + mock_reasoning_details = [ + {"type": "reasoning.text", "text": "Let me think about this..."}, + {"type": "reasoning.text", "text": " The answer is 42."}, + ] + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="z-ai/glm-5.2", + choices=[ + Choice( + index=0, + message=cast(Any, ChatCompletionMessage)( + role="assistant", + content="The answer is 42.", + reasoning_details=mock_reasoning_details, + ), + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + message = response.messages[0] + reasoning_contents = [c for c in message.contents if c.type == "text_reasoning"] + assert len(reasoning_contents) == 1 + + # Text should be extracted from the plaintext entries + assert reasoning_contents[0].text == "Let me think about this... The answer is 42." + # protected_data should still be preserved for round-trip + assert reasoning_contents[0].protected_data is not None + parsed = json.loads(reasoning_contents[0].protected_data) + assert parsed == mock_reasoning_details + + +def test_parse_reasoning_details_extracts_plaintext_streaming( + openai_unit_test_env: dict[str, str], +) -> None: + """Test streaming path extracts plaintext from reasoning_details (issue #6979).""" + from openai.types.chat.chat_completion_chunk import ChatCompletionChunk + from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice + from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta + + client = OpenAIChatCompletionClient() + + mock_reasoning_details = [ + {"type": "reasoning.text", "text": "Thinking step 1"}, + ] + + mock_chunk = ChatCompletionChunk( + id="test-chunk", + object="chat.completion.chunk", + created=1234567890, + model="z-ai/glm-5.2", + choices=[ + ChunkChoice( + index=0, + delta=cast(Any, ChunkChoiceDelta)( + role="assistant", + content=None, + reasoning_details=mock_reasoning_details, + ), + finish_reason=None, + ) + ], + ) + + update = client._parse_response_update_from_openai(mock_chunk) + + reasoning_contents = [c for c in update.contents if c.type == "text_reasoning"] + assert len(reasoning_contents) == 1 + assert reasoning_contents[0].text == "Thinking step 1" + assert reasoning_contents[0].protected_data is not None + + +def test_parse_mistral_chunked_content_from_response( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that Mistral-style list content (thinking + text chunks) is parsed correctly (issue #6978).""" + from openai.types.chat.chat_completion import ChatCompletion, Choice + from openai.types.chat.chat_completion_message import ChatCompletionMessage + + client = OpenAIChatCompletionClient() + + # Mistral returns content as a list of typed chunks. + # The OpenAI SDK passes this through without strict validation in streaming, + # so we use model_construct to bypass Pydantic validation in the test. + chunked_content = [ + {"type": "thinking", "thinking": [{"type": "text", "text": "Let me reason about this..."}]}, + {"type": "text", "text": "The answer is 42."}, + ] + + message = ChatCompletionMessage.model_construct( + role="assistant", + content=chunked_content, + ) + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="mistral-medium-latest", + choices=[ + Choice( + index=0, + message=message, + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + message_out = response.messages[0] + assert len(message_out.contents) == 2 + + # First content should be text_reasoning from the thinking chunk + assert message_out.contents[0].type == "text_reasoning" + assert message_out.contents[0].text == "Let me reason about this..." + + # Second content should be text from the text chunk + assert message_out.contents[1].type == "text" + assert message_out.contents[1].text == "The answer is 42." + + +def test_parse_mistral_chunked_content_streaming( + openai_unit_test_env: dict[str, str], +) -> None: + """Test streaming path handles Mistral-style list content (issue #6978).""" + from openai.types.chat.chat_completion_chunk import ChatCompletionChunk + from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice + from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta + + client = OpenAIChatCompletionClient() + + # Streaming chunk with list content - use model_construct to bypass validation + chunked_content = [ + {"type": "thinking", "thinking": "Deep thought..."}, + {"type": "text", "text": "Here's the answer."}, + ] + + delta = ChunkChoiceDelta.model_construct( + role="assistant", + content=chunked_content, + ) + + mock_chunk = ChatCompletionChunk( + id="test-chunk", + object="chat.completion.chunk", + created=1234567890, + model="mistral-medium-latest", + choices=[ + ChunkChoice( + index=0, + delta=delta, + finish_reason=None, + ) + ], + ) + + update = client._parse_response_update_from_openai(mock_chunk) + + reasoning_contents = [c for c in update.contents if c.type == "text_reasoning"] + text_contents = [c for c in update.contents if c.type == "text"] + + assert len(reasoning_contents) == 1 + assert reasoning_contents[0].text == "Deep thought..." + + assert len(text_contents) == 1 + assert text_contents[0].text == "Here's the answer." + + +def test_parse_plain_string_content_still_works( + openai_unit_test_env: dict[str, str], +) -> None: + """Regression test: plain string content still works after list-content handling.""" + from openai.types.chat.chat_completion import ChatCompletion, Choice + from openai.types.chat.chat_completion_message import ChatCompletionMessage + + client = OpenAIChatCompletionClient() + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="gpt-4o", + choices=[ + Choice( + index=0, + message=cast(Any, ChatCompletionMessage)( + role="assistant", + content="Just a normal string response.", + ), + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + message = response.messages[0] + assert len(message.contents) == 1 + assert message.contents[0].type == "text" + assert message.contents[0].text == "Just a normal string response." + + # endregion From 427bb5bd3d79347fdf23f26e25feae270c2205cc Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 9 Jul 2026 18:53:32 +0000 Subject: [PATCH 2/7] Fix pyright strict-mode type errors and handle content-as-string shape - Use cast() for proper type narrowing in _extract_reasoning_text and _parse_chunked_content to satisfy pyright strict mode - Handle {"content": "..."} string shape in _extract_reasoning_text (addresses review comment about missing format coverage) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_chat_completion_client.py | 63 +++++++++++-------- 1 file changed, 38 insertions(+), 25 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index a6adcda98a8..ad6a0dfef28 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -104,7 +104,7 @@ def _extract_reasoning_text(reasoning_details: Any) -> str | None: OpenAI-compatible providers return reasoning_details in various forms: - A list of dicts with ``"text"`` keys (e.g. OpenRouter: ``[{"type": "reasoning.text", "text": "..."}]``) - - A dict with a ``"content"`` list containing text entries + - A dict with a ``"content"`` key (list of text entries or a plain string) - A plain string Returns the concatenated plaintext if any is found, otherwise None. @@ -114,25 +114,35 @@ def _extract_reasoning_text(reasoning_details: Any) -> str | None: if isinstance(reasoning_details, list): parts: list[str] = [] - for entry in reasoning_details: - if isinstance(entry, dict): - if text := entry.get("text"): - parts.append(text) - elif isinstance(entry, str): - parts.append(entry) + for item in cast(list[object], reasoning_details): + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + entry_text = cast(dict[str, object], item).get("text") + if isinstance(entry_text, str) and entry_text: + parts.append(entry_text) return "".join(parts) or None if isinstance(reasoning_details, dict): - # Handle {"content": [...], "text": "..."} style - if text := reasoning_details.get("text"): - return text - if content_list := reasoning_details.get("content"): - if isinstance(content_list, list): - parts = [] - for entry in content_list: - if isinstance(entry, dict) and (text := entry.get("text")): - parts.append(text) - return "".join(parts) or None + detail_dict = cast(dict[str, object], reasoning_details) + # Handle {"text": "..."} style + text_val = detail_dict.get("text") + if isinstance(text_val, str) and text_val: + return text_val + # Handle {"content": [...]} or {"content": "..."} style + content_val = detail_dict.get("content") + if isinstance(content_val, str) and content_val: + return content_val + if isinstance(content_val, list): + parts = [] + for item in cast(list[object], content_val): + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + entry_text2 = cast(dict[str, object], item).get("text") + if isinstance(entry_text2, str) and entry_text2: + parts.append(entry_text2) + return "".join(parts) or None return None @@ -864,26 +874,29 @@ def _parse_chunked_content(chunks: list[Any], choice: Choice | ChunkChoice) -> l - ``{"type": "text", "text": "..."}`` """ results: list[Content] = [] - for chunk in chunks: - if not isinstance(chunk, dict): + for item in cast(list[object], chunks): + if not isinstance(item, dict): continue - chunk_type = chunk.get("type") + chunk_dict = cast(dict[str, object], item) + chunk_type = chunk_dict.get("type") if chunk_type == "thinking": - thinking = chunk.get("thinking") + thinking = chunk_dict.get("thinking") if isinstance(thinking, str): text = thinking elif isinstance(thinking, list): text = "".join( - part.get("text", "") for part in thinking if isinstance(part, dict) + str(cast(dict[str, object], part).get("text", "")) + for part in cast(list[object], thinking) + if isinstance(part, dict) ) else: text = "" if text: results.append(Content.from_text_reasoning(text=text, raw_representation=choice)) elif chunk_type == "text": - text = chunk.get("text") - if text: - results.append(Content.from_text(text=text, raw_representation=choice)) + text_val = chunk_dict.get("text") + if isinstance(text_val, str) and text_val: + results.append(Content.from_text(text=text_val, raw_representation=choice)) return results def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]: From 933a37f97f678a8c6217c72a2e3284be3af0b81f Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 9 Jul 2026 19:35:56 +0000 Subject: [PATCH 3/7] Fix mypy errors: cast list content to Any in tests model_construct bypasses Pydantic runtime validation but mypy still checks declared types. Use cast(Any, ...) for the list content args. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/tests/openai/test_openai_chat_completion_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index bfee35dc6bf..30ac194913c 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2193,7 +2193,7 @@ def test_parse_mistral_chunked_content_from_response( message = ChatCompletionMessage.model_construct( role="assistant", - content=chunked_content, + content=cast(Any, chunked_content), ) mock_response = ChatCompletion( @@ -2242,7 +2242,7 @@ def test_parse_mistral_chunked_content_streaming( delta = ChunkChoiceDelta.model_construct( role="assistant", - content=chunked_content, + content=cast(Any, chunked_content), ) mock_chunk = ChatCompletionChunk( From 55e8a115679407ed375f702305eb9d9403112bc9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 14 Jul 2026 16:56:06 +0000 Subject: [PATCH 4/7] Address review comments: summary field, reasoning field, and round-trip - Add 'summary' field extraction in _extract_reasoning_text for reasoning.summary entries from OpenRouter - Handle message.reasoning and message.reasoning_content top-level fields (plaintext reasoning without reasoning_details) in both streaming and non-streaming paths - reasoning_details takes priority when both fields are present - Preserve original Mistral chunk list in additional_properties ('_source_content_list') so _prepare_message_for_openai can reconstruct the structured list content for multi-turn reasoning - Add 5 new tests covering all new behaviors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_chat_completion_client.py | 60 ++++- .../test_openai_chat_completion_client.py | 208 ++++++++++++++++++ 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index ad6a0dfef28..858ee22ad5d 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -118,9 +118,16 @@ def _extract_reasoning_text(reasoning_details: Any) -> str | None: if isinstance(item, str): parts.append(item) elif isinstance(item, dict): - entry_text = cast(dict[str, object], item).get("text") + entry_dict = cast(dict[str, object], item) + # Check "text" field (reasoning.text entries) + entry_text = entry_dict.get("text") if isinstance(entry_text, str) and entry_text: parts.append(entry_text) + continue + # Check "summary" field (reasoning.summary entries) + entry_summary = entry_dict.get("summary") + if isinstance(entry_summary, str) and entry_summary: + parts.append(entry_summary) return "".join(parts) or None if isinstance(reasoning_details, dict): @@ -129,6 +136,10 @@ def _extract_reasoning_text(reasoning_details: Any) -> str | None: text_val = detail_dict.get("text") if isinstance(text_val, str) and text_val: return text_val + # Handle {"summary": "..."} style (reasoning.summary entries) + summary_val = detail_dict.get("summary") + if isinstance(summary_val, str) and summary_val: + return summary_val # Handle {"content": [...]} or {"content": "..."} style content_val = detail_dict.get("content") if isinstance(content_val, str) and content_val: @@ -139,9 +150,14 @@ def _extract_reasoning_text(reasoning_details: Any) -> str | None: if isinstance(item, str): parts.append(item) elif isinstance(item, dict): - entry_text2 = cast(dict[str, object], item).get("text") + entry_dict2 = cast(dict[str, object], item) + entry_text2 = entry_dict2.get("text") if isinstance(entry_text2, str) and entry_text2: parts.append(entry_text2) + continue + entry_summary2 = entry_dict2.get("summary") + if isinstance(entry_summary2, str) and entry_summary2: + parts.append(entry_summary2) return "".join(parts) or None return None @@ -765,6 +781,15 @@ def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping text=_extract_reasoning_text(reasoning_details), protected_data=json.dumps(reasoning_details), )) + # Some OpenAI-compatible providers (e.g. OpenRouter) expose plaintext reasoning + # via a top-level "reasoning" or "reasoning_content" field instead of (or in + # addition to) "reasoning_details". Surface it when no reasoning was already parsed. + elif reasoning_str := ( + getattr(choice.message, "reasoning", None) + or getattr(choice.message, "reasoning_content", None) + ): + if isinstance(reasoning_str, str) and reasoning_str: + contents.append(Content.from_text_reasoning(text=reasoning_str)) messages.append(Message(role="assistant", contents=contents)) return ChatResponse( response_id=response.id, @@ -811,6 +836,12 @@ def _parse_response_update_from_openai( text=_extract_reasoning_text(reasoning_details), protected_data=json.dumps(reasoning_details), )) + elif reasoning_str := ( + getattr(choice.delta, "reasoning", None) + or getattr(choice.delta, "reasoning_content", None) + ): + if isinstance(reasoning_str, str) and reasoning_str: + contents.append(Content.from_text_reasoning(text=reasoning_str)) return ChatResponseUpdate( created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), contents=contents, @@ -872,6 +903,11 @@ def _parse_chunked_content(chunks: list[Any], choice: Choice | ChunkChoice) -> l Handles chunk types: - ``{"type": "thinking", "thinking": "..." | [{"type": "text", "text": "..."}]}`` - ``{"type": "text", "text": "..."}`` + + The original chunk list is stored in the first Content item's + ``additional_properties["_source_content_list"]`` so that + ``_prepare_message_for_openai`` can reconstruct the original list + format required by Mistral for multi-turn reasoning. """ results: list[Content] = [] for item in cast(list[object], chunks): @@ -897,6 +933,10 @@ def _parse_chunked_content(chunks: list[Any], choice: Choice | ChunkChoice) -> l text_val = chunk_dict.get("text") if isinstance(text_val, str) and text_val: results.append(Content.from_text(text=text_val, raw_representation=choice)) + + # Store the original list on the first item so it can round-trip correctly. + if results: + results[0].additional_properties["_source_content_list"] = chunks return results def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]: @@ -981,11 +1021,20 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: all_messages: list[dict[str, Any]] = [] pending_reasoning: Any = None + _skip_structured_siblings = False for content in message.contents: # Skip approval content - it's internal framework state, not for the LLM if content.type in ("function_approval_request", "function_approval_response"): continue + # Skip sibling content items that were part of a structured content list + # already emitted (e.g. the text portion following a Mistral thinking chunk). + if _skip_structured_siblings and "_source_content_list" not in content.additional_properties: + if content.type in ("text", "text_reasoning"): + continue + # Non-text items (function calls etc.) are NOT skipped. + _skip_structured_siblings = False + args: dict[str, Any] = { "role": message.role, } @@ -1018,6 +1067,13 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: args["content"] = content.result if content.result is not None else "" all_messages.append(args) continue + case "text_reasoning" if "_source_content_list" in content.additional_properties: + # Mistral-style: emit a single message with the original structured list content + # so it round-trips correctly for multi-turn reasoning / tool continuation. + source_list: list[Any] = content.additional_properties["_source_content_list"] + args["content"] = source_list + # Mark that subsequent content items from the same chunked source should be skipped + _skip_structured_siblings = True case "text_reasoning" if (protected_data := content.protected_data) is not None: # Buffer reasoning to attach to the next message with content/tool_calls pending_reasoning = json.loads(protected_data) diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 30ac194913c..ba5527c3cc4 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2306,3 +2306,211 @@ def test_parse_plain_string_content_still_works( # endregion + + +# region Review comment tests (PR #7028) + + +def test_parse_reasoning_summary_entries( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that reasoning.summary entries (with 'summary' field) are extracted.""" + client = OpenAIChatCompletionClient() + + mock_reasoning_details = [ + {"type": "reasoning.summary", "summary": "Key insight about the problem."}, + {"type": "reasoning.text", "text": "Detailed thinking..."}, + ] + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="z-ai/glm-5.2", + choices=[ + Choice( + index=0, + message=cast(Any, ChatCompletionMessage)( + role="assistant", + content="Answer.", + reasoning_details=mock_reasoning_details, + ), + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + message = response.messages[0] + reasoning_contents = [c for c in message.contents if c.type == "text_reasoning"] + assert len(reasoning_contents) == 1 + # Both summary and text entries should be extracted + assert reasoning_contents[0].text == "Key insight about the problem.Detailed thinking..." + + +def test_parse_reasoning_field_from_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that message.reasoning field is surfaced as text_reasoning (non-streaming).""" + client = OpenAIChatCompletionClient() + + # OpenRouter sometimes sends a top-level "reasoning" string field + message = ChatCompletionMessage.model_construct( + role="assistant", + content="The answer is 42.", + reasoning="I thought carefully about this problem...", + ) + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="openrouter-model", + choices=[ + Choice( + index=0, + message=message, + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + msg = response.messages[0] + reasoning_contents = [c for c in msg.contents if c.type == "text_reasoning"] + assert len(reasoning_contents) == 1 + assert reasoning_contents[0].text == "I thought carefully about this problem..." + # No protected_data since this is plaintext + assert reasoning_contents[0].protected_data is None + + +def test_parse_reasoning_content_field_streaming( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that delta.reasoning_content field is surfaced in streaming.""" + from openai.types.chat.chat_completion_chunk import ChatCompletionChunk + from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice + from openai.types.chat.chat_completion_chunk import ChoiceDelta as ChunkChoiceDelta + + client = OpenAIChatCompletionClient() + + delta = ChunkChoiceDelta.model_construct( + role="assistant", + content=None, + reasoning_content="Step 1: analyze the input...", + ) + + mock_chunk = ChatCompletionChunk( + id="test-chunk", + object="chat.completion.chunk", + created=1234567890, + model="openrouter-model", + choices=[ + ChunkChoice( + index=0, + delta=delta, + finish_reason=None, + ) + ], + ) + + update = client._parse_response_update_from_openai(mock_chunk) + + reasoning_contents = [c for c in update.contents if c.type == "text_reasoning"] + assert len(reasoning_contents) == 1 + assert reasoning_contents[0].text == "Step 1: analyze the input..." + + +def test_mistral_chunked_content_roundtrips_as_single_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Test that Mistral chunked content round-trips as a single message with list content.""" + client = OpenAIChatCompletionClient() + + chunked_content = [ + {"type": "thinking", "thinking": [{"type": "text", "text": "Let me reason about this..."}]}, + {"type": "text", "text": "The answer is 42."}, + ] + + message = ChatCompletionMessage.model_construct( + role="assistant", + content=cast(Any, chunked_content), + ) + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="mistral-medium-latest", + choices=[ + Choice( + index=0, + message=message, + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + # Framework sees both reasoning and text + msg = response.messages[0] + assert len(msg.contents) == 2 + assert msg.contents[0].type == "text_reasoning" + assert msg.contents[1].type == "text" + + # The original list should be stored for round-trip + assert "_source_content_list" in msg.contents[0].additional_properties + assert msg.contents[0].additional_properties["_source_content_list"] == chunked_content + + # Round-trip: prepare_message should produce a single message with list content + prepared = client._prepare_message_for_openai(msg) + assert len(prepared) == 1 + assert prepared[0]["role"] == "assistant" + assert prepared[0]["content"] == chunked_content + + +def test_reasoning_details_takes_priority_over_reasoning_field( + openai_unit_test_env: dict[str, str], +) -> None: + """When both reasoning_details and reasoning are present, reasoning_details wins.""" + client = OpenAIChatCompletionClient() + + mock_reasoning_details = [ + {"type": "reasoning.text", "text": "From reasoning_details."}, + ] + + # Message has both reasoning_details AND reasoning field + message = ChatCompletionMessage.model_construct( + role="assistant", + content="Answer.", + reasoning_details=mock_reasoning_details, + reasoning="From reasoning field.", + ) + + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="some-model", + choices=[ + Choice( + index=0, + message=message, + finish_reason="stop", + ) + ], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + msg = response.messages[0] + reasoning_contents = [c for c in msg.contents if c.type == "text_reasoning"] + # Should only have one reasoning content (from reasoning_details, not both) + assert len(reasoning_contents) == 1 + assert reasoning_contents[0].text == "From reasoning_details." + + +# endregion \ No newline at end of file From 0b0e1dcb82d57751a44a69a7d0230acf42955866 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 14 Jul 2026 17:28:54 +0000 Subject: [PATCH 5/7] Fix ruff used-dummy-variable: rename _skip_structured_siblings Remove leading underscore from _skip_structured_siblings variable since it is accessed (not a dummy variable). Ruff's used-dummy-variable rule flags variables with leading underscores that are read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_chat_completion_client.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index 858ee22ad5d..d267fc9f887 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -784,12 +784,11 @@ def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping # Some OpenAI-compatible providers (e.g. OpenRouter) expose plaintext reasoning # via a top-level "reasoning" or "reasoning_content" field instead of (or in # addition to) "reasoning_details". Surface it when no reasoning was already parsed. - elif reasoning_str := ( + elif (reasoning_str := ( getattr(choice.message, "reasoning", None) or getattr(choice.message, "reasoning_content", None) - ): - if isinstance(reasoning_str, str) and reasoning_str: - contents.append(Content.from_text_reasoning(text=reasoning_str)) + )) and isinstance(reasoning_str, str) and reasoning_str: + contents.append(Content.from_text_reasoning(text=reasoning_str)) messages.append(Message(role="assistant", contents=contents)) return ChatResponse( response_id=response.id, @@ -836,12 +835,11 @@ def _parse_response_update_from_openai( text=_extract_reasoning_text(reasoning_details), protected_data=json.dumps(reasoning_details), )) - elif reasoning_str := ( + elif (reasoning_str := ( getattr(choice.delta, "reasoning", None) or getattr(choice.delta, "reasoning_content", None) - ): - if isinstance(reasoning_str, str) and reasoning_str: - contents.append(Content.from_text_reasoning(text=reasoning_str)) + )) and isinstance(reasoning_str, str) and reasoning_str: + contents.append(Content.from_text_reasoning(text=reasoning_str)) return ChatResponseUpdate( created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), contents=contents, @@ -1021,7 +1019,7 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: all_messages: list[dict[str, Any]] = [] pending_reasoning: Any = None - _skip_structured_siblings = False + skip_structured_siblings = False for content in message.contents: # Skip approval content - it's internal framework state, not for the LLM if content.type in ("function_approval_request", "function_approval_response"): @@ -1029,11 +1027,11 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: # Skip sibling content items that were part of a structured content list # already emitted (e.g. the text portion following a Mistral thinking chunk). - if _skip_structured_siblings and "_source_content_list" not in content.additional_properties: + if skip_structured_siblings and "_source_content_list" not in content.additional_properties: if content.type in ("text", "text_reasoning"): continue # Non-text items (function calls etc.) are NOT skipped. - _skip_structured_siblings = False + skip_structured_siblings = False args: dict[str, Any] = { "role": message.role, @@ -1073,7 +1071,7 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: source_list: list[Any] = content.additional_properties["_source_content_list"] args["content"] = source_list # Mark that subsequent content items from the same chunked source should be skipped - _skip_structured_siblings = True + skip_structured_siblings = True case "text_reasoning" if (protected_data := content.protected_data) is not None: # Buffer reasoning to attach to the next message with content/tool_calls pending_reasoning = json.loads(protected_data) From d6d1576fe4b5d9a8f7b7d5474ee15e44f57d0c55 Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 14 Jul 2026 17:49:54 +0000 Subject: [PATCH 6/7] Fix missing newline at end of test file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openai/tests/openai/test_openai_chat_completion_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index ba5527c3cc4..5d9d41692da 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2513,4 +2513,4 @@ def test_reasoning_details_takes_priority_over_reasoning_field( assert reasoning_contents[0].text == "From reasoning_details." -# endregion \ No newline at end of file +# endregion From 50fa86bc3dbc07d9962cb7a3f3a3a091fff0f4d8 Mon Sep 17 00:00:00 2001 From: Giles Odigwe Date: Wed, 15 Jul 2026 11:09:30 -0700 Subject: [PATCH 7/7] Address review: type-agnostic chunk round-trip and reasoning field echo-back - Honor the _source_content_list marker regardless of the first emitted content's type by handling it before the type match, so a chunk list beginning with a text chunk still round-trips as one structured message (addresses github-actions review comment on results[0]). - Tag every chunked-content item with a shared _structured_content_group id and skip only exact group siblings during serialization, instead of suppressing all later text/reasoning content. - Record provenance of top-level reasoning/reasoning_content fields in _reasoning_source_field and echo the value back under the same key on the next request, which providers such as vLLM require (addresses Kimahriman review comment). Replaces the prior behavior that replayed surfaced reasoning as visible answer text. - Factor the duplicated reasoning parsing into _parse_reasoning_content. - Add tests for provenance capture, reasoning/reasoning_content round-trip, reasoning-only messages, text-first chunk round-trip, and unrelated sibling preservation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5 --- .../_chat_completion_client.py | 155 +++++++++++----- .../test_openai_chat_completion_client.py | 168 ++++++++++++++++++ 2 files changed, 276 insertions(+), 47 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_completion_client.py b/python/packages/openai/agent_framework_openai/_chat_completion_client.py index d267fc9f887..cdbae209094 100644 --- a/python/packages/openai/agent_framework_openai/_chat_completion_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_completion_client.py @@ -16,6 +16,7 @@ from datetime import datetime, timezone from itertools import chain from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload +from uuid import uuid4 from agent_framework._clients import BaseChatClient from agent_framework._compaction import CompactionStrategy, TokenizerProtocol @@ -163,6 +164,42 @@ def _extract_reasoning_text(reasoning_details: Any) -> str | None: return None +def _parse_reasoning_content(source: Any) -> list[Content]: + """Parse reasoning content from a chat message or streaming delta. + + ``reasoning_details`` (structured, possibly encrypted) takes priority. When it is + absent, the top-level plaintext ``reasoning`` / ``reasoning_content`` fields used by + some OpenAI-compatible providers (e.g. OpenRouter, vLLM) are surfaced instead. The + originating field name is recorded in ``additional_properties["_reasoning_source_field"]`` + so it can be echoed back under the same key on subsequent requests, which providers + such as vLLM require for multi-turn reasoning continuity. + + Args: + source: The ``ChatCompletionMessage`` or streaming ``ChoiceDelta`` to inspect. + + Returns: + A list with a single reasoning ``Content`` when reasoning is present, otherwise + an empty list. + """ + if reasoning_details := getattr(source, "reasoning_details", None): + return [ + Content.from_text_reasoning( + text=_extract_reasoning_text(reasoning_details), + protected_data=json.dumps(reasoning_details), + ) + ] + for field in ("reasoning", "reasoning_content"): + value = getattr(source, field, None) + if isinstance(value, str) and value: + return [ + Content.from_text_reasoning( + text=value, + additional_properties={"_reasoning_source_field": field}, + ) + ] + return [] + + # region OpenAI Chat Options TypedDict @@ -776,19 +813,7 @@ def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping contents.extend(self._parse_text_from_openai(choice)) if parsed_tool_calls := [tool for tool in self._parse_tool_calls_from_openai(choice)]: contents.extend(parsed_tool_calls) - if reasoning_details := getattr(choice.message, "reasoning_details", None): - contents.append(Content.from_text_reasoning( - text=_extract_reasoning_text(reasoning_details), - protected_data=json.dumps(reasoning_details), - )) - # Some OpenAI-compatible providers (e.g. OpenRouter) expose plaintext reasoning - # via a top-level "reasoning" or "reasoning_content" field instead of (or in - # addition to) "reasoning_details". Surface it when no reasoning was already parsed. - elif (reasoning_str := ( - getattr(choice.message, "reasoning", None) - or getattr(choice.message, "reasoning_content", None) - )) and isinstance(reasoning_str, str) and reasoning_str: - contents.append(Content.from_text_reasoning(text=reasoning_str)) + contents.extend(_parse_reasoning_content(choice.message)) messages.append(Message(role="assistant", contents=contents)) return ChatResponse( response_id=response.id, @@ -830,16 +855,7 @@ def _parse_response_update_from_openai( contents.extend(self._parse_tool_calls_from_openai(choice)) contents.extend(self._parse_text_from_openai(choice)) - if reasoning_details := getattr(choice.delta, "reasoning_details", None): - contents.append(Content.from_text_reasoning( - text=_extract_reasoning_text(reasoning_details), - protected_data=json.dumps(reasoning_details), - )) - elif (reasoning_str := ( - getattr(choice.delta, "reasoning", None) - or getattr(choice.delta, "reasoning_content", None) - )) and isinstance(reasoning_str, str) and reasoning_str: - contents.append(Content.from_text_reasoning(text=reasoning_str)) + contents.extend(_parse_reasoning_content(choice.delta)) return ChatResponseUpdate( created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), contents=contents, @@ -902,10 +918,13 @@ def _parse_chunked_content(chunks: list[Any], choice: Choice | ChunkChoice) -> l - ``{"type": "thinking", "thinking": "..." | [{"type": "text", "text": "..."}]}`` - ``{"type": "text", "text": "..."}`` - The original chunk list is stored in the first Content item's - ``additional_properties["_source_content_list"]`` so that - ``_prepare_message_for_openai`` can reconstruct the original list - format required by Mistral for multi-turn reasoning. + The original chunk list is stored on the first emitted Content item's + ``additional_properties["_source_content_list"]`` (regardless of that item's + type) so that ``_prepare_message_for_openai`` can reconstruct the original list + format required by Mistral for multi-turn reasoning. Every emitted item is also + tagged with a shared ``additional_properties["_structured_content_group"]`` id so + the serializer skips exactly the siblings that belong to this list and nothing + else. """ results: list[Content] = [] for item in cast(list[object], chunks): @@ -932,8 +951,13 @@ def _parse_chunked_content(chunks: list[Any], choice: Choice | ChunkChoice) -> l if isinstance(text_val, str) and text_val: results.append(Content.from_text(text=text_val, raw_representation=choice)) - # Store the original list on the first item so it can round-trip correctly. + # Store the original list on the first item so it can round-trip correctly, and + # tag every item with a shared group id so siblings (and only siblings) can be + # collapsed back into a single message during serialization. if results: + group_id = str(uuid4()) + for result in results: + result.additional_properties["_structured_content_group"] = group_id results[0].additional_properties["_source_content_list"] = chunks return results @@ -1019,19 +1043,24 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: all_messages: list[dict[str, Any]] = [] pending_reasoning: Any = None - skip_structured_siblings = False + pending_reasoning_fields: dict[str, str] = {} + emitted_structured_groups: set[str] = set() for content in message.contents: # Skip approval content - it's internal framework state, not for the LLM if content.type in ("function_approval_request", "function_approval_response"): continue - # Skip sibling content items that were part of a structured content list - # already emitted (e.g. the text portion following a Mistral thinking chunk). - if skip_structured_siblings and "_source_content_list" not in content.additional_properties: - if content.type in ("text", "text_reasoning"): - continue - # Non-text items (function calls etc.) are NOT skipped. - skip_structured_siblings = False + # A structured (chunked) content list round-trips as a single message. + # Skip sibling items belonging to a group already emitted via its + # source-list marker; unrelated content (and the source item itself) is + # left untouched. + sibling_group = content.additional_properties.get("_structured_content_group") + if ( + sibling_group is not None + and sibling_group in emitted_structured_groups + and "_source_content_list" not in content.additional_properties + ): + continue args: dict[str, Any] = { "role": message.role, @@ -1042,6 +1071,23 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: details := message.additional_properties["reasoning_details"] ): args["reasoning_details"] = details + + # Emit structured chunked content (e.g. Mistral thinking+text) as a single + # message with the original list, regardless of the first item's type, so it + # round-trips intact for multi-turn reasoning / tool continuation. + if "_source_content_list" in content.additional_properties: + args["content"] = content.additional_properties["_source_content_list"] + if sibling_group is not None: + emitted_structured_groups.add(sibling_group) + if pending_reasoning is not None: + args["reasoning_details"] = pending_reasoning + pending_reasoning = None + for field, value in pending_reasoning_fields.items(): + args[field] = value + pending_reasoning_fields.clear() + all_messages.append(args) + continue + match content.type: case "function_call": if all_messages and "tool_calls" in all_messages[-1]: @@ -1065,13 +1111,18 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: args["content"] = content.result if content.result is not None else "" all_messages.append(args) continue - case "text_reasoning" if "_source_content_list" in content.additional_properties: - # Mistral-style: emit a single message with the original structured list content - # so it round-trips correctly for multi-turn reasoning / tool continuation. - source_list: list[Any] = content.additional_properties["_source_content_list"] - args["content"] = source_list - # Mark that subsequent content items from the same chunked source should be skipped - skip_structured_siblings = True + case "text_reasoning" if ( + reasoning_field := content.additional_properties.get("_reasoning_source_field") + ) is not None: + # Plaintext reasoning surfaced from a top-level provider field + # (e.g. vLLM's `reasoning`). Providers expect it echoed back under + # the same key on the next request, so buffer it to attach to the + # message that carries the answer / tool call. + if content.text is not None: + field_name = cast(str, reasoning_field) + pending_reasoning_fields[field_name] = ( + pending_reasoning_fields.get(field_name, "") + content.text + ) case "text_reasoning" if (protected_data := content.protected_data) is not None: # Buffer reasoning to attach to the next message with content/tool_calls pending_reasoning = json.loads(protected_data) @@ -1088,18 +1139,28 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: if pending_reasoning is not None: args["reasoning_details"] = pending_reasoning pending_reasoning = None + for field, value in pending_reasoning_fields.items(): + args[field] = value + pending_reasoning_fields.clear() all_messages.append(args) - # If reasoning was the only content, emit a valid message with empty content - if pending_reasoning is not None: + # If reasoning was the only content, attach it to the last message (if any) or + # emit a valid standalone message with empty content. + if pending_reasoning is not None or pending_reasoning_fields: if all_messages: - all_messages[-1]["reasoning_details"] = pending_reasoning + if pending_reasoning is not None: + all_messages[-1]["reasoning_details"] = pending_reasoning + for field, value in pending_reasoning_fields.items(): + all_messages[-1][field] = value else: pending_args: dict[str, Any] = { "role": message.role, "content": "", - "reasoning_details": pending_reasoning, } + if pending_reasoning is not None: + pending_args["reasoning_details"] = pending_reasoning + for field, value in pending_reasoning_fields.items(): + pending_args[field] = value if message.author_name and message.role != "tool": pending_args["name"] = message.author_name all_messages.append(pending_args) diff --git a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py index 5d9d41692da..3667910f821 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_completion_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_completion_client.py @@ -2513,4 +2513,172 @@ def test_reasoning_details_takes_priority_over_reasoning_field( assert reasoning_contents[0].text == "From reasoning_details." +def test_reasoning_field_records_source_provenance( + openai_unit_test_env: dict[str, str], +) -> None: + """Reasoning surfaced from a top-level field records which field it came from.""" + client = OpenAIChatCompletionClient() + + message = ChatCompletionMessage.model_construct( + role="assistant", + content="Answer.", + reasoning="Thinking...", + ) + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="vllm-model", + choices=[Choice(index=0, message=message, finish_reason="stop")], + ) + + response = client._parse_response_from_openai(mock_response, {}) + + reasoning_contents = [c for c in response.messages[0].contents if c.type == "text_reasoning"] + assert len(reasoning_contents) == 1 + assert reasoning_contents[0].additional_properties.get("_reasoning_source_field") == "reasoning" + + +def test_reasoning_field_roundtrips_under_source_key( + openai_unit_test_env: dict[str, str], +) -> None: + """Plaintext reasoning echoes back under the originating `reasoning` field (issue #7028 comment).""" + client = OpenAIChatCompletionClient() + + message = ChatCompletionMessage.model_construct( + role="assistant", + content="The answer is 42.", + reasoning="I reasoned about this...", + ) + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="vllm-model", + choices=[Choice(index=0, message=message, finish_reason="stop")], + ) + + parsed = client._parse_response_from_openai(mock_response, {}) + + prepared = client._prepare_message_for_openai(parsed.messages[0]) + # A single assistant message carrying both the answer and the reasoning echoed back + # under its original key (not replayed as visible answer text). + assert len(prepared) == 1 + assert prepared[0]["role"] == "assistant" + assert prepared[0]["content"] == "The answer is 42." + assert prepared[0]["reasoning"] == "I reasoned about this..." + + +def test_reasoning_content_field_roundtrips_under_source_key( + openai_unit_test_env: dict[str, str], +) -> None: + """Plaintext reasoning echoes back under the originating `reasoning_content` field.""" + client = OpenAIChatCompletionClient() + + message = ChatCompletionMessage.model_construct( + role="assistant", + content="Done.", + reasoning_content="Step-by-step trace.", + ) + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="vllm-model", + choices=[Choice(index=0, message=message, finish_reason="stop")], + ) + + parsed = client._parse_response_from_openai(mock_response, {}) + + prepared = client._prepare_message_for_openai(parsed.messages[0]) + assert len(prepared) == 1 + assert prepared[0]["content"] == "Done." + assert prepared[0]["reasoning_content"] == "Step-by-step trace." + assert "reasoning" not in prepared[0] + + +def test_reasoning_field_only_roundtrips_as_standalone_message( + openai_unit_test_env: dict[str, str], +) -> None: + """Reasoning with no accompanying answer still round-trips under its source key.""" + client = OpenAIChatCompletionClient() + + reasoning_content = Content.from_text_reasoning( + text="Only reasoning here.", + additional_properties={"_reasoning_source_field": "reasoning"}, + ) + message = Message(role="assistant", contents=[reasoning_content]) + + prepared = client._prepare_message_for_openai(message) + assert len(prepared) == 1 + assert prepared[0]["reasoning"] == "Only reasoning here." + assert prepared[0]["content"] == "" + + +def test_chunked_content_text_first_roundtrips_as_single_message( + openai_unit_test_env: dict[str, str], +) -> None: + """A chunk list whose first item is `text` still round-trips as one structured message. + + Regression for the case where the ``_source_content_list`` marker lands on a ``text`` + Content instead of a ``text_reasoning`` one (issue #7028 comment). + """ + client = OpenAIChatCompletionClient() + + # Text chunk before the thinking chunk => results[0] is a `text` Content. + chunked_content = [ + {"type": "text", "text": "The answer is 42."}, + {"type": "thinking", "thinking": [{"type": "text", "text": "Let me reason..."}]}, + ] + message = ChatCompletionMessage.model_construct(role="assistant", content=cast(Any, chunked_content)) + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="mistral-medium-latest", + choices=[Choice(index=0, message=message, finish_reason="stop")], + ) + + parsed = client._parse_response_from_openai(mock_response, {}) + msg = parsed.messages[0] + # First emitted content is `text`; the source-list marker sits on it. + assert msg.contents[0].type == "text" + assert "_source_content_list" in msg.contents[0].additional_properties + + prepared = client._prepare_message_for_openai(msg) + assert len(prepared) == 1 + assert prepared[0]["content"] == chunked_content + + +def test_chunked_content_does_not_drop_unrelated_siblings( + openai_unit_test_env: dict[str, str], +) -> None: + """Only the chunk list's own siblings are collapsed; unrelated content is preserved.""" + client = OpenAIChatCompletionClient() + + chunked_content = [ + {"type": "thinking", "thinking": "Reasoning..."}, + {"type": "text", "text": "Structured answer."}, + ] + message = ChatCompletionMessage.model_construct(role="assistant", content=cast(Any, chunked_content)) + mock_response = ChatCompletion( + id="test-response", + object="chat.completion", + created=1234567890, + model="mistral-medium-latest", + choices=[Choice(index=0, message=message, finish_reason="stop")], + ) + parsed = client._parse_response_from_openai(mock_response, {}) + + # Append an unrelated plain-text content that is NOT part of the chunked group. + unrelated = Content.from_text(text="Separate note.") + combined = Message(role="assistant", contents=[*parsed.messages[0].contents, unrelated]) + + prepared = client._prepare_message_for_openai(combined) + # One message for the structured chunk list, one for the unrelated content. + assert len(prepared) == 2 + assert prepared[0]["content"] == chunked_content + assert prepared[1]["content"] == "Separate note." + + # endregion