diff --git a/README.md b/README.md index 8909a915..3e519b1b 100644 --- a/README.md +++ b/README.md @@ -1293,6 +1293,24 @@ foreach ($response->data as $data) { $response->toArray(); // ['created' => 1589478378, data => ['url' => 'https://oaidalleapiprodscus...', ...]] ``` +#### `create streamed` + +When you create an image with stream set to true, the server will emit server-sent events to the client as the image is generated. All events and their payloads can be found in [OpenAI docs](https://platform.openai.com/docs/api-reference/images-streaming). + +```php +$stream = $client->images()->createStreamed([ + 'model' => 'gpt-image-1', + 'prompt' => 'A cute baby sea otter', + 'n' => 1, + 'size' => '1024x1024', + 'response_format' => 'url', +]); + +foreach ($stream as $image) { + $image->type; // 'image_generation.partial_image' +} +``` + #### `edit` Creates an edited or extended image given an original image and a prompt. @@ -1317,6 +1335,24 @@ foreach ($response->data as $data) { $response->toArray(); // ['created' => 1589478378, data => ['url' => 'https://oaidalleapiprodscus...', ...]] ``` +#### `edit streamed` + +When you edit an image with stream set to true, the server will emit server-sent events to the client as the image is generated. All events and their payloads can be found in [OpenAI docs](https://platform.openai.com/docs/api-reference/images-streaming). + +```php +$stream = $client->images()->editStreamed([ + 'model' => 'gpt-image-1', + 'prompt' => 'A cute baby sea otter', + 'n' => 1, + 'size' => '1024x1024', + 'response_format' => 'url', +]); + +foreach ($stream as $image) { + $image->type; // 'image_generation.partial_image' +} +``` + #### `variation` Creates a variation of a given image. diff --git a/src/Resources/Images.php b/src/Resources/Images.php index 2aa73a67..0b78b072 100644 --- a/src/Resources/Images.php +++ b/src/Resources/Images.php @@ -6,13 +6,17 @@ use OpenAI\Contracts\Resources\ImagesContract; use OpenAI\Responses\Images\CreateResponse; +use OpenAI\Responses\Images\CreateStreamedResponse; use OpenAI\Responses\Images\EditResponse; +use OpenAI\Responses\Images\EditStreamedResponse; use OpenAI\Responses\Images\VariationResponse; +use OpenAI\Responses\StreamResponse; use OpenAI\ValueObjects\Transporter\Payload; use OpenAI\ValueObjects\Transporter\Response; final class Images implements ImagesContract { + use Concerns\Streamable; use Concerns\Transportable; /** @@ -24,6 +28,8 @@ final class Images implements ImagesContract */ public function create(array $parameters): CreateResponse { + $this->ensureNotStreamed($parameters); + $payload = Payload::create('images/generations', $parameters); /** @var Response, usage?: array{total_tokens: int, input_tokens: int, output_tokens: int, input_tokens_details: array{text_tokens: int, image_tokens: int}}}> $response */ @@ -32,6 +38,25 @@ public function create(array $parameters): CreateResponse return CreateResponse::from($response->data(), $response->meta()); } + /** + * Creates a streamed image given a prompt. + * + * @see https://platform.openai.com/docs/api-reference/images/create + * + * @param array $parameters + * @return StreamResponse + */ + public function createStreamed(array $parameters): StreamResponse + { + $parameters = $this->setStreamParameter($parameters); + + $payload = Payload::create('images/generations', $parameters); + + $response = $this->transporter->requestStream($payload); + + return new StreamResponse(CreateStreamedResponse::class, $response); + } + /** * Creates an edited or extended image given an original image and a prompt. * @@ -49,6 +74,24 @@ public function edit(array $parameters): EditResponse return EditResponse::from($response->data(), $response->meta()); } + /** + * Creates a streamed image edit given a prompt. + * + * @see https://platform.openai.com/docs/api-reference/images/create + * + * @param array $parameters + * @return StreamResponse + */ + public function editStreamed(array $parameters): StreamResponse + { + $parameters = $this->setStreamParameter($parameters, 'true'); // Ensure the parameter is a string for upload + + $payload = Payload::upload('images/edits', $parameters); + $response = $this->transporter->requestStream($payload); + + return new StreamResponse(EditStreamedResponse::class, $response); + } + /** * Creates a variation of a given image. * diff --git a/src/Responses/Images/CreateStreamedResponse.php b/src/Responses/Images/CreateStreamedResponse.php new file mode 100644 index 00000000..7dbd4671 --- /dev/null +++ b/src/Responses/Images/CreateStreamedResponse.php @@ -0,0 +1,66 @@ +} + * + * @implements ResponseContract + */ +final class CreateStreamedResponse implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use FakeableForStreamedResponse; + + private function __construct( + public readonly string $event, + public readonly ImageGenerationPartialImage|ImageGenerationCompleted|Error $response, + ) {} + + /** + * @param array $attributes + */ + public static function from(array $attributes): self + { + $event = $attributes['type'] ?? throw new UnknownEventException('Missing event type in streamed response'); + $meta = $attributes['__meta']; + unset($attributes['__meta']); + + $response = match ($event) { + 'image_generation.partial_image' => ImageGenerationPartialImage::from($attributes, $meta), // @phpstan-ignore-line + 'image_generation.completed' => ImageGenerationCompleted::from($attributes, $meta), // @phpstan-ignore-line + 'error' => Error::from($attributes, $meta), // @phpstan-ignore-line + default => throw new UnknownEventException('Unknown Images streaming event: '.$event), + }; + + return new self( + event: $event, // @phpstan-ignore-line + response: $response, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'event' => $this->event, + 'data' => $this->response->toArray(), + ]; + } +} diff --git a/src/Responses/Images/EditStreamedResponse.php b/src/Responses/Images/EditStreamedResponse.php new file mode 100644 index 00000000..3b3c85ab --- /dev/null +++ b/src/Responses/Images/EditStreamedResponse.php @@ -0,0 +1,66 @@ +} + * + * @implements ResponseContract + */ +final class EditStreamedResponse implements ResponseContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use FakeableForStreamedResponse; + + private function __construct( + public readonly string $event, + public readonly ImageGenerationPartialImage|ImageGenerationCompleted|Error $response, + ) {} + + /** + * @param array $attributes + */ + public static function from(array $attributes): self + { + $event = $attributes['type'] ?? throw new UnknownEventException('Missing event type in streamed response'); + $meta = $attributes['__meta']; + unset($attributes['__meta']); + + $response = match ($event) { + 'image_edit.partial_image' => ImageGenerationPartialImage::from($attributes, $meta), // @phpstan-ignore-line + 'image_edit.completed' => ImageGenerationCompleted::from($attributes, $meta), // @phpstan-ignore-line + 'error' => Error::from($attributes, $meta), // @phpstan-ignore-line + default => throw new UnknownEventException('Unknown Images streaming event: '.$event), + }; + + return new self( + event: $event, // @phpstan-ignore-line + response: $response, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'event' => $this->event, + 'data' => $this->response->toArray(), + ]; + } +} diff --git a/src/Responses/Images/ImageResponseUsage.php b/src/Responses/Images/ImageResponseUsage.php index 4681dcb2..aeb8e4a3 100644 --- a/src/Responses/Images/ImageResponseUsage.php +++ b/src/Responses/Images/ImageResponseUsage.php @@ -14,7 +14,7 @@ private function __construct( ) {} /** - * @param array{total_tokens: int, input_tokens?: int, output_tokens?: int, input_tokens_details?: array{text_tokens: int, image_tokens: int}} $attributes + * @param array{total_tokens: int, input_tokens?: int|null, output_tokens?: int|null, input_tokens_details?: array{text_tokens: int, image_tokens: int}|null} $attributes */ public static function from(array $attributes): self { diff --git a/src/Responses/Images/Streaming/Error.php b/src/Responses/Images/Streaming/Error.php new file mode 100644 index 00000000..c5921784 --- /dev/null +++ b/src/Responses/Images/Streaming/Error.php @@ -0,0 +1,63 @@ + + */ +final class Error implements ResponseContract, ResponseHasMetaInformationContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + use HasMetaInformation; + + private function __construct( + public readonly string $type, + public readonly ?string $code, + public readonly string $message, + public readonly ?string $param, + private readonly MetaInformation $meta, + ) {} + + /** + * @param ErrorType $attributes + */ + public static function from(array $attributes, MetaInformation $meta): self + { + return new self( + type: $attributes['type'], + code: $attributes['code'], + message: $attributes['message'], + param: $attributes['param'], + meta: $meta, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'code' => $this->code, + 'message' => $this->message, + 'param' => $this->param, + ]; + } +} diff --git a/src/Responses/Images/Streaming/ImageGenerationCompleted.php b/src/Responses/Images/Streaming/ImageGenerationCompleted.php new file mode 100644 index 00000000..6287d142 --- /dev/null +++ b/src/Responses/Images/Streaming/ImageGenerationCompleted.php @@ -0,0 +1,81 @@ + + */ +final class ImageGenerationCompleted implements ResponseContract, ResponseHasMetaInformationContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + use HasMetaInformation; + + private function __construct( + public readonly string $type, + public readonly string $b64Json, + public readonly int $createdAt, + public readonly string $size, + public readonly string $quality, + public readonly string $background, + public readonly string $outputFormat, + private readonly MetaInformation $meta, + public readonly ?ImageResponseUsage $usage = null, + ) {} + + /** + * @param ImageGenerationCompletedType $attributes + */ + public static function from(array $attributes, MetaInformation $meta): self + { + return new self( + type: $attributes['type'], + b64Json: $attributes['b64_json'], + createdAt: $attributes['created_at'], + size: $attributes['size'], + quality: $attributes['quality'], + background: $attributes['background'], + outputFormat: $attributes['output_format'], + meta: $meta, + usage: isset($attributes['usage']) ? ImageResponseUsage::from($attributes['usage']) : null, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + $result = [ + 'type' => $this->type, + 'b64_json' => $this->b64Json, + 'created_at' => $this->createdAt, + 'size' => $this->size, + 'quality' => $this->quality, + 'background' => $this->background, + 'output_format' => $this->outputFormat, + ]; + + if ($this->usage !== null) { + $result['usage'] = $this->usage->toArray(); + } + + return $result; + } +} diff --git a/src/Responses/Images/Streaming/ImageGenerationPartialImage.php b/src/Responses/Images/Streaming/ImageGenerationPartialImage.php new file mode 100644 index 00000000..11c6c573 --- /dev/null +++ b/src/Responses/Images/Streaming/ImageGenerationPartialImage.php @@ -0,0 +1,75 @@ + + */ +final class ImageGenerationPartialImage implements ResponseContract, ResponseHasMetaInformationContract +{ + /** + * @use ArrayAccessible + */ + use ArrayAccessible; + + use Fakeable; + use HasMetaInformation; + + private function __construct( + public readonly string $type, + public readonly string $b64Json, + public readonly int $createdAt, + public readonly string $size, + public readonly string $quality, + public readonly string $background, + public readonly string $outputFormat, + public readonly int $partialImageIndex, + private readonly MetaInformation $meta, + ) {} + + /** + * @param ImageGenerationPartialImageType $attributes + */ + public static function from(array $attributes, MetaInformation $meta): self + { + return new self( + type: $attributes['type'], + b64Json: $attributes['b64_json'], + createdAt: $attributes['created_at'], + size: $attributes['size'], + quality: $attributes['quality'], + background: $attributes['background'], + outputFormat: $attributes['output_format'], + partialImageIndex: $attributes['partial_image_index'], + meta: $meta, + ); + } + + /** + * {@inheritDoc} + */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'b64_json' => $this->b64Json, + 'created_at' => $this->createdAt, + 'size' => $this->size, + 'quality' => $this->quality, + 'background' => $this->background, + 'output_format' => $this->outputFormat, + 'partial_image_index' => $this->partialImageIndex, + ]; + } +} diff --git a/tests/Fixtures/Image.php b/tests/Fixtures/Image.php index 5e79371b..042b4f40 100644 --- a/tests/Fixtures/Image.php +++ b/tests/Fixtures/Image.php @@ -219,6 +219,22 @@ function imageVariationWithUsage(): array ]; } +/** + * @return resource + */ +function imageCreateStream() +{ + return fopen(__DIR__.'/Streams/ImageCreate.txt', 'r'); +} + +/** + * @return resource + */ +function imageEditStream() +{ + return fopen(__DIR__.'/Streams/ImageEdit.txt', 'r'); +} + /** * @return array */ diff --git a/tests/Fixtures/Streams/ImageCreate.txt b/tests/Fixtures/Streams/ImageCreate.txt new file mode 100644 index 00000000..4b2c055c --- /dev/null +++ b/tests/Fixtures/Streams/ImageCreate.txt @@ -0,0 +1,3 @@ +data: {"type":"image_generation.partial_image","b64_json":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=","created_at":1719946519,"size":"1024x1024","quality":"low","background":"opaque","output_format":"png","partial_image_index":0} +data: {"type":"image_generation.completed","b64_json":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=","created_at":1719946520,"size":"1024x1024","quality":"low","background":"opaque","output_format":"png","partial_image_index":1,"usage":{"total_tokens":100,"input_tokens":50,"output_tokens":50,"input_tokens_details":{"text_tokens":10,"image_tokens":40}}} +data: [DONE] diff --git a/tests/Fixtures/Streams/ImageEdit.txt b/tests/Fixtures/Streams/ImageEdit.txt new file mode 100644 index 00000000..b92aa78e --- /dev/null +++ b/tests/Fixtures/Streams/ImageEdit.txt @@ -0,0 +1,3 @@ +data: {"type":"image_edit.partial_image","b64_json":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=","created_at":1719946519,"size":"512x512","quality":"medium","background":"transparent","output_format":"webp","partial_image_index":0} +data: {"type":"image_edit.completed","b64_json":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=","created_at":1719946520,"size":"512x512","quality":"medium","background":"transparent","output_format":"webp","partial_image_index":1,"usage":{"total_tokens":200,"input_tokens":120,"output_tokens":80,"input_tokens_details":{"text_tokens":40,"image_tokens":80}}} +data: [DONE] diff --git a/tests/Resources/Images.php b/tests/Resources/Images.php index a09959b5..70c88df7 100644 --- a/tests/Resources/Images.php +++ b/tests/Resources/Images.php @@ -1,14 +1,21 @@ toBeInstanceOf(MetaInformation::class); }); +test('create streamed', function () { + $response = new Response( + headers: metaHeaders(), + body: new Stream(imageCreateStream()), + ); + + $client = mockStreamClient('POST', 'images/generations', [ + 'prompt' => 'A cute baby sea otter', + 'n' => 1, + 'size' => '256x256', + 'response_format' => 'b64_json', + 'stream' => true, + ], $response); + + $result = $client->images()->createStreamed([ + 'prompt' => 'A cute baby sea otter', + 'n' => 1, + 'size' => '256x256', + 'response_format' => 'b64_json', + ]); + + expect($result) + ->toBeInstanceOf(StreamResponse::class) + ->toBeInstanceOf(IteratorAggregate::class); + + $first = $result->getIterator()->current(); + + expect($first) + ->toBeInstanceOf(CreateStreamedResponse::class) + ->event->toBe('image_generation.partial_image'); + + expect($first->response) + ->toBeInstanceOf(ImageGenerationPartialImage::class) + ->b64Json->toBe('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=') + ->createdAt->toBe(1719946519) + ->partialImageIndex->toBe(0); + + $iterator = iterator_to_array($result->getIterator()); + $last = end($iterator); + + expect($last->response) + ->toBeInstanceOf(ImageGenerationCompleted::class) + ->usage->toBeInstanceOf(ImageResponseUsage::class) + ->usage->totalTokens->toBe(100) + ->usage->inputTokens->toBe(50) + ->usage->outputTokens->toBe(50) + ->usage->inputTokensDetails->toBeInstanceOf(ImageResponseUsageInputTokensDetails::class) + ->usage->inputTokensDetails->textTokens->toBe(10) + ->usage->inputTokensDetails->imageTokens->toBe(40); + + expect($result->meta()) + ->toBeInstanceOf(MetaInformation::class); +}); + test('edit', function () { $client = mockClient('POST', 'images/edits', [ 'image' => fileResourceResource(), @@ -146,6 +207,64 @@ ->toBeInstanceOf(MetaInformation::class); }); +test('edit streamed', function () { + $response = new Response( + headers: metaHeaders(), + body: new Stream(imageEditStream()), + ); + + $client = mockStreamClient('POST', 'images/edits', [ + 'image' => fileResourceResource(), + 'mask' => fileResourceResource(), + 'prompt' => 'A sunlit indoor lounge area with a pool containing a flamingo', + 'n' => 1, + 'size' => '256x256', + 'response_format' => 'b64_json', + 'stream' => 'true', + ], $response, validateParams: false); + + $result = $client->images()->editStreamed([ + 'image' => fileResourceResource(), + 'mask' => fileResourceResource(), + 'prompt' => 'A sunlit indoor lounge area with a pool containing a flamingo', + 'n' => 1, + 'size' => '256x256', + 'response_format' => 'b64_json', + ]); + + expect($result) + ->toBeInstanceOf(StreamResponse::class) + ->toBeInstanceOf(IteratorAggregate::class); + + $first = $result->getIterator()->current(); + + expect($first) + ->toBeInstanceOf(EditStreamedResponse::class) + ->event->toBe('image_edit.partial_image'); + + expect($first->response) + ->toBeInstanceOf(ImageGenerationPartialImage::class) + ->b64Json->toBe('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=') + ->createdAt->toBe(1719946519) + ->partialImageIndex->toBe(0); + + $iterator = iterator_to_array($result->getIterator()); + $last = end($iterator); + + expect($last->response) + ->toBeInstanceOf(ImageGenerationCompleted::class) + ->usage->toBeInstanceOf(ImageResponseUsage::class) + ->usage->totalTokens->toBe(200) + ->usage->inputTokens->toBe(120) + ->usage->outputTokens->toBe(80) + ->usage->inputTokensDetails->toBeInstanceOf(ImageResponseUsageInputTokensDetails::class) + ->usage->inputTokensDetails->textTokens->toBe(40) + ->usage->inputTokensDetails->imageTokens->toBe(80); + + expect($result->meta()) + ->toBeInstanceOf(MetaInformation::class); +}); + test('variation', function () { $client = mockClient('POST', 'images/variations', [ 'image' => fileResourceResource(),