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');