diff --git a/cli.php b/cli.php index 17f913f9..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; @@ -156,7 +156,11 @@ function logError(string $message, int $exit_code = 1): void } try { - $result = $promptBuilder->generateTextResult(); + if ($outputFormat === 'image-json' || $outputFormat === 'image-base64') { + $result = $promptBuilder->generateImageResult(); + } else { + $result = $promptBuilder->generateTextResult(); + } } catch (InvalidArgumentException $e) { logError('Invalid arguments while trying to generate text result: ' . $e->getMessage()); } catch (ResponseException $e) { @@ -173,6 +177,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(); 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/Google/GoogleImageGenerationModel.php b/src/ProviderImplementations/Google/GoogleImageGenerationModel.php new file mode 100644 index 00000000..d89df321 --- /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..74dc07a1 --- /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/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/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php new file mode 100644 index 00000000..c61f4160 --- /dev/null +++ b/src/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModel.php @@ -0,0 +1,375 @@ +, + * usage?: UsageData + * } + */ +abstract class AbstractOpenAiCompatibleImageGenerationModel extends AbstractApiBasedModel implements + ImageGenerationModelInterface +{ + /** + * @inheritDoc + */ + public function generateImageResult(array $prompt): GenerativeAiResult + { + $httpTransporter = $this->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'; + } else { + // The 'response_format' parameter is required, so we default to 'b64_json' if not set. + $params['response_format'] = '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 + { + /* + * 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. + * + * @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 { + /** @var ResponseData $responseData */ + $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.' + ); + } + + $candidates[] = $this->parseResponseChoiceToCandidate($choiceData, $expectedMimeType); + } + + $id = isset($responseData['id']) && is_string($responseData['id']) ? $responseData['id'] : ''; + + if (isset($responseData['usage']) && is_array($responseData['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, + $this->providerMetadata(), + $this->metadata(), + $providerMetadata + ); + } + + /** + * Parses a single choice from the API response into a Candidate object. + * + * @since n.e.x.t + * + * @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. + */ + 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()); + } +} diff --git a/src/polyfills.php b/src/polyfills.php index 892c2d80..51db0bed 100644 --- a/src/polyfills.php +++ b/src/polyfills.php @@ -17,7 +17,6 @@ * @since n.e.x.t * * @param array $array The array to check. - * * @return bool True if the array is a list, false otherwise. */ function array_is_list(array $array): bool @@ -37,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; + } +} diff --git a/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php b/tests/mocks/MockOpenAiCompatibleImageGenerationModel.php new file mode 100644 index 00000000..81160be9 --- /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/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php new file mode 100644 index 00000000..89e17e9f --- /dev/null +++ b/tests/unit/Providers/OpenAiCompatibleImplementation/AbstractOpenAiCompatibleImageGenerationModelTest.php @@ -0,0 +1,829 @@ +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->getAdditionalData()); + } + + /** + * 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); + } +}