From 83872c19ba0295081f81f8c91c4be37f9aff9a7f Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 9 Jul 2026 18:41:43 +0000 Subject: [PATCH 1/3] 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/3] 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/3] 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(