Skip to content

Python: Add response/request customization hooks to OpenAIChatCompletionClient - #7028

Merged
Evan Mattson (moonbox3) merged 14 commits into
microsoft:mainfrom
giles17:fix/chat-completion-reasoning-parsing
Aug 7, 2026
Merged

Python: Add response/request customization hooks to OpenAIChatCompletionClient#7028
Evan Mattson (moonbox3) merged 14 commits into
microsoft:mainfrom
giles17:fix/chat-completion-reasoning-parsing

Conversation

@giles17

@giles17 Giles Odigwe (giles17) commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Motivation & Context

OpenAIChatCompletionClient targets the OpenAI Chat Completions wire format, but it is widely
pointed at "almost OpenAI-compatible" endpoints (OpenRouter, vLLM, Mistral, DeepSeek, Ollama, …)
that diverge on the edges — most visibly in how they represent reasoning (reasoning,
reasoning_content, reasoning_details) and, for some Mistral models, by returning content as a
list of typed chunks instead of a string. Branching in the stock client for each provider quirk does
not scale and puts the client on the critical path of every third-party implementation.

Following reviewer feedback, this keeps the stock client provider-agnostic and instead exposes two
optional callables so callers can adapt parsing and request-building to their specific endpoint
without subclassing. It also hardens default parsing so structured (non-string) content can no longer
produce a malformed Content that crashes downstream.

Description & Review Guide

  • What are the major changes?

    • Two optional constructor callables on RawOpenAIChatCompletionClient / OpenAIChatCompletionClient:
      • response_parser (OpenAIChatResponseContentsParser): (choice, default_contents) -> contents.
        Post-processes the Content list parsed from each response choice / streaming delta, e.g. to
        surface a provider's non-standard reasoning field for display.
      • message_preparer (OpenAIChatMessagePreparer): (message, default_dicts) -> dicts.
        Post-processes the outgoing request message dicts built from each framework Message, e.g. to
        echo a provider's reasoning field back on later turns (required by vLLM-style endpoints for
        multi-turn continuity).
    • Both default to None — a no-op, so stock OpenAI/Azure behavior is unchanged.
    • Default text parsing now skips non-string content instead of wrapping it verbatim into a
      malformed text Content; this prevents a downstream crash and gives a response_parser a clean
      slate to expand structured chunks.
    • The two callable type aliases are exported from the package and the core lazy
      agent_framework.openai namespace (and its .pyi).
  • What is the impact of these changes?

    • No behavior change for stock OpenAI/Azure users; the hooks are opt-in and default to None.
    • Provider quirks (OpenRouter/vLLM reasoning surfacing and echo-back) are now addressable through the
      hooks rather than in-core branches; Mistral chunked content is handled by the dedicated
      agent-framework-mistral client.
    • New public API surface: two callable type aliases and two constructor parameters.
  • What do you want reviewers to focus on?

    • The parse and prepare hook seams (placement and per-choice granularity), and the non-string
      content guard in _parse_text_from_openai.

Related Issue

Related to #6978 and #6979. Rather than fix these with provider-specific branches in the stock client,
this adds the supported extension path to resolve them: a response_parser surfaces plaintext
reasoning (#6979) and a message_preparer echoes it back for multi-turn continuity, while the
non-string-content guard removes the #6978 crash and the dedicated agent-framework-mistral client
handles Mistral chunked content.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

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 Giles Odigwe (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.py4472095%543, 637, 644–645, 649, 674, 849, 937, 939, 944, 947, 1067, 1084, 1106, 1114, 1138, 1151, 1184, 1207, 1550
TOTAL44743410490% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9150 34 💤 0 ❌ 0 🔥 2m 28s ⏱️

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
Giles Odigwe (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
Giles Odigwe (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

Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
@giles17
Giles Odigwe (giles17) marked this pull request as draft July 13, 2026 18:28
Copilot and others added 3 commits July 14, 2026 16:56
- 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>
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>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@giles17
Giles Odigwe (giles17) marked this pull request as ready for review July 14, 2026 17:53

@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: 76%

✓ Correctness

The PR correctly fixes both issues (#6978 and #6979) with well-structured parsing logic and thorough tests. The _extract_reasoning_text() helper handles documented provider formats (including reasoning.summary entries), the fallback to message.reasoning/reasoning_content fields is properly gated behind an elif, and the Mistral round-trip via _source_content_list is tested end-to-end. I found no high-severity correctness issues.

✓ Security Reliability

The PR is well-structured with proper defensive type checks and graceful handling of untrusted provider response shapes. The _extract_reasoning_text helper safely handles all expected formats. The _parse_chunked_content method correctly validates chunk types before processing. The main concern is a minor reliability edge case in the skip_structured_siblings mechanism in _prepare_message_for_openai, which could silently drop unrelated text/reasoning content if it follows Mistral-style chunked content in the same Message. However, given the current parsing architecture, this situation does not arise in practice. No critical security or reliability issues found.

✓ Test Coverage

The PR adds 11 new tests covering the core happy paths for reasoning text extraction, Mistral chunked content, streaming, and round-trip serialization. The tests are well-structured with meaningful assertions. However, there is a notable gap: no test verifies that opaque/encrypted reasoning_details (entries without 'text' or 'summary' fields) correctly yield Content.text=None, which is the key invariant distinguishing genuinely encrypted data from displayable reasoning. The existing test test_parse_text_reasoning_content_from_response (line 787) tests this shape but was written before the extraction logic existed and doesn't assert the text field value — it now silently extracts 'summary' text where it previously had None.

✓ Failure Modes

The PR correctly fixes the two reported issues (reasoning plaintext extraction and Mistral list-content parsing). The skip_structured_siblings mechanism in _prepare_message_for_openai uses a type-based heuristic that can silently drop non-sibling text_reasoning content (e.g., from reasoning_details) if it follows a Mistral chunked source without intervening tool-call items. This is unlikely with current providers but represents a latent silent-failure path. No other significant failure modes were found.

✓ Design Approach

The new reasoning/chunked-content handling fixes the reported cases, but the round-trip design still has two silent data-loss edges in _prepare_message_for_openai(): it only recognizes _source_content_list when the marked content is text_reasoning, and its skip flag suppresses every later text/text_reasoning item in the same message instead of only the siblings from that structured list.


Automated review by giles17's agents

Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
@giles17
Giles Odigwe (giles17) marked this pull request as draft July 14, 2026 18:16
…ho-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
@giles17
Giles Odigwe (giles17) marked this pull request as ready for review July 22, 2026 20:13
@giles17
Giles Odigwe (giles17) marked this pull request as draft July 22, 2026 21:23
@giles17
Giles Odigwe (giles17) marked this pull request as ready for review July 22, 2026 21:24
@giles17
Giles Odigwe (giles17) marked this pull request as draft July 22, 2026 21:29
@giles17
Giles Odigwe (giles17) marked this pull request as draft July 27, 2026 16:48
@eavanvalkenburg
Eduard van Valkenburg (eavanvalkenburg) dismissed their stale review July 30, 2026 09:29

Confident Giles will followup appropriately

…pare hooks

Following review feedback (microsoft#7028), keep OpenAIChatCompletionClient free of
provider-specific quirks for 'almost OpenAI-compatible' endpoints. Instead of
branching in core for OpenRouter/vLLM/Mistral, expose two optional callables so
callers adapt the client themselves:

- response_parser (OpenAIChatResponseContentsParser): post-processes the Content
  list parsed from each response choice/streaming delta, to surface non-standard
  fields (e.g. reasoning/reasoning_content/reasoning_details) for display.
- message_preparer (OpenAIChatMessagePreparer): post-processes the outgoing request
  message dicts built from each framework Message, to echo provider-specific fields
  back on later turns (e.g. vLLM reasoning) for multi-turn continuity.

Both default to None (no-op; byte-identical stock OpenAI behavior). This reverts the
provider-specific reasoning/chunked-content parsing and round-trip markers previously
added to core; Mistral chunked content is now handled by agent-framework-mistral.

- Add the two callables to RawOpenAIChatCompletionClient / OpenAIChatCompletionClient
  constructors and invoke them at the parse and prepare seams.
- Export the type aliases from the package and the core lazy openai namespace (+ .pyi).
- Replace the removed-behavior tests with tests for the two hooks.
- Document the hooks in packages/openai/AGENTS.md.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5
@giles17
Giles Odigwe (giles17) marked this pull request as ready for review August 3, 2026 22:56
@agent-framework-automation agent-framework-automation Bot added the documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs label Aug 3, 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: 4 | Confidence: 50%

✓ Correctness

The Mistral crash remains because the new hook runs only after default content parsing.

✓ Security Reliability

This revision replaces the provider-specific parsing branches with two optional, opt-in extension points (response_parser, message_preparer) that default to None, so stock OpenAI behavior is unchanged and no new parsing of untrusted provider payloads is added in core. From a security/reliability standpoint the diff introduces no new deserialization, secret handling, subprocess, or network surface: the hooks are caller-supplied callables invoked on data the caller already owns, and json.dumps(reasoning_details) behavior is unchanged from before. IMPORTANT CAVEAT: my file-read and shell tools failed with permission errors for every attempt in this session, so I could not perform the repository context pass. Per evidence discipline I am therefore emitting no inline findings, since I could not open _chat_completion_client.py, Content, or the surrounding streaming aggregation code to confirm or refute any suspicion. Treat this as a diff-only, non-blocking review rather than a verified pass.

✓ Failure Modes

I was unable to complete the required context pass: every read tool (bash, grep, glob, view) in this session returned 'unexpected user permission response', so I could not open any repository file to verify constructor forwarding, hook invocation coverage, or serialization behavior. Reviewing the diff alone, the change is a narrow, opt-in extension: two Callable type aliases are added, stored on the client, and invoked only when non-None (_chat_completion_client.py:861, :908, :1147). Default behavior with no hooks is unchanged, and test_no_hooks_keeps_default_behavior pins the stock reasoning_details path. From a failure-modes standpoint the hooks propagate exceptions rather than swallowing them, there is no retry/partial-write/cleanup surface introduced, and the streaming path was correctly restructured to scope per-choice contents before extending the aggregate list. I found no concrete failure path I can support with verified file:line evidence, and per evidence discipline I am reporting no findings rather than speculating from unread files.

✓ Design Approach

I was unable to complete the required context pass: every tool invocation in this session (view, grep, bash) was denied, so I could not open _chat_completion_client.py, its helpers, or the surrounding tests to verify any hypothesis against source. Per the evidence discipline for design_approach findings, I am emitting zero findings rather than speculating from the diff alone. One observation that is fully supported by the diff text itself, without needing file reads: the <pr_rationale> block describes an implementation (_extract_reasoning_text(), a refactored _parse_text_from_openai() returning list[Content], a _parse_chunked_content() static method, and five named tests such as test_parse_mistral_chunked_content_from_response) that does not appear anywhere in this diff. The diff instead adds two generic extension points (OpenAIChatResponseContentsParser / OpenAIChatMessagePreparer) wired into _parse_response_from_openai, _parse_response_update_from_openai, and _prepare_message_for_openai, with a different test set — which matches the unresolved review feedback at line 127 asking for a user-supplied callable instead of provider branches. The PR body appears stale relative to the current head and should be refreshed so reviewers and future readers can tell how (or whether) #6978 and #6979 are still addressed by the hook-based design.


Automated review by giles17's agents

Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
Structured list content (e.g. Mistral reasoning models returning content as a
list of chunks) was wrapped verbatim into a text Content, producing a malformed
Content whose text is a list that crashes downstream (issue microsoft#6978). Default text
parsing now skips non-string content so a configured response_parser receives a
clean slate to expand it. Applies to both streaming and non-streaming paths.

Add tests for the skip and for a response_parser expanding chunked content.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5
@giles17 Giles Odigwe (giles17) changed the title Python: Fix reasoning content parsing in OpenAIChatCompletionClient Python: Add response/request customization hooks to OpenAIChatCompletionClient Aug 4, 2026
Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
Comment thread python/packages/openai/agent_framework_openai/_chat_completion_client.py Outdated
@giles17
Giles Odigwe (giles17) marked this pull request as draft August 5, 2026 18:37
- response_parser now receives the already-selected ChatCompletionMessage /
  ChoiceDelta instead of Choice | ChunkChoice, so callers no longer duplicate the
  streaming dispatch (removes the Any/hasattr pattern from tests). The client owns
  the dispatch; parsers read provider fields directly.
- message_preparer now runs once per Message for every role: the build logic moved
  to _build_openai_messages and the hook is applied at a single exit point in
  _prepare_message_for_openai, so system/developer messages no longer bypass it.
- Round-trip example/test now correlates surfaced reasoning via an
  additional_properties marker on message.contents with bounded, order-aware,
  one-to-one dict removal, instead of fragile request-string matching. Adds a test
  proving an answer whose text equals the reasoning text is no longer dropped.
- Update packages/openai/AGENTS.md for the new parser signature and guidance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2f3c0308-51bf-4b66-8b53-87a8546743f5
@giles17
Giles Odigwe (giles17) marked this pull request as ready for review August 5, 2026 18:40

@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: 2 | Confidence: 91% | Result: All clear

Reviewed: Correctness, Test Coverage


Automated review by giles17's agents

@giles17
Giles Odigwe (giles17) added this pull request to the merge queue Aug 6, 2026
@moonbox3
Evan Mattson (moonbox3) removed this pull request from the merge queue due to a manual request Aug 7, 2026
@moonbox3
Evan Mattson (moonbox3) added this pull request to the merge queue Aug 7, 2026
Merged via the queue into microsoft:main with commit b2a2fcb Aug 7, 2026
38 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants