Skip to content
This repository was archived by the owner on Jul 16, 2025. It is now read-only.

feat: basic support for Google Gemini models #215

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ PINECONE_HOST=

# Some examples are expensive to run, so we disable them by default
RUN_EXPENSIVE_EXAMPLES=false

# For using Gemini
GOOGLE_API_KEY=
28 changes: 28 additions & 0 deletions examples/chat-gemini-google.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use PhpLlm\LlmChain\Bridge\Google\GoogleModel;
use PhpLlm\LlmChain\Bridge\Google\PlatformFactory;
use PhpLlm\LlmChain\Chain;
use PhpLlm\LlmChain\Model\Message\Message;
use PhpLlm\LlmChain\Model\Message\MessageBag;
use Symfony\Component\Dotenv\Dotenv;

require_once dirname(__DIR__).'/vendor/autoload.php';
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');

if (empty($_ENV['GOOGLE_API_KEY'])) {
echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL;
exit(1);
}

$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']);
$llm = new GoogleModel(GoogleModel::GEMINI_2_FLASH);

$chain = new Chain($platform, $llm);
$messages = new MessageBag(
Message::forSystem('You are a pirate and you write funny.'),
Message::ofUser('What is the Symfony framework?'),
);
$response = $chain->call($messages);

echo $response->getContent().PHP_EOL;
60 changes: 60 additions & 0 deletions src/Bridge/Google/GoogleModel.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\Google;

use PhpLlm\LlmChain\Model\LanguageModel;

final readonly class GoogleModel implements LanguageModel
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would favor to call this just Gemini - WDYT?

Suggested change
final readonly class GoogleModel implements LanguageModel
final readonly class Gemini implements LanguageModel

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes good point, maybe both. There is also Gemma. So maybe GoogleModel as a parent so we only need one instanceof check in the Handler?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, sounds fair - depends a bit on the difference Gemma would need in request/response handling, but if there's a synergy that's a good point 👍

{
public const GEMINI_2_FLASH = 'gemini-2.0-flash';
public const GEMINI_2_PRO = 'gemini-2.0-pro-exp-02-05';
public const GEMINI_2_FLASH_LITE = 'gemini-2.0-flash-lite-preview-02-05';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to replace those explicit dated version strings with latest tag? I have no key to try it but i am curious from the documentation.

https://ai.google.dev/gemini-api/docs/models/gemini?hl=en#model-versions

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will check this out later!

If you have a Google account you can actually get an API key for free, in case you're interested.

Copy link
Contributor Author

@onemoreangle onemoreangle Feb 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to replace those explicit dated version strings with latest tag? I have no key to try it but i am curious from the documentation.

https://ai.google.dev/gemini-api/docs/models/gemini?hl=en#model-versions

Alright, I've checked, these seem to be specific previews right now, because neither using latest nor without tag works for these models. Would you want these removed and only list the stable models and latest models that do work?

Latest could point to preview models, so if it's going to point to that it should probably be transparent

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just suprised over the dates in the versions. OpenAI has them too but also high level naming for the "latest". Wondered why this should not be possible with Google Gemini. So many thanks for checking!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just suprised over the dates in the versions. OpenAI has them too but also high level naming for the "latest". Wondered why this should not be possible with Google Gemini. So many thanks for checking!

Yes it is strange, based on their description I would have assumed it would be pointing to there as well. I read it a bit more thoroughly and checked gemini-2.0-pro-exp and that does work, so looks they just don't consider it ready yet :)

public const GEMINI_2_FLASH_THINKING = 'gemini-2.0-flash-thinking-exp-01-21';
public const GEMINI_1_5_FLASH = 'gemini-1.5-flash';

/**
* @param array<string, mixed> $options The default options for the model usage
*/
public function __construct(
private string $version = self::GEMINI_2_PRO,
private array $options = ['temperature' => 1.0],
) {
}

public function getVersion(): string
{
return $this->version;
}

public function getOptions(): array
{
return $this->options;
}

public function supportsAudioInput(): bool
{
return false; // it does, but implementation here is still open; in_array($this->version, [self::GEMINI_2_FLASH, self::GEMINI_2_PRO, self::GEMINI_1_5_FLASH], true);
}

public function supportsImageInput(): bool
{
return false; // it does, but implementation here is still open;in_array($this->version, [self::GEMINI_2_FLASH, self::GEMINI_2_PRO, self::GEMINI_2_FLASH_LITE, self::GEMINI_2_FLASH_THINKING, self::GEMINI_1_5_FLASH], true);
}

public function supportsStreaming(): bool
{
return true;
}

public function supportsStructuredOutput(): bool
{
return false;
}

public function supportsToolCalling(): bool
{
return false;
}
}
97 changes: 97 additions & 0 deletions src/Bridge/Google/GoogleRequestBodyProducer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

namespace PhpLlm\LlmChain\Bridge\Google;

use PhpLlm\LlmChain\Model\Message\AssistantMessage;
use PhpLlm\LlmChain\Model\Message\Content\Audio;
use PhpLlm\LlmChain\Model\Message\Content\ContentVisitor;
use PhpLlm\LlmChain\Model\Message\Content\Image;
use PhpLlm\LlmChain\Model\Message\Content\Text;
use PhpLlm\LlmChain\Model\Message\MessageBagInterface;
use PhpLlm\LlmChain\Model\Message\MessageVisitor;
use PhpLlm\LlmChain\Model\Message\SystemMessage;
use PhpLlm\LlmChain\Model\Message\ToolCallMessage;
use PhpLlm\LlmChain\Model\Message\UserMessage;
use PhpLlm\LlmChain\Platform\RequestBodyProducer;

final class GoogleRequestBodyProducer implements RequestBodyProducer, MessageVisitor, ContentVisitor, \JsonSerializable
{
protected MessageBagInterface $bag;

public function __construct(MessageBagInterface $bag)
{
$this->bag = $bag;
}

public function createBody(): array
{
$contents = [];
foreach ($this->bag->withoutSystemMessage()->getMessages() as $message) {
$contents[] = [
'role' => $message->getRole(),
'parts' => $message->accept($this),
];
}

$body = [
'contents' => $contents,
];

$systemMessage = $this->bag->getSystemMessage();
if (null !== $systemMessage) {
$body['systemInstruction'] = [
'parts' => $systemMessage->accept($this),
];
}

return $body;
}

public function visitUserMessage(UserMessage $message): array
{
$parts = [];
foreach ($message->content as $content) {
$parts[] = [...$content->accept($this)];
}

return $parts;
}

public function visitAssistantMessage(AssistantMessage $message): array
{
return [['text' => $message->content]];
}

public function visitSystemMessage(SystemMessage $message): array
{
return [['text' => $message->content]];
}

public function visitText(Text $content): array
{
return ['text' => $content->text];
}

public function visitImage(Image $content): array
{
// TODO: support image
return [];
}

public function visitAudio(Audio $content): array
{
// TODO: support audio
return [];
}

public function visitToolCallMessage(ToolCallMessage $message): array
{
// TODO: support tool call message
return [];
}

public function jsonSerialize(): array
{
return $this->createBody();
}
}
103 changes: 103 additions & 0 deletions src/Bridge/Google/ModelHandler.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\Google;

use PhpLlm\LlmChain\Exception\RuntimeException;
use PhpLlm\LlmChain\Model\Message\MessageBagInterface;
use PhpLlm\LlmChain\Model\Model;
use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse;
use PhpLlm\LlmChain\Model\Response\StreamResponse;
use PhpLlm\LlmChain\Model\Response\TextResponse;
use PhpLlm\LlmChain\Platform\ModelClient;
use PhpLlm\LlmChain\Platform\ResponseConverter;
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Component\HttpClient\Exception\JsonException;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use Webmozart\Assert\Assert;

final readonly class ModelHandler implements ModelClient, ResponseConverter
{
private EventSourceHttpClient $httpClient;

public function __construct(
HttpClientInterface $httpClient,
#[\SensitiveParameter] private string $apiKey,
) {
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
}

public function supports(Model $model, array|string|object $input): bool
{
return $model instanceof GoogleModel && $input instanceof MessageBagInterface;
}

/**
* @throws TransportExceptionInterface
*/
public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface
{
Assert::isInstanceOf($input, MessageBagInterface::class);

$body = new GoogleRequestBodyProducer($input);

return $this->httpClient->request('POST', sprintf('https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent', $model->getVersion()), [
'headers' => [
'x-goog-api-key' => $this->apiKey,
],
'json' => $body,
]);
}

/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws DecodingExceptionInterface
* @throws ClientExceptionInterface
*/
public function convert(ResponseInterface $response, array $options = []): LlmResponse
{
if ($options['stream'] ?? false) {
return new StreamResponse($this->convertStream($response));
}

$data = $response->toArray();

if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) {
throw new RuntimeException('Response does not contain any content');
}

return new TextResponse($data['candidates'][0]['content']['parts'][0]['text']);
}

private function convertStream(ResponseInterface $response): \Generator
{
foreach ((new EventSourceHttpClient())->stream($response) as $chunk) {
if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) {
continue;
}

try {
$data = $chunk->getArrayData();
} catch (JsonException) {
// try catch only needed for Symfony 6.4
continue;
}

if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) {
continue;
}

yield $data['candidates'][0]['content']['parts'][0]['text'];
}
}
}
23 changes: 23 additions & 0 deletions src/Bridge/Google/PlatformFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace PhpLlm\LlmChain\Bridge\Google;

use PhpLlm\LlmChain\Platform;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

final readonly class PlatformFactory
{
public static function create(
#[\SensitiveParameter]
string $apiKey,
?HttpClientInterface $httpClient = null,
): Platform {
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
$responseHandler = new ModelHandler($httpClient, $apiKey);

return new Platform([$responseHandler], [$responseHandler]);
}
}
5 changes: 5 additions & 0 deletions src/Model/Message/AssistantMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,9 @@ public function jsonSerialize(): array

return $array;
}

public function accept(MessageVisitor $visitor): array
{
return $visitor->visitAssistantMessage($this);
}
}
5 changes: 5 additions & 0 deletions src/Model/Message/Content/Audio.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,9 @@ public function jsonSerialize(): array
],
];
}

public function accept(ContentVisitor $visitor): array
{
return $visitor->visitAudio($this);
}
}
1 change: 1 addition & 0 deletions src/Model/Message/Content/Content.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@

interface Content extends \JsonSerializable
{
public function accept(ContentVisitor $visitor): array;
}
12 changes: 12 additions & 0 deletions src/Model/Message/Content/ContentVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace PhpLlm\LlmChain\Model\Message\Content;

interface ContentVisitor
{
public function visitAudio(Audio $content): array;

public function visitImage(Image $content): array;

public function visitText(Text $content): array;
}
5 changes: 5 additions & 0 deletions src/Model/Message/Content/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,9 @@ private function fromFile(string $filePath): string

return sprintf('data:image/%s;base64,%s', $type, base64_encode($data));
}

public function accept(ContentVisitor $visitor): array
{
return $visitor->visitImage($this);
}
}
5 changes: 5 additions & 0 deletions src/Model/Message/Content/Text.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,9 @@ public function jsonSerialize(): array
{
return ['type' => 'text', 'text' => $this->text];
}

public function accept(ContentVisitor $visitor): array
{
return $visitor->visitText($this);
}
}
2 changes: 2 additions & 0 deletions src/Model/Message/MessageInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@
interface MessageInterface extends \JsonSerializable
{
public function getRole(): Role;

public function accept(MessageVisitor $visitor): array;
}
Loading
Loading