| 
 | 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