fix(openai): normalize list content from reasoning models#7731
fix(openai): normalize list content from reasoning models#7731ATOM00blue wants to merge 1 commit into
Conversation
Some OpenAI-compatible reasoning endpoints (gpt-5, o1) return message.content as a list of typed blocks instead of a string. The non-streaming create() path passed that list straight into CreateResult, which expects a string, so callers received a raw list (or a Pydantic validation error). Flatten text/output_text blocks into the content string and collect reasoning/thinking blocks into thought.
|
@ATOM00blue please read the following Contributor License Agreement(CLA). If you agree with the CLA, please reply with the following information.
Contributor License AgreementContribution License AgreementThis Contribution License Agreement (“Agreement”) is agreed to by the party signing below (“You”),
|
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds support for OpenAI-compatible reasoning endpoints that return message.content as a list of typed blocks by normalizing it into plain text (content) and reasoning (thought).
Changes:
- Add
_normalize_list_contenthelper to flatten block-basedmessage.content. - Update
create()to detect list-based content and normalize it. - Add an async test validating normalization behavior (text vs reasoning extraction).
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| python/packages/autogen-ext/src/autogen_ext/models/openai/_openai_client.py | Normalizes list-based message.content into string content and optional thought during create(). |
| python/packages/autogen-ext/tests/models/test_openai_model_client.py | Adds test coverage for list-of-blocks responses from reasoning endpoints. |
| finish_reason = choice.finish_reason | ||
| content = choice.message.content or "" | ||
| message_content = choice.message.content | ||
| if isinstance(message_content, list): | ||
| # Some OpenAI-compatible reasoning endpoints (e.g. gpt-5, o1) return content | ||
| # as a list of typed blocks instead of a string. Flatten it so downstream | ||
| # callers receive a string rather than the raw list. | ||
| content, thought = _normalize_list_content(cast(List[Any], message_content)) | ||
| else: | ||
| content = message_content or "" |
| content, thought = _normalize_list_content(cast(List[Any], message_content)) | ||
| else: | ||
| content = message_content or "" | ||
| # if there is a reasoning_content field, then we populate the thought field. This is for models such as R1 - direct from deepseek api. | ||
| if choice.message.model_extra is not None: | ||
| reasoning_content = choice.message.model_extra.get("reasoning_content") |
Why are these changes needed?
Some OpenAI-compatible reasoning endpoints (e.g. gpt-5, o1) return
choice.message.contentas a list of typed blocks instead of a string, for example:[ {"type": "reasoning", "text": "..."}, {"type": "text", "text": "the actual response"} ]In the non-streaming
create()path, the text branch doescontent = choice.message.content or "". A non-empty list is truthy, so the raw list is assigned tocontentand passed straight intoCreateResult, whosecontentfield expectsstr | list[FunctionCall]. Callers then either get the raw list of dicts upstream or hit a Pydantic validation error, resulting in empty/malformed responses.Repro (mocking an endpoint that returns list content) raises:
This adds a small helper that, when
contentis a list, flattenstext/output_textblocks into the content string and collectsreasoning/thinkingblocks intothought(matching the existing content/thought split used forreasoning_content). Unknown block types are ignored. String content is unchanged.Related issue number
Fixes #7410
Checks
Verified locally with
ruff check,ruff format --check,mypy, andpyrighton the touched files, pluspytest packages/autogen-ext/tests/models/test_openai_model_client.py(54 passed). The added testtest_create_normalizes_list_contentfails onmainand passes with this change.