Description
AbstractOpenAiCompatibleTextGenerationModel::prepareResponseFormatParam() emits a response_format.json_schema payload that the OpenAI Structured Outputs spec
rejects. Any provider extending this base class fails with HTTP 400 missing_required_parameter: response_format.json_schema.name whenever a caller uses
asJsonResponse($schema). Plain text generation through the same providers is unaffected because the broken code path is gated behind application/json output mime.
Reproduced against OpenRouter routing to Azure-hosted OpenAI, and reproducible against the OpenAI Chat Completions endpoint itself per spec. Permissive self-hosted
OpenAI-compatible servers (vLLM, llama.cpp's OpenAI shim, etc.) may silently accept the malformed payload.
Steps to Reproduce
-
Configure any provider that extends AbstractOpenAiCompatibleTextGenerationModel (e.g. an OpenRouter provider) with a valid API key.
-
Run a structured output request:
$schema = [
'type' => 'object',
'properties' => [
'suggestions' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'review_type' => ['type' => 'string'],
'text' => ['type' => 'string'],
],
'required' => ['review_type', 'text'],
],
],
],
'required' => ['suggestions'],
];
$registry
->prompt('Suggest one improvement to: Hello world.')
->usingModelPreference([['<provider>', '<model>']])
->asJsonResponse($schema)
->generateText();
-
Observe the outgoing request body sent to /chat/completions:
{
"response_format": {
"type": "json_schema",
"json_schema": {
"type": "object",
"properties": { "suggestions": { ... } },
"required": ["suggestions"]
}
}
}
-
The upstream returns 400:
{
"error": {
"message": "Missing required parameter: 'response_format.json_schema.name'.",
"type": "invalid_request_error",
"param": "response_format.json_schema.name",
"code": "missing_required_parameter"
}
}
Expected Behavior
Per the [Chat Completions API reference][1], the response_format parameter
of type json_schema requires the json_schema value to be an object with
fields { name, description?, schema?, strict? }. By the reference page's
own convention (fields marked optional are optional, others are required),
only name is required:
name: string
The name of the response format. Must be a-z, A-Z, 0-9, or contain
underscores and dashes, with a maximum length of 64.
description: optional string …
schema: optional map[unknown] …
strict: optional boolean …
This matches the code: missing_required_parameter error shown above.
The expected request body therefore looks like:
{
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "response",
"strict": true,
"schema": {
"type": "object",
"properties": { "suggestions": { ... } },
"required": ["suggestions"]
}
}
}
}
## Root Cause
`src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php`:
```php
protected function prepareResponseFormatParam(?array $outputSchema): array
{
if (is_array($outputSchema)) {
return ['type' => 'json_schema', 'json_schema' => $outputSchema];
}
return ['type' => 'json_object'];
}
The user's schema is placed directly under json_schema rather than under json_schema.schema, and name is never emitted.
The same envelope requirement was previously surfaced in #151. The fix in #163 only updated AnthropicTextGenerationModel.php (Anthropic was switched to its
dedicated output_format parameter), and #161 migrated the native OpenAI provider off /chat/completions onto the Responses API. Neither change touched the abstract
base class, leaving the malformed envelope in place for every other provider that relies on it (OpenRouter, Together, Fireworks, Groq, Azure OpenAI relays, …).
[Chat Completions API reference][1]
[1]: hhttps://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
Proposed Fix
Wrap the schema in the OpenAI envelope, with a pass-through for callers already supplying {name, schema}:
protected function prepareResponseFormatParam(?array $outputSchema): array
{
if (!is_array($outputSchema)) {
return ['type' => 'json_object'];
}
if (isset($outputSchema['schema']) && is_array($outputSchema['schema'])) {
$envelope = $outputSchema;
if (!isset($envelope['name']) || !is_string($envelope['name']) || $envelope['name'] === '') {
$envelope['name'] = 'response';
}
return ['type' => 'json_schema', 'json_schema' => $envelope];
}
return [
'type' => 'json_schema',
'json_schema' => [
'name' => 'response',
'strict' => true,
'schema' => $outputSchema,
],
];
}
A small minority of permissive OpenAI-compatible servers might reject the additional name/strict fields. If that's a concern, the wrap can be gated behind a
provider-level toggle (default on) so subclasses can opt out — happy to open a PR once the desired direction is confirmed.
Verified end-to-end against OpenRouter openai/gpt-4o-mini from inside the WordPress 7.0-RC2 ai plugin's wp_get_ability('ai/review-notes')->execute(...) flow,
returning a valid {"suggestions": [...]} payload after the override.
Downstream Impact
In the WordPress 7.0-RC2 bundle (wp-includes/php-ai-client/), this breaks the abilities in the official ai plugin that opt into structured output:
| Ability |
Output mode |
Status |
| Summarization, Title Generation, Excerpt Generation, Meta Description, Refine Notes |
plain text |
OK |
| Review Notes |
as_json_response($schema) |
400 |
| Content Classification |
as_json_response($schema) |
400 |
Environment
- php-ai-client: 1.3.1 (also reproduced on
trunk HEAD)
- PHP: 8.3
- Provider tested: OpenRouter (
openai/gpt-4o-mini, upstream Azure OpenAI)
- Embedded in: WordPress 7.0-RC2 (
wp-includes/php-ai-client/)
Description
AbstractOpenAiCompatibleTextGenerationModel::prepareResponseFormatParam()emits aresponse_format.json_schemapayload that the OpenAI Structured Outputs specrejects. Any provider extending this base class fails with
HTTP 400 missing_required_parameter: response_format.json_schema.namewhenever a caller usesasJsonResponse($schema). Plain text generation through the same providers is unaffected because the broken code path is gated behindapplication/jsonoutput mime.Reproduced against OpenRouter routing to Azure-hosted OpenAI, and reproducible against the OpenAI Chat Completions endpoint itself per spec. Permissive self-hosted
OpenAI-compatible servers (vLLM, llama.cpp's OpenAI shim, etc.) may silently accept the malformed payload.
Steps to Reproduce
Configure any provider that extends
AbstractOpenAiCompatibleTextGenerationModel(e.g. an OpenRouter provider) with a valid API key.Run a structured output request:
Observe the outgoing request body sent to
/chat/completions:{ "response_format": { "type": "json_schema", "json_schema": { "type": "object", "properties": { "suggestions": { ... } }, "required": ["suggestions"] } } }The upstream returns 400:
{ "error": { "message": "Missing required parameter: 'response_format.json_schema.name'.", "type": "invalid_request_error", "param": "response_format.json_schema.name", "code": "missing_required_parameter" } }Expected Behavior
Per the [Chat Completions API reference][1], the
response_formatparameterof type
json_schemarequires thejson_schemavalue to be an object withfields
{ name, description?, schema?, strict? }. By the reference page'sown convention (fields marked
optionalare optional, others are required),only
nameis required:This matches the
code: missing_required_parametererror shown above.The expected request body therefore looks like:
{ "response_format": { "type": "json_schema", "json_schema": { "name": "response", "strict": true, "schema": { "type": "object", "properties": { "suggestions": { ... } }, "required": ["suggestions"] } } } } ## Root Cause `src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php`: ```php protected function prepareResponseFormatParam(?array $outputSchema): array { if (is_array($outputSchema)) { return ['type' => 'json_schema', 'json_schema' => $outputSchema]; } return ['type' => 'json_object']; }The user's schema is placed directly under
json_schemarather than underjson_schema.schema, andnameis never emitted.The same envelope requirement was previously surfaced in #151. The fix in #163 only updated
AnthropicTextGenerationModel.php(Anthropic was switched to itsdedicated
output_formatparameter), and #161 migrated the native OpenAI provider off/chat/completionsonto the Responses API. Neither change touched the abstractbase class, leaving the malformed envelope in place for every other provider that relies on it (OpenRouter, Together, Fireworks, Groq, Azure OpenAI relays, …).
[Chat Completions API reference][1]
[1]: hhttps://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create
Proposed Fix
Wrap the schema in the OpenAI envelope, with a pass-through for callers already supplying
{name, schema}:A small minority of permissive OpenAI-compatible servers might reject the additional
name/strictfields. If that's a concern, the wrap can be gated behind aprovider-level toggle (default on) so subclasses can opt out — happy to open a PR once the desired direction is confirmed.
Verified end-to-end against OpenRouter
openai/gpt-4o-minifrom inside the WordPress 7.0-RC2aiplugin'swp_get_ability('ai/review-notes')->execute(...)flow,returning a valid
{"suggestions": [...]}payload after the override.Downstream Impact
In the WordPress 7.0-RC2 bundle (
wp-includes/php-ai-client/), this breaks the abilities in the officialaiplugin that opt into structured output:as_json_response($schema)as_json_response($schema)Environment
trunkHEAD)openai/gpt-4o-mini, upstream Azure OpenAI)wp-includes/php-ai-client/)