diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..711e4cf --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [aydinfatih,Plytas] diff --git a/README.md b/README.md index 4d1488b..7f63347 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,24 @@ $result = $client $result->text(); // The picture shows a table with a white tablecloth. On the table are two cups of coffee, a bowl of blueberries, a silver spoon, and some flowers. There are also some blueberry scones on the table. ``` +#### Image Generation +Generate images from text prompts using the Imagen model. + +```php +use Gemini\Data\ImageConfig; +use Gemini\Data\GenerationConfig; + +$imageConfig = new ImageConfig(aspectRatio: '16:9'); +$generationConfig = new GenerationConfig(imageConfig: $imageConfig); + +$response = $client->generativeModel(model: 'gemini-2.5-flash-image') + ->withGenerationConfig($generationConfig) + ->generateContent('Draw a futuristic city'); + +// Save the image +file_put_contents('image.png', base64_decode($response->parts()[0]->inlineData->data)); +``` + #### Multi-turn Conversations (Chat) Using Gemini, you can build freeform conversations across multiple turns. @@ -249,6 +267,20 @@ $response = $chat->sendMessage('Rewrite the same story in 1600s England'); echo $response->text(); // In the heart of England's lush countryside, amidst emerald fields and thatched-roof cottages, the village of Willowbrook unfolded a tapestry of love, mystery, and the enchantment of ordinary days in the 17th century. ``` +#### Chat with Streaming +You can also stream the response in a chat session. The history is automatically updated with the full response after the stream completes. + +```php +$chat = $client->generativeModel(model: 'gemini-2.0-flash')->startChat(); + +$stream = $chat->streamSendMessage('Hello'); + +foreach ($stream as $response) { + echo $response->text(); +} +``` + + #### Stream Generate Content By default, the model returns a response after completing the entire generation process. You can achieve faster interactions by not waiting for the entire result, and instead use streaming to handle partial results. @@ -343,7 +375,8 @@ function handleFunctionCall(FunctionCall $functionCall): Content functionResponse: new FunctionResponse( name: 'addition', response: ['answer' => $functionCall->args['number1'] + $functionCall->args['number2']], - ) + ), + thoughtSignature: 'some-signature' // Optional: Required for some models (e.g. Gemini 3 Pro) ) ], role: Role::USER @@ -382,6 +415,7 @@ $chat = $client $response = $chat->sendMessage('What is 4 + 3?'); if ($response->parts()[0]->functionCall !== null) { + $thoughtSignature = $response->parts()[0]->thoughtSignature; // Access the thought signature $functionResponse = handleFunctionCall($response->parts()[0]->functionCall); $response = $chat->sendMessage($functionResponse); @@ -390,6 +424,7 @@ if ($response->parts()[0]->functionCall !== null) { echo $response->text(); // 4 + 3 = 7 ``` + #### Speech generation Gemini allows generating [speech from a text](https://ai.google.dev/gemini-api/docs/speech-generation). To use that, make sure to use a model that supports this functionality. The model will output base64 encoded audio string. diff --git a/composer.json b/composer.json index 9ae2e9b..e12a855 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,9 @@ "natural", "language", "processing", - "NLP" + "NLP", + "banana", + "google" ], "authors": [ { @@ -28,10 +30,10 @@ "require-dev": { "guzzlehttp/guzzle": "^7.8.1", "guzzlehttp/psr7": "^2.6.2", - "pestphp/pest": "^2.30", + "pestphp/pest": "^2.32.3", "laravel/pint": "^1.18.1", "mockery/mockery": "^1.6.7", - "phpstan/phpstan": "^1.10" + "phpstan/phpstan": "^2.1.32" }, "suggest": { "ext-fileinfo": "Reads upload file size and mime type if not provided" diff --git a/src/Contracts/Resources/ChatSessionContract.php b/src/Contracts/Resources/ChatSessionContract.php index 1d5d5d1..2554f3f 100644 --- a/src/Contracts/Resources/ChatSessionContract.php +++ b/src/Contracts/Resources/ChatSessionContract.php @@ -6,14 +6,22 @@ use Gemini\Data\Blob; use Gemini\Data\Content; +use Gemini\Data\UploadedFile; use Gemini\Responses\GenerativeModel\GenerateContentResponse; +use Generator; interface ChatSessionContract { /** * @param string|Blob|array|Content ...$parts */ - public function sendMessage(string|Blob|array|Content ...$parts): GenerateContentResponse; + public function sendMessage(string|Blob|array|Content|UploadedFile ...$parts): GenerateContentResponse; + + /** + * @param string|Blob|array|Content|UploadedFile ...$parts + * @return \Generator + */ + public function streamSendMessage(string|Blob|array|Content|UploadedFile ...$parts): Generator; /** * @param array $history diff --git a/src/Enums/BlockReason.php b/src/Enums/BlockReason.php index 08834d8..f0f7ab6 100644 --- a/src/Enums/BlockReason.php +++ b/src/Enums/BlockReason.php @@ -11,6 +11,11 @@ */ enum BlockReason: string { + /** + * The blocked reason is unspecified. + */ + case BLOCKED_REASON_UNSPECIFIED = 'BLOCKED_REASON_UNSPECIFIED'; + /** * Default value. This value is unused. */ @@ -40,4 +45,14 @@ enum BlockReason: string * Candidates blocked due to unsafe image generation content. */ case IMAGE_SAFETY = 'IMAGE_SAFETY'; + + /** + * The prompt was blocked by Model Armor. + */ + case MODEL_ARMOR = 'MODEL_ARMOR'; + + /** + * The prompt was blocked as a jailbreak attempt. + */ + case JAILBREAK = 'JAILBREAK'; } diff --git a/src/Enums/FinishReason.php b/src/Enums/FinishReason.php index 2e11f70..ff2c9ef 100644 --- a/src/Enums/FinishReason.php +++ b/src/Enums/FinishReason.php @@ -100,4 +100,9 @@ enum FinishReason: string * Model called too many tools consecutively, thus the system exited execution. */ case TOO_MANY_TOOL_CALLS = 'TOO_MANY_TOOL_CALLS'; + + /** + * The model response was blocked by Model Armor. + */ + case MODEL_ARMOR = 'MODEL_ARMOR'; } diff --git a/src/Exceptions/ErrorException.php b/src/Exceptions/ErrorException.php index 28697a8..3c49b7b 100644 --- a/src/Exceptions/ErrorException.php +++ b/src/Exceptions/ErrorException.php @@ -31,7 +31,7 @@ public function getErrorMessage(): string /** * Returns the error status. */ - public function getErrorStatus(): ?string + public function getErrorStatus(): string { return $this->contents['status']; } @@ -39,7 +39,7 @@ public function getErrorStatus(): ?string /** * Returns the error code. */ - public function getErrorCode(): string|int|null + public function getErrorCode(): int { return $this->contents['code']; } diff --git a/src/Requests/GenerativeModel/GenerateContentRequest.php b/src/Requests/GenerativeModel/GenerateContentRequest.php index a59655c..01e003e 100644 --- a/src/Requests/GenerativeModel/GenerateContentRequest.php +++ b/src/Requests/GenerativeModel/GenerateContentRequest.php @@ -53,17 +53,17 @@ protected function defaultBody(): array { return [ 'contents' => array_map( - static fn (Content $content): array => $content->toArray(), - $this->partsToContents(...$this->parts) + callback: static fn (Content $content): array => $content->toArray(), + array: $this->partsToContents(...$this->parts) ), 'tools' => array_map( - static fn (Tool $tool): array => $tool->toArray(), - $this->tools ?? [] + callback: static fn (Tool $tool): array => $tool->toArray(), + array: $this->tools ), 'toolConfig' => $this->toolConfig?->toArray(), 'safetySettings' => array_map( - static fn (SafetySetting $setting): array => $setting->toArray(), - $this->safetySettings ?? [] + callback: static fn (SafetySetting $setting): array => $setting->toArray(), + array: $this->safetySettings ), 'systemInstruction' => $this->systemInstruction?->toArray(), 'generationConfig' => $this->generationConfig?->toArray(), diff --git a/src/Requests/GenerativeModel/StreamGenerateContentRequest.php b/src/Requests/GenerativeModel/StreamGenerateContentRequest.php index 6fc78dd..a86f0b2 100644 --- a/src/Requests/GenerativeModel/StreamGenerateContentRequest.php +++ b/src/Requests/GenerativeModel/StreamGenerateContentRequest.php @@ -56,17 +56,17 @@ protected function defaultBody(): array { return [ 'contents' => array_map( - static fn (Content $content): array => $content->toArray(), - $this->partsToContents(...$this->parts) + callback: static fn (Content $content): array => $content->toArray(), + array: $this->partsToContents(...$this->parts) ), 'tools' => array_map( - static fn (Tool $tool): array => $tool->toArray(), - $this->tools ?? [] + callback: static fn (Tool $tool): array => $tool->toArray(), + array: $this->tools ), 'toolConfig' => $this->toolConfig?->toArray(), 'safetySettings' => array_map( - static fn (SafetySetting $setting): array => $setting->toArray(), - $this->safetySettings ?? [] + callback: static fn (SafetySetting $setting): array => $setting->toArray(), + array: $this->safetySettings ), 'systemInstruction' => $this->systemInstruction?->toArray(), 'generationConfig' => $this->generationConfig?->toArray(), diff --git a/src/Resources/ChatSession.php b/src/Resources/ChatSession.php index 7d50d2e..822a9a4 100644 --- a/src/Resources/ChatSession.php +++ b/src/Resources/ChatSession.php @@ -8,8 +8,11 @@ use Gemini\Contracts\Resources\ChatSessionContract; use Gemini\Data\Blob; use Gemini\Data\Content; +use Gemini\Data\Part; use Gemini\Data\UploadedFile; +use Gemini\Enums\Role; use Gemini\Responses\GenerativeModel\GenerateContentResponse; +use Generator; /** * https://ai.google.dev/tutorials/rest_quickstart#multi-turn_conversations_chat @@ -43,6 +46,47 @@ public function sendMessage(string|Blob|array|Content|UploadedFile ...$parts): G return $response; } + /** + * @param string|Blob|array|Content|UploadedFile ...$parts + * @return Generator + */ + public function streamSendMessage(string|Blob|array|Content|UploadedFile ...$parts): Generator + { + $this->history = array_merge($this->history, $this->partsToContents(...$parts)); + + $stream = $this->model->streamGenerateContent(...$this->history); + + /** @var array $parts */ + $parts = []; + + foreach ($stream as $response) { + /** @var GenerateContentResponse $response */ + if (empty($response->candidates) === false) { + foreach ($response->parts() as $index => $part) { + if (isset($parts[$index]) === false) { + $parts[$index] = $part; + + continue; + } + + $parts[$index] = Part::from( + attributes: array_merge( /* @phpstan-ignore argument.type */ + $parts[$index]->toArray(), + ['text' => ($parts[$index]->text ?? '').($part->text ?? '')] + ) + ); + } + } + + yield $response; + } + + $this->history[] = new Content( + parts: $parts, + role: Role::MODEL, + ); + } + /** * @param array $history */ diff --git a/src/Resources/GenerativeModel.php b/src/Resources/GenerativeModel.php index f6fb3a6..7769731 100644 --- a/src/Resources/GenerativeModel.php +++ b/src/Resources/GenerativeModel.php @@ -133,6 +133,8 @@ public function generateContent(string|Blob|array|Content|UploadedFile ...$parts * Generates a streamed response from the model given an input GenerateContentRequest. * * @see https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent + * + * @return StreamResponse */ public function streamGenerateContent(string|Blob|array|Content|UploadedFile ...$parts): StreamResponse { diff --git a/src/Responses/Files/MetadataResponse.php b/src/Responses/Files/MetadataResponse.php index e55cf68..3e32173 100644 --- a/src/Responses/Files/MetadataResponse.php +++ b/src/Responses/Files/MetadataResponse.php @@ -20,7 +20,7 @@ public function __construct( public readonly string $name, public readonly string $displayName, public readonly string $mimeType, - public readonly string $sizeBytes, + public readonly ?string $sizeBytes, public readonly string $createTime, public readonly string $updateTime, public readonly string $expirationTime, @@ -31,7 +31,7 @@ public function __construct( ) {} /** - * @param array{ name: string, displayName: string, mimeType: string, sizeBytes: string, createTime: string, updateTime: string, expirationTime: string, sha256Hash: string, uri: string, state: string, videoMetadata: ?array{ videoDuration: string } } $attributes + * @param array{ name: string, displayName: string, mimeType: string, sizeBytes: ?string, createTime: string, updateTime: string, expirationTime: string, sha256Hash: string, uri: string, state: string, videoMetadata: ?array{ videoDuration: string } } $attributes */ public static function from(array $attributes): self { @@ -39,7 +39,7 @@ public static function from(array $attributes): self name: $attributes['name'], displayName: $attributes['displayName'], mimeType: $attributes['mimeType'], - sizeBytes: $attributes['sizeBytes'], + sizeBytes: $attributes['sizeBytes'] ?? null, createTime: $attributes['createTime'], updateTime: $attributes['updateTime'], expirationTime: $attributes['expirationTime'], diff --git a/src/Responses/StreamResponse.php b/src/Responses/StreamResponse.php index 0e488d8..791babd 100644 --- a/src/Responses/StreamResponse.php +++ b/src/Responses/StreamResponse.php @@ -21,6 +21,8 @@ final class StreamResponse implements IteratorAggregate /** * Creates a new Stream ResponseDTO instance. + * + * @param class-string $responseClass */ public function __construct( private readonly string $responseClass, @@ -29,6 +31,9 @@ public function __construct( // } + /** + * @return Generator + */ public function getIterator(): Generator { while (! $this->response->getBody()->eof()) { diff --git a/src/Testing/Resources/ChatSessionTestResource.php b/src/Testing/Resources/ChatSessionTestResource.php index 9b3e74a..0840502 100644 --- a/src/Testing/Resources/ChatSessionTestResource.php +++ b/src/Testing/Resources/ChatSessionTestResource.php @@ -7,9 +7,11 @@ use Gemini\Contracts\Resources\ChatSessionContract; use Gemini\Data\Blob; use Gemini\Data\Content; +use Gemini\Data\UploadedFile; use Gemini\Resources\ChatSession; use Gemini\Responses\GenerativeModel\GenerateContentResponse; use Gemini\Testing\Resources\Concerns\Testable; +use Generator; final class ChatSessionTestResource implements ChatSessionContract { @@ -20,11 +22,16 @@ protected function resource(): string return ChatSession::class; } - public function sendMessage(string|array|Blob|Content ...$parts): GenerateContentResponse + public function sendMessage(string|Blob|array|Content|UploadedFile ...$parts): GenerateContentResponse { return $this->record(method: __FUNCTION__, args: func_get_args(), model: $this->model); } + public function streamSendMessage(string|Blob|array|Content|UploadedFile ...$parts): Generator + { + yield $this->record(method: __FUNCTION__, args: func_get_args(), model: $this->model); + } + /** * @param array $history */ diff --git a/src/Transporters/DTOs/ResponseDTO.php b/src/Transporters/DTOs/ResponseDTO.php index 327379c..6f118c5 100644 --- a/src/Transporters/DTOs/ResponseDTO.php +++ b/src/Transporters/DTOs/ResponseDTO.php @@ -5,7 +5,7 @@ namespace Gemini\Transporters\DTOs; /** - * @template-covariant TData of array + * @template TData of array * * @internal */ diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index f968175..2ed5880 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -24,6 +24,7 @@ final class HttpTransporter implements TransporterContract * * @param array $headers * @param array $queryParams + * @param Closure(\Psr\Http\Message\RequestInterface): ResponseInterface $streamHandler */ public function __construct( private readonly ClientInterface $client, @@ -59,7 +60,6 @@ public function request(Request $request): ResponseDTO } /** - * @throws \Psr\Http\Client\ClientExceptionInterface * @throws \Exception */ public function requestStream(Request $request): ResponseInterface @@ -74,8 +74,7 @@ public function requestStream(Request $request): ResponseInterface } /** - * @throws ErrorException - * @throws UnserializableResponse + * @param Closure(): ResponseInterface $callable */ private function sendRequest(Closure $callable): ResponseInterface { diff --git a/tests/Resources/ChatSession.php b/tests/Resources/ChatSession.php index c705ea1..3c6c61c 100644 --- a/tests/Resources/ChatSession.php +++ b/tests/Resources/ChatSession.php @@ -1,8 +1,13 @@ toBeInstanceOf(ChatSession::class); }); + +test('stream send message', function () { + $response = new Response( + body: new Stream( + GenerateContentResponse::fakeResource() + ), + ); + + $modelType = 'models/gemini-1.5-flash'; + $client = mockStreamClient(method: Method::POST, endpoint: "{$modelType}:streamGenerateContent", response: $response); + + $chat = $client->generativeModel($modelType)->startChat(); + $result = $chat->streamSendMessage('Hello'); + + expect($result) + ->toBeInstanceOf(Generator::class) + ->toBeInstanceOf(Iterator::class) + ->and($result->current()) + ->toBeInstanceOf(GenerateContentResponse::class) + ->candidates->toBeArray()->each->toBeInstanceOf(Candidate::class) + ->promptFeedback->toBeInstanceOf(PromptFeedback::class) + ->usageMetadata->toBeInstanceOf(UsageMetadata::class) + ->and($chat->history)->toHaveCount(1); + +}); diff --git a/tests/Resources/GenerativeModel.php b/tests/Resources/GenerativeModel.php index 8aa3826..a7e6a97 100644 --- a/tests/Resources/GenerativeModel.php +++ b/tests/Resources/GenerativeModel.php @@ -259,3 +259,20 @@ $generativeModel->generateContent('Hello'); }); + +test('generate content with image config', function () { + $modelType = 'models/gemini-2.5-flash-image'; + $client = mockClient(method: Method::POST, endpoint: "{$modelType}:generateContent", response: GenerateContentResponse::fake()); + + $imageConfig = new \Gemini\Data\ImageConfig(aspectRatio: '16:9'); + $generationConfig = new GenerationConfig(imageConfig: $imageConfig); + + $generativeModel = $client + ->generativeModel(model: $modelType) + ->withGenerationConfig($generationConfig); + + $generativeModel->generateContent('Draw a cat'); + + expect($generativeModel) + ->generationConfig->imageConfig->toBe($imageConfig); +}); diff --git a/tests/Testing/Resources/ChatSessionTestResource.php b/tests/Testing/Resources/ChatSessionTestResource.php index 16cc444..e73f120 100644 --- a/tests/Testing/Resources/ChatSessionTestResource.php +++ b/tests/Testing/Resources/ChatSessionTestResource.php @@ -34,3 +34,19 @@ $parameters[0][1] === $file; }); }); + +it('records a stream chat message request', function () { + $fake = new ClientFake([ + GenerateContentResponse::fake(), + ]); + + $generator = $fake->chat('models/gemini-1.5-flash')->streamSendMessage('Hello'); + + // Advance the generator to record the call + $generator->current(); + + $fake->assertSent(resource: ChatSession::class, model: 'models/gemini-1.5-flash', callback: function (string $method, array $parameters) { + return $method === 'streamSendMessage' && + $parameters[0] === 'Hello'; + }); +});