-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Python: Fix reasoning content parsing in OpenAIChatCompletionClient #7028
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
giles17
wants to merge
9
commits into
microsoft:main
Choose a base branch
from
giles17:fix/chat-completion-reasoning-parsing
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+821
−17
Draft
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
83872c1
Python: Fix reasoning content parsing in OpenAIChatCompletionClient
invalid-email-address 427bb5b
Fix pyright strict-mode type errors and handle content-as-string shape
invalid-email-address 933a37f
Fix mypy errors: cast list content to Any in tests
invalid-email-address 55e8a11
Address review comments: summary field, reasoning field, and round-trip
invalid-email-address 0b0e1dc
Fix ruff used-dummy-variable: rename _skip_structured_siblings
invalid-email-address d6d1576
Fix missing newline at end of test file
invalid-email-address 50fa86b
Address review: type-agnostic chunk round-trip and reasoning field ec…
giles17 1bbb73f
Merge branch 'main' into fix/chat-completion-reasoning-parsing
giles17 99fee4a
Merge branch 'main' into fix/chat-completion-reasoning-parsing
giles17 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ | |
| from datetime import datetime, timezone | ||
| from itertools import chain | ||
| from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, cast, overload | ||
| from uuid import uuid4 | ||
|
|
||
| from agent_framework._clients import BaseChatClient | ||
| from agent_framework._compaction import CompactionStrategy, TokenizerProtocol | ||
|
|
@@ -120,6 +121,106 @@ class PromptCacheOptions(TypedDict, total=False): | |
| 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_dict = cast(dict[str, object], item) | ||
| # Check "text" field (reasoning.text entries) | ||
| entry_text = entry_dict.get("text") | ||
| if isinstance(entry_text, str) and entry_text: | ||
| parts.append(entry_text) | ||
| continue | ||
| # Check "summary" field (reasoning.summary entries) | ||
| entry_summary = entry_dict.get("summary") | ||
| if isinstance(entry_summary, str) and entry_summary: | ||
| parts.append(entry_summary) | ||
| 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 {"summary": "..."} style (reasoning.summary entries) | ||
| summary_val = detail_dict.get("summary") | ||
| if isinstance(summary_val, str) and summary_val: | ||
| return summary_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_dict2 = cast(dict[str, object], item) | ||
| entry_text2 = entry_dict2.get("text") | ||
| if isinstance(entry_text2, str) and entry_text2: | ||
| parts.append(entry_text2) | ||
| continue | ||
| entry_summary2 = entry_dict2.get("summary") | ||
| if isinstance(entry_summary2, str) and entry_summary2: | ||
| parts.append(entry_summary2) | ||
| return "".join(parts) or None | ||
|
|
||
| return None | ||
|
|
||
|
|
||
| def _parse_reasoning_content(source: Any) -> list[Content]: | ||
| """Parse reasoning content from a chat message or streaming delta. | ||
|
|
||
| ``reasoning_details`` (structured, possibly encrypted) takes priority. When it is | ||
| absent, the top-level plaintext ``reasoning`` / ``reasoning_content`` fields used by | ||
| some OpenAI-compatible providers (e.g. OpenRouter, vLLM) are surfaced instead. The | ||
| originating field name is recorded in ``additional_properties["_reasoning_source_field"]`` | ||
| so it can be echoed back under the same key on subsequent requests, which providers | ||
| such as vLLM require for multi-turn reasoning continuity. | ||
|
|
||
| Args: | ||
| source: The ``ChatCompletionMessage`` or streaming ``ChoiceDelta`` to inspect. | ||
|
|
||
| Returns: | ||
| A list with a single reasoning ``Content`` when reasoning is present, otherwise | ||
| an empty list. | ||
| """ | ||
| if reasoning_details := getattr(source, "reasoning_details", None): | ||
| return [ | ||
| Content.from_text_reasoning( | ||
| text=_extract_reasoning_text(reasoning_details), | ||
| protected_data=json.dumps(reasoning_details), | ||
| ) | ||
| ] | ||
| for field in ("reasoning", "reasoning_content"): | ||
| value = getattr(source, field, None) | ||
| if isinstance(value, str) and value: | ||
| return [ | ||
| Content.from_text_reasoning( | ||
| text=value, | ||
| additional_properties={"_reasoning_source_field": field}, | ||
| ) | ||
| ] | ||
| return [] | ||
|
|
||
|
|
||
| # region OpenAI Chat Options TypedDict | ||
|
|
||
|
|
||
|
|
@@ -742,12 +843,10 @@ def _parse_response_from_openai(self, response: ChatCompletion, options: Mapping | |
| if choice.finish_reason: | ||
| finish_reason = "tool_calls" if choice.finish_reason == "function_call" else 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.extend(_parse_reasoning_content(choice.message)) | ||
| messages.append(Message(role="assistant", contents=contents)) | ||
| return ChatResponse( | ||
| response_id=response.id, | ||
|
|
@@ -788,10 +887,8 @@ 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) | ||
| if reasoning_details := getattr(choice.delta, "reasoning_details", None): | ||
| contents.append(Content.from_text_reasoning(protected_data=json.dumps(reasoning_details))) | ||
| contents.extend(self._parse_text_from_openai(choice)) | ||
| contents.extend(_parse_reasoning_content(choice.delta)) | ||
| return ChatResponseUpdate( | ||
| created_at=datetime.fromtimestamp(chunk.created, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"), | ||
| contents=contents, | ||
|
|
@@ -828,14 +925,74 @@ 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). | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: why would Mistral show up here in the OpenAI client? |
||
|
|
||
| Handles chunk types: | ||
| - ``{"type": "thinking", "thinking": "..." | [{"type": "text", "text": "..."}]}`` | ||
| - ``{"type": "text", "text": "..."}`` | ||
|
|
||
| The original chunk list is stored on the first emitted Content item's | ||
| ``additional_properties["_source_content_list"]`` (regardless of that item's | ||
| type) so that ``_prepare_message_for_openai`` can reconstruct the original list | ||
| format required by Mistral for multi-turn reasoning. Every emitted item is also | ||
| tagged with a shared ``additional_properties["_structured_content_group"]`` id so | ||
| the serializer skips exactly the siblings that belong to this list and nothing | ||
| else. | ||
| """ | ||
| 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)) | ||
|
giles17 marked this conversation as resolved.
|
||
| 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)) | ||
|
|
||
| # Store the original list on the first item so it can round-trip correctly, and | ||
| # tag every item with a shared group id so siblings (and only siblings) can be | ||
| # collapsed back into a single message during serialization. | ||
| if results: | ||
| group_id = str(uuid4()) | ||
| for result in results: | ||
| result.additional_properties["_structured_content_group"] = group_id | ||
| results[0].additional_properties["_source_content_list"] = chunks | ||
|
giles17 marked this conversation as resolved.
|
||
| return results | ||
|
|
||
| def _get_metadata_from_chat_response(self, response: ChatCompletion) -> dict[str, Any]: | ||
| """Get metadata from a chat response.""" | ||
|
|
@@ -929,11 +1086,25 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: | |
|
|
||
| all_messages: list[dict[str, Any]] = [] | ||
| pending_reasoning: Any = None | ||
| pending_reasoning_fields: dict[str, str] = {} | ||
| emitted_structured_groups: set[str] = set() | ||
| for content in message.contents: | ||
| # Skip approval content - it's internal framework state, not for the LLM | ||
| if content.type in ("function_approval_request", "function_approval_response"): | ||
| continue | ||
|
|
||
| # A structured (chunked) content list round-trips as a single message. | ||
| # Skip sibling items belonging to a group already emitted via its | ||
| # source-list marker; unrelated content (and the source item itself) is | ||
| # left untouched. | ||
| sibling_group = content.additional_properties.get("_structured_content_group") | ||
| if ( | ||
| sibling_group is not None | ||
| and sibling_group in emitted_structured_groups | ||
| and "_source_content_list" not in content.additional_properties | ||
| ): | ||
| continue | ||
|
|
||
| args: dict[str, Any] = { | ||
| "role": message.role, | ||
| } | ||
|
|
@@ -943,6 +1114,23 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: | |
| details := message.additional_properties["reasoning_details"] | ||
| ): | ||
| args["reasoning_details"] = details | ||
|
|
||
| # Emit structured chunked content (e.g. Mistral thinking+text) as a single | ||
| # message with the original list, regardless of the first item's type, so it | ||
| # round-trips intact for multi-turn reasoning / tool continuation. | ||
| if "_source_content_list" in content.additional_properties: | ||
| args["content"] = content.additional_properties["_source_content_list"] | ||
| if sibling_group is not None: | ||
| emitted_structured_groups.add(sibling_group) | ||
| if pending_reasoning is not None: | ||
| args["reasoning_details"] = pending_reasoning | ||
| pending_reasoning = None | ||
| for field, value in pending_reasoning_fields.items(): | ||
| args[field] = value | ||
| pending_reasoning_fields.clear() | ||
| all_messages.append(args) | ||
| continue | ||
|
|
||
| match content.type: | ||
| case "function_call": | ||
| if all_messages and "tool_calls" in all_messages[-1]: | ||
|
|
@@ -966,6 +1154,18 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: | |
| args["content"] = content.result if content.result is not None else "" | ||
| all_messages.append(args) | ||
| continue | ||
| case "text_reasoning" if ( | ||
| reasoning_field := content.additional_properties.get("_reasoning_source_field") | ||
| ) is not None: | ||
| # Plaintext reasoning surfaced from a top-level provider field | ||
| # (e.g. vLLM's `reasoning`). Providers expect it echoed back under | ||
| # the same key on the next request, so buffer it to attach to the | ||
| # message that carries the answer / tool call. | ||
| if content.text is not None: | ||
| field_name = cast(str, reasoning_field) | ||
| pending_reasoning_fields[field_name] = ( | ||
| pending_reasoning_fields.get(field_name, "") + content.text | ||
| ) | ||
| case "text_reasoning" if (protected_data := content.protected_data) is not None: | ||
| # Buffer reasoning to attach to the next message with content/tool_calls | ||
| pending_reasoning = json.loads(protected_data) | ||
|
|
@@ -982,18 +1182,28 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]: | |
| if pending_reasoning is not None: | ||
| args["reasoning_details"] = pending_reasoning | ||
| pending_reasoning = None | ||
| for field, value in pending_reasoning_fields.items(): | ||
| args[field] = value | ||
| pending_reasoning_fields.clear() | ||
| all_messages.append(args) | ||
|
|
||
| # If reasoning was the only content, emit a valid message with empty content | ||
| if pending_reasoning is not None: | ||
| # If reasoning was the only content, attach it to the last message (if any) or | ||
| # emit a valid standalone message with empty content. | ||
| if pending_reasoning is not None or pending_reasoning_fields: | ||
| if all_messages: | ||
| all_messages[-1]["reasoning_details"] = pending_reasoning | ||
| if pending_reasoning is not None: | ||
| all_messages[-1]["reasoning_details"] = pending_reasoning | ||
| for field, value in pending_reasoning_fields.items(): | ||
| all_messages[-1][field] = value | ||
| else: | ||
| pending_args: dict[str, Any] = { | ||
| "role": message.role, | ||
| "content": "", | ||
| "reasoning_details": pending_reasoning, | ||
| } | ||
| if pending_reasoning is not None: | ||
| pending_args["reasoning_details"] = pending_reasoning | ||
| for field, value in pending_reasoning_fields.items(): | ||
| pending_args[field] = value | ||
| if message.author_name and message.role != "tool": | ||
| pending_args["name"] = message.author_name | ||
| all_messages.append(pending_args) | ||
|
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this for me begs the question of what we want, is the
OpenAIChatCompletionClientfor OpenAI or for all "almost-compatible" OpenAI endpoints, because what we are solving for here is that the so-called compatible endpoints, are not actually compatible because they behave differently. So I hesitate to let this in, because at some point we might end up with a long tree of dedicated parsers that we have to go through for each of those, I would rather then see if we can add a callable that the user can supply that does that for their particular querk in the almost compatible surface or using subclassing to achieve this, I think that is both a more sustainable route and ultimately one that will make things easier for users, because they can then either create aOpenRouterParser/MistralParseror aOpenRouterChatCompletionClient/MistralChatClient(the latter we will probably want anyway, but that is besides the point) instead of this class bloating with all the possible exceptions and tweaks.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FWIW we had to switch to pydantic ai because of this lack of reasoning support. I accidentally came across the fact that even the responses client doesn't send reasoning back to the server, even though it correctly populates it in the output (we use vLLM). I couldn't find any easy way to add custom hooks to support it, as the parsing of responses and building of requests were not very extensible, and by the time any middleware runs the original request was lost.
The main case for including this here is the shift toward the responses API for all actual openai things, where it seems the chat completion API is moreso used by "compatible" services. But making it possible to easily extend it with this type of functionality would go a long way toward making this useful for self-hosted users
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Kimahriman thanks for the background, we will take that into account