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..ad6a0dfef28 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,54 @@ 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"`` key (list of text entries or a plain string) + - 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 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): + 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 + + # region OpenAI Chat Options TypedDict @@ -709,12 +757,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 +805,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 +847,57 @@ 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 item in cast(list[object], chunks): + if not isinstance(item, dict): + continue + chunk_dict = cast(dict[str, object], item) + chunk_type = chunk_dict.get("type") + if chunk_type == "thinking": + thinking = chunk_dict.get("thinking") + if isinstance(thinking, str): + text = thinking + elif isinstance(thinking, list): + text = "".join( + 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_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]: """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..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 @@ -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=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, {}) + + 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=cast(Any, 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