WordPress / WordPress/php-ai-client

OpenAI-compatible providers fail with 400 on structured output (missing json_schema envelope)

Open Beginner friendly
#229 0 comments 0 reactions 0 assignees View on GitHub

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

  1. Configure any provider that extends AbstractOpenAiCompatibleTextGenerationModel (e.g. an OpenRouter provider) with a valid API key.

  2. 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();
    
  3. Observe the outgoing request body sent to /chat/completions:

    {
      "response_format": {
        "type": "json_schema",
        "json_schema": {
          "type": "object",
          "properties": { "suggestions": { ... } },
          "required": ["suggestions"]
        }
      }
    }
    
  4. 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/)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.