From ab90d2876455dbbd356272545b01f47b1beb80b9 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Mon, 25 Aug 2025 19:06:26 -0700 Subject: [PATCH 01/13] Implement abstract image generation model class plus implementations for Google and OpenAI. --- .../Google/GoogleImageGenerationModel.php | 30 ++ .../Google/GoogleProvider.php | 5 +- .../OpenAi/OpenAiImageGenerationModel.php | 50 +++ .../OpenAi/OpenAiProvider.php | 5 +- ...ctOpenAiCompatibleImageGenerationModel.php | 351 ++++++++++++++++++ 5 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 src/ProviderImplementations/Google/GoogleImageGenerationModel.php create mode 100644 src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php create mode 100644 src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php diff --git a/src/ProviderImplementations/Google/GoogleImageGenerationModel.php b/src/ProviderImplementations/Google/GoogleImageGenerationModel.php new file mode 100644 index 00000000..84a2b68f --- /dev/null +++ b/src/ProviderImplementations/Google/GoogleImageGenerationModel.php @@ -0,0 +1,30 @@ +isImageGeneration()) { - // TODO: Implement GoogleImageGenerationModel. - throw new RuntimeException( - 'Google image generation model class is not yet implemented.' - ); + return new GoogleImageGenerationModel($modelMetadata, $providerMetadata); } } diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php new file mode 100644 index 00000000..13a00dad --- /dev/null +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -0,0 +1,50 @@ +isImageGeneration()) { - // TODO: Implement OpenAiImageGenerationModel. - throw new RuntimeException( - 'OpenAI image generation model class is not yet implemented.' - ); + return new OpenAiImageGenerationModel($modelMetadata, $providerMetadata); } if ($capability->isTextToSpeechConversion()) { // TODO: Implement OpenAiTextToSpeechConversionModel. diff --git a/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php new file mode 100644 index 00000000..9ade4bce --- /dev/null +++ b/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php @@ -0,0 +1,351 @@ +getHttpTransporter(); + + $params = $this->prepareGenerateImageParams($prompt); + + $request = $this->createRequest( + HttpMethodEnum::POST(), + 'images/generations', + ['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); + return $this->parseResponseToGenerativeAiResult( + $response, + isset($params['output_format']) && is_string($params['output_format']) ? + "image/{$params['output_format']}" : + 'image/png' + ); + } + + /** + * Prepares the given prompt and the model configuration into parameters for the API request. + * + * @since n.e.x.t + * + * @param list $prompt The prompt to generate an image for. Either a single message or a list of messages + * from a chat. However as of today, OpenAI compatible image generation endpoints only + * support a single user message. + * @return array The parameters for the API request. + */ + protected function prepareGenerateImageParams(array $prompt): array + { + $config = $this->getConfig(); + + $params = [ + 'model' => $this->metadata()->getId(), + 'prompt' => $this->preparePromptParam($prompt), + ]; + + $candidateCount = $config->getCandidateCount(); + if ($candidateCount !== null) { + $params['n'] = $candidateCount; + } + + $outputFileType = $config->getOutputFileType(); + if ($outputFileType !== null) { + $params['response_format'] = $outputFileType->isRemote() ? 'url' : 'b64_json'; + } + + $outputMimeType = $config->getOutputMimeType(); + if ($outputMimeType !== null) { + $params['output_format'] = preg_replace('/^image\//', '', $outputMimeType); + } + + $outputMediaOrientation = $config->getOutputMediaOrientation(); + $outputMediaAspectRatio = $config->getOutputMediaAspectRatio(); + if ($outputMediaOrientation !== null || $outputMediaAspectRatio !== null) { + $params['size'] = $this->prepareSizeParam($outputMediaOrientation, $outputMediaAspectRatio); + } + + /* + * 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. + */ + $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 prompt parameter for the API request. + * + * @since n.e.x.t + * + * @param list $messages The messages to prepare. However as of today, OpenAI compatible image generation + * endpoints only support a single user message. + * @return string The prepared prompt parameter. + */ + protected function preparePromptParam(array $messages): string + { + if (count($messages) !== 1) { + throw new InvalidArgumentException( + 'The API only supports a single user message as prompt.' + ); + } + $message = $messages[0]; + if (!$message->getRole()->isUser()) { + throw new InvalidArgumentException( + 'The API only supports a user message as prompt.' + ); + } + + $text = null; + foreach ($message->getParts() as $part) { + $text = $part->getText(); + if ($text !== null) { + break; + } + } + + if ($text === null) { + throw new InvalidArgumentException( + 'The API only supports a single text message part as prompt.' + ); + } + + return $text; + } + + /** + * Prepares the size parameter for the API request. + * + * @since n.e.x.t + * + * @param MediaOrientationEnum|null $orientation The desired media orientation. + * @param string|null $aspectRatio The desired media aspect ratio. + * @return string The prepared size parameter. + */ + protected function prepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string + { + // If both values are set, validate that they are compatible. + if ($orientation !== null && $aspectRatio !== null) { + if ($orientation->isSquare() && $aspectRatio !== '1:1') { + throw new InvalidArgumentException( + 'The aspect ratio "' . $aspectRatio . '" is not compatible with the square orientation.' + ); + } + $aspectRatioParts = explode(':', $aspectRatio); + if ($orientation->isLandscape() && $aspectRatioParts[0] <= $aspectRatioParts[1]) { + throw new InvalidArgumentException( + 'The aspect ratio "' . $aspectRatio . '" is not compatible with the landscape orientation.' + ); + } + if ($orientation->isPortrait() && $aspectRatioParts[0] >= $aspectRatioParts[1]) { + throw new InvalidArgumentException( + 'The aspect ratio "' . $aspectRatio . '" is not compatible with the portrait orientation.' + ); + } + } + + // Use aspect ratio if set, as it is more specific. + if ($aspectRatio !== null) { + switch ($aspectRatio) { + case '1:1': + return '1024x1024'; + case '3:2': + return '1536x1024'; + case '7:4': + return '1792x1024'; + case '2:3': + return '1024x1536'; + case '4:7': + return '1024x1792'; + default: + throw new InvalidArgumentException( + 'The aspect ratio "' . $aspectRatio . '" is not supported.' + ); + } + } + + // This should always have a value, as the method is only called if at least one or the other is set. + if ($orientation !== null) { + if ($orientation->isLandscape()) { + return '1536x1024'; + } + if ($orientation->isPortrait()) { + return '1024x1536'; + } + } + return '1024x1024'; + } + + /** + * 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 + { + ResponseUtil::throwIfNotSuccessful($response); + } + + /** + * Parses the response from the API endpoint to a generative AI result. + * + * @since n.e.x.t + * + * @param Response $response The response from the API endpoint. + * @param string $expectedMimeType The expected MIME type the response is in. + * @return GenerativeAiResult The parsed generative AI result. + */ + protected function parseResponseToGenerativeAiResult( + Response $response, + string $expectedMimeType = 'image/png' + ): GenerativeAiResult { + $responseData = $response->getData(); + if (!isset($responseData['data']) || !$responseData['data']) { + throw new RuntimeException( + 'Unexpected API response: Missing the data key.' + ); + } + if (!is_array($responseData['data'])) { + throw new RuntimeException( + 'Unexpected API response: The data key must contain an array.' + ); + } + + $candidates = []; + foreach ($responseData['data'] as $choiceData) { + if (!is_array($choiceData) || array_is_list($choiceData)) { + throw new RuntimeException( + 'Unexpected API response: Each element in the data key must be an associative array.' + ); + } + + /** @var array $choiceData */ + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $expectedMimeType); + } + + $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + + if (isset($responseData['usage']) && is_array($responseData['usage'])) { + /** @var array $usage */ + $usage = $responseData['usage']; + + $tokenUsage = new TokenUsage( + $usage['input_tokens'] ?? 0, + $usage['output_tokens'] ?? 0, + $usage['total_tokens'] ?? 0 + ); + } else { + $tokenUsage = new TokenUsage(0, 0, 0); + } + + // Use any other data from the response as provider metadata. + $providerMetadata = $responseData; + unset($providerMetadata['id'], $providerMetadata['data'], $providerMetadata['usage']); + + return new GenerativeAiResult( + $id, + $candidates, + $tokenUsage, + $providerMetadata + ); + } + + /** + * Parses a single choice from the API response into a Candidate object. + * + * @since n.e.x.t + * + * @param array $choiceData The choice data from the API response. + * @param string $expectedMimeType The expected MIME type the response is in. + * @return Candidate The parsed candidate. + * @throws RuntimeException If the choice data is invalid. + */ + protected function parseResponseChoiceToCandidate( + array $choiceData, + string $expectedMimeType = 'image/png' + ): Candidate { + if (isset($choiceData['url']) && is_string($choiceData['url'])) { + $imageFile = new File($choiceData['url'], $expectedMimeType); + } elseif (isset($choiceData['b64_json']) && is_string($choiceData['b64_json'])) { + $imageFile = new File($choiceData['b64_json'], $expectedMimeType); + } else { + throw new RuntimeException( + 'Unexpected API response: Each choice must contain either a url or b64_json key with a string value.' + ); + } + + $parts = [new MessagePart($imageFile)]; + + $message = new Message(MessageRoleEnum::model(), $parts); + + return new Candidate($message, FinishReasonEnum::stop()); + } +} From 0aa16cf207595cc61adf2125107d4b86bd0517e9 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 00:04:47 -0700 Subject: [PATCH 02/13] Fix minor incorrectness in image model metadata for Google and OpenAI. --- .../Google/GoogleModelMetadataDirectory.php | 9 ++++++--- .../OpenAi/OpenAiModelMetadataDirectory.php | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php index 00897480..599d4d6b 100644 --- a/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php +++ b/src/ProviderImplementations/Google/GoogleModelMetadataDirectory.php @@ -127,10 +127,13 @@ protected function parseResponseToModelMetadataList(Response $response): array new SupportedOption(ModelConfig::KEY_OUTPUT_FILE_TYPE, [FileTypeEnum::inline()]), new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION, [ MediaOrientationEnum::square(), - MediaOrientationEnum::landscape(), - MediaOrientationEnum::portrait(), + // The following orientations are normally supported, but not when using the OpenAI compatible endpoint. + // MediaOrientationEnum::landscape(), + // MediaOrientationEnum::portrait(), ]), - new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '16:9', '4:3', '9:16', '3:4']), + // Aspect ratio is normally supported, but not when using the OpenAI compatible endpoint. + // new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '16:9', '4:3', '9:16', '3:4']), + new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), ]; /** @var array> $modelsData */ diff --git a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php index 176e5b41..1a790641 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php +++ b/src/ProviderImplementations/OpenAi/OpenAiModelMetadataDirectory.php @@ -102,6 +102,7 @@ protected function parseResponseToModelMetadataList(Response $response): array MediaOrientationEnum::portrait(), ]), new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '7:4', '4:7']), + new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), ]; $gptImageOptions = [ new SupportedOption(ModelConfig::KEY_CANDIDATE_COUNT), @@ -113,6 +114,7 @@ protected function parseResponseToModelMetadataList(Response $response): array MediaOrientationEnum::portrait(), ]), new SupportedOption(ModelConfig::KEY_OUTPUT_MEDIA_ASPECT_RATIO, ['1:1', '3:2', '2:3']), + new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), ]; $ttsCapabilities = [ CapabilityEnum::textToSpeechConversion(), @@ -120,6 +122,7 @@ protected function parseResponseToModelMetadataList(Response $response): array $ttsOptions = [ new SupportedOption(ModelConfig::KEY_OUTPUT_MIME_TYPE, ['audio/mpeg', 'audio/ogg', 'audio/wav']), new SupportedOption(ModelConfig::KEY_OUTPUT_SPEECH_VOICE), + new SupportedOption(ModelConfig::KEY_CUSTOM_OPTIONS), ]; /** @var array> $modelsData */ From e84907eef368543300fc3fa8389434dd0d89c49c Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 00:05:23 -0700 Subject: [PATCH 03/13] Fix error about missing parameter in abstract OpenAI compatible image generation implementation. --- .../Models/AbstractOpenAiCompatibleImageGenerationModel.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php index 9ade4bce..6dabba54 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php @@ -87,6 +87,9 @@ protected function prepareGenerateImageParams(array $prompt): array $outputFileType = $config->getOutputFileType(); if ($outputFileType !== null) { $params['response_format'] = $outputFileType->isRemote() ? 'url' : 'b64_json'; + } else { + // The 'response_format' parameter is required, so we default to 'b64_json' if not set. + $params['response_format'] = 'b64_json'; } $outputMimeType = $config->getOutputMimeType(); From 1d4eb8124fddfe8345822c291b007e5606cee555 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 00:05:49 -0700 Subject: [PATCH 04/13] Allow generating images via the CLI testing tool. --- cli.php | 59 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/cli.php b/cli.php index 58f56bab..e6429226 100755 --- a/cli.php +++ b/cli.php @@ -13,6 +13,8 @@ declare(strict_types=1); +use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\Util\MessageUtil; use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; @@ -23,6 +25,7 @@ use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\DTO\RequiredOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; @@ -160,14 +163,24 @@ function logError(string $message, int $exit_code = 1): void $modelConfig = ModelConfig::fromArray($model_config_data); +if ($outputFormat === 'image-json' || $outputFormat === 'image-base64') { + $requiredCapabilities = [CapabilityEnum::imageGeneration()]; +} else { + $requiredCapabilities = [CapabilityEnum::textGeneration()]; +} + $requiredOptions = []; foreach ($modelConfig->toArray() as $option => $value) { + // Manual workarounds for enum config values. + if ($option === ModelConfig::KEY_OUTPUT_FILE_TYPE) { + $value = FileTypeEnum::from($value); + } elseif ($option === ModelConfig::KEY_OUTPUT_MEDIA_ORIENTATION) { + $value = MediaOrientationEnum::from($value); + } $requiredOptions[] = new RequiredOption($option, $value); } $modelRequirements = new ModelRequirements( - [ - CapabilityEnum::textGeneration(), - ], + $requiredCapabilities, $requiredOptions ); @@ -200,18 +213,32 @@ function logError(string $message, int $exit_code = 1): void logInfo("Using provider ID: \"{$modelInstance->providerMetadata()->getId()}\""); logInfo("Using model ID: \"{$modelInstance->metadata()->getId()}\""); -if (!($modelInstance instanceof TextGenerationModelInterface)) { - logError('The model class ' . get_class($modelInstance) . ' does not support text generation.'); -} - $modelInstance->setConfig($modelConfig); -try { - $result = $modelInstance->generateTextResult($messages); -} catch (InvalidArgumentException $e) { - logError('Invalid arguments while trying to generate text result: ' . $e->getMessage()); -} catch (ResponseException $e) { - logError('Request failed while trying to generate text result: ' . $e->getMessage()); +if ($outputFormat === 'image-json' || $outputFormat === 'image-base64') { + if (!($modelInstance instanceof ImageGenerationModelInterface)) { + logError('The model class ' . get_class($modelInstance) . ' does not support image generation.'); + } + + try { + $result = $modelInstance->generateImageResult($messages); + } catch (InvalidArgumentException $e) { + logError('Invalid arguments while trying to generate image result: ' . $e->getMessage()); + } catch (ResponseException $e) { + logError('Request failed while trying to generate image result: ' . $e->getMessage()); + } +} else { + if (!($modelInstance instanceof TextGenerationModelInterface)) { + logError('The model class ' . get_class($modelInstance) . ' does not support text generation.'); + } + + try { + $result = $modelInstance->generateTextResult($messages); + } catch (InvalidArgumentException $e) { + logError('Invalid arguments while trying to generate text result: ' . $e->getMessage()); + } catch (ResponseException $e) { + logError('Request failed while trying to generate text result: ' . $e->getMessage()); + } } switch ($outputFormat) { @@ -221,6 +248,12 @@ function logError(string $message, int $exit_code = 1): void case 'candidates-json': $output = json_encode($result->getCandidates(), JSON_PRETTY_PRINT); break; + case 'image-json': + $output = json_encode($result->toFile(), JSON_PRETTY_PRINT); + break; + case 'image-base64': + $output = $result->toFile()->getBase64Data(); + break; case 'message-text': default: $output = $result->toText(); From 4fcb9fca49597ac1cdf5c186464455ab939a4ce0 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 00:15:06 -0700 Subject: [PATCH 05/13] Add test coverage for OpenAI compatible image generation model class. --- ...ckOpenAiCompatibleImageGenerationModel.php | 103 +++ ...enAiCompatibleImageGenerationModelTest.php | 804 ++++++++++++++++++ 2 files changed, 907 insertions(+) create mode 100644 tests/mocks/MockOpenAiCompatibleImageGenerationModel.php create mode 100644 tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php diff --git a/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php b/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php new file mode 100644 index 00000000..98bcc0e9 --- /dev/null +++ b/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php @@ -0,0 +1,103 @@ + $prompt + * @return array + */ + public function exposePrepareGenerateImageParams(array $prompt): array + { + return $this->prepareGenerateImageParams($prompt); + } + + /** + * Exposes the protected preparePromptParam method. + * + * @param list $messages + * @return string + */ + public function exposePreparePromptParam(array $messages): string + { + return $this->preparePromptParam($messages); + } + + /** + * Exposes the protected prepareSizeParam method. + * + * @param MediaOrientationEnum|null $orientation + * @param string|null $aspectRatio + * @return string + */ + public function exposePrepareSizeParam(?MediaOrientationEnum $orientation, ?string $aspectRatio): string + { + return $this->prepareSizeParam($orientation, $aspectRatio); + } + + /** + * Exposes the protected throwIfNotSuccessful method. + * + * @param Response $response + */ + public function exposeThrowIfNotSuccessful(Response $response): void + { + $this->throwIfNotSuccessful($response); + } + + /** + * Exposes the protected parseResponseToGenerativeAiResult method. + * + * @param Response $response + * @param string $expectedMimeType + * @return GenerativeAiResult + */ + public function exposeParseResponseToGenerativeAiResult( + Response $response, + string $expectedMimeType = 'image/png' + ): GenerativeAiResult { + return $this->parseResponseToGenerativeAiResult($response, $expectedMimeType); + } + + /** + * Exposes the protected parseResponseChoiceToCandidate method. + * + * @param array $choiceData + * @param string $expectedMimeType + * @return \WordPress\AiClient\Results\DTO\Candidate + */ + public function exposeParseResponseChoiceToCandidate( + array $choiceData, + string $expectedMimeType = 'image/png' + ): \WordPress\AiClient\Results\DTO\Candidate { + return $this->parseResponseChoiceToCandidate($choiceData, $expectedMimeType); + } +} diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php new file mode 100644 index 00000000..6802b979 --- /dev/null +++ b/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -0,0 +1,804 @@ +modelMetadata = $this->createStub(ModelMetadata::class); + $this->modelMetadata->method('getId')->willReturn('test-image-model'); + $this->providerMetadata = $this->createStub(ProviderMetadata::class); + $this->mockHttpTransporter = $this->createMock(HttpTransporterInterface::class); + $this->mockRequestAuthentication = $this->createMock(RequestAuthenticationInterface::class); + } + + /** + * Creates a mock instance of MockOpenAiCompatibleImageGenerationModel. + * + * @param ModelConfig|null $modelConfig + * @return MockOpenAiCompatibleImageGenerationModel + */ + private function createModel(?ModelConfig $modelConfig = null): MockOpenAiCompatibleImageGenerationModel + { + $model = new MockOpenAiCompatibleImageGenerationModel( + $this->modelMetadata, + $this->providerMetadata + ); + // Explicitly set the transporter and request authentication, as the parent constructor does not set them. + $model->setHttpTransporter($this->mockHttpTransporter); + $model->setRequestAuthentication($this->mockRequestAuthentication); + if ($modelConfig) { + $model->setConfig($modelConfig); + } + return $model; + } + + /** + * Tests generateImageResult() method on success with URL output. + * + * @return void + */ + public function testGenerateImageResultSuccessWithUrlOutput(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('A cat')])]; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'image-gen-123', + 'data' => [ + [ + 'url' => 'https://example.com/cat.png', + ], + ], + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 0, + 'total_tokens' => 10, + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $modelConfig = ModelConfig::fromArray(['outputFileType' => FileTypeEnum::remote()->value]); + $model = $this->createModel($modelConfig); + $result = $model->generateImageResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('image-gen-123', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals( + 'https://example.com/cat.png', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getUrl() + ); + $this->assertEquals('image/png', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(10, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(10, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests generateImageResult() method on success with base64 JSON output. + * + * @return void + */ + public function testGenerateImageResultSuccessWithBase64JsonOutput(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('A dog')])]; + $base64Image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'image-gen-456', + 'data' => [ + [ + 'b64_json' => $base64Image, + ], + ], + 'usage' => [ + 'input_tokens' => 12, + 'output_tokens' => 0, + 'total_tokens' => 12, + ], + ]) + ); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $modelConfig = ModelConfig::fromArray(['outputFileType' => FileTypeEnum::inline()->value]); + $model = $this->createModel($modelConfig); + $result = $model->generateImageResult($prompt); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('image-gen-456', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals( + $base64Image, + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getBase64Data() + ); + $this->assertEquals('image/png', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(12, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(12, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests generateImageResult() method on API failure. + * + * @return void + */ + public function testGenerateImageResultApiFailure(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('A tree')])]; + $response = new Response(400, [], '{"error": "Bad Request"}'); + + $this->mockRequestAuthentication + ->expects($this->once()) + ->method('authenticateRequest') + ->willReturnArgument(0); + + $this->mockHttpTransporter + ->expects($this->once()) + ->method('send') + ->willReturn($response); + + $model = $this->createModel(); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Bad status code: 400. Bad Request'); + + $model->generateImageResult($prompt); + } + + /** + * Tests prepareGenerateImageParams() with basic text prompt. + * + * @return void + */ + public function testPrepareGenerateImageParamsBasicText(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test image prompt')])]; + $model = $this->createModel(); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('model', $params); + $this->assertEquals('test-image-model', $params['model']); + $this->assertArrayHasKey('prompt', $params); + $this->assertEquals('Test image prompt', $params['prompt']); + $this->assertArrayHasKey('response_format', $params); + $this->assertEquals('b64_json', $params['response_format']); + $this->assertArrayNotHasKey('n', $params); + $this->assertArrayNotHasKey('output_format', $params); + $this->assertArrayNotHasKey('size', $params); + } + + /** + * Tests prepareGenerateImageParams() with candidate count. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithCandidateCount(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['candidateCount' => 2]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('n', $params); + $this->assertEquals(2, $params['n']); + } + + /** + * Tests prepareGenerateImageParams() with remote output file type. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithRemoteOutputFileType(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputFileType' => FileTypeEnum::remote()->value]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('response_format', $params); + $this->assertEquals('url', $params['response_format']); + } + + /** + * Tests prepareGenerateImageParams() with inline output file type. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithInlineOutputFileType(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputFileType' => FileTypeEnum::inline()->value]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('response_format', $params); + $this->assertEquals('b64_json', $params['response_format']); + } + + /** + * Tests prepareGenerateImageParams() with output MIME type. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithOutputMimeType(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputMimeType' => 'image/jpeg']); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('output_format', $params); + $this->assertEquals('jpeg', $params['output_format']); + } + + /** + * Tests prepareGenerateImageParams() with output media orientation. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithOutputMediaOrientation(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputMediaOrientation' => MediaOrientationEnum::landscape()->value]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('size', $params); + $this->assertEquals('1536x1024', $params['size']); + } + + /** + * Tests prepareGenerateImageParams() with output media aspect ratio. + * + * @return void + * @dataProvider aspectRatioProvider + */ + public function testPrepareGenerateImageParamsWithOutputMediaAspectRatio(string $aspectRatio, string $expectedSize): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['outputMediaAspectRatio' => $aspectRatio]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('size', $params); + $this->assertEquals($expectedSize, $params['size']); + } + + /** + * Provides aspect ratios and their expected sizes. + * + * @return array> + */ + public function aspectRatioProvider(): array + { + return [ + '1:1' => ['1:1', '1024x1024'], + '3:2' => ['3:2', '1536x1024'], + '7:4' => ['7:4', '1792x1024'], + '2:3' => ['2:3', '1024x1536'], + '4:7' => ['4:7', '1024x1792'], + ]; + } + + /** + * Tests prepareGenerateImageParams() with custom options. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithCustomOptions(): void + { + $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; + $modelConfig = ModelConfig::fromArray(['customOptions' => ['my_custom_key' => 'my_custom_value']]); + $model = $this->createModel($modelConfig); + + $params = $model->exposePrepareGenerateImageParams($prompt); + + $this->assertArrayHasKey('my_custom_key', $params); + $this->assertEquals('my_custom_value', $params['my_custom_key']); + } + + /** + * Tests prepareGenerateImageParams() with conflicting custom option. + * + * @return void + */ + public function testPrepareGenerateImageParamsWithConflictingCustomOption(): 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->exposePrepareGenerateImageParams($prompt); + } + + /** + * Tests preparePromptParam() with a single user message. + * + * @return void + */ + public function testPreparePromptParamSingleUserMessage(): void + { + $message = new Message(MessageRoleEnum::user(), [new MessagePart('Hello image')]); + $model = $this->createModel(); + + $preparedPrompt = $model->exposePreparePromptParam([$message]); + + $this->assertEquals('Hello image', $preparedPrompt); + } + + /** + * Tests preparePromptParam() with multiple messages. + * + * @return void + */ + public function testPreparePromptParamMultipleMessages(): void + { + $messages = [ + new Message(MessageRoleEnum::user(), [new MessagePart('Hello')]), + new Message(MessageRoleEnum::model(), [new MessagePart('Hi')]), + ]; + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API only supports a single user message as prompt.'); + + $model->exposePreparePromptParam($messages); + } + + /** + * Tests preparePromptParam() with a non-user message. + * + * @return void + */ + public function testPreparePromptParamNonUserMessage(): void + { + $message = new Message(MessageRoleEnum::model(), [new MessagePart('Hello')]); + $model = $this->createModel(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API only supports a user message as prompt.'); + + $model->exposePreparePromptParam([$message]); + } + + /** + * Tests preparePromptParam() with a message without text part. + * + * @return void + */ + public function testPreparePromptParamMessageWithoutTextPart(): 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('The API only supports a single text message part as prompt.'); + + $model->exposePreparePromptParam([$message]); + } + + /** + * Tests prepareSizeParam() with square orientation and 1:1 aspect ratio. + * + * @return void + */ + public function testPrepareSizeParamSquare1x1(): void + { + $model = $this->createModel(); + $size = $model->exposePrepareSizeParam(MediaOrientationEnum::square(), '1:1'); + $this->assertEquals('1024x1024', $size); + } + + /** + * Tests prepareSizeParam() with square orientation and incompatible aspect ratio. + * + * @return void + */ + public function testPrepareSizeParamSquareIncompatibleAspectRatio(): void + { + $model = $this->createModel(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The aspect ratio "3:2" is not compatible with the square orientation.'); + $model->exposePrepareSizeParam(MediaOrientationEnum::square(), '3:2'); + } + + /** + * Tests prepareSizeParam() with landscape orientation and compatible aspect ratio. + * + * @return void + */ + public function testPrepareSizeParamLandscape3x2(): void + { + $model = $this->createModel(); + $size = $model->exposePrepareSizeParam(MediaOrientationEnum::landscape(), '3:2'); + $this->assertEquals('1536x1024', $size); + } + + /** + * Tests prepareSizeParam() with landscape orientation and incompatible aspect ratio. + * + * @return void + */ + public function testPrepareSizeParamLandscapeIncompatibleAspectRatio(): void + { + $model = $this->createModel(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The aspect ratio "2:3" is not compatible with the landscape orientation.'); + $model->exposePrepareSizeParam(MediaOrientationEnum::landscape(), '2:3'); + } + + /** + * Tests prepareSizeParam() with portrait orientation and compatible aspect ratio. + * + * @return void + */ + public function testPrepareSizeParamPortrait2x3(): void + { + $model = $this->createModel(); + $size = $model->exposePrepareSizeParam(MediaOrientationEnum::portrait(), '2:3'); + $this->assertEquals('1024x1536', $size); + } + + /** + * Tests prepareSizeParam() with portrait orientation and incompatible aspect ratio. + * + * @return void + */ + public function testPrepareSizeParamPortraitIncompatibleAspectRatio(): void + { + $model = $this->createModel(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The aspect ratio "3:2" is not compatible with the portrait orientation.'); + $model->exposePrepareSizeParam(MediaOrientationEnum::portrait(), '3:2'); + } + + /** + * Tests prepareSizeParam() with unsupported aspect ratio. + * + * @return void + */ + public function testPrepareSizeParamUnsupportedAspectRatio(): void + { + $model = $this->createModel(); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The aspect ratio "16:9" is not supported.'); + $model->exposePrepareSizeParam(null, '16:9'); + } + + /** + * Tests prepareSizeParam() with only orientation. + * + * @dataProvider orientationOnlyProvider + * @param MediaOrientationEnum $orientation + * @param string $expectedSize + * @return void + */ + public function testPrepareSizeParamOrientationOnly(MediaOrientationEnum $orientation, string $expectedSize): void + { + $model = $this->createModel(); + $size = $model->exposePrepareSizeParam($orientation, null); + $this->assertEquals($expectedSize, $size); + } + + /** + * Provides orientations and their expected sizes. + * + * @return array> + */ + public function orientationOnlyProvider(): array + { + return [ + 'square' => [MediaOrientationEnum::square(), '1024x1024'], + 'landscape' => [MediaOrientationEnum::landscape(), '1536x1024'], + 'portrait' => [MediaOrientationEnum::portrait(), '1024x1536'], + ]; + } + + /** + * Tests throwIfNotSuccessful() with a successful response. + * + * @return void + */ + public function testThrowIfNotSuccessfulSuccess(): void + { + $response = new Response(200, [], '{"status":"success"}'); + $model = $this->createModel(); + $model->exposeThrowIfNotSuccessful($response); + $this->assertTrue(true); // No exception means success. + } + + /** + * Tests throwIfNotSuccessful() with an unsuccessful response. + * + * @return void + */ + public function testThrowIfNotSuccessfulFailure(): void + { + $response = new Response(404, [], '{"error":"Not Found"}'); + $model = $this->createModel(); + + $this->expectException(ResponseException::class); + $this->expectExceptionMessage('Bad status code: 404. Not Found'); + + $model->exposeThrowIfNotSuccessful($response); + } + + /** + * Tests parseResponseToGenerativeAiResult() with valid response (URL). + * + * @return void + */ + public function testParseResponseToGenerativeAiResultValidResponseUrl(): void + { + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'test-id-url', + 'data' => [ + [ + 'url' => 'https://example.com/img.jpg', + ], + ], + 'usage' => [ + 'input_tokens' => 5, + 'output_tokens' => 0, + 'total_tokens' => 5, + ], + 'created' => 1678886400, + ]) + ); + $model = $this->createModel(); + $result = $model->exposeParseResponseToGenerativeAiResult($response, 'image/jpeg'); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('test-id-url', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals('https://example.com/img.jpg', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getUrl()); + $this->assertEquals('image/jpeg', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(5, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(5, $result->getTokenUsage()->getTotalTokens()); + $this->assertEquals(['created' => 1678886400], $result->getProviderMetadata()); + } + + /** + * Tests parseResponseToGenerativeAiResult() with valid response (b64_json). + * + * @return void + */ + public function testParseResponseToGenerativeAiResultValidResponseB64Json(): void + { + $base64Image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + $response = new Response( + 200, + [], + json_encode([ + 'id' => 'test-id-b64', + 'data' => [ + [ + 'b64_json' => $base64Image, + ], + ], + 'usage' => [ + 'input_tokens' => 7, + 'output_tokens' => 0, + 'total_tokens' => 7, + ], + ]) + ); + $model = $this->createModel(); + $result = $model->exposeParseResponseToGenerativeAiResult($response); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('test-id-b64', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals($base64Image, $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getBase64Data()); + $this->assertEquals('image/png', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); + $this->assertEquals(7, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(7, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests parseResponseToGenerativeAiResult() with missing data key. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultMissingData(): void + { + $response = new Response(200, [], json_encode(['id' => 'test-id'])); + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unexpected API response: Missing the data key.'); + + $model->exposeParseResponseToGenerativeAiResult($response); + } + + /** + * Tests parseResponseToGenerativeAiResult() with invalid data type. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultInvalidDataType(): void + { + $response = new Response( + 200, + [], + json_encode(['data' => 'invalid']) + ); + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unexpected API response: The data key must contain an array.'); + + $model->exposeParseResponseToGenerativeAiResult($response); + } + + /** + * Tests parseResponseToGenerativeAiResult() with invalid choice element type. + * + * @return void + */ + public function testParseResponseToGenerativeAiResultInvalidChoiceElementType(): void + { + $response = new Response(200, [], json_encode(['data' => ['invalid']])); + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Unexpected API response: Each element in the data key must be an associative array.' + ); + + $model->exposeParseResponseToGenerativeAiResult($response); + } + + /** + * Tests parseResponseChoiceToCandidate() with valid URL data. + * + * @return void + */ + public function testParseResponseChoiceToCandidateValidUrlData(): void + { + $choiceData = [ + 'url' => 'https://example.com/image.png', + ]; + $model = $this->createModel(); + $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData, 'image/png'); + + $this->assertInstanceOf(Candidate::class, $candidate); + $this->assertEquals('https://example.com/image.png', $candidate->getMessage()->getParts()[0]->getFile()->getUrl()); + $this->assertEquals('image/png', $candidate->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals(MessageRoleEnum::model(), $candidate->getMessage()->getRole()); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + } + + /** + * Tests parseResponseChoiceToCandidate() with valid b64_json data. + * + * @return void + */ + public function testParseResponseChoiceToCandidateValidB64JsonData(): void + { + $base64Image = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; + $choiceData = [ + 'b64_json' => $base64Image, + ]; + $model = $this->createModel(); + $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData, 'image/png'); + + $this->assertInstanceOf(Candidate::class, $candidate); + $this->assertEquals($base64Image, $candidate->getMessage()->getParts()[0]->getFile()->getBase64Data()); + $this->assertEquals('image/png', $candidate->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals(MessageRoleEnum::model(), $candidate->getMessage()->getRole()); + $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); + } + + /** + * Tests parseResponseChoiceToCandidate() with missing url or b64_json. + * + * @return void + */ + public function testParseResponseChoiceToCandidateMissingUrlOrB64Json(): void + { + $choiceData = [ + 'other_key' => 'value', + ]; + $model = $this->createModel(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Unexpected API response: Each choice must contain either a url or b64_json key with a string value.' + ); + + $model->exposeParseResponseChoiceToCandidate($choiceData); + } +} From f8daf7fba0e92e5211cf7c6ea735d92ecfd0c6e7 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 13:46:41 -0700 Subject: [PATCH 06/13] Fix PHPCS violations. --- ...enAiCompatibleImageGenerationModelTest.php | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php index 6802b979..1c0ff377 100644 --- a/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -8,8 +8,8 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\Files\DTO\File; -use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Files\Enums\MediaOrientationEnum; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; @@ -22,7 +22,6 @@ use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Tests\mocks\MockOpenAiCompatibleImageGenerationModel; @@ -130,7 +129,10 @@ public function testGenerateImageResultSuccessWithUrlOutput(): void 'https://example.com/cat.png', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getUrl() ); - $this->assertEquals('image/png', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals( + 'image/png', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType() + ); $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); $this->assertEquals(10, $result->getTokenUsage()->getPromptTokens()); $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); @@ -185,7 +187,10 @@ public function testGenerateImageResultSuccessWithBase64JsonOutput(): void $base64Image, $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getBase64Data() ); - $this->assertEquals('image/png', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals( + 'image/png', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType() + ); $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); $this->assertEquals(12, $result->getTokenUsage()->getPromptTokens()); $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); @@ -334,8 +339,10 @@ public function testPrepareGenerateImageParamsWithOutputMediaOrientation(): void * @return void * @dataProvider aspectRatioProvider */ - public function testPrepareGenerateImageParamsWithOutputMediaAspectRatio(string $aspectRatio, string $expectedSize): void - { + public function testPrepareGenerateImageParamsWithOutputMediaAspectRatio( + string $aspectRatio, + string $expectedSize + ): void { $prompt = [new Message(MessageRoleEnum::user(), [new MessagePart('Test')])]; $modelConfig = ModelConfig::fromArray(['outputMediaAspectRatio' => $aspectRatio]); $model = $this->createModel($modelConfig); @@ -453,7 +460,10 @@ public function testPreparePromptParamNonUserMessage(): void */ public function testPreparePromptParamMessageWithoutTextPart(): void { - $message = new Message(MessageRoleEnum::user(), [new MessagePart(new File('https://example.com/image.png', 'image/png'))]); + $message = new Message( + MessageRoleEnum::user(), + [new MessagePart(new File('https://example.com/image.png', 'image/png'))] + ); $model = $this->createModel(); $this->expectException(InvalidArgumentException::class); @@ -639,8 +649,14 @@ public function testParseResponseToGenerativeAiResultValidResponseUrl(): void $this->assertInstanceOf(GenerativeAiResult::class, $result); $this->assertEquals('test-id-url', $result->getId()); $this->assertCount(1, $result->getCandidates()); - $this->assertEquals('https://example.com/img.jpg', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getUrl()); - $this->assertEquals('image/jpeg', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals( + 'https://example.com/img.jpg', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getUrl() + ); + $this->assertEquals( + 'image/jpeg', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType() + ); $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); $this->assertEquals(5, $result->getTokenUsage()->getPromptTokens()); $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); @@ -679,8 +695,14 @@ public function testParseResponseToGenerativeAiResultValidResponseB64Json(): voi $this->assertInstanceOf(GenerativeAiResult::class, $result); $this->assertEquals('test-id-b64', $result->getId()); $this->assertCount(1, $result->getCandidates()); - $this->assertEquals($base64Image, $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getBase64Data()); - $this->assertEquals('image/png', $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType()); + $this->assertEquals( + $base64Image, + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getBase64Data() + ); + $this->assertEquals( + 'image/png', + $result->getCandidates()[0]->getMessage()->getParts()[0]->getFile()->getMimeType() + ); $this->assertEquals(FinishReasonEnum::stop(), $result->getCandidates()[0]->getFinishReason()); $this->assertEquals(7, $result->getTokenUsage()->getPromptTokens()); $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); @@ -755,7 +777,10 @@ public function testParseResponseChoiceToCandidateValidUrlData(): void $candidate = $model->exposeParseResponseChoiceToCandidate($choiceData, 'image/png'); $this->assertInstanceOf(Candidate::class, $candidate); - $this->assertEquals('https://example.com/image.png', $candidate->getMessage()->getParts()[0]->getFile()->getUrl()); + $this->assertEquals( + 'https://example.com/image.png', + $candidate->getMessage()->getParts()[0]->getFile()->getUrl() + ); $this->assertEquals('image/png', $candidate->getMessage()->getParts()[0]->getFile()->getMimeType()); $this->assertEquals(MessageRoleEnum::model(), $candidate->getMessage()->getRole()); $this->assertEquals(FinishReasonEnum::stop(), $candidate->getFinishReason()); From 46d31ef0cc0444ea4f7170be07f8eec10bab7e06 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 16:14:00 -0700 Subject: [PATCH 07/13] Fix image model class after GenerativeAiResult update. --- .../Models/AbstractOpenAiCompatibleImageGenerationModel.php | 2 ++ .../Models/AbstractOpenAiCompatibleImageGenerationModelTest.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php index 6dabba54..1b16518c 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php @@ -317,6 +317,8 @@ protected function parseResponseToGenerativeAiResult( $id, $candidates, $tokenUsage, + $this->providerMetadata(), + $this->metadata(), $providerMetadata ); } diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php index 1c0ff377..f9bb6849 100644 --- a/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -661,7 +661,7 @@ public function testParseResponseToGenerativeAiResultValidResponseUrl(): void $this->assertEquals(5, $result->getTokenUsage()->getPromptTokens()); $this->assertEquals(0, $result->getTokenUsage()->getCompletionTokens()); $this->assertEquals(5, $result->getTokenUsage()->getTotalTokens()); - $this->assertEquals(['created' => 1678886400], $result->getProviderMetadata()); + $this->assertEquals(['created' => 1678886400], $result->getAdditionalData()); } /** From 149225702648941b555b6b89eb7374c6a91256a4 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 16:51:32 -0700 Subject: [PATCH 08/13] Move classes around according to updated directory/namespace structure. --- .../Google/GoogleImageGenerationModel.php | 2 +- .../OpenAi/OpenAiImageGenerationModel.php | 2 +- .../AbstractOpenAiCompatibleImageGenerationModel.php | 3 ++- tests/mocks/MockOpenAiCompatibleImageGenerationModel.php | 2 +- .../AbstractOpenAiCompatibleImageGenerationModelTest.php | 4 ++-- 5 files changed, 7 insertions(+), 6 deletions(-) rename src/Providers/{Models => OpenAiCompatibleImplementation}/AbstractOpenAiCompatibleImageGenerationModel.php (98%) rename tests/unit/Providers/{Models => OpenAiCompatibleImplementation}/AbstractOpenAiCompatibleImageGenerationModelTest.php (99%) diff --git a/src/ProviderImplementations/Google/GoogleImageGenerationModel.php b/src/ProviderImplementations/Google/GoogleImageGenerationModel.php index 84a2b68f..d89df321 100644 --- a/src/ProviderImplementations/Google/GoogleImageGenerationModel.php +++ b/src/ProviderImplementations/Google/GoogleImageGenerationModel.php @@ -6,7 +6,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleImageGenerationModel; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel; /** * Class for a Google image generation model. diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 13a00dad..5cd86936 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -6,7 +6,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleImageGenerationModel; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel; /** * Class for an OpenAI image generation model. diff --git a/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php similarity index 98% rename from src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php rename to src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index 1b16518c..ba998d83 100644 --- a/src/Providers/Models/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Providers\Models; +namespace WordPress\AiClient\Providers\OpenAiCompatibleImplementation; use InvalidArgumentException; use RuntimeException; @@ -11,6 +11,7 @@ use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\MessageRoleEnum; +use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiBasedModel; use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; diff --git a/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php b/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php index 98bcc0e9..81160be9 100644 --- a/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php +++ b/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php @@ -9,7 +9,7 @@ use WordPress\AiClient\Providers\Http\DTO\Request; use WordPress\AiClient\Providers\Http\DTO\Response; use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum; -use WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleImageGenerationModel; +use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel; use WordPress\AiClient\Results\DTO\GenerativeAiResult; /** diff --git a/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php similarity index 99% rename from tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php rename to tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php index f9bb6849..89e17e9f 100644 --- a/tests/unit/Providers/Models/AbstractOpenAiCompatibleImageGenerationModelTest.php +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace WordPress\AiClient\Tests\unit\Providers\Models; +namespace WordPress\AiClient\Tests\unit\Providers\OpenAiCompatibleImplementation; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -26,7 +26,7 @@ use WordPress\AiClient\Tests\mocks\MockOpenAiCompatibleImageGenerationModel; /** - * @covers \WordPress\AiClient\Providers\Models\AbstractOpenAiCompatibleImageGenerationModel + * @covers \WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel */ class AbstractOpenAiCompatibleImageGenerationModelTest extends TestCase { From c026b977dd708744ab95b298f00f5bd6913817af Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 11:18:09 -0700 Subject: [PATCH 09/13] Add polyfills for str_starts_with and str_ends_with. --- src/polyfills.php | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/src/polyfills.php b/src/polyfills.php index 51e599ed..8d4fb645 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -14,10 +14,10 @@ * * An array is considered a list if its keys consist of consecutive numbers from 0 to count($array)-1. * + * @since n.e.x.t + * * @param array $array The array to check. * @return bool True if the array is a list, false otherwise. - * - * @since n.e.x.t */ function array_is_list(array $array): bool { @@ -36,3 +36,45 @@ function array_is_list(array $array): bool return true; } } + +if (!function_exists('str_starts_with')) { + /** + * Checks if a string starts with a given substring. + * + * @since n.e.x.t + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for. + * @return bool True if $haystack starts with $needle, false otherwise. + */ + function str_starts_with(string $haystack, string $needle): bool + { + if ( '' === $needle ) { + return true; + } + + return 0 === strpos( $haystack, $needle ); + } +} + +if (!function_exists('str_ends_with')) { + /** + * Checks if a string ends with a given substring. + * + * @since n.e.x.t + * + * @param string $haystack The string to search in. + * @param string $needle The substring to search for. + * @return bool True if $haystack ends with $needle, false otherwise. + */ + function str_ends_with(string $haystack, string $needle): bool + { + if ( '' === $haystack ) { + return '' === $needle; + } + + $len = strlen( $needle ); + + return substr( $haystack, -$len, $len ) === $needle; + } +} From 97cda2625acd03045c7cc61c406fb4e38211e48f Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 11:20:32 -0700 Subject: [PATCH 10/13] Use str_starts_with where applicable. --- cli.php | 2 +- src/Common/AbstractEnum.php | 2 +- src/Files/ValueObjects/MimeType.php | 2 +- .../OpenAi/OpenAiImageGenerationModel.php | 2 +- src/Providers/Models/Enums/OptionEnum.php | 2 +- src/polyfills.php | 10 +++++----- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/cli.php b/cli.php index 79bb2482..0af7e33c 100755 --- a/cli.php +++ b/cli.php @@ -90,7 +90,7 @@ function logError(string $message, int $exit_code = 1): void // Prompt input. Allow complex input as a JSON string. $promptInput = $positional_args[0]; -if (strpos($promptInput, '{') === 0 || strpos($promptInput, '[') === 0) { +if (str_starts_with($promptInput, '{') || str_starts_with($promptInput, '[')) { $decodedInput = json_decode($promptInput, true); if ($decodedInput) { $promptInput = $decodedInput; diff --git a/src/Common/AbstractEnum.php b/src/Common/AbstractEnum.php index 685002b9..b6db168a 100644 --- a/src/Common/AbstractEnum.php +++ b/src/Common/AbstractEnum.php @@ -328,7 +328,7 @@ protected static function determineClassEnumerations(string $className): array final public function __call(string $name, array $arguments): bool { // Handle is* methods - if (strpos($name, 'is') === 0) { + if (str_starts_with($name, 'is')) { $constantName = self::camelCaseToConstant(substr($name, 2)); $constants = static::getConstants(); diff --git a/src/Files/ValueObjects/MimeType.php b/src/Files/ValueObjects/MimeType.php index 966f7423..7420239a 100644 --- a/src/Files/ValueObjects/MimeType.php +++ b/src/Files/ValueObjects/MimeType.php @@ -204,7 +204,7 @@ public static function isValid(string $mimeType): bool */ public function isType(string $mimeType): bool { - return strpos($this->value, strtolower($mimeType) . '/') === 0; + return str_starts_with($this->value, strtolower($mimeType) . '/'); } /** diff --git a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php index 5cd86936..74dc07a1 100644 --- a/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php +++ b/src/ProviderImplementations/OpenAi/OpenAiImageGenerationModel.php @@ -39,7 +39,7 @@ protected function prepareGenerateImageParams(array $prompt): array * Only the newer 'gpt-image-' models support passing a MIME type ('output_format'). * Conversely, they do not support 'response_format', but always return a base64 encoded image. */ - if (isset($params['model']) && is_string($params['model']) && 0 === strpos($params['model'], 'gpt-image-')) { + if (isset($params['model']) && is_string($params['model']) && str_starts_with($params['model'], 'gpt-image-')) { unset($params['response_format']); } else { unset($params['output_format']); diff --git a/src/Providers/Models/Enums/OptionEnum.php b/src/Providers/Models/Enums/OptionEnum.php index 7c28f593..2d775686 100644 --- a/src/Providers/Models/Enums/OptionEnum.php +++ b/src/Providers/Models/Enums/OptionEnum.php @@ -97,7 +97,7 @@ protected static function determineClassEnumerations(string $className): array // Add ModelConfig constants that start with KEY_ foreach ($modelConfigConstants as $constantName => $constantValue) { - if (strpos($constantName, 'KEY_') === 0) { + if (str_starts_with($constantName, 'KEY_')) { // Remove KEY_ prefix to get the enum constant name $enumConstantName = substr($constantName, 4); diff --git a/src/polyfills.php b/src/polyfills.php index 8d4fb645..51db0bed 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -49,11 +49,11 @@ function array_is_list(array $array): bool */ function str_starts_with(string $haystack, string $needle): bool { - if ( '' === $needle ) { + if ('' === $needle) { return true; } - return 0 === strpos( $haystack, $needle ); + return 0 === strpos($haystack, $needle); } } @@ -69,12 +69,12 @@ function str_starts_with(string $haystack, string $needle): bool */ function str_ends_with(string $haystack, string $needle): bool { - if ( '' === $haystack ) { + if ('' === $haystack) { return '' === $needle; } - $len = strlen( $needle ); + $len = strlen($needle); - return substr( $haystack, -$len, $len ) === $needle; + return substr($haystack, -$len, $len) === $needle; } } From 27b3d66098ed03d0c8428f1b34964ae03b9ffe22 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 11:21:48 -0700 Subject: [PATCH 11/13] Clarify throwIfNotSuccessful method. --- .../AbstractOpenAiCompatibleImageGenerationModel.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index ba998d83..407f9df8 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -255,6 +255,10 @@ abstract protected function createRequest( */ 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); } From 023becac73642b4f62a12b18bd0a6a4d24127297 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Fri, 29 Aug 2025 11:26:34 -0700 Subject: [PATCH 12/13] Use response array shapes for OpenAI image generation. --- ...actOpenAiCompatibleImageGenerationModel.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index 407f9df8..9953893e 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -27,6 +27,21 @@ * Base class for an image generation model for an OpenAI compatible provider. * * @since n.e.x.t + * + * @phpstan-type ChoiceData array{ + * url?: string, + * b64_json?: string + * } + * @phpstan-type UsageData array{ + * input_tokens?: int, + * output_tokens?: int, + * total_tokens?: int + * } + * @phpstan-type ResponseData array{ + * id?: string, + * data?: list, + * usage?: UsageData + * } */ abstract class AbstractOpenAiCompatibleImageGenerationModel extends AbstractApiBasedModel implements ImageGenerationModelInterface @@ -275,6 +290,7 @@ protected function parseResponseToGenerativeAiResult( Response $response, string $expectedMimeType = 'image/png' ): GenerativeAiResult { + /** @var ResponseData $responseData */ $responseData = $response->getData(); if (!isset($responseData['data']) || !$responseData['data']) { throw new RuntimeException( @@ -295,14 +311,12 @@ protected function parseResponseToGenerativeAiResult( ); } - /** @var array $choiceData */ $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $expectedMimeType); } $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; if (isset($responseData['usage']) && is_array($responseData['usage'])) { - /** @var array $usage */ $usage = $responseData['usage']; $tokenUsage = new TokenUsage( From 11accbbdbd8ea12a7db3e6a0d125c83860ef1b20 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Fri, 29 Aug 2025 11:34:20 -0700 Subject: [PATCH 13/13] chore: updates choice data type --- .../AbstractOpenAiCompatibleImageGenerationModel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php index 9953893e..c61f4160 100644 --- a/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -347,7 +347,7 @@ protected function parseResponseToGenerativeAiResult( * * @since n.e.x.t * - * @param array $choiceData The choice data from the API response. + * @param ChoiceData $choiceData The choice data from the API response. * @param string $expectedMimeType The expected MIME type the response is in. * @return Candidate The parsed candidate. * @throws RuntimeException If the choice data is invalid.