From af5e00d8ad70623288dcfbdf6f86b597a91fa3aa Mon Sep 17 00:00:00 2001 From: Denis Zunke Date: Mon, 6 Jan 2025 18:43:14 +0100 Subject: [PATCH] feat: Implement DALL-E image generation for OpenAI Bridge (#178) * Implement Dall-E image generation * Adjust some testcases for styling * Rename GeneratedImagesResponse to ImagesResponse * Some review adjustments --- examples/image-generator-dall-e-2.php | 29 ++++++ examples/image-generator-dall-e-3.php | 37 ++++++++ src/Bridge/OpenAI/DallE.php | 31 ++++++ src/Bridge/OpenAI/DallE/Base64Image.php | 16 ++++ src/Bridge/OpenAI/DallE/ImageResponse.php | 28 ++++++ src/Bridge/OpenAI/DallE/ModelClient.php | 66 +++++++++++++ src/Bridge/OpenAI/DallE/UrlImage.php | 16 ++++ src/Bridge/OpenAI/PlatformFactory.php | 15 ++- tests/Bridge/OpenAI/DallE/Base64ImageTest.php | 34 +++++++ .../Bridge/OpenAI/DallE/ImageResponseTest.php | 56 +++++++++++ tests/Bridge/OpenAI/DallE/ModelClientTest.php | 95 +++++++++++++++++++ tests/Bridge/OpenAI/DallE/UrlImageTest.php | 33 +++++++ tests/Bridge/OpenAI/DallETest.php | 34 +++++++ 13 files changed, 488 insertions(+), 2 deletions(-) create mode 100644 examples/image-generator-dall-e-2.php create mode 100644 examples/image-generator-dall-e-3.php create mode 100644 src/Bridge/OpenAI/DallE.php create mode 100644 src/Bridge/OpenAI/DallE/Base64Image.php create mode 100644 src/Bridge/OpenAI/DallE/ImageResponse.php create mode 100644 src/Bridge/OpenAI/DallE/ModelClient.php create mode 100644 src/Bridge/OpenAI/DallE/UrlImage.php create mode 100644 tests/Bridge/OpenAI/DallE/Base64ImageTest.php create mode 100644 tests/Bridge/OpenAI/DallE/ImageResponseTest.php create mode 100644 tests/Bridge/OpenAI/DallE/ModelClientTest.php create mode 100644 tests/Bridge/OpenAI/DallE/UrlImageTest.php create mode 100644 tests/Bridge/OpenAI/DallETest.php diff --git a/examples/image-generator-dall-e-2.php b/examples/image-generator-dall-e-2.php new file mode 100644 index 0000000..2544fac --- /dev/null +++ b/examples/image-generator-dall-e-2.php @@ -0,0 +1,29 @@ +loadEnv(dirname(__DIR__).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); + +$response = $platform->request( + model: new DallE(), + input: 'A cartoon-style elephant with a long trunk and large ears.', + options: [ + 'version' => DallE::DALL_E_2, // Utilize Dall-E 2 version + 'response_format' => 'url', // Generate response as URL + 'n' => 2, // Generate multiple images for example + ], +); + +foreach ($response->getContent() as $index => $image) { + echo 'Image '.$index.': '.$image->url.PHP_EOL; +} diff --git a/examples/image-generator-dall-e-3.php b/examples/image-generator-dall-e-3.php new file mode 100644 index 0000000..fd2693a --- /dev/null +++ b/examples/image-generator-dall-e-3.php @@ -0,0 +1,37 @@ +loadEnv(dirname(__DIR__).'/.env'); + +if (empty($_ENV['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.PHP_EOL; + exit(1); +} + +$platform = PlatformFactory::create($_ENV['OPENAI_API_KEY']); + +$response = $platform->request( + model: new DallE(), + input: 'A cartoon-style elephant with a long trunk and large ears.', + options: [ + 'version' => DallE::DALL_E_3, // Utilize Dall-E 3 version + ], +); + +if ($response instanceof AsyncResponse) { + $response = $response->unwrap(); +} + +assert($response instanceof ImageResponse); + +echo 'Revised Prompt: '.$response->revisedPrompt.PHP_EOL.PHP_EOL; + +foreach ($response->getContent() as $index => $image) { + echo 'Image '.$index.': '.$image->url.PHP_EOL; +} diff --git a/src/Bridge/OpenAI/DallE.php b/src/Bridge/OpenAI/DallE.php new file mode 100644 index 0000000..1db9555 --- /dev/null +++ b/src/Bridge/OpenAI/DallE.php @@ -0,0 +1,31 @@ + $options The default options for the model usage */ + public function __construct( + private string $version = self::DALL_E_2, + private array $options = [], + ) { + } + + public function getVersion(): string + { + return $this->version; + } + + /** @return array */ + public function getOptions(): array + { + return $this->options; + } +} diff --git a/src/Bridge/OpenAI/DallE/Base64Image.php b/src/Bridge/OpenAI/DallE/Base64Image.php new file mode 100644 index 0000000..d1f8c90 --- /dev/null +++ b/src/Bridge/OpenAI/DallE/Base64Image.php @@ -0,0 +1,16 @@ + */ + private readonly array $images; + + public function __construct( + public ?string $revisedPrompt = null, // Only string on Dall-E 3 usage + Base64Image|UrlImage ...$images, + ) { + $this->images = \array_values($images); + } + + /** + * @return list + */ + public function getContent(): array + { + return $this->images; + } +} diff --git a/src/Bridge/OpenAI/DallE/ModelClient.php b/src/Bridge/OpenAI/DallE/ModelClient.php new file mode 100644 index 0000000..d9fcdaf --- /dev/null +++ b/src/Bridge/OpenAI/DallE/ModelClient.php @@ -0,0 +1,66 @@ +httpClient->request('POST', 'https://api.openai.com/v1/images/generations', [ + 'auth_bearer' => $this->apiKey, + 'json' => \array_merge($options, [ + 'model' => $model->getVersion(), + 'prompt' => $input, + ]), + ]); + } + + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + $response = $response->toArray(); + if (!isset($response['data'][0])) { + throw new \RuntimeException('No image generated.'); + } + + $images = []; + foreach ($response['data'] as $image) { + if ('url' === $options['response_format']) { + $images[] = new UrlImage($image['url']); + + continue; + } + + $images[] = new Base64Image($image['b64_json']); + } + + return new ImageResponse($image['revised_prompt'] ?? null, ...$images); + } +} diff --git a/src/Bridge/OpenAI/DallE/UrlImage.php b/src/Bridge/OpenAI/DallE/UrlImage.php new file mode 100644 index 0000000..a9b2d8d --- /dev/null +++ b/src/Bridge/OpenAI/DallE/UrlImage.php @@ -0,0 +1,16 @@ +encodedImage); + } + + #[Test] + public function itThrowsExceptionWhenBase64ImageIsEmpty(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The base64 encoded image generated must be given.'); + + new Base64Image(''); + } +} diff --git a/tests/Bridge/OpenAI/DallE/ImageResponseTest.php b/tests/Bridge/OpenAI/DallE/ImageResponseTest.php new file mode 100644 index 0000000..abda0dd --- /dev/null +++ b/tests/Bridge/OpenAI/DallE/ImageResponseTest.php @@ -0,0 +1,56 @@ +revisedPrompt); + self::assertCount(1, $generatedImagesResponse->getContent()); + self::assertSame($base64Image, $generatedImagesResponse->getContent()[0]); + } + + #[Test] + public function itCreatesImagesResponseWithRevisedPrompt(): void + { + $base64Image = new Base64Image('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='); + $generatedImagesResponse = new ImageResponse('revised prompt', $base64Image); + + self::assertSame('revised prompt', $generatedImagesResponse->revisedPrompt); + self::assertCount(1, $generatedImagesResponse->getContent()); + self::assertSame($base64Image, $generatedImagesResponse->getContent()[0]); + } + + #[Test] + public function itIsCreatableWithMultipleImages(): void + { + $image1 = new UrlImage('https://example'); + $image2 = new UrlImage('https://example2'); + + $generatedImagesResponse = new ImageResponse(null, $image1, $image2); + + self::assertCount(2, $generatedImagesResponse->getContent()); + self::assertSame($image1, $generatedImagesResponse->getContent()[0]); + self::assertSame($image2, $generatedImagesResponse->getContent()[1]); + } +} diff --git a/tests/Bridge/OpenAI/DallE/ModelClientTest.php b/tests/Bridge/OpenAI/DallE/ModelClientTest.php new file mode 100644 index 0000000..75f9602 --- /dev/null +++ b/tests/Bridge/OpenAI/DallE/ModelClientTest.php @@ -0,0 +1,95 @@ +supports(new DallE(), 'foo')); + } + + #[Test] + public function itIsExecutingTheCorrectRequest(): void + { + $responseCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.openai.com/v1/images/generations', $url); + self::assertSame('Authorization: Bearer sk-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"n":1,"response_format":"url","model":"dall-e-2","prompt":"foo"}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$responseCallback]); + $modelClient = new ModelClient($httpClient, 'sk-api-key'); + $modelClient->request(new DallE(), 'foo', ['n' => 1, 'response_format' => 'url']); + } + + #[Test] + public function itIsConvertingTheResponse(): void + { + $httpResponse = self::createStub(HttpResponse::class); + $httpResponse->method('toArray')->willReturn([ + 'data' => [ + ['url' => 'https://example.com/image.jpg'], + ], + ]); + + $modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key'); + $response = $modelClient->convert($httpResponse, ['response_format' => 'url']); + + self::assertCount(1, $response->getContent()); + self::assertInstanceOf(UrlImage::class, $response->getContent()[0]); + self::assertSame('https://example.com/image.jpg', $response->getContent()[0]->url); + } + + #[Test] + public function itIsConvertingTheResponseWithRevisedPrompt(): void + { + $emptyPixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + $httpResponse = self::createStub(HttpResponse::class); + $httpResponse->method('toArray')->willReturn([ + 'data' => [ + ['b64_json' => $emptyPixel, 'revised_prompt' => 'revised prompt'], + ], + ]); + + $modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key'); + $response = $modelClient->convert($httpResponse, ['response_format' => 'b64_json']); + + self::assertInstanceOf(ImageResponse::class, $response); + self::assertCount(1, $response->getContent()); + self::assertInstanceOf(Base64Image::class, $response->getContent()[0]); + self::assertSame($emptyPixel, $response->getContent()[0]->encodedImage); + self::assertSame('revised prompt', $response->revisedPrompt); + } +} diff --git a/tests/Bridge/OpenAI/DallE/UrlImageTest.php b/tests/Bridge/OpenAI/DallE/UrlImageTest.php new file mode 100644 index 0000000..904041e --- /dev/null +++ b/tests/Bridge/OpenAI/DallE/UrlImageTest.php @@ -0,0 +1,33 @@ +url); + } + + #[Test] + public function itThrowsExceptionWhenUrlIsEmpty(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The image url must be given.'); + + new UrlImage(''); + } +} diff --git a/tests/Bridge/OpenAI/DallETest.php b/tests/Bridge/OpenAI/DallETest.php new file mode 100644 index 0000000..45cf908 --- /dev/null +++ b/tests/Bridge/OpenAI/DallETest.php @@ -0,0 +1,34 @@ +getVersion()); + self::assertSame([], $dallE->getOptions()); + } + + #[Test] + public function itCreatesDallEWithCustomSettings(): void + { + $dallE = new DallE(DallE::DALL_E_3, ['response_format' => 'base64', 'n' => 2]); + + self::assertSame(DallE::DALL_E_3, $dallE->getVersion()); + self::assertSame(['response_format' => 'base64', 'n' => 2], $dallE->getOptions()); + } +}