Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 106 additions & 11 deletions python/packages/openai/agent_framework_openai/_chat_completion_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should visible reasoning normalization cover the other documented OpenRouter plaintext forms here? reasoning.summary entries put the displayable value under summary, while raw-reasoning models return message.reasoning (or reasoning_content) without reasoning_details; on this head the first becomes text=None and the second is discarded entirely. Could we normalize those fields in both streaming and non-streaming paths so the fix covers the provider's supported response shapes?

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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we preserve the original structured assistant content for the next turn? Converting the ThinkChunk here and the text chunk below into separate framework contents makes _prepare_message_for_openai() replay them as two plain assistant messages, so the original [{"type":"thinking", ...}, {"type":"text", ...}] is lost. Mistral requires the complete assistant message, including the ThinkChunk, for multi-turn reasoning and tool continuation; could we retain or reconstruct that list so it round-trips as one message?

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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading