-
Notifications
You must be signed in to change notification settings - Fork 2k
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
base: main
Are you sure you want to change the base?
Changes from all commits
83872c1
427bb5b
933a37f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) | ||
|
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. Should we preserve the original structured assistant content for the next turn? Converting the |
||
| 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.""" | ||
|
|
||
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.
Should visible reasoning normalization cover the other documented OpenRouter plaintext forms here?
reasoning.summaryentries put the displayable value undersummary, while raw-reasoning models returnmessage.reasoning(orreasoning_content) withoutreasoning_details; on this head the first becomestext=Noneand 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?