Skip to content

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

@benridane

Description

@benridane

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/)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions