Skip to content

Commit

Permalink
feat: Implement DALL-E image generation for OpenAI Bridge (#178)
Browse files Browse the repository at this point in the history
* Implement Dall-E image generation

* Adjust some testcases for styling

* Rename GeneratedImagesResponse to ImagesResponse

* Some review adjustments
  • Loading branch information
DZunke authored Jan 6, 2025
1 parent 7779a13 commit af5e00d
Show file tree
Hide file tree
Showing 13 changed files with 488 additions and 2 deletions.
29 changes: 29 additions & 0 deletions examples/image-generator-dall-e-2.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

use PhpLlm\LlmChain\Bridge\OpenAI\DallE;
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
use Symfony\Component\Dotenv\Dotenv;

require_once dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->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;
}
37 changes: 37 additions & 0 deletions examples/image-generator-dall-e-3.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

use PhpLlm\LlmChain\Bridge\OpenAI\DallE;
use PhpLlm\LlmChain\Bridge\OpenAI\DallE\ImageResponse;
use PhpLlm\LlmChain\Bridge\OpenAI\PlatformFactory;
use PhpLlm\LlmChain\Model\Response\AsyncResponse;
use Symfony\Component\Dotenv\Dotenv;

require_once dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->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;
}
31 changes: 31 additions & 0 deletions src/Bridge/OpenAI/DallE.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\OpenAI;

use PhpLlm\LlmChain\Model\Model;

final readonly class DallE implements Model
{
public const DALL_E_2 = 'dall-e-2';
public const DALL_E_3 = 'dall-e-3';

/** @param array<string, mixed> $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<string, mixed> */
public function getOptions(): array
{
return $this->options;
}
}
16 changes: 16 additions & 0 deletions src/Bridge/OpenAI/DallE/Base64Image.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE;

use Webmozart\Assert\Assert;

final readonly class Base64Image
{
public function __construct(
public string $encodedImage,
) {
Assert::stringNotEmpty($encodedImage, 'The base64 encoded image generated must be given.');
}
}
28 changes: 28 additions & 0 deletions src/Bridge/OpenAI/DallE/ImageResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE;

use PhpLlm\LlmChain\Model\Response\ResponseInterface;

class ImageResponse implements ResponseInterface
{
/** @var list<Base64Image|UrlImage> */
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<Base64Image|UrlImage>
*/
public function getContent(): array
{
return $this->images;
}
}
66 changes: 66 additions & 0 deletions src/Bridge/OpenAI/DallE/ModelClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE;

use PhpLlm\LlmChain\Bridge\OpenAI\DallE;
use PhpLlm\LlmChain\Model\Model;
use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse;
use PhpLlm\LlmChain\Platform\ModelClient as PlatformResponseFactory;
use PhpLlm\LlmChain\Platform\ResponseConverter as PlatformResponseConverter;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
use Webmozart\Assert\Assert;

/**
* @see https://platform.openai.com/docs/api-reference/images/create
*/
final readonly class ModelClient implements PlatformResponseFactory, PlatformResponseConverter
{
public function __construct(
private HttpClientInterface $httpClient,
#[\SensitiveParameter]
private string $apiKey,
) {
Assert::stringNotEmpty($apiKey, 'The API key must not be empty.');
Assert::startsWith($apiKey, 'sk-', 'The API key must start with "sk-".');
}

public function supports(Model $model, array|string|object $input): bool
{
return $model instanceof DallE;
}

public function request(Model $model, object|array|string $input, array $options = []): HttpResponse
{
return $this->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);
}
}
16 changes: 16 additions & 0 deletions src/Bridge/OpenAI/DallE/UrlImage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\OpenAI\DallE;

use Webmozart\Assert\Assert;

final readonly class UrlImage
{
public function __construct(
public string $url,
) {
Assert::stringNotEmpty($url, 'The image url must be given.');
}
}
15 changes: 13 additions & 2 deletions src/Bridge/OpenAI/PlatformFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace PhpLlm\LlmChain\Bridge\OpenAI;

use PhpLlm\LlmChain\Bridge\OpenAI\DallE\ModelClient as DallEModelClient;
use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings\ModelClient as EmbeddingsModelClient;
use PhpLlm\LlmChain\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter;
use PhpLlm\LlmChain\Bridge\OpenAI\GPT\ModelClient as GPTModelClient;
Expand All @@ -21,9 +22,19 @@ public static function create(
): Platform {
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);

$dallEModelClient = new DallEModelClient($httpClient, $apiKey);

return new Platform(
[new GPTModelClient($httpClient, $apiKey), new EmbeddingsModelClient($httpClient, $apiKey)],
[new GPTResponseConverter(), new EmbeddingsResponseConverter()],
[
new GPTModelClient($httpClient, $apiKey),
new EmbeddingsModelClient($httpClient, $apiKey),
$dallEModelClient,
],
[
new GPTResponseConverter(),
new EmbeddingsResponseConverter(),
$dallEModelClient,
],
);
}
}
34 changes: 34 additions & 0 deletions tests/Bridge/OpenAI/DallE/Base64ImageTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\DallE;

use PhpLlm\LlmChain\Bridge\OpenAI\DallE\Base64Image;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

#[CoversClass(Base64Image::class)]
#[Small]
final class Base64ImageTest extends TestCase
{
#[Test]
public function itCreatesBase64Image(): void
{
$emptyPixel = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
$base64Image = new Base64Image($emptyPixel);

self::assertSame($emptyPixel, $base64Image->encodedImage);
}

#[Test]
public function itThrowsExceptionWhenBase64ImageIsEmpty(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The base64 encoded image generated must be given.');

new Base64Image('');
}
}
56 changes: 56 additions & 0 deletions tests/Bridge/OpenAI/DallE/ImageResponseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Tests\Bridge\OpenAI\DallE;

use PhpLlm\LlmChain\Bridge\OpenAI\DallE\Base64Image;
use PhpLlm\LlmChain\Bridge\OpenAI\DallE\ImageResponse;
use PhpLlm\LlmChain\Bridge\OpenAI\DallE\UrlImage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Small;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\UsesClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(ImageResponse::class)]
#[UsesClass(Base64Image::class)]
#[UsesClass(UrlImage::class)]
#[Small]
final class ImageResponseTest extends TestCase
{
#[Test]
public function itCreatesImagesResponse(): void
{
$base64Image = new Base64Image('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
$generatedImagesResponse = new ImageResponse(null, $base64Image);

self::assertNull($generatedImagesResponse->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]);
}
}
Loading

0 comments on commit af5e00d

Please sign in to comment.