Skip to content

Python: Fix reasoning content parsing in OpenAIChatCompletionClient#7028

Open
giles17 wants to merge 3 commits into
microsoft:mainfrom
giles17:fix/chat-completion-reasoning-parsing
Open

Python: Fix reasoning content parsing in OpenAIChatCompletionClient#7028
giles17 wants to merge 3 commits into
microsoft:mainfrom
giles17:fix/chat-completion-reasoning-parsing

Conversation

@giles17

@giles17 giles17 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Motivation and Context

Fixes #6978 and #6979.

Two related bugs in OpenAIChatCompletionClient prevent reasoning content from being displayed correctly:

  1. Python: [Bug]: OpenAI Chat Completions client buries plaintext reasoning_details as encrypted data #6979 - Plaintext reasoning buried as encrypted data: The client dumps the entire reasoning_details array into Content.protected_data without setting Content.text. Downstream, AG-UI emits ReasoningEncryptedValueEvent (opaque) instead of visible ReasoningMessageContentEvent for providers like OpenRouter that send plaintext reasoning.

  2. Python: [Bug]: OpenAI Chat Completions client crashes on (and drops) Mistral reasoning content chunks #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 crash in AG-UI.

Changes

_chat_completion_client.py

  • _extract_reasoning_text() helper (module-level): Extracts readable text from various reasoning_details formats (list of dicts with text keys, dict with content list, plain strings). Returns None for genuinely encrypted/opaque data.
  • Updated reasoning_details handling (lines 753-757, 799-803): Now passes extracted plaintext as Content.text while preserving the full JSON in protected_data for round-trip fidelity.
  • Refactored _parse_text_from_openai(): Returns list[Content] instead of Content | None. Detects when message.content is a list and delegates to _parse_chunked_content().
  • _parse_chunked_content() static method: Parses Mistral-style thinking chunks → Content.from_text_reasoning and text chunks → Content.from_text.

Tests

  • test_parse_reasoning_details_extracts_plaintext — verifies plaintext extraction from OpenRouter-style reasoning_details
  • test_parse_reasoning_details_extracts_plaintext_streaming — same for streaming path
  • test_parse_mistral_chunked_content_from_response — verifies Mistral list content parsing (non-streaming)
  • test_parse_mistral_chunked_content_streaming — verifies Mistral list content parsing (streaming)
  • test_parse_plain_string_content_still_works — regression test for normal string content

Validation

  • All 79 tests in test_openai_chat_completion_client.py pass (including 6 new tests)
  • All pre-existing reasoning round-trip tests continue to pass
  • No changes to the Responses API client (_chat_client.py) which already handles reasoning correctly

Fix two issues with reasoning content handling in the Chat Completions
client:

1. (microsoft#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. (microsoft#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>
Copilot AI review requested due to automatic review settings July 9, 2026 18:42
@giles17 giles17 added the python Usage: [Issues, PRs], Target: Python label Jul 9, 2026

@github-actions github-actions Bot left a comment

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.

Automated Code Review

Reviewers: 5 | Confidence: 75% | Result: All clear

Reviewed: Correctness, Security Reliability, Test Coverage, Failure Modes, Design Approach


Automated review by giles17's agents

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/openai/agent_framework_openai
   _chat_completion_client.py4282594%113, 119, 131, 140, 147, 488, 584–585, 589, 834, 836, 841, 844, 879, 893, 978, 980, 997, 1018, 1026, 1050, 1063, 1087, 1107, 1422
TOTAL44153527788% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
8838 33 💤 0 ❌ 0 🔥 2m 23s ⏱️

Copilot AI left a comment

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.

Pull request overview

This PR fixes parsing of “reasoning” outputs in the Python OpenAIChatCompletionClient so downstream consumers (notably AG-UI) can display plaintext reasoning correctly and avoid crashes when providers return structured chunked content.

Changes:

  • Add _extract_reasoning_text() and use it to populate Content.text for plaintext reasoning_details while preserving full payload round-tripped in protected_data.
  • Refactor _parse_text_from_openai() to return list[Content] and introduce _parse_chunked_content() to handle Mistral-style content: [...] chunks (thinking + text).
  • Add regression tests covering plaintext reasoning_details extraction and chunked list-content parsing in both streaming and non-streaming paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
python/packages/openai/agent_framework_openai/_chat_completion_client.py Adds plaintext reasoning extraction + chunked content parsing; updates response parsing to emit correct Content items.
python/packages/openai/tests/openai/test_openai_chat_completion_client.py Adds tests for plaintext reasoning extraction and Mistral chunked content in both streaming and non-streaming modes.

Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
@giles17 giles17 marked this pull request as draft July 9, 2026 18:47
Copilot and others added 2 commits July 9, 2026 18:53
- 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>
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>
@giles17 giles17 marked this pull request as ready for review July 9, 2026 19:38

@github-actions github-actions Bot left a comment

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.

Automated Code Review

Reviewers: 5 | Confidence: 70% | Result: All clear

Reviewed: Correctness, Security Reliability, Test Coverage, Failure Modes, Design Approach


Automated review by giles17's agents

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?

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: OpenAI Chat Completions client crashes on (and drops) Mistral reasoning content chunks

3 participants