-
Notifications
You must be signed in to change notification settings - Fork 24
feat: basic support for Google Gemini models #215
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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; |
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 | ||
{ | ||
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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would it be possible to replace those explicit dated version strings with https://ai.google.dev/gemini-api/docs/models/gemini?hl=en#model-versions There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Alright, I've checked, these seem to be specific previews right now, because neither using Latest could point to preview models, so if it's going to point to that it should probably be transparent There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 |
||
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; | ||
} | ||
} |
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(); | ||
} | ||
} |
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']; | ||
} | ||
} | ||
} |
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]); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,4 +6,5 @@ | |
|
||
interface Content extends \JsonSerializable | ||
{ | ||
public function accept(ContentVisitor $visitor): array; | ||
} |
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; | ||
} |
There was a problem hiding this comment.
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?There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 👍