From 617ec604a896a1998ff55199830755693e152faa Mon Sep 17 00:00:00 2001 From: Aleksandar Vucenovic Date: Thu, 4 Jun 2026 13:56:29 +0200 Subject: [PATCH 1/3] Fix malformed json_schema envelope for OpenAI-compatible providers prepareResponseFormatParam() placed the user schema directly under the json_schema key and never included the required name field, causing HTTP 400 errors on compliant OpenAI Chat Completions endpoints (OpenRouter, Together, Fireworks, Groq, Azure OpenAI relays) when using asJsonResponse(). Wrap the schema in the required envelope (name + schema), deriving name from the schema title when present and falling back to "response". Already wrapped payloads (arrays containing both name and schema keys) are passed through unchanged. Fixes #229. Co-Authored-By: Claude Opus 4.8 --- ...actOpenAiCompatibleTextGenerationModel.php | 25 ++++++- ...penAiCompatibleTextGenerationModelTest.php | 73 ++++++++++++++++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index e0e2e71b..9b7c93b0 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -504,7 +504,11 @@ protected function prepareToolsParam(array $functionDeclarations): array /** * Prepares the response format parameter for the API request. * - * This is only called if the output MIME type is `application/json`. + * This is only called if the output MIME type is `application/json`. When an output schema is + * provided, it is wrapped in the `json_schema` envelope expected by the OpenAI Chat Completions + * API, which requires a `name` field and the schema under the `schema` key. If a caller already + * supplies a wrapped payload (i.e. an array containing both `name` and `schema` keys), it is + * passed through unchanged. * * @since 0.1.0 * @@ -513,15 +517,30 @@ protected function prepareToolsParam(array $functionDeclarations): array */ protected function prepareResponseFormatParam(?array $outputSchema): array { - if (is_array($outputSchema)) { + if (!is_array($outputSchema)) { + return [ + 'type' => 'json_object', + ]; + } + + // Pass through payloads that are already wrapped in the json_schema envelope. + if (isset($outputSchema['name']) && isset($outputSchema['schema']) && is_array($outputSchema['schema'])) { return [ 'type' => 'json_schema', 'json_schema' => $outputSchema, ]; } + $jsonSchema = [ + 'name' => isset($outputSchema['title']) && is_string($outputSchema['title']) + ? $outputSchema['title'] + : 'response', + 'schema' => $outputSchema, + ]; + return [ - 'type' => 'json_object', + 'type' => 'json_schema', + 'json_schema' => $jsonSchema, ]; } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6c99c75b..0824b631 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -418,7 +418,16 @@ public function testPrepareGenerateTextParamsWithJsonOutputSchema(): void $params = $model->exposePrepareGenerateTextParams($prompt); $this->assertArrayHasKey('response_format', $params); - $this->assertEquals(['type' => 'json_schema', 'json_schema' => $schema], $params['response_format']); + $this->assertEquals( + [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'response', + 'schema' => $schema, + ], + ], + $params['response_format'] + ); } /** @@ -950,7 +959,67 @@ public function testPrepareResponseFormatParamWithSchema(): void $model = $this->createModel(); $format = $model->exposePrepareResponseFormatParam($schema); - $this->assertEquals(['type' => 'json_schema', 'json_schema' => $schema], $format); + $this->assertEquals( + [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'response', + 'schema' => $schema, + ], + ], + $format + ); + } + + /** + * Tests prepareResponseFormatParam() uses the schema title as the json_schema name. + * + * @return void + */ + public function testPrepareResponseFormatParamUsesSchemaTitleAsName(): void + { + $schema = [ + 'title' => 'review_notes', + 'type' => 'object', + 'properties' => ['key' => ['type' => 'string']], + ]; + $model = $this->createModel(); + $format = $model->exposePrepareResponseFormatParam($schema); + + $this->assertEquals( + [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'review_notes', + 'schema' => $schema, + ], + ], + $format + ); + } + + /** + * Tests prepareResponseFormatParam() passes through an already-wrapped envelope. + * + * @return void + */ + public function testPrepareResponseFormatParamPassesThroughWrappedEnvelope(): void + { + $envelope = [ + 'name' => 'classification', + 'strict' => true, + 'schema' => ['type' => 'object', 'properties' => ['key' => ['type' => 'string']]], + ]; + $model = $this->createModel(); + $format = $model->exposePrepareResponseFormatParam($envelope); + + $this->assertEquals( + [ + 'type' => 'json_schema', + 'json_schema' => $envelope, + ], + $format + ); } /** From 96b5ddaec9e1c8c269ee132c030c91c83498c32f Mon Sep 17 00:00:00 2001 From: Aleksandar Vucenovic Date: Thu, 4 Jun 2026 14:34:35 +0200 Subject: [PATCH 2/3] Use a fixed, spec-valid json_schema name instead of the schema title The OpenAI API requires the json_schema name to match ^[a-zA-Z0-9_-]{1,64}$. Deriving it from the user-supplied schema title risked producing an invalid name (e.g. titles with spaces or over 64 characters), which would itself cause the HTTP 400 the fix is meant to prevent. Use a fixed identifier instead, matching the dedicated OpenAiTextGenerationModel ("response_schema"). Co-Authored-By: Claude Opus 4.8 --- ...actOpenAiCompatibleTextGenerationModel.php | 19 +++++++++---------- ...penAiCompatibleTextGenerationModelTest.php | 16 ++++++++++------ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index 9b7c93b0..f7b250fd 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -506,9 +506,12 @@ protected function prepareToolsParam(array $functionDeclarations): array * * This is only called if the output MIME type is `application/json`. When an output schema is * provided, it is wrapped in the `json_schema` envelope expected by the OpenAI Chat Completions - * API, which requires a `name` field and the schema under the `schema` key. If a caller already + * API, which requires a `name` field and the schema under the `schema` key. The `name` uses a + * fixed identifier that satisfies the API's `^[a-zA-Z0-9_-]{1,64}$` constraint, rather than a + * value derived from the schema, to avoid producing an invalid name. If a caller already * supplies a wrapped payload (i.e. an array containing both `name` and `schema` keys), it is - * passed through unchanged. + * passed through unchanged, allowing callers to opt into provider-specific options such as + * `strict`. * * @since 0.1.0 * @@ -531,16 +534,12 @@ protected function prepareResponseFormatParam(?array $outputSchema): array ]; } - $jsonSchema = [ - 'name' => isset($outputSchema['title']) && is_string($outputSchema['title']) - ? $outputSchema['title'] - : 'response', - 'schema' => $outputSchema, - ]; - return [ 'type' => 'json_schema', - 'json_schema' => $jsonSchema, + 'json_schema' => [ + 'name' => 'response_schema', + 'schema' => $outputSchema, + ], ]; } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 0824b631..ae99b332 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php @@ -422,7 +422,7 @@ public function testPrepareGenerateTextParamsWithJsonOutputSchema(): void [ 'type' => 'json_schema', 'json_schema' => [ - 'name' => 'response', + 'name' => 'response_schema', 'schema' => $schema, ], ], @@ -963,7 +963,7 @@ public function testPrepareResponseFormatParamWithSchema(): void [ 'type' => 'json_schema', 'json_schema' => [ - 'name' => 'response', + 'name' => 'response_schema', 'schema' => $schema, ], ], @@ -972,14 +972,18 @@ public function testPrepareResponseFormatParamWithSchema(): void } /** - * Tests prepareResponseFormatParam() uses the schema title as the json_schema name. + * Tests prepareResponseFormatParam() ignores the schema title when naming the envelope. + * + * The OpenAI API requires the json_schema name to match ^[a-zA-Z0-9_-]{1,64}$, so a + * user-supplied title (which may contain spaces or other invalid characters) must not be + * used as the name. A fixed, valid identifier is used instead. * * @return void */ - public function testPrepareResponseFormatParamUsesSchemaTitleAsName(): void + public function testPrepareResponseFormatParamIgnoresSchemaTitle(): void { $schema = [ - 'title' => 'review_notes', + 'title' => 'Review Notes!', 'type' => 'object', 'properties' => ['key' => ['type' => 'string']], ]; @@ -990,7 +994,7 @@ public function testPrepareResponseFormatParamUsesSchemaTitleAsName(): void [ 'type' => 'json_schema', 'json_schema' => [ - 'name' => 'review_notes', + 'name' => 'response_schema', 'schema' => $schema, ], ], From b3c68456e22c47577ccdb424b496acb653b2fb6c Mon Sep 17 00:00:00 2001 From: Aleksandar Vucenovic Date: Thu, 4 Jun 2026 15:29:50 +0200 Subject: [PATCH 3/3] Address review feedback: clarify null check and pass-through condition Use a strict null check (the parameter is ?array) and extract the json_schema envelope detection into a named variable for readability. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../AbstractOpenAiCompatibleTextGenerationModel.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index f7b250fd..4d61c82b 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -520,14 +520,17 @@ protected function prepareToolsParam(array $functionDeclarations): array */ protected function prepareResponseFormatParam(?array $outputSchema): array { - if (!is_array($outputSchema)) { + if ($outputSchema === null) { return [ 'type' => 'json_object', ]; } // Pass through payloads that are already wrapped in the json_schema envelope. - if (isset($outputSchema['name']) && isset($outputSchema['schema']) && is_array($outputSchema['schema'])) { + $isWrappedEnvelope = isset($outputSchema['name']) + && isset($outputSchema['schema']) + && is_array($outputSchema['schema']); + if ($isWrappedEnvelope) { return [ 'type' => 'json_schema', 'json_schema' => $outputSchema,