Skip to content

Commit f300bbb

Browse files
feat: support gemini (#220)
* feat: added initial Google provider supporting basic text generation with it * refactor: remove visitor infavor of single converter class --------- Co-authored-by: Roy Garrido <[email protected]>
1 parent 9b9c17f commit f300bbb

10 files changed

+473
-1
lines changed

.env

+3
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,6 @@ PINECONE_HOST=
3939

4040
# Some examples are expensive to run, so we disable them by default
4141
RUN_EXPENSIVE_EXAMPLES=false
42+
43+
# For using Gemini
44+
GOOGLE_API_KEY=

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ LLM Chain categorizes two main types of models: **Language Models** and **Embedd
3838
Language Models, like GPT, Claude and Llama, as essential centerpiece of LLM applications
3939
and Embeddings Models as supporting models to provide vector representations of text.
4040

41-
Those models are provided by different **platforms**, like OpenAI, Azure, Replicate, and others.
41+
Those models are provided by different **platforms**, like OpenAI, Azure, Google, Replicate, and others.
4242

4343
#### Example Instantiation
4444

@@ -63,6 +63,7 @@ $embeddings = new Embeddings();
6363
* [OpenAI's GPT](https://platform.openai.com/docs/models/overview) with [OpenAI](https://platform.openai.com/docs/overview) and [Azure](https://learn.microsoft.com/azure/ai-services/openai/concepts/models) as Platform
6464
* [Anthropic's Claude](https://www.anthropic.com/claude) with [Anthropic](https://www.anthropic.com/) as Platform
6565
* [Meta's Llama](https://www.llama.com/) with [Ollama](https://ollama.com/) and [Replicate](https://replicate.com/) as Platform
66+
* [Google's Gemini](https://gemini.google.com/) with [Google](https://ai.google.dev/) as Platform
6667
* [Google's Gemini](https://gemini.google.com/) with [OpenRouter](https://www.openrouter.com/) as Platform
6768
* [DeepSeek's R1](https://www.deepseek.com/) with [OpenRouter](https://www.openrouter.com/) as Platform
6869
* Embeddings Models

examples/chat-gemini-google.php

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Bridge\Google\Gemini;
4+
use PhpLlm\LlmChain\Bridge\Google\PlatformFactory;
5+
use PhpLlm\LlmChain\Chain;
6+
use PhpLlm\LlmChain\Model\Message\Message;
7+
use PhpLlm\LlmChain\Model\Message\MessageBag;
8+
use Symfony\Component\Dotenv\Dotenv;
9+
10+
require_once dirname(__DIR__).'/vendor/autoload.php';
11+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
12+
13+
if (empty($_ENV['GOOGLE_API_KEY'])) {
14+
echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL;
15+
exit(1);
16+
}
17+
18+
$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']);
19+
$llm = new Gemini(Gemini::GEMINI_2_FLASH);
20+
21+
$chain = new Chain($platform, $llm);
22+
$messages = new MessageBag(
23+
Message::forSystem('You are a pirate and you write funny.'),
24+
Message::ofUser('What is the Symfony framework?'),
25+
);
26+
$response = $chain->call($messages);
27+
28+
echo $response->getContent().PHP_EOL;
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Bridge\Google\Gemini;
4+
use PhpLlm\LlmChain\Bridge\Google\PlatformFactory;
5+
use PhpLlm\LlmChain\Chain;
6+
use PhpLlm\LlmChain\Model\Message\Content\Image;
7+
use PhpLlm\LlmChain\Model\Message\Message;
8+
use PhpLlm\LlmChain\Model\Message\MessageBag;
9+
use Symfony\Component\Dotenv\Dotenv;
10+
11+
require_once dirname(__DIR__).'/vendor/autoload.php';
12+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
13+
14+
if (empty($_ENV['GOOGLE_API_KEY'])) {
15+
echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL;
16+
exit(1);
17+
}
18+
19+
$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']);
20+
$llm = new Gemini(Gemini::GEMINI_1_5_FLASH);
21+
22+
$chain = new Chain($platform, $llm);
23+
$messages = new MessageBag(
24+
Message::forSystem('You are an image analyzer bot that helps identify the content of images.'),
25+
Message::ofUser(
26+
'Describe the image as a comedian would do it.',
27+
new Image(dirname(__DIR__).'/tests/Fixture/image.jpg'),
28+
),
29+
);
30+
$response = $chain->call($messages);
31+
32+
echo $response->getContent().PHP_EOL;

examples/stream-google-gemini.php

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
use PhpLlm\LlmChain\Bridge\Google\Gemini;
4+
use PhpLlm\LlmChain\Bridge\Google\PlatformFactory;
5+
use PhpLlm\LlmChain\Chain;
6+
use PhpLlm\LlmChain\Model\Message\Message;
7+
use PhpLlm\LlmChain\Model\Message\MessageBag;
8+
use Symfony\Component\Dotenv\Dotenv;
9+
10+
require_once dirname(__DIR__).'/vendor/autoload.php';
11+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
12+
13+
if (empty($_ENV['GOOGLE_API_KEY'])) {
14+
echo 'Please set the GOOGLE_API_KEY environment variable.'.PHP_EOL;
15+
exit(1);
16+
}
17+
18+
$platform = PlatformFactory::create($_ENV['GOOGLE_API_KEY']);
19+
$llm = new Gemini(Gemini::GEMINI_2_FLASH);
20+
21+
$chain = new Chain($platform, $llm);
22+
$messages = new MessageBag(
23+
Message::forSystem('You are a funny clown that entertains people.'),
24+
Message::ofUser('What is the purpose of an ant?'),
25+
);
26+
$response = $chain->call($messages, [
27+
'stream' => true, // enable streaming of response text
28+
]);
29+
30+
foreach ($response->getContent() as $word) {
31+
echo $word;
32+
}
33+
echo PHP_EOL;

src/Bridge/Google/Gemini.php

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Bridge\Google;
6+
7+
use PhpLlm\LlmChain\Model\LanguageModel;
8+
9+
final readonly class Gemini implements LanguageModel
10+
{
11+
public const GEMINI_2_FLASH = 'gemini-2.0-flash';
12+
public const GEMINI_2_PRO = 'gemini-2.0-pro-exp-02-05';
13+
public const GEMINI_2_FLASH_LITE = 'gemini-2.0-flash-lite-preview-02-05';
14+
public const GEMINI_2_FLASH_THINKING = 'gemini-2.0-flash-thinking-exp-01-21';
15+
public const GEMINI_1_5_FLASH = 'gemini-1.5-flash';
16+
17+
/**
18+
* @param array<string, mixed> $options The default options for the model usage
19+
*/
20+
public function __construct(
21+
private string $version = self::GEMINI_2_PRO,
22+
private array $options = ['temperature' => 1.0],
23+
) {
24+
}
25+
26+
public function getVersion(): string
27+
{
28+
return $this->version;
29+
}
30+
31+
public function getOptions(): array
32+
{
33+
return $this->options;
34+
}
35+
36+
public function supportsAudioInput(): bool
37+
{
38+
return false; // it does, but implementation here is still open
39+
}
40+
41+
public function supportsImageInput(): bool
42+
{
43+
return true;
44+
}
45+
46+
public function supportsStreaming(): bool
47+
{
48+
return true;
49+
}
50+
51+
public function supportsStructuredOutput(): bool
52+
{
53+
return false; // it does, but implementation here is still open
54+
}
55+
56+
public function supportsToolCalling(): bool
57+
{
58+
return false; // it does, but implementation here is still open
59+
}
60+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Bridge\Google;
6+
7+
use PhpLlm\LlmChain\Model\Message\AssistantMessage;
8+
use PhpLlm\LlmChain\Model\Message\Content\Image;
9+
use PhpLlm\LlmChain\Model\Message\Content\Text;
10+
use PhpLlm\LlmChain\Model\Message\MessageBagInterface;
11+
use PhpLlm\LlmChain\Model\Message\MessageInterface;
12+
use PhpLlm\LlmChain\Model\Message\Role;
13+
use PhpLlm\LlmChain\Model\Message\UserMessage;
14+
15+
use function Symfony\Component\String\u;
16+
17+
final class GooglePromptConverter
18+
{
19+
/**
20+
* @return array{
21+
* contents: list<array{
22+
* role: 'model'|'user',
23+
* parts: list<array{inline_data?: array{mime_type: string, data: string}|array{text: string}}>
24+
* }>,
25+
* system_instruction?: array{parts: array{text: string}}
26+
* }
27+
*/
28+
public function convertToPrompt(MessageBagInterface $bag): array
29+
{
30+
$body = ['contents' => []];
31+
32+
$systemMessage = $bag->getSystemMessage();
33+
if (null !== $systemMessage) {
34+
$body['system_instruction'] = [
35+
'parts' => ['text' => $systemMessage->content],
36+
];
37+
}
38+
39+
foreach ($bag->withoutSystemMessage()->getMessages() as $message) {
40+
$body['contents'][] = [
41+
'role' => $message->getRole()->equals(Role::Assistant) ? 'model' : 'user',
42+
'parts' => $this->convertMessage($message),
43+
];
44+
}
45+
46+
return $body;
47+
}
48+
49+
/**
50+
* @return list<array{inline_data?: array{mime_type: string, data: string}|array{text: string}}>
51+
*/
52+
private function convertMessage(MessageInterface $message): array
53+
{
54+
if ($message instanceof AssistantMessage) {
55+
return [['text' => $message->content]];
56+
}
57+
58+
if ($message instanceof UserMessage) {
59+
$parts = [];
60+
foreach ($message->content as $content) {
61+
if ($content instanceof Text) {
62+
$parts[] = ['text' => $content->text];
63+
}
64+
if ($content instanceof Image) {
65+
$parts[] = ['inline_data' => [
66+
'mime_type' => u($content->url)->after('data:')->before(';')->toString(),
67+
'data' => u($content->url)->after('base64,')->toString(),
68+
]];
69+
}
70+
}
71+
72+
return $parts;
73+
}
74+
75+
return [];
76+
}
77+
}

src/Bridge/Google/ModelHandler.php

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpLlm\LlmChain\Bridge\Google;
6+
7+
use PhpLlm\LlmChain\Exception\RuntimeException;
8+
use PhpLlm\LlmChain\Model\Message\MessageBagInterface;
9+
use PhpLlm\LlmChain\Model\Model;
10+
use PhpLlm\LlmChain\Model\Response\ResponseInterface as LlmResponse;
11+
use PhpLlm\LlmChain\Model\Response\StreamResponse;
12+
use PhpLlm\LlmChain\Model\Response\TextResponse;
13+
use PhpLlm\LlmChain\Platform\ModelClient;
14+
use PhpLlm\LlmChain\Platform\ResponseConverter;
15+
use Symfony\Component\HttpClient\EventSourceHttpClient;
16+
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
17+
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
18+
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
19+
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
20+
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
21+
use Symfony\Contracts\HttpClient\HttpClientInterface;
22+
use Symfony\Contracts\HttpClient\ResponseInterface;
23+
use Webmozart\Assert\Assert;
24+
25+
final readonly class ModelHandler implements ModelClient, ResponseConverter
26+
{
27+
private EventSourceHttpClient $httpClient;
28+
29+
public function __construct(
30+
HttpClientInterface $httpClient,
31+
#[\SensitiveParameter] private string $apiKey,
32+
private GooglePromptConverter $promptConverter = new GooglePromptConverter(),
33+
) {
34+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
35+
}
36+
37+
public function supports(Model $model, array|string|object $input): bool
38+
{
39+
return $model instanceof Gemini && $input instanceof MessageBagInterface;
40+
}
41+
42+
/**
43+
* @throws TransportExceptionInterface
44+
*/
45+
public function request(Model $model, object|array|string $input, array $options = []): ResponseInterface
46+
{
47+
Assert::isInstanceOf($input, MessageBagInterface::class);
48+
49+
$url = sprintf(
50+
'https://generativelanguage.googleapis.com/v1beta/models/%s:%s',
51+
$model->getVersion(),
52+
$options['stream'] ?? false ? 'streamGenerateContent' : 'generateContent',
53+
);
54+
55+
$generationConfig = ['generationConfig' => $options];
56+
unset($generationConfig['generationConfig']['stream']);
57+
58+
return $this->httpClient->request('POST', $url, [
59+
'headers' => [
60+
'x-goog-api-key' => $this->apiKey,
61+
],
62+
'json' => array_merge($generationConfig, $this->promptConverter->convertToPrompt($input)),
63+
]);
64+
}
65+
66+
/**
67+
* @throws TransportExceptionInterface
68+
* @throws ServerExceptionInterface
69+
* @throws RedirectionExceptionInterface
70+
* @throws DecodingExceptionInterface
71+
* @throws ClientExceptionInterface
72+
*/
73+
public function convert(ResponseInterface $response, array $options = []): LlmResponse
74+
{
75+
if ($options['stream'] ?? false) {
76+
return new StreamResponse($this->convertStream($response));
77+
}
78+
79+
$data = $response->toArray();
80+
81+
if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) {
82+
throw new RuntimeException('Response does not contain any content');
83+
}
84+
85+
return new TextResponse($data['candidates'][0]['content']['parts'][0]['text']);
86+
}
87+
88+
private function convertStream(ResponseInterface $response): \Generator
89+
{
90+
foreach ((new EventSourceHttpClient())->stream($response) as $chunk) {
91+
if ($chunk->isFirst() || $chunk->isLast()) {
92+
continue;
93+
}
94+
95+
$jsonDelta = trim($chunk->getContent());
96+
97+
// Remove leading/trailing brackets
98+
if (str_starts_with($jsonDelta, '[') || str_starts_with($jsonDelta, ',')) {
99+
$jsonDelta = substr($jsonDelta, 1);
100+
}
101+
if (str_ends_with($jsonDelta, ']')) {
102+
$jsonDelta = substr($jsonDelta, 0, -1);
103+
}
104+
105+
// Split in case of multiple JSON objects
106+
$deltas = explode(",\r\n", $jsonDelta);
107+
108+
foreach ($deltas as $delta) {
109+
if ('' === $delta) {
110+
continue;
111+
}
112+
113+
try {
114+
$data = json_decode($delta, true, 512, JSON_THROW_ON_ERROR);
115+
} catch (\JsonException $e) {
116+
dump($delta);
117+
throw new RuntimeException('Failed to decode JSON response', 0, $e);
118+
}
119+
120+
if (!isset($data['candidates'][0]['content']['parts'][0]['text'])) {
121+
continue;
122+
}
123+
124+
yield $data['candidates'][0]['content']['parts'][0]['text'];
125+
}
126+
}
127+
}
128+
}

0 commit comments

Comments
 (0)