Description
Contract/Message/AssistantMessageNormalizer::normalize() serializes a text-only
AssistantMessage (no tool calls) as:
return [
'role' => $data->getRole()->value,
'type' => 'message',
'content' => '' === $text ? null : $text, // <-- raw string
];
For the OpenAI Responses API, a type: "message" item's content field is documented
as an array of content parts (e.g. [{"type": "input_text", "text": "..."}]), not a bare
string. This is inconsistent with this same package's own
Contract/Message/Content/TextNormalizer.php, which correctly wraps UserMessage text
content as {"type": "input_text", "text": "..."}.
Impact
Sending a raw string in content works with some backends (seemingly tolerant/lenient
parsers), but breaks strict validators. Reproduced against an AI Hub / litellm proxy in
front of qwen-3.6-35b-sovereign: as soon as the conversation history contains any prior
AssistantMessage (e.g. a stored greeting/initial message), the very next request fails
with:
litellm.BadRequestError: OpenAIException - {"error":{"message":"2 validation errors for ValidatorIterator
0.ResponseOutputTextParam
Input should be a valid dictionary [type=dict_type, input_value='W', input_type=str]
0.ResponseOutputRefusalParam
Input should be a valid dictionary [type=dict_type, input_value='W', input_type=str]
","type":"BadRequestError","param":null,"code":400}}
The input_value='W' is the first character of the assistant's prior message text
("Wie kann ich dir..."), which strongly suggests litellm's Pydantic validation is
iterating the raw content string character-by-character and attempting to validate each
character as a content-part dict.
Reproduction
Configure a ResponsesModel (open-responses bridge) pointed at a strict backend (litellm / AI Hub proxy in front of a qwen model, in our case).
Build a MessageBag containing a SystemMessage, an AssistantMessage with plain text content (no tool calls — e.g. a stored greeting), and a UserMessage.
Call the agent/platform with this bag as history.
Observe the 400 from the backend on the very first request — no streaming, no tool call needed to trigger it.
Expected
AssistantMessageNormalizer::normalize() should emit content as an array of content
parts, matching the shape already used by TextNormalizer for UserMessage:
return [
'role' => $data->getRole()->value,
'type' => 'message',
'content' => '' === $text ? [] : [['type' => 'input_text', 'text' => $text]],
];
Environment
symfony/ai-open-responses-platform: v0.10.0 (also reproduced against v0.11.0 — same code in that version, so not yet fixed there)
Backend: litellm proxy in front of qwen-3.6-35b-sovereign
PHP 8.4, Symfony 7.2
Description
Contract/Message/AssistantMessageNormalizer::normalize()serializes a text-onlyAssistantMessage(no tool calls) as:For the OpenAI Responses API, a type: "message" item's content field is documented
as an array of content parts (e.g. [{"type": "input_text", "text": "..."}]), not a bare
string. This is inconsistent with this same package's own
Contract/Message/Content/TextNormalizer.php, which correctly wraps UserMessage text
content as {"type": "input_text", "text": "..."}.
Impact
Sending a raw string in content works with some backends (seemingly tolerant/lenient
parsers), but breaks strict validators. Reproduced against an AI Hub / litellm proxy in
front of qwen-3.6-35b-sovereign: as soon as the conversation history contains any prior
AssistantMessage (e.g. a stored greeting/initial message), the very next request fails
with:
litellm.BadRequestError: OpenAIException - {"error":{"message":"2 validation errors for ValidatorIterator
0.ResponseOutputTextParam
Input should be a valid dictionary [type=dict_type, input_value='W', input_type=str]
0.ResponseOutputRefusalParam
Input should be a valid dictionary [type=dict_type, input_value='W', input_type=str]
","type":"BadRequestError","param":null,"code":400}}
The input_value='W' is the first character of the assistant's prior message text
("Wie kann ich dir..."), which strongly suggests litellm's Pydantic validation is
iterating the raw content string character-by-character and attempting to validate each
character as a content-part dict.
Reproduction
Configure a ResponsesModel (open-responses bridge) pointed at a strict backend (litellm / AI Hub proxy in front of a qwen model, in our case).
Build a MessageBag containing a SystemMessage, an AssistantMessage with plain text content (no tool calls — e.g. a stored greeting), and a UserMessage.
Call the agent/platform with this bag as history.
Observe the 400 from the backend on the very first request — no streaming, no tool call needed to trigger it.
Expected
AssistantMessageNormalizer::normalize() should emit content as an array of content
parts, matching the shape already used by TextNormalizer for UserMessage:
Environment
symfony/ai-open-responses-platform: v0.10.0 (also reproduced against v0.11.0 — same code in that version, so not yet fixed there)
Backend: litellm proxy in front of qwen-3.6-35b-sovereign
PHP 8.4, Symfony 7.2