diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..a27b9e1 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,39 @@ +name: Code Quality + +on: ['push'] + +jobs: + ci: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php-version: [8.2] + + name: Code Quality + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v1 + with: + path: ~/.composer/cache/files + key: php-${{ matrix.php-version }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Install Composer dependencies + run: composer update --no-interaction --prefer-stable --prefer-dist + + - name: Coding Style Checks + run: composer test:lint + + - name: Types Checks + run: composer test:types diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..92a83cd --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,47 @@ +name: Tests + +on: ['push'] + +jobs: + ci: + runs-on: ubuntu-latest + + strategy: + fail-fast: true + matrix: + php-version: [8.1, 8.2, 8.3] + laravel: [11.*, 10.*, 9.*] + dependency-version: [prefer-lowest, prefer-stable] + exclude: + - php-version: 8.1 + laravel: 11.* + + name: Tests P${{ matrix.php-version }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v1 + with: + path: ~/.composer/cache/files + key: php-${{ matrix.php-version }}-laravel-${{ matrix.laravel }}-${{ matrix.dependency-version }}-composer-${{ hashFiles('composer.json') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + coverage: none + + - name: Require Laravel Version + run: > + composer require --dev + "laravel/framework:${{ matrix.laravel }}" + --no-interaction --no-update + + - name: Install Composer dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist + + - name: Unit Tests + run: composer test:unit diff --git a/.gitignore b/.gitignore index 297959a..8475559 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,6 @@ +composer.phar /vendor/ -node_modules/ -npm-debug.log -yarn-error.log - -# Laravel 4 specific -bootstrap/compiled.php -app/storage/ - -# Laravel 5 & Lumen specific -public/storage -public/hot - -# Laravel 5 & Lumen specific with changed public path -public_html/storage -public_html/hot - -storage/*.key -.env -Homestead.yaml -Homestead.json -/.vagrant -.phpunit.result.cache +/composer.lock +/.php-cs-fixer.cache +/.phpunit.cache/ +.idea diff --git a/README.md b/README.md index de2e4b3..70f5cfb 100644 --- a/README.md +++ b/README.md @@ -1 +1,157 @@ -# laravel \ No newline at end of file +

+ Gemini API Client for Laravel - Examples +

+

+ Total Downloads + Latest Version + License +

+ +# Gemini API Client for Laravel + +Gemini API Client for Laravel allows you to use the Google's generative AI models, like Gemini Pro and Gemini Pro Vision in your Laravel application. + +Supports PHP 8.1 and Laravel v9, v10. + +_This library is not developed or endorsed by Google._ + +- Erdem Köse - **[github.com/erdemkose](https://github.com/erdemkose)** + +## Table of Contents +- [Installation](#installation) +- [Configuration](#configuration) +- [How to use](#how-to-use) + - [Text Generation](#text-generation) + - [Text Generation using Image File](#text-generation-using-image-file) + - [Text Generation using Image Data](#text-generation-using-image-data) + - [Chat Session (Multi-Turn Conversations)](#chat-session-multi-turn-conversations) + - [Tokens counting](#tokens-counting) + - [Listing models](#listing-models) + +## Installation + +> You need an API key to gain access to Google's Gemini API. +> Visit [Google AI Studio](https://makersuite.google.com/) to get an API key. + +First step is to install the Gemini API Client for Laravel with Composer. + +```shell +composer require gemini-api-php/laravel +``` + +## Configuration + +There are two ways to configure the client. + +### Environment variables + +You can set the `GEMINI_API_KEY` environment variable with the API key you obtained from Google AI studio. + +Add the following line into your `.env` file. + +```shell +GEMINI_API_KEY='YOUR_GEMINI_API_KEY' +``` + +### Configuration file + +You can also run the following command to create a configuration file in your applications config folder. + +```shell +php artisan vendor:publish --provider=GeminiAPI\Laravel\ServiceProvider +``` + +Now you can edit the `config/gemini.php` file to configure the Gemini API client. + +## How to use + +### Text Generation + +```php +use GeminiAPI\Laravel\Facades\Gemini; + +print Gemini::generateText('PHP in less than 100 chars'); +// PHP: A server-side scripting language used to create dynamic web applications. +// Easy to learn, widely used, and open-source. +``` + +### Text Generation Using Image File + +```php +use GeminiAPI\Laravel\Facades\Gemini; + +print Gemini::generateTextUsingImageFile( + 'image/jpeg', + 'elephpant.jpg', + 'Explain what is in the image', +); +// The image shows an elephant standing on the Earth. +// The elephant is made of metal and has a glowing symbol on its forehead. +// The Earth is surrounded by a network of glowing lines. +// The image is set against a starry background. +``` + +### Text Generation Using Image Data + +```php +use GeminiAPI\Laravel\Facades\Gemini; + +print Gemini::generateTextUsingImage( + 'image/jpeg', + base64_encode(file_get_contents('elephpant.jpg')), + 'Explain what is in the image', +); +// The image shows an elephant standing on the Earth. +// The elephant is made of metal and has a glowing symbol on its forehead. +// The Earth is surrounded by a network of glowing lines. +// The image is set against a starry background. +``` + +### Chat Session (Multi-Turn Conversations) + +```php +use GeminiAPI\Laravel\Facades\Gemini; + +$chat = Gemini::startChat(); + +print $chat->sendMessage('Hello World in PHP'); +// echo "Hello World!"; +// This code will print "Hello World!" to the standard output. + +print $chat->sendMessage('in Go'); +// fmt.Println("Hello World!") +// This code will print "Hello World!" to the standard output. +``` + +### Tokens counting + +```php +use GeminiAPI\Laravel\Facades\Gemini; + +print Gemini::countTokens('PHP in less than 100 chars'); +// 10 +``` + +### Listing models + +```php +use GeminiAPI\Laravel\Facades\Gemini; + +print_r(Gemini::listModels()); +//[ +// [0] => GeminiAPI\Resources\Model Object +// ( +// [name] => models/gemini-pro +// [displayName] => Gemini Pro +// [description] => The best model for scaling across a wide range of tasks +// ... +// ) +// [1] => GeminiAPI\Resources\Model Object +// ( +// [name] => models/gemini-pro-vision +// [displayName] => Gemini Pro Vision +// [description] => The best image understanding model to handle a broad range of applications +// ... +// ) +//] +``` diff --git a/assets/example.png b/assets/example.png new file mode 100644 index 0000000..0564848 Binary files /dev/null and b/assets/example.png differ diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f5b36dc --- /dev/null +++ b/composer.json @@ -0,0 +1,78 @@ +{ + "name": "gemini-api-php/laravel", + "description": "Gemini API client for Laravel", + "keywords": [ + "laravel", + "php", + "client", + "sdk", + "api", + "google", + "gemini", + "gemini pro", + "gemini pro vision", + "ai" + ], + "license": "MIT", + "authors": [ + { + "name": "Erdem Köse", + "email": "erdemkose@gmail.com" + } + ], + "require": { + "php": "^8.1", + "gemini-api-php/client": "^1.1", + "illuminate/support": "^9.0 || ^10.0 || ^11.0", + "psr/container": "^1.0 || ^2.0", + "psr/http-client": "^1.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.8.1", + "guzzlehttp/psr7": "^2.0", + "laravel/framework": "^9.0 || ^10.0 || ^11.0", + "laravel/pint": "^1.13.6", + "pestphp/pest": "^2.27.0", + "pestphp/pest-plugin-arch": "^2.4.1", + "phpstan/phpstan": "^1.10.47", + "symfony/var-dumper": "^6.4.0|^7.0.1" + }, + "autoload": { + "psr-4": { + "GeminiAPI\\Laravel\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "GeminiAPI\\Laravel\\Tests\\": "tests" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true, + "preferred-install": "dist", + "allow-plugins": { + "php-http/discovery": true, + "pestphp/pest-plugin": true + } + }, + "extra": { + "laravel": { + "providers": [ + "GeminiAPI\\Laravel\\ServiceProvider" + ] + } + }, + "scripts": { + "lint": "pint -v", + "test:lint": "pint --test -v", + "test:types": "phpstan analyse --ansi", + "test:unit": "pest --colors=always", + "test": [ + "@test:lint", + "@test:types", + "@test:unit" + ] + } +} diff --git a/config/gemini.php b/config/gemini.php new file mode 100644 index 0000000..8cab295 --- /dev/null +++ b/config/gemini.php @@ -0,0 +1,21 @@ + env('GEMINI_API_KEY'), + + /** + * Gemini Base URL + * + * If you need a specific base URL for the Gemini API, you can provide it here. + * Otherwise, leave empty to use the default value. + */ + 'base_url' => env('GEMINI_BASE_URL'), +]; diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..7ab41e4 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + level: max + paths: + - src + - config + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..c93d169 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,28 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/src/ChatSession.php b/src/ChatSession.php new file mode 100644 index 0000000..b01702d --- /dev/null +++ b/src/ChatSession.php @@ -0,0 +1,27 @@ +chatSession + ->sendMessage(new TextPart($text)) + ->text(); + } +} diff --git a/src/Contracts/GeminiContract.php b/src/Contracts/GeminiContract.php new file mode 100644 index 0000000..cb8678d --- /dev/null +++ b/src/Contracts/GeminiContract.php @@ -0,0 +1,9 @@ + $mimeType->value, + MimeType::cases(), + ); + + return new self( + sprintf( + 'The Gemini API does not support the image type [%s]. Supported image types are [%s]', + $mimeType, + implode(',', $supportedTypes) + ), + ); + } +} diff --git a/src/Exceptions/MissingApiKey.php b/src/Exceptions/MissingApiKey.php new file mode 100644 index 0000000..54410ec --- /dev/null +++ b/src/Exceptions/MissingApiKey.php @@ -0,0 +1,20 @@ +client + ->generativeModel(ModelName::GeminiPro) + ->generateContent( + new TextPart($prompt), + ); + + return $response->text(); + } + + /** + * Generates a text based on the given image file. + * You can also provide a prompt. + * + * The image type must be one of the types below + * * image/png + * * image/jpeg + * * image/heic + * * image/heif + * * image/webp + * + * @throws ClientExceptionInterface + * @throws InvalidMimeType + */ + public function generateTextUsingImageFile( + string $imageType, + string $imagePath, + string $prompt = '', + ): string { + if (! is_file($imagePath) || ! is_readable($imagePath)) { + throw new InvalidArgumentException( + sprintf('The "%s" file does not exist or is not readable.', $imagePath), + ); + } + + $contents = file_get_contents($imagePath); + if ($contents === false) { + throw new InvalidArgumentException( + sprintf('Cannot read contents of the "%s" file', $imagePath), + ); + } + + $image = base64_encode($contents); + + return $this->generateTextUsingImage($prompt, $imageType, $image); + } + + /** + * Generates a text based on the given image. + * You can also provide a prompt. + * + * The image type must be one of the types below + * * image/png + * * image/jpeg + * * image/heic + * * image/heif + * * image/webp + * + * @throws ClientExceptionInterface + * @throws InvalidMimeType + */ + public function generateTextUsingImage( + string $imageType, + string $image, + string $prompt = '', + ): string { + $mimeType = MimeType::tryFrom($imageType); + if (is_null($mimeType)) { + throw InvalidMimeType::create($imageType); + } + + $parts = [ + new ImagePart($mimeType, $image), + ]; + + if (! empty($prompt)) { + $parts[] = new TextPart($prompt); + } + + $response = $this->client + ->generativeModel(ModelName::GeminiProVision) + ->generateContent(...$parts); + + return $response->text(); + } + + public function startChat(): ChatSession + { + $chatSession = $this->client + ->generativeModel(ModelName::GeminiPro) + ->startChat(); + + return new ChatSession($chatSession); + } + + /** + * @throws ClientExceptionInterface + */ + public function countTokens(string $prompt): int + { + $response = $this->client + ->generativeModel(ModelName::GeminiPro) + ->countTokens( + new TextPart($prompt), + ); + + return $response->totalTokens; + } + + /** + * @return Model[] + */ + public function listModels(): array + { + $response = $this->client->listModels(); + + return $response->models; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..437b435 --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,97 @@ +mergeConfigFrom( + __DIR__.'/../config/gemini.php', 'gemini', + ); + + $this->app->singleton( + ClientInterface::class, + static function (Container $container): ClientInterface { + /** @var Repository $config */ + $config = $container->get('config'); + + $apiKey = $config->get('gemini.api_key'); + if (! is_string($apiKey)) { + throw MissingApiKey::create(); + } + + $baseUrl = $config->get('gemini.base_url'); + if (isset($baseUrl) && ! is_string($baseUrl)) { + throw new InvalidArgumentException('The Gemini API Base URL is invalid.'); + } + + try { + /** @var HttpClientContract|PsrHttpClientInterface|null $httpClient */ + $httpClient = $container->has(HttpClientContract::class) + ? $container->get(HttpClientContract::class) + : $container->get(PsrHttpClientInterface::class); + } catch (NotFoundExceptionInterface) { + $httpClient = null; + } + + $client = new Client($apiKey, $httpClient); + + if (! empty($baseUrl)) { + $client = $client->withBaseUrl($baseUrl); + } + + return $client; + } + ); + $this->app->singleton(GeminiContract::class, Gemini::class); + $this->app->alias(GeminiContract::class, 'gemini'); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + $this->publishes([ + __DIR__.'/../config/gemini.php' => $this->app->configPath('gemini.php'), + ]); + } + + /** + * Get the services provided by the provider. + * + * @return array + */ + public function provides(): array + { + return [ + Client::class, + ClientInterface::class, + Gemini::class, + GeminiContract::class, + 'gemini', + ]; + } +} diff --git a/tests/Facades/Gemini.php b/tests/Facades/Gemini.php new file mode 100644 index 0000000..44bc377 --- /dev/null +++ b/tests/Facades/Gemini.php @@ -0,0 +1,26 @@ +bind('config', fn () => new Repository([ + 'gemini' => [ + 'api_key' => 'test', + ], + ])); + + (new ServiceProvider($app))->register(); + + Gemini::setFacadeApplication($app); + + $chat = Gemini::startChat(); + + expect($chat)->toBeInstanceOf(ChatSession::class); +}); diff --git a/tests/ServiceProvider.php b/tests/ServiceProvider.php new file mode 100644 index 0000000..2d453f0 --- /dev/null +++ b/tests/ServiceProvider.php @@ -0,0 +1,100 @@ +bind('config', fn () => new Repository([ + 'gemini' => [ + 'api_key' => 'test', + ], + ])); + + (new ServiceProvider($app))->register(); + + expect($app->get(ClientInterface::class))->toBeInstanceOf(Client::class); +}); + +it('binds the client on the container as singleton', function () { + $app = app(); + + $app->bind('config', fn () => new Repository([ + 'gemini' => [ + 'api_key' => 'test', + ], + ])); + + (new ServiceProvider($app))->register(); + + $client = $app->get(ClientInterface::class); + + expect($app->get(ClientInterface::class))->toBe($client); +}); + +it('requires an api key', function () { + $app = app(); + + $app->bind('config', fn () => new Repository([])); + + (new ServiceProvider($app))->register(); +})->throws( + MissingApiKey::class, + 'The Gemini API Key is missing. Please publish the [gemini.php] configuration file and set the [api_key].', +); + +it('validates base url', function () { + $app = app(); + + $app->bind('config', fn () => new Repository([ + 'gemini' => [ + 'api_key' => 'test', + 'base_url' => [], // not a string + ], + ])); + + (new ServiceProvider($app))->register(); +})->throws( + InvalidArgumentException::class, + 'The Gemini API Base URL is invalid.', +); + +it('allows base url', function () { + $app = app(); + + $app->bind('config', fn () => new Repository([ + 'gemini' => [ + 'api_key' => 'test', + 'base_url' => 'https://localhost', + ], + ])); + + (new ServiceProvider($app))->register(); + + $client = $app->get(ClientInterface::class); + + expect($app->get(ClientInterface::class))->toBe($client); +}); + +it('returns provided services', function () { + $app = app(); + + $provides = (new ServiceProvider($app))->provides(); + + expect($provides)->toBe([ + Client::class, + ClientInterface::class, + Gemini::class, + GeminiContract::class, + 'gemini', + ]); +});