WordPress / WordPress/php-ai-client
OpenAI-compatible providers fail with 400 on structured output (missing json_schema envelope)
Nobody has claimed this yet.
- Dominant language
- PHP
- Stars
- 308
- Forks
- 84
- Avg merge
- 7d 21h
- Merged PRs (30d)
- 2
Description
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
trunkHEAD) - PHP: 8.3
- Provider tested: OpenRouter (
openai/gpt-4o-mini, upstream Azure OpenAI) - Embedded in: WordPress 7.0-RC2 (
wp-includes/php-ai-client/)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php at prepareResponseFormatParam(), then inspect callers of asJsonResponse() and the OpenAI-compatible request construction. The change is complete when structured output requests use the required json_schema envelope with a name and nested schema, while plain JSON object responses remain unchanged; verify the outgoing payload against the documented example.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php
- Domain
- api, backend-api-design
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100