From 0df8123711401b20f3982ac1f5034a1ec7cd1f4f Mon Sep 17 00:00:00 2001 From: Marcello Duarte Date: Fri, 31 Jul 2026 17:30:26 +0100 Subject: [PATCH] Generate images through generateContent, not Imagen generateImage() has been broken for a while. It builds an Imagen-shaped request (instances/parameters posted to :predict) and its default model, imagen-4.0-fast-generate-001, has already dropped off Google's published list. The whole Imagen line shuts down on 17 August 2026 and the :predict endpoint goes with it. The Gemini image models that replace it are reached through generateContent, asking for an image modality back. That is the same shape editImage() has always sent and parseImageResponse() has always parsed, so both methods now share one private seam where the image wire format lives. Two behaviour changes worth knowing about: - Neither method invents an aspect ratio or an image size any more. editImage() used to force 1:1 and 2K on every call, which reshaped whatever the caller passed in. Omitting them lets the model keep the source proportions. This is the same mistake the Agent's unconditional temperature of 0.7 was. - numberOfImages above 1 now throws. The Gemini image models have no equivalent of Imagen's sampleCount, so the alternative was handing back one image and pretending four had been asked for. The IMAGEN_* constants stay, all deprecated. Nothing defaults to them now, so model-watch no longer reports a deprecated default. The README documented IMAGEN_3 and IMAGEN_3_FAST, neither of which exists as a constant; copying that example was a fatal error. Both docs now list the models the class actually ships. --- README.md | 75 ++++++++---- docs/provider.md | 67 +++++++---- src/GoogleProvider.php | 189 +++++++++++++++--------------- tests/Unit/GoogleProviderTest.php | 105 +++++++++++++---- 4 files changed, 276 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 2ea3e1f..68c368c 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,12 @@ use PapiAI\Google\GoogleProvider; $provider = new GoogleProvider( apiKey: $_ENV['GOOGLE_API_KEY'], - defaultModel: GoogleProvider::MODEL_3_0_PRO, + defaultModel: GoogleProvider::MODEL_3_6_FLASH, ); $agent = new Agent( provider: $provider, - model: 'gemini-3.0-pro', + model: GoogleProvider::MODEL_3_6_FLASH, instructions: 'You are a helpful assistant.', ); @@ -33,62 +33,93 @@ echo $response->text; ## Available Models -### Gemini (Text/Chat) +### Gemini (text and chat) ```php -GoogleProvider::MODEL_3_1_PRO // 'gemini-3.1-pro' (newest) -GoogleProvider::MODEL_3_0_PRO // 'gemini-3.0-pro' (default) -GoogleProvider::MODEL_2_0_FLASH // 'gemini-2.0-flash-exp' (fast) -GoogleProvider::MODEL_1_5_PRO // 'gemini-1.5-pro' -GoogleProvider::MODEL_1_5_FLASH // 'gemini-1.5-flash' (cost-effective) +GoogleProvider::MODEL_3_6_FLASH // 'gemini-3.6-flash' (default) +GoogleProvider::MODEL_3_5_FLASH // 'gemini-3.5-flash' +GoogleProvider::MODEL_3_5_FLASH_LITE // 'gemini-3.5-flash-lite' +GoogleProvider::MODEL_3_1_PRO // 'gemini-3.1-pro-preview' +GoogleProvider::MODEL_3_FLASH // 'gemini-3-flash-preview' +GoogleProvider::MODEL_2_5_PRO // 'gemini-2.5-pro' +GoogleProvider::MODEL_2_5_FLASH // 'gemini-2.5-flash' ``` -### Imagen (Image Generation) +### Image generation and editing ```php -GoogleProvider::IMAGEN_3 // 'imagen-3.0-generate-001' (best quality) -GoogleProvider::IMAGEN_3_FAST // 'imagen-3.0-fast-generate-001' (faster) +GoogleProvider::MODEL_3_1_FLASH_IMAGE // 'gemini-3.1-flash-image' (default) +GoogleProvider::MODEL_3_1_FLASH_LITE_IMAGE // 'gemini-3.1-flash-lite-image' +GoogleProvider::MODEL_3_PRO_IMAGE // 'gemini-3-pro-image' +GoogleProvider::MODEL_2_5_FLASH_IMAGE // 'gemini-2.5-flash-image' +``` + +The Imagen constants are still here but every one of them is `@deprecated`: the Imagen line +shuts down on **17 August 2026**, and its `:predict` endpoint has no successor. Use the Gemini +image models above. + +### Video generation + +```php +GoogleProvider::MODEL_VEO_3_1 // 'veo-3.1-generate-preview' (default) +GoogleProvider::MODEL_VEO_3_1_LITE // 'veo-3.1-lite-generate-preview' ``` ## Features -- Tool/function calling +- Tool/function calling, including forced tool choice - Vision/multimodal support - Structured output (JSON mode) - Streaming support -- Image generation (Imagen 3) +- Reasoning effort, mapped to Gemini's thinking levels and budgets +- Image generation and editing +- Video generation and text embeddings ## Image Generation -Generate images using Google's Imagen 3 model: - ```php use PapiAI\Google\GoogleProvider; $provider = new GoogleProvider($_ENV['GOOGLE_API_KEY']); -// Generate image and get base64 data $result = $provider->generateImage( prompt: 'A professional product photo of headphones on a white background', options: [ - 'model' => GoogleProvider::IMAGEN_3, - 'aspectRatio' => '1:1', // 1:1, 16:9, 9:16, 4:3, 3:4 - 'numberOfImages' => 1, - 'negativePrompt' => 'blurry, low quality', + 'model' => GoogleProvider::MODEL_3_1_FLASH_IMAGE, + 'aspectRatio' => '1:1', // 1:1, 16:9, 9:16, 4:3, 3:4, and more on 3.1 Flash Image + 'imageSize' => '2K', // 1K, 2K or 4K ('0.5K' on 3.1 Flash Image) ] ); -// Access generated image $imageData = base64_decode($result['images'][0]['data']); file_put_contents('output.png', $imageData); -// Or save directly to file +// Or save straight to disk $provider->generateImageToFile( prompt: 'A modern minimalist workspace', outputPath: '/path/to/image.png' ); ``` +Leave `aspectRatio` and `imageSize` out and the model picks its own, rather than being forced +into a square. + +These models return **one image per request**. Asking for `numberOfImages` greater than one +throws a `ProviderException` instead of quietly handing back a single image; call +`generateImage()` once per image you need. + +## Image Editing + +```php +$result = $provider->editImage( + imageUrl: 'https://example.com/photo.jpg', + prompt: 'Make the sky dramatic and overcast', +); + +file_put_contents('edited.png', base64_decode($result['images'][0]['data'])); +echo $result['text']; // any commentary the model returned alongside the image +``` + ## License MIT diff --git a/docs/provider.md b/docs/provider.md index d4cbb37..28cc550 100644 --- a/docs/provider.md +++ b/docs/provider.md @@ -16,12 +16,12 @@ use PapiAI\Google\GoogleProvider; $provider = new GoogleProvider( apiKey: $_ENV['GOOGLE_API_KEY'], - defaultModel: GoogleProvider::MODEL_3_0_PRO, + defaultModel: GoogleProvider::MODEL_3_6_FLASH, ); $agent = new Agent( provider: $provider, - model: 'gemini-3.0-pro', + model: GoogleProvider::MODEL_3_6_FLASH, instructions: 'You are a helpful assistant.', ); @@ -34,43 +34,48 @@ echo $response->text; ### Chat Models ```php -GoogleProvider::MODEL_3_1_PRO // gemini-3.1-pro (newest) -GoogleProvider::MODEL_3_0_PRO // gemini-3.0-pro -GoogleProvider::MODEL_3_FLASH // gemini-3-flash -GoogleProvider::MODEL_2_5_PRO // gemini-2.5-pro -GoogleProvider::MODEL_2_5_FLASH // gemini-2.5-flash -GoogleProvider::MODEL_2_0_FLASH // gemini-2.0-flash -GoogleProvider::MODEL_1_5_PRO // gemini-1.5-pro -GoogleProvider::MODEL_1_5_FLASH // gemini-1.5-flash +GoogleProvider::MODEL_3_6_FLASH // gemini-3.6-flash (default) +GoogleProvider::MODEL_3_5_FLASH // gemini-3.5-flash +GoogleProvider::MODEL_3_5_FLASH_LITE // gemini-3.5-flash-lite +GoogleProvider::MODEL_3_1_PRO // gemini-3.1-pro-preview +GoogleProvider::MODEL_3_FLASH // gemini-3-flash-preview +GoogleProvider::MODEL_2_5_PRO // gemini-2.5-pro +GoogleProvider::MODEL_2_5_FLASH // gemini-2.5-flash +GoogleProvider::MODEL_2_5_FLASH_LITE // gemini-2.5-flash-lite +GoogleProvider::MODEL_2_0_FLASH // gemini-2.0-flash ``` -### Image Generation (Imagen) +### Image Models ```php -GoogleProvider::IMAGEN_4 // imagen-4.0-generate-001 -GoogleProvider::IMAGEN_4_ULTRA // imagen-4.0-ultra-generate-001 +GoogleProvider::MODEL_3_1_FLASH_IMAGE // gemini-3.1-flash-image (default) +GoogleProvider::MODEL_3_1_FLASH_LITE_IMAGE // gemini-3.1-flash-lite-image +GoogleProvider::MODEL_3_PRO_IMAGE // gemini-3-pro-image +GoogleProvider::MODEL_2_5_FLASH_IMAGE // gemini-2.5-flash-image ``` +The `IMAGEN_*` constants remain for backwards compatibility and are all `@deprecated`. Imagen +shuts down on 17 August 2026 and its separate `:predict` endpoint goes with it. + ## Image Generation -Generate images using Google's Imagen model: +Image generation and editing both go through `generateContent`, asking for an image modality +back. There is no separate endpoint any more. ```php use PapiAI\Google\GoogleProvider; $provider = new GoogleProvider($_ENV['GOOGLE_API_KEY']); -// Generate image and get base64 data $result = $provider->generateImage( prompt: 'A professional product photo of headphones', options: [ - 'model' => GoogleProvider::IMAGEN_4, - 'aspectRatio' => '1:1', // 1:1, 16:9, 9:16, 4:3, 3:4 - 'numberOfImages' => 1, + 'model' => GoogleProvider::MODEL_3_1_FLASH_IMAGE, + 'aspectRatio' => '1:1', // 1:1, 16:9, 9:16, 4:3, 3:4, and more on 3.1 Flash Image + 'imageSize' => '2K', // 1K, 2K or 4K ] ); -// Access generated image $imageData = base64_decode($result['images'][0]['data']); file_put_contents('output.png', $imageData); @@ -81,6 +86,25 @@ $provider->generateImageToFile( ); ``` +Both options are optional. Omit them and the model chooses, rather than being pushed into a +square by a default the caller never asked for. + +One image comes back per request. `numberOfImages` above 1 throws a `ProviderException` rather +than silently returning a single image, because the Gemini image models have no equivalent of +Imagen's `sampleCount`. + +## Image Editing + +```php +$result = $provider->editImage( + imageUrl: 'https://example.com/photo.jpg', + prompt: 'Make the sky dramatic and overcast', +); + +file_put_contents('edited.png', base64_decode($result['images'][0]['data'])); +echo $result['text']; +``` + ## Capabilities | Capability | Supported | @@ -93,9 +117,12 @@ $provider->generateImageToFile( | Embeddings | Yes | | Image generation | Yes | | Image editing | Yes | +| Video generation | Yes | +| Forced tool choice | Yes, including a named tool | +| Reasoning effort | Yes | ## Requirements - PHP 8.2+ - `ext-curl` -- `papi-ai/papi-core` ^0.14 +- `papi-ai/papi-core` ^0.15 diff --git a/src/GoogleProvider.php b/src/GoogleProvider.php index ecaf153..c8dd329 100644 --- a/src/GoogleProvider.php +++ b/src/GoogleProvider.php @@ -42,7 +42,7 @@ * Bridges PapiAI's core types (Message, Response, ToolCall) with Google's Generative Language * API, handling format conversion in both directions. Supports chat completions, streaming, * tool calling with thought signatures, vision (multimodal), structured JSON output, image - * generation/editing via Imagen, and text embeddings. + * generation and editing, video generation via Veo, and text embeddings. * * Authentication is via API key passed as a query parameter. All HTTP is done with ext-curl * directly, with no HTTP abstraction layer. @@ -50,18 +50,19 @@ * Supported model families: * * Gemini 3.x (Latest): - * - gemini-3.1-pro, gemini-3.0-pro, gemini-3-flash, gemini-3-pro-image + * - gemini-3.6-flash, gemini-3.5-flash, gemini-3.5-flash-lite + * - gemini-3.1-pro, gemini-3-flash * * Gemini 2.x: * - gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite * - gemini-2.0-flash, gemini-2.0-flash-lite * - * Gemini 1.5: - * - gemini-1.5-pro, gemini-1.5-flash + * Image generation and editing: + * - gemini-3.1-flash-image, gemini-3.1-flash-lite-image + * - gemini-3-pro-image, gemini-2.5-flash-image * - * Imagen (image generation): - * - imagen-4.0-generate-001, imagen-4.0-ultra-generate-001 - * - imagen-4.0-fast-generate-001, imagen-3.0-capability-001 + * Video generation: + * - veo-3.1-generate-preview, veo-3.1-lite-generate-preview * * @see https://ai.google.dev/gemini-api/docs * @@ -102,7 +103,8 @@ class GoogleProvider implements ProviderInterface, ImageProviderInterface, Embed /** @deprecated Retired; no longer published by Google. */ public const MODEL_1_5_FLASH = 'gemini-1.5-flash'; - // Image generation. Imagen is retired as a product line; these replace it. + // Image generation and editing, all reached through generateContent. Imagen is retired as a + // product line and its separate predict endpoint went with it; these replace both. public const MODEL_3_1_FLASH_IMAGE = 'gemini-3.1-flash-image'; public const MODEL_3_1_FLASH_LITE_IMAGE = 'gemini-3.1-flash-lite-image'; public const MODEL_2_5_FLASH_IMAGE = 'gemini-2.5-flash-image'; @@ -165,8 +167,7 @@ public function chat(array $messages, array $options = []): Response $model = $options['model'] ?? $this->defaultModel; $payload = $this->buildPayload($messages, $options); - $url = self::API_BASE . "/{$model}:generateContent?key={$this->apiKey}"; - $response = $this->request($url, $payload); + $response = $this->request($this->generateContentUrl($model), $payload); return $this->parseResponse($response, $messages); } @@ -254,7 +255,7 @@ public function getName(): string /** * Whether this provider supports image generation from text prompts. * - * Supported via Google's Imagen 4 model family through the predict endpoint. + * Supported via Gemini's native image models (e.g. gemini-3.1-flash-image). * * @return bool Always true for Google */ @@ -266,8 +267,8 @@ public function supportsImageGeneration(): bool /** * Whether this provider supports AI-powered image editing. * - * Supported via Gemini's multimodal models (e.g., gemini-3-pro-image) which - * can accept an image + text prompt and return a modified image. + * Supported via Gemini's native image models (e.g. gemini-3-pro-image) which + * can accept an image and a text prompt and return a modified image. * * @return bool Always true for Google */ @@ -277,75 +278,51 @@ public function supportsImageEditing(): bool } /** - * Generate images from a text prompt using Google's Imagen 4 API. + * Generate images from a text prompt using Gemini's native image models. * - * Sends the prompt to the Imagen predict endpoint and parses the response, - * handling both the "predictions" and "generatedImages" response formats. + * Goes through generateContent, asking for an image modality back. Imagen's separate + * predict endpoint is gone: the whole Imagen line shuts down on 17 August 2026 and the + * Gemini image models that replace it do not speak that endpoint. * * @param string $prompt Descriptive text prompt for image generation * @param array{ * model?: string, * numberOfImages?: int, * aspectRatio?: string, - * imageSize?: int, - * } $options Generation options (defaults: model=imagen-4.0-fast, 1 image, 1:1 ratio) + * imageSize?: string, + * } $options Generation options (aspect ratio and size default to the model's own) * * @return array{images: array} Base64-encoded images with MIME types * + * @throws ProviderException When more than one image is asked for, or the API returns an error * @throws AuthenticationException When the API key is invalid (HTTP 401) * @throws RateLimitException When rate limits are exceeded (HTTP 429) - * @throws ProviderException When the API returns any other error (HTTP 4xx/5xx) * @throws RuntimeException When the cURL request itself fails */ public function generateImage(string $prompt, array $options = []): array { - // Imagen, not one of the Gemini image models: the request below is Imagen-shaped - // (instances/parameters on :predict) and the Gemini models do not speak that endpoint. - // Imagen shuts down 17 August 2026, so this path needs replacing before then. - $model = $options['model'] ?? self::IMAGEN_4_FAST; - $numberOfImages = $options['numberOfImages'] ?? 1; - $aspectRatio = $options['aspectRatio'] ?? '1:1'; - - // Imagen 4 uses the predict endpoint with instances/parameters format - $payload = [ - 'instances' => [ - ['prompt' => $prompt], - ], - 'parameters' => [ - 'sampleCount' => $numberOfImages, - 'aspectRatio' => $aspectRatio, - 'outputOptions' => [ - 'mimeType' => 'image/png', - ], - ], - ]; + $numberOfImages = (int) ($options['numberOfImages'] ?? 1); - $url = self::API_BASE . "/{$model}:predict?key={$this->apiKey}"; - $response = $this->request($url, $payload); - - $images = []; - - // Imagen returns predictions with bytesBase64Encoded - foreach ($response['predictions'] ?? [] as $prediction) { - if (isset($prediction['bytesBase64Encoded'])) { - $images[] = [ - 'mimeType' => $prediction['mimeType'] ?? 'image/png', - 'data' => $prediction['bytesBase64Encoded'], - ]; - } + // Imagen's sampleCount has no equivalent here, so a caller asking for four images + // would silently receive one. Refusing is the same posture as Cohere declining to + // fake a named tool: better a loud failure than a quiet one. + if ($numberOfImages > 1) { + throw new ProviderException( + sprintf( + 'Gemini image models return one image per request, so "numberOfImages" of %d cannot be honoured. Call generateImage() once per image instead.', + $numberOfImages, + ), + $this->getName(), + ); } - // Also check generateImages response format - foreach ($response['generatedImages'] ?? [] as $image) { - if (isset($image['image']['imageBytes'])) { - $images[] = [ - 'mimeType' => 'image/png', - 'data' => $image['image']['imageBytes'], - ]; - } - } + $result = $this->requestImage( + [['text' => $prompt]], + $options['model'] ?? self::MODEL_3_1_FLASH_IMAGE, + $options, + ); - return ['images' => $images]; + return ['images' => $result['images']]; } /** @@ -361,8 +338,8 @@ public function generateImage(string $prompt, array $options = []): array * @param array{ * model?: string, * aspectRatio?: string, - * imageSize?: int, - * } $options Edit options (defaults: model=gemini-3-pro-image, 1:1 ratio, 2K size) + * imageSize?: string, + * } $options Edit options (aspect ratio and size default to the source image's own) * * @return array{images: array, text: string} Edited images and any descriptive text * @@ -379,42 +356,68 @@ public function editImage(string $imageUrl, string $prompt, array $options = []) throw new RuntimeException("Failed to fetch image from: {$imageUrl}"); } - $base64Image = base64_encode($imageData); - $mimeType = $this->detectMimeType($imageUrl, $imageData); - - $model = $options['model'] ?? self::MODEL_3_PRO_IMAGE; - $aspectRatio = $options['aspectRatio'] ?? '1:1'; - $imageSize = $options['imageSize'] ?? '2K'; - - $payload = [ - 'contents' => [ - [ - 'parts' => [ - [ - 'inlineData' => [ - 'mimeType' => $mimeType, - 'data' => $base64Image, - ], - ], - ['text' => $prompt], - ], - ], - ], - 'generationConfig' => [ - 'responseModalities' => ['TEXT', 'IMAGE'], - 'imageConfig' => [ - 'aspectRatio' => $aspectRatio, - 'imageSize' => $imageSize, + $parts = [ + [ + 'inlineData' => [ + 'mimeType' => $this->detectMimeType($imageUrl, $imageData), + 'data' => base64_encode($imageData), ], ], + ['text' => $prompt], ]; - $url = self::API_BASE . "/{$model}:generateContent?key={$this->apiKey}"; - $response = $this->request($url, $payload); + return $this->requestImage($parts, $options['model'] ?? self::MODEL_3_PRO_IMAGE, $options); + } + + /** + * Ask a Gemini image model for an image and parse what comes back. + * + * Generation and editing differ only in the parts they send, so the wire format for both + * lives here: image output is opt-in through responseModalities, and the shape and size + * knobs sit in imageConfig. + * + * @param array> $parts The content parts, text alone or an image and text + * @param string $model The image model to call + * @param array $options The caller's options, read for aspectRatio and imageSize + * + * @return array{images: array, text: string} Images and any descriptive text + * + * @throws AuthenticationException When the API key is invalid (HTTP 401) + * @throws RateLimitException When rate limits are exceeded (HTTP 429) + * @throws ProviderException When the API returns any other error (HTTP 4xx/5xx) + * @throws RuntimeException When the cURL request itself fails + */ + private function requestImage(array $parts, string $model, array $options): array + { + $generationConfig = ['responseModalities' => ['TEXT', 'IMAGE']]; + + // Only sent when asked for. Inventing a 1:1 default used to reshape whatever the + // caller passed in, which is the same mistake as the Agent's invented temperature. + $imageConfig = array_filter([ + 'aspectRatio' => $options['aspectRatio'] ?? null, + 'imageSize' => $options['imageSize'] ?? null, + ], static fn ($value) => $value !== null); + + if ($imageConfig !== []) { + $generationConfig['imageConfig'] = $imageConfig; + } + + $response = $this->request($this->generateContentUrl($model), [ + 'contents' => [['parts' => $parts]], + 'generationConfig' => $generationConfig, + ]); return $this->parseImageResponse($response); } + /** + * The generateContent endpoint for a model, with the API key Google expects on the query string. + */ + private function generateContentUrl(string $model): string + { + return self::API_BASE . "/{$model}:generateContent?key={$this->apiKey}"; + } + /** * Whether this provider supports video generation from text prompts. * @@ -946,7 +949,7 @@ private function detectMimeType(string $url, string $data): string * model?: string, * numberOfImages?: int, * aspectRatio?: string, - * imageSize?: int, + * imageSize?: string, * } $options Generation options passed through to generateImage() * * @return string The output path where the image was saved diff --git a/tests/Unit/GoogleProviderTest.php b/tests/Unit/GoogleProviderTest.php index 3c8d280..358468f 100644 --- a/tests/Unit/GoogleProviderTest.php +++ b/tests/Unit/GoogleProviderTest.php @@ -16,6 +16,7 @@ use PapiAI\Core\Contracts\ImageProviderInterface; use PapiAI\Core\Contracts\ProviderInterface; use PapiAI\Core\EmbeddingResponse; +use PapiAI\Core\Exception\ProviderException; use PapiAI\Core\Message; use PapiAI\Core\Response; use PapiAI\Core\StreamChunk; @@ -494,51 +495,97 @@ public function callThrowForStatusCode(int $httpCode, ?array $data): never }); describe('generateImage', function () { - it('sends prompt to imagen predict endpoint', function () { + beforeEach(function () { $this->provider->fakeResponse = [ - 'predictions' => [ + 'candidates' => [ [ - 'bytesBase64Encoded' => base64_encode('fake-png-data'), - 'mimeType' => 'image/png', + 'content' => [ + 'parts' => [ + [ + 'inlineData' => [ + 'mimeType' => 'image/png', + 'data' => base64_encode('fake-png-data'), + ], + ], + ], + ], ], ], ]; + }); + it('generates through generateContent, which is where the Gemini image models live', function () { $result = $this->provider->generateImage('A cat'); - expect($this->provider->lastUrl)->toContain(':predict'); - expect($this->provider->lastPayload['instances'][0]['prompt'])->toBe('A cat'); + expect($this->provider->lastUrl)->toContain(':generateContent'); + expect($this->provider->lastPayload['contents'][0]['parts'][0]['text'])->toBe('A cat'); expect($result['images'])->toHaveCount(1); expect($result['images'][0]['mimeType'])->toBe('image/png'); }); - it('handles generatedImages response format', function () { + it('defaults to a Gemini image model, not the retired Imagen line', function () { + $this->provider->generateImage('A cat'); + + expect($this->provider->lastUrl)->toContain(GoogleProvider::MODEL_3_1_FLASH_IMAGE); + expect($this->provider->lastUrl)->not->toContain('imagen'); + }); + + it('asks for an image back, which the model will not return by default', function () { + $this->provider->generateImage('A cat'); + + expect($this->provider->lastPayload['generationConfig']['responseModalities']) + ->toBe(['TEXT', 'IMAGE']); + }); + + it('passes the aspect ratio and size through imageConfig', function () { + $this->provider->generateImage('A cat', [ + 'model' => GoogleProvider::MODEL_3_PRO_IMAGE, + 'aspectRatio' => '16:9', + 'imageSize' => '4K', + ]); + + expect($this->provider->lastUrl)->toContain(GoogleProvider::MODEL_3_PRO_IMAGE); + expect($this->provider->lastPayload['generationConfig']['imageConfig']) + ->toBe(['aspectRatio' => '16:9', 'imageSize' => '4K']); + }); + + it('leaves out imageConfig entirely when the caller asks for neither', function () { + $this->provider->generateImage('A cat'); + + expect($this->provider->lastPayload['generationConfig'])->not->toHaveKey('imageConfig'); + }); + + it('drops the model thoughts and keeps the finished image', function () { $this->provider->fakeResponse = [ - 'generatedImages' => [ + 'candidates' => [ [ - 'image' => ['imageBytes' => base64_encode('fake-data')], + 'content' => [ + 'parts' => [ + ['thought' => true, 'inlineData' => ['mimeType' => 'image/png', 'data' => 'draft']], + ['inlineData' => ['mimeType' => 'image/png', 'data' => 'final']], + ], + ], ], ], ]; - $result = $this->provider->generateImage('A dog'); + $result = $this->provider->generateImage('A cat'); expect($result['images'])->toHaveCount(1); - expect($result['images'][0]['mimeType'])->toBe('image/png'); + expect($result['images'][0]['data'])->toBe('final'); }); - it('passes custom options', function () { - $this->provider->fakeResponse = ['predictions' => []]; + // The Gemini image models return one image per request and offer no equivalent of + // Imagen's sampleCount. Handing back a single image would quietly ignore the ask. + it('refuses a request for several images rather than returning one', function () { + expect(fn () => $this->provider->generateImage('A cat', ['numberOfImages' => 4])) + ->toThrow(ProviderException::class, 'one image per request'); + }); - $this->provider->generateImage('A cat', [ - 'model' => 'imagen-4.0-ultra-generate-001', - 'numberOfImages' => 2, - 'aspectRatio' => '16:9', - ]); + it('accepts numberOfImages of 1, which it can honour', function () { + $result = $this->provider->generateImage('A cat', ['numberOfImages' => 1]); - expect($this->provider->lastUrl)->toContain('imagen-4.0-ultra-generate-001'); - expect($this->provider->lastPayload['parameters']['sampleCount'])->toBe(2); - expect($this->provider->lastPayload['parameters']['aspectRatio'])->toBe('16:9'); + expect($result['images'])->toHaveCount(1); }); }); @@ -693,10 +740,18 @@ public function callThrowForStatusCode(int $httpCode, ?array $data): never describe('generateImageToFile', function () { it('saves generated image to file', function () { $this->provider->fakeResponse = [ - 'predictions' => [ + 'candidates' => [ [ - 'bytesBase64Encoded' => base64_encode('fake-png-data'), - 'mimeType' => 'image/png', + 'content' => [ + 'parts' => [ + [ + 'inlineData' => [ + 'mimeType' => 'image/png', + 'data' => base64_encode('fake-png-data'), + ], + ], + ], + ], ], ], ]; @@ -713,7 +768,7 @@ public function callThrowForStatusCode(int $httpCode, ?array $data): never }); it('throws when no images generated', function () { - $this->provider->fakeResponse = ['predictions' => []]; + $this->provider->fakeResponse = ['candidates' => []]; expect(fn () => $this->provider->generateImageToFile('A cat', '/tmp/test.png')) ->toThrow(RuntimeException::class, 'No images generated');