diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php index e0e2e71b..4d61c82b 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModel.php @@ -504,7 +504,14 @@ 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. 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, allowing callers to opt into provider-specific options such as + * `strict`. * * @since 0.1.0 * @@ -513,7 +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. + $isWrappedEnvelope = isset($outputSchema['name']) + && isset($outputSchema['schema']) + && is_array($outputSchema['schema']); + if ($isWrappedEnvelope) { return [ 'type' => 'json_schema', 'json_schema' => $outputSchema, @@ -521,7 +538,11 @@ protected function prepareResponseFormatParam(?array $outputSchema): array } return [ - 'type' => 'json_object', + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'response_schema', + 'schema' => $outputSchema, + ], ]; } diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextGenerationModelTest.php index 6c99c75b..ae99b332 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' => $schema, + ], + ], + $params['response_format'] + ); } /** @@ -950,7 +959,71 @@ 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' => $schema, + ], + ], + $format + ); + } + + /** + * 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 testPrepareResponseFormatParamIgnoresSchemaTitle(): 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' => 'response_schema', + '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 + ); } /**