From be8dae2641a2ebf24545ba33ece995bde005c55a Mon Sep 17 00:00:00 2001 From: Casey Manos Date: Tue, 25 Nov 2025 16:24:10 -0600 Subject: [PATCH 1/4] OpenAI TTS model implementation --- .../OpenAiTextToSpeechConversionModel.php | 42 +++ ...iCompatibleTextToSpeechConversionModel.php | 279 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php create mode 100644 src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php new file mode 100644 index 00000000..041807b7 --- /dev/null +++ b/src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php @@ -0,0 +1,42 @@ +> $headers The request headers. + * @param string|array|null $data The request data. + * @return Request The request object. + */ + protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request + { + return new Request( + $method, + OpenAiProvider::url($path), + $headers, + $data + ); + } +} diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php new file mode 100644 index 00000000..5bbff151 --- /dev/null +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php @@ -0,0 +1,279 @@ + + */ + protected const MIME_TYPE_TO_FORMAT = [ + 'audio/mpeg' => 'mp3', + 'audio/mp3' => 'mp3', + 'audio/ogg' => 'opus', + 'audio/opus' => 'opus', + 'audio/aac' => 'aac', + 'audio/flac' => 'flac', + 'audio/wav' => 'wav', + 'audio/x-wav' => 'wav', + 'audio/pcm' => 'pcm', + ]; + + /** + * Mapping of OpenAI response_format values to MIME types. + * + * @since n.e.x.t + * + * @var array + */ + protected const FORMAT_TO_MIME_TYPE = [ + 'mp3' => 'audio/mpeg', + 'opus' => 'audio/ogg', + 'aac' => 'audio/aac', + 'flac' => 'audio/flac', + 'wav' => 'audio/wav', + 'pcm' => 'audio/pcm', + ]; + + /** + * Converts text to speech. + * + * @since n.e.x.t + * + * @param list $prompt Array of messages containing the text to convert to speech. + * @return GenerativeAiResult Result containing generated speech audio. + */ + public function convertTextToSpeechResult(array $prompt): GenerativeAiResult + { + $httpTransporter = $this->getHttpTransporter(); + + $params = $this->prepareConvertTextToSpeechParams($prompt); + + $request = $this->createRequest( + HttpMethodEnum::POST(), + 'audio/speech', + ['Content-Type' => 'application/json'], + $params + ); + + // Add authentication credentials to the request. + $request = $this->getRequestAuthentication()->authenticateRequest($request); + + // Send and process the request. + $response = $httpTransporter->send($request); + $this->throwIfNotSuccessful($response); + + // Determine the expected MIME type from the response_format parameter. + $responseFormat = $params['response_format'] ?? 'mp3'; + $expectedMimeType = self::FORMAT_TO_MIME_TYPE[$responseFormat] ?? self::DEFAULT_MIME_TYPE; + + return $this->parseResponseToGenerativeAiResult($response, $expectedMimeType); + } + + /** + * Prepares the given prompt and the model configuration into parameters for the API request. + * + * @since n.e.x.t + * + * @param list $prompt The prompt containing text to convert to speech. + * @return array The parameters for the API request. + */ + protected function prepareConvertTextToSpeechParams(array $prompt): array + { + $config = $this->getConfig(); + + $params = [ + 'model' => $this->metadata()->getId(), + 'input' => $this->prepareInputParam($prompt), + 'voice' => $config->getOutputSpeechVoice() ?? self::DEFAULT_VOICE, + ]; + + $outputMimeType = $config->getOutputMimeType(); + if ($outputMimeType !== null && isset(self::MIME_TYPE_TO_FORMAT[$outputMimeType])) { + $params['response_format'] = self::MIME_TYPE_TO_FORMAT[$outputMimeType]; + } + + /* + * Any custom options are added to the parameters as well. + * This allows developers to pass other options that may be more niche or not yet supported by the SDK, + * such as 'speed' (0.25 to 4.0) or 'instructions' (for gpt-4o-mini-tts models). + */ + $customOptions = $config->getCustomOptions(); + foreach ($customOptions as $key => $value) { + if (isset($params[$key])) { + throw new InvalidArgumentException( + sprintf( + 'The custom option "%s" conflicts with an existing parameter.', + $key + ) + ); + } + $params[$key] = $value; + } + + return $params; + } + + /** + * Prepares the input parameter for the API request. + * + * @since n.e.x.t + * + * @param list $messages The messages to prepare. + * @return string The prepared input parameter containing the text to convert. + */ + protected function prepareInputParam(array $messages): string + { + if (count($messages) === 0) { + throw new InvalidArgumentException( + 'At least one message is required for text-to-speech conversion.' + ); + } + + // Concatenate text from all messages. + $textParts = []; + foreach ($messages as $message) { + foreach ($message->getParts() as $part) { + $text = $part->getText(); + if ($text !== null) { + $textParts[] = $text; + } + } + } + + if (count($textParts) === 0) { + throw new InvalidArgumentException( + 'At least one text message part is required for text-to-speech conversion.' + ); + } + + return implode(' ', $textParts); + } + + /** + * Creates a request object for the provider's API. + * + * @since n.e.x.t + * + * @param HttpMethodEnum $method The HTTP method. + * @param string $path The API endpoint path, relative to the base URI. + * @param array> $headers The request headers. + * @param string|array|null $data The request data. + * @return Request The request object. + */ + abstract protected function createRequest( + HttpMethodEnum $method, + string $path, + array $headers = [], + $data = null + ): Request; + + /** + * Throws an exception if the response is not successful. + * + * @since n.e.x.t + * + * @param Response $response The HTTP response to check. + * @throws ResponseException If the response is not successful. + */ + protected function throwIfNotSuccessful(Response $response): void + { + /* + * While this method only calls the utility method, it's important to have it here as a protected method so + * that child classes can override it if needed. + */ + ResponseUtil::throwIfNotSuccessful($response); + } + + /** + * Parses the response from the API endpoint to a generative AI result. + * + * The OpenAI TTS API returns binary audio data directly in the response body, + * not as JSON. This method handles that binary response format. + * + * @since n.e.x.t + * + * @param Response $response The response from the API endpoint. + * @param string $expectedMimeType The expected MIME type of the audio response. + * @return GenerativeAiResult The parsed generative AI result. + */ + protected function parseResponseToGenerativeAiResult( + Response $response, + string $expectedMimeType = 'audio/mpeg' + ): GenerativeAiResult { + $binaryData = $response->getBody(); + + if ($binaryData === '' || $binaryData === null) { + throw ResponseException::fromMissingData($this->providerMetadata()->getName(), 'audio data'); + } + + // Encode the binary audio data as base64. + $base64Data = base64_encode($binaryData); + + // Create a File object with the audio data. + $audioFile = new File($base64Data, $expectedMimeType); + + $parts = [new MessagePart($audioFile)]; + $message = new Message(MessageRoleEnum::model(), $parts); + $candidate = new Candidate($message, FinishReasonEnum::stop()); + + // TTS API does not return token usage information. + $tokenUsage = new TokenUsage(0, 0, 0); + + return new GenerativeAiResult( + '', + [$candidate], + $tokenUsage, + $this->providerMetadata(), + $this->metadata(), + [] + ); + } +} From 0023e8598980f9a5e327e33b545f20b1c2007953 Mon Sep 17 00:00:00 2001 From: Casey Manos Date: Tue, 25 Nov 2025 16:24:14 -0600 Subject: [PATCH 2/4] tests --- ...iCompatibleTextToSpeechConversionModel.php | 76 +++ ...patibleTextToSpeechConversionModelTest.php | 499 ++++++++++++++++++ 2 files changed, 575 insertions(+) create mode 100644 tests/mocks/MockOpenAiCompatibleTextToSpeechConversionModel.php create mode 100644 tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php diff --git a/tests/mocks/MockOpenAiCompatibleTextToSpeechConversionModel.php b/tests/mocks/MockOpenAiCompatibleTextToSpeechConversionModel.php new file mode 100644 index 00000000..7b313a8e --- /dev/null +++ b/tests/mocks/MockOpenAiCompatibleTextToSpeechConversionModel.php @@ -0,0 +1,76 @@ + $prompt The prompt to prepare parameters for. + * @return array The prepared parameters. + */ + public function exposePrepareConvertTextToSpeechParams(array $prompt): array + { + return $this->prepareConvertTextToSpeechParams($prompt); + } + + /** + * Exposes the protected prepareInputParam method. + * + * @param list $messages The messages to prepare. + * @return string The prepared input parameter. + */ + public function exposePrepareInputParam(array $messages): string + { + return $this->prepareInputParam($messages); + } + + /** + * Exposes the protected throwIfNotSuccessful method. + * + * @param Response $response The response to check. + */ + public function exposeThrowIfNotSuccessful(Response $response): void + { + $this->throwIfNotSuccessful($response); + } + + /** + * Exposes the protected parseResponseToGenerativeAiResult method. + * + * @param Response $response The response to parse. + * @param string $expectedMimeType The expected MIME type. + * @return GenerativeAiResult The parsed result. + */ + public function exposeParseResponseToGenerativeAiResult( + Response $response, + string $expectedMimeType = 'audio/mpeg' + ): GenerativeAiResult { + return $this->parseResponseToGenerativeAiResult($response, $expectedMimeType); + } +} diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php new file mode 100644 index 00000000..8bbe532e --- /dev/null +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php @@ -0,0 +1,499 @@ +modelMetadata = $this->createStub(ModelMetadata::class); + $this->modelMetadata->method('getId')->willReturn('tts-1'); + $this->providerMetadata = $this->createStub(ProviderMetadata::class); + $this->providerMetadata->method('getName')->willReturn('TestProvider'); + $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); + $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); + } + + /** + * Creates a mock instance of MockOpenAiCompatibleTextToSpeechConversionModel. + * + * @param ModelConfig|null $modelConfig The model configuration. + * @return MockOpenAiCompatibleTextToSpeechConversionModel The mock model instance. + */ + private function createModel(?ModelConfig $modelConfig = null): MockOpenAiCompatibleTextToSpeechConversionModel + { + $model = new MockOpenAiCompatibleTextToSpeechConversionModel( + $this->modelMetadata, + $this->providerMetadata + ); + $model->setHttpTransporter($this->mockHttpTransporter); + $model->setRequestAuthentication($this->mockRequestAuthentication); + if ($modelConfig) { + $model->setConfig($modelConfig); + } + return $model; + } + + /** + * Tests convertTextToSpeechResult() method on success. + * + * @return void + */ + public function testConvertTextToSpeechResultSuccess(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello world')])]; + // Simulated binary audio data. + $binaryAudioData = 'fake-binary-audio-data-for-testing'; + $response = new Response(200, ['Content-Type' => ['audio/mpeg']], $binaryAudioData); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + $result = $model->convertTextToSpeechResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals( + base64_encode($binaryAudioData), + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getBase64Data() + ); + $this->assertEquals( + 'audio/mpeg', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType() + ); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + } + + /** + * Tests convertTextToSpeechResult() with custom voice. + * + * @return void + */ + public function testConvertTextToSpeechResultWithCustomVoice(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $binaryAudioData = 'audio-data'; + $response = new Response(200, [], $binaryAudioData); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $modelConfig = ModelConfig::fromArray(['outputSpeechVoice' => 'nova']); + $model = $this->createModel($modelConfig); + $result = $model->convertTextToSpeechResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + } + + /** + * Tests convertTextToSpeechResult() method on API failure. + * + * @return void + */ + public function testConvertTextToSpeechResultApiFailure(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Hello')])]; + $response = new Response(400, [], '{"error": "Invalid parameter."}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Bad Request (400) - Invalid parameter.'); + + $model->convertTextToSpeechResult($prompt); + } + + /** + * Tests prepareConvertTextToSpeechParams() with basic text prompt. + * + * @return void + */ + public function testPrepareConvertTextToSpeechParamsBasicText(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test speech text')])]; + $model = $this->createModel(); + + $params = $model->exposePrepareConvertTextToSpeechParams($prompt); + + $this->assertArrayHasKey('model', $params); + $this->assertEquals('tts-1', $params['model']); + $this->assertArrayHasKey('input', $params); + $this->assertEquals('Test speech text', $params['input']); + $this->assertArrayHasKey('voice', $params); + $this->assertEquals('alloy', $params['voice']); + } + + /** + * Tests prepareConvertTextToSpeechParams() with custom voice. + * + * @return void + */ + public function testPrepareConvertTextToSpeechParamsWithCustomVoice(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputSpeechVoice' => 'shimmer']); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareConvertTextToSpeechParams($prompt); + + $this->assertArrayHasKey('voice', $params); + $this->assertEquals('shimmer', $params['voice']); + } + + /** + * Tests prepareConvertTextToSpeechParams() with output MIME type. + * + * @return void + * @dataProvider mimeTypeToFormatProvider + */ + public function testPrepareConvertTextToSpeechParamsWithOutputMimeType( + string $mimeType, + string $expectedFormat + ): void { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputMimeType' => $mimeType]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareConvertTextToSpeechParams($prompt); + + $this->assertArrayHasKey('response_format', $params); + $this->assertEquals($expectedFormat, $params['response_format']); + } + + /** + * Provides MIME types and their expected response_format values. + * + * @return array> + */ + public function mimeTypeToFormatProvider(): array + { + return [ + 'audio/mpeg' => ['audio/mpeg', 'mp3'], + 'audio/ogg' => ['audio/ogg', 'opus'], + 'audio/aac' => ['audio/aac', 'aac'], + 'audio/flac' => ['audio/flac', 'flac'], + 'audio/wav' => ['audio/wav', 'wav'], + ]; + } + + /** + * Tests prepareConvertTextToSpeechParams() with custom options. + * + * @return void + */ + public function testPrepareConvertTextToSpeechParamsWithCustomOptions(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray([ + 'customOptions' => [ + 'speed' => 1.5, + 'instructions' => 'Speak in a cheerful tone.', + ], + ]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareConvertTextToSpeechParams($prompt); + + $this->assertArrayHasKey('speed', $params); + $this->assertEquals(1.5, $params['speed']); + $this->assertArrayHasKey('instructions', $params); + $this->assertEquals('Speak in a cheerful tone.', $params['instructions']); + } + + /** + * Tests prepareConvertTextToSpeechParams() with conflicting custom option. + * + * @return void + */ + public function testPrepareConvertTextToSpeechParamsWithConflictingCustomOption(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['customOptions' => ['model' => 'conflicting-model']]); + $model = $this->createModel($modelConfig); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The custom option "model" conflicts with an existing parameter.'); + + $model->exposePrepareConvertTextToSpeechParams($prompt); + } + + /** + * Tests prepareInputParam() with a single user message. + * + * @return void + */ + public function testPrepareInputParamSingleUserMessage(): void + { + $message = new Message(MessageRoleEnum::user(), [new MessagePart('Hello speech')]); + $model = $this->createModel(); + + $preparedInput = $model->exposePrepareInputParam([$message]); + + $this->assertEquals('Hello speech', $preparedInput); + } + + /** + * Tests prepareInputParam() with multiple messages. + * + * @return void + */ + public function testPrepareInputParamMultipleMessages(): void + { + $messages = [ + new Message(MessageRoleEnum::user(), [new MessagePart('First part.')]), + new Message(MessageRoleEnum::user(), [new MessagePart('Second part.')]), + ]; + $model = $this->createModel(); + + $preparedInput = $model->exposePrepareInputParam($messages); + + $this->assertEquals('First part. Second part.', $preparedInput); + } + + /** + * Tests prepareInputParam() with multiple message parts. + * + * @return void + */ + public function testPrepareInputParamMultipleMessageParts(): void + { + $message = new Message(MessageRoleEnum::user(), [ + new MessagePart('Part one.'), + new MessagePart('Part two.'), + ]); + $model = $this->createModel(); + + $preparedInput = $model->exposePrepareInputParam([$message]); + + $this->assertEquals('Part one. Part two.', $preparedInput); + } + + /** + * Tests prepareInputParam() with empty messages array. + * + * @return void + */ + public function testPrepareInputParamEmptyMessages(): void + { + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one message is required for text-to-speech conversion.'); + + $model->exposePrepareInputParam([]); + } + + /** + * Tests prepareInputParam() with message without text part. + * + * @return void + */ + public function testPrepareInputParamMessageWithoutTextPart(): void + { + $message = new Message( + MessageRoleEnum::user(), + [new MessagePart(new File('https://example.com/image.png', 'image/png'))] + ); + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one text message part is required for text-to-speech conversion.'); + + $model->exposePrepareInputParam([$message]); + } + + /** + * Tests throwIfNotSuccessful() with a successful response. + * + * @return void + */ + public function testThrowIfNotSuccessfulSuccess(): void + { + $response = new Response(200, [], 'binary-audio-data'); + $model = $this->createModel(); + $model->exposeThrowIfNotSuccessful($response); + $this->assertTrue(true); + } + + /** + * Tests throwIfNotSuccessful() with an unsuccessful response. + * + * @return void + */ + public function testThrowIfNotSuccessfulFailure(): void + { + $response = new Response(404, [], '{"error":"The resource does not exist."}'); + $model = $this->createModel(); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('Not Found (404) - The resource does not exist.'); + + $model->exposeThrowIfNotSuccessful($response); + } + + /** + * Tests parseResponseToGenerativeAiResult() with valid binary audio response. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultValidResponse(): void + { + $binaryAudioData = 'test-binary-audio-data'; + $response = new Response(200, [], $binaryAudioData); + $model = $this->createModel(); + + $result = $model->exposeParseResponseToGenerativeAiResult($response, 'audio/mpeg'); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals( + base64_encode($binaryAudioData), + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getBase64Data() + ); + $this->assertEquals( + 'audio/mpeg', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType() + ); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(MessageRoleEnum::model(), $result->getCandidates()[0]->getMessage()->getRole()); + $this->assertEquals(0, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests parseResponseToGenerativeAiResult() with different MIME types. + * + * @return void + * @dataProvider audioMimeTypeProvider + */ + public function testParseResponseToGenerativeAiResultWithDifferentMimeTypes(string $mimeType): void + { + $binaryAudioData = 'audio-data-' . $mimeType; + $response = new Response(200, [], $binaryAudioData); + $model = $this->createModel(); + + $result = $model->exposeParseResponseToGenerativeAiResult($response, $mimeType); + + $this->assertEquals( + $mimeType, + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType() + ); + } + + /** + * Provides different audio MIME types. + * + * @return array> + */ + public function audioMimeTypeProvider(): array + { + return [ + 'mp3' => ['audio/mpeg'], + 'ogg' => ['audio/ogg'], + 'aac' => ['audio/aac'], + 'flac' => ['audio/flac'], + 'wav' => ['audio/wav'], + ]; + } + + /** + * Tests parseResponseToGenerativeAiResult() with empty body. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultEmptyBody(): void + { + $response = new Response(200, [], ''); + $model = $this->createModel(); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Unexpected TestProvider API response: Missing the "audio data" key.'); + + $model->exposeParseResponseToGenerativeAiResult($response); + } + + /** + * Tests parseResponseToGenerativeAiResult() with null body. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultNullBody(): void + { + $response = new Response(200, [], null); + $model = $this->createModel(); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Unexpected TestProvider API response: Missing the "audio data" key.'); + + $model->exposeParseResponseToGenerativeAiResult($response); + } +} From 460e6aecf59fad052c0335d3359a40c96b43eec1 Mon Sep 17 00:00:00 2001 From: Casey Manos Date: Tue, 25 Nov 2025 16:24:45 -0600 Subject: [PATCH 3/4] class implementation --- src/ProviderImplementations/OpenAi/OpenAiProvider.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiProvider.php b/src/ProviderImplementations/OpenAi/OpenAiProvider.php index 6857feea..a1f70ee9 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiProvider.php +++ b/src/ProviderImplementations/OpenAi/OpenAiProvider.php @@ -49,10 +49,7 @@ protected static function createModel( return new OpenAiImageGenerationModel($modelMetadata, $providerMetadata); } if ($capability->isTextToSpeechConversion()) { - // TODO: Implement OpenAiTextToSpeechConversionModel. - throw new RuntimeException( - 'OpenAI text to speech conversion model class is not yet implemented.' - ); + return new OpenAiTextToSpeechConversionModel($modelMetadata, $providerMetadata); } } From 72fe000094aeeaa4d805d701a8e5fd9781e86fb9 Mon Sep 17 00:00:00 2001 From: Casey Manos Date: Fri, 28 Nov 2025 10:04:14 -0500 Subject: [PATCH 4/4] Remove default voice from TTS abstract class Move voice parameter handling to follow codebase philosophy: SDK validates structure/format, API validates business rules. The abstract class no longer sets a default voice, allowing the OpenAI API to return a clear error if voice is not configured. This makes the TTS implementation consistent with text generation and image generation models. --- .../OpenAi/OpenAiTextToSpeechConversionModel.php | 10 +--------- ...tOpenAiCompatibleTextToSpeechConversionModel.php | 13 +++++-------- ...nAiCompatibleTextToSpeechConversionModelTest.php | 4 ++-- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php b/src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php index 041807b7..d596c6b6 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiTextToSpeechConversionModel.php @@ -20,15 +20,7 @@ class OpenAiTextToSpeechConversionModel extends AbstractOpenAiCompatibleTextToSpeechConversionModel { /** - * Creates a request object for the OpenAI API. - * - * @since n.e.x.t - * - * @param HttpMethodEnum $method The HTTP method. - * @param string $path The API endpoint path, relative to the base URI. - * @param array> $headers The request headers. - * @param string|array|null $data The request data. - * @return Request The request object. + * {@inheritDoc} */ protected function createRequest(HttpMethodEnum $method, string $path, array $headers = [], $data = null): Request { diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php index 5bbff151..839dba57 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModel.php @@ -33,13 +33,6 @@ abstract class AbstractOpenAiCompatibleTextToSpeechConversionModel extends AbstractApiBasedModel implements TextToSpeechConversionModelInterface { - /** - * Default voice to use if none is specified. - * - * @since n.e.x.t - */ - protected const DEFAULT_VOICE = 'alloy'; - /** * Default output MIME type. * @@ -132,9 +125,13 @@ protected function prepareConvertTextToSpeechParams(array $prompt): array $params = [ 'model' => $this->metadata()->getId(), 'input' => $this->prepareInputParam($prompt), - 'voice' => $config->getOutputSpeechVoice() ?? self::DEFAULT_VOICE, ]; + $voice = $config->getOutputSpeechVoice(); + if ($voice !== null) { + $params['voice'] = $voice; + } + $outputMimeType = $config->getOutputMimeType(); if ($outputMimeType !== null && isset(self::MIME_TYPE_TO_FORMAT[$outputMimeType])) { $params['response_format'] = self::MIME_TYPE_TO_FORMAT[$outputMimeType]; diff --git a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php index 8bbe532e..094521b2 100644 --- a/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleTextToSpeechConversionModelTest.php @@ -189,8 +189,8 @@ public function testPrepareConvertTextToSpeechParamsBasicText(): void $this->assertEquals('tts-1', $params['model']); $this->assertArrayHasKey('input', $params); $this->assertEquals('Test speech text', $params['input']); - $this->assertArrayHasKey('voice', $params); - $this->assertEquals('alloy', $params['voice']); + // Voice is not set by the abstract class; concrete implementations add provider-specific defaults. + $this->assertArrayNotHasKey('voice', $params); } /**