From b3a826684dab590e05daa48754be05fd274ea68d Mon Sep 17 00:00:00 2001 From: Mateusz Charytoniuk <mateusz.charytoniuk@protonmail.com> Date: Thu, 18 Jan 2024 13:52:34 +0100 Subject: [PATCH] chore: Ollama setup --- .../{OllamaEmbedding.php => OllamaChat.php} | 44 +++---- ...llamaCompletion.php => OllamaGenerate.php} | 24 +--- src/Command/OllamaGenerate/Completion.php | 33 +++++ src/Command/OllamaGenerate/Embedding.php | 51 ++++++++ src/OllamaChatMessage.php | 29 +++++ src/OllamaChatRequest.php | 30 +++++ src/OllamaChatRole.php | 12 ++ src/OllamaChatSession.php | 41 ++++++ src/OllamaChatToken.php | 21 +++ src/OllamaClient.php | 122 +++++++++++++----- src/OllamaCompletionToken.php | 21 +++ src/OllamaRequestOptions.php | 4 +- 12 files changed, 354 insertions(+), 78 deletions(-) rename src/Command/{OllamaEmbedding.php => OllamaChat.php} (55%) rename src/Command/{OllamaCompletion.php => OllamaGenerate.php} (64%) create mode 100644 src/Command/OllamaGenerate/Completion.php create mode 100644 src/Command/OllamaGenerate/Embedding.php create mode 100644 src/OllamaChatMessage.php create mode 100644 src/OllamaChatRequest.php create mode 100644 src/OllamaChatRole.php create mode 100644 src/OllamaChatSession.php create mode 100644 src/OllamaChatToken.php create mode 100644 src/OllamaCompletionToken.php diff --git a/src/Command/OllamaEmbedding.php b/src/Command/OllamaChat.php similarity index 55% rename from src/Command/OllamaEmbedding.php rename to src/Command/OllamaChat.php index 9ec87ea6..656210f4 100644 --- a/src/Command/OllamaEmbedding.php +++ b/src/Command/OllamaChat.php @@ -5,26 +5,25 @@ declare(strict_types=1); namespace Distantmagic\Resonance\Command; use Distantmagic\Resonance\Attribute\ConsoleCommand; -use Distantmagic\Resonance\Command; use Distantmagic\Resonance\CoroutineCommand; -use Distantmagic\Resonance\JsonSerializer; +use Distantmagic\Resonance\OllamaChatSession; use Distantmagic\Resonance\OllamaClient; -use Distantmagic\Resonance\OllamaEmbeddingRequest; use Distantmagic\Resonance\SwooleConfiguration; -use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; #[ConsoleCommand( - name: 'ollama:embedding', - description: 'Generate LLM embedding' + name: 'ollama:chat', + description: 'Chat with LLM model through Ollama' )] -final class OllamaEmbedding extends CoroutineCommand +final class OllamaChat extends CoroutineCommand { public function __construct( - private JsonSerializer $jsonSerializer, - private OllamaClient $ollamaClient, + protected OllamaClient $ollamaClient, SwooleConfiguration $swooleConfiguration, ) { parent::__construct($swooleConfiguration); @@ -32,7 +31,6 @@ final class OllamaEmbedding extends CoroutineCommand protected function configure(): void { - $this->addArgument('prompt', InputArgument::REQUIRED); $this->addOption( default: 'mistral', mode: InputOption::VALUE_REQUIRED, @@ -48,25 +46,25 @@ final class OllamaEmbedding extends CoroutineCommand $model = $input->getOption('model'); /** - * @var string $prompt + * @var QuestionHelper $helper */ - $prompt = $input->getArgument('prompt'); + $helper = $this->getHelper('question'); + $userInputQuestion = new Question('> '); - $embeddingRequest = new OllamaEmbeddingRequest( + $chatSession = new OllamaChatSession( model: $model, - prompt: $prompt, + ollamaClient: $this->ollamaClient, ); - $embeddingResponse = $this - ->ollamaClient - ->generateEmbedding($embeddingRequest) - ; + while (true) { + $userMessageContent = $helper->ask($input, $output, $userInputQuestion); - $output->writeln( - $this - ->jsonSerializer - ->serialize($embeddingResponse) - ); + foreach ($chatSession->respond($userMessageContent) as $value) { + $output->write((string) $value); + } + + $output->writeln(''); + } return Command::SUCCESS; } diff --git a/src/Command/OllamaCompletion.php b/src/Command/OllamaGenerate.php similarity index 64% rename from src/Command/OllamaCompletion.php rename to src/Command/OllamaGenerate.php index 0d7e5e6d..9cbc8bb1 100644 --- a/src/Command/OllamaCompletion.php +++ b/src/Command/OllamaGenerate.php @@ -4,25 +4,20 @@ declare(strict_types=1); namespace Distantmagic\Resonance\Command; -use Distantmagic\Resonance\Attribute\ConsoleCommand; -use Distantmagic\Resonance\Command; use Distantmagic\Resonance\CoroutineCommand; use Distantmagic\Resonance\OllamaClient; -use Distantmagic\Resonance\OllamaCompletionRequest; use Distantmagic\Resonance\SwooleConfiguration; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -#[ConsoleCommand( - name: 'ollama:completion', - description: 'Generate LLM completion' -)] -final class OllamaCompletion extends CoroutineCommand +abstract class OllamaGenerate extends CoroutineCommand { + abstract protected function executeOllamaCommand(InputInterface $input, OutputInterface $output, string $model, string $prompt): int; + public function __construct( - private OllamaClient $ollamaClient, + protected OllamaClient $ollamaClient, SwooleConfiguration $swooleConfiguration, ) { parent::__construct($swooleConfiguration); @@ -50,15 +45,6 @@ final class OllamaCompletion extends CoroutineCommand */ $prompt = $input->getArgument('prompt'); - $completionRequest = new OllamaCompletionRequest( - model: $model, - prompt: $prompt, - ); - - foreach ($this->ollamaClient->generateCompletion($completionRequest) as $token) { - $output->write($token); - } - - return Command::SUCCESS; + return $this->executeOllamaCommand($input, $output, $model, $prompt); } } diff --git a/src/Command/OllamaGenerate/Completion.php b/src/Command/OllamaGenerate/Completion.php new file mode 100644 index 00000000..9d9d523b --- /dev/null +++ b/src/Command/OllamaGenerate/Completion.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Command\OllamaGenerate; + +use Distantmagic\Resonance\Attribute\ConsoleCommand; +use Distantmagic\Resonance\Command; +use Distantmagic\Resonance\Command\OllamaGenerate; +use Distantmagic\Resonance\OllamaCompletionRequest; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[ConsoleCommand( + name: 'ollama:completion', + description: 'Generate LLM completion' +)] +final class Completion extends OllamaGenerate +{ + protected function executeOllamaCommand(InputInterface $input, OutputInterface $output, string $model, string $prompt): int + { + $completionRequest = new OllamaCompletionRequest( + model: $model, + prompt: $prompt, + ); + + foreach ($this->ollamaClient->generateCompletion($completionRequest) as $token) { + $output->write((string) $token); + } + + return Command::SUCCESS; + } +} diff --git a/src/Command/OllamaGenerate/Embedding.php b/src/Command/OllamaGenerate/Embedding.php new file mode 100644 index 00000000..9c5ec24e --- /dev/null +++ b/src/Command/OllamaGenerate/Embedding.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance\Command\OllamaGenerate; + +use Distantmagic\Resonance\Attribute\ConsoleCommand; +use Distantmagic\Resonance\Command; +use Distantmagic\Resonance\Command\OllamaGenerate; +use Distantmagic\Resonance\JsonSerializer; +use Distantmagic\Resonance\OllamaClient; +use Distantmagic\Resonance\OllamaEmbeddingRequest; +use Distantmagic\Resonance\SwooleConfiguration; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[ConsoleCommand( + name: 'ollama:embedding', + description: 'Generate LLM embedding' +)] +final class Embedding extends OllamaGenerate +{ + public function __construct( + private JsonSerializer $jsonSerializer, + OllamaClient $ollamaClient, + SwooleConfiguration $swooleConfiguration, + ) { + parent::__construct($ollamaClient, $swooleConfiguration); + } + + protected function executeOllamaCommand(InputInterface $input, OutputInterface $output, string $model, string $prompt): int + { + $embeddingRequest = new OllamaEmbeddingRequest( + model: $model, + prompt: $prompt, + ); + + $embeddingResponse = $this + ->ollamaClient + ->generateEmbedding($embeddingRequest) + ; + + $output->writeln( + $this + ->jsonSerializer + ->serialize($embeddingResponse) + ); + + return Command::SUCCESS; + } +} diff --git a/src/OllamaChatMessage.php b/src/OllamaChatMessage.php new file mode 100644 index 00000000..a50091df --- /dev/null +++ b/src/OllamaChatMessage.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use JsonSerializable; +use Stringable; + +readonly class OllamaChatMessage implements JsonSerializable, Stringable +{ + public function __construct( + public string $content, + public OllamaChatRole $role, + ) {} + + public function __toString(): string + { + return $this->content; + } + + public function jsonSerialize(): array + { + return [ + 'content' => $this->content, + 'role' => $this->role->value, + ]; + } +} diff --git a/src/OllamaChatRequest.php b/src/OllamaChatRequest.php new file mode 100644 index 00000000..c87c64eb --- /dev/null +++ b/src/OllamaChatRequest.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use JsonSerializable; + +readonly class OllamaChatRequest implements JsonSerializable +{ + /** + * @param array<OllamaChatMessage> $messages + */ + public function __construct( + public string $model, + public array $messages, + public OllamaRequestOptions $options = new OllamaRequestOptions(), + ) {} + + public function jsonSerialize(): array + { + return [ + 'model' => $this->model, + 'messages' => $this->messages, + 'options' => $this->options, + 'raw' => true, + 'stream' => true, + ]; + } +} diff --git a/src/OllamaChatRole.php b/src/OllamaChatRole.php new file mode 100644 index 00000000..0a66628a --- /dev/null +++ b/src/OllamaChatRole.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +enum OllamaChatRole: string +{ + case Assistant = 'assistant'; + case System = 'system'; + case User = 'user'; +} diff --git a/src/OllamaChatSession.php b/src/OllamaChatSession.php new file mode 100644 index 00000000..2ff877d7 --- /dev/null +++ b/src/OllamaChatSession.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use Ds\Set; +use Generator; + +readonly class OllamaChatSession +{ + /** + * @var Set<OllamaChatMessage> + */ + private Set $messages; + + public function __construct( + public string $model, + public OllamaClient $ollamaClient, + ) { + $this->messages = new Set(); + } + + /** + * @return Generator<OllamaChatToken> + */ + public function respond(string $userMessageContent): Generator + { + $this + ->messages + ->add(new OllamaChatMessage($userMessageContent, OllamaChatRole::User)) + ; + + $chatRequest = new OllamaChatRequest( + model: $this->model, + messages: $this->messages->toArray(), + ); + + yield from $this->ollamaClient->generateChatCompletion($chatRequest); + } +} diff --git a/src/OllamaChatToken.php b/src/OllamaChatToken.php new file mode 100644 index 00000000..9e77141a --- /dev/null +++ b/src/OllamaChatToken.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use DateTimeImmutable; +use Stringable; + +readonly class OllamaChatToken implements Stringable +{ + public function __construct( + public DateTimeImmutable $createdAt, + public OllamaChatMessage $message, + ) {} + + public function __toString(): string + { + return (string) $this->message; + } +} diff --git a/src/OllamaClient.php b/src/OllamaClient.php index 28a2fb85..2d351aa0 100644 --- a/src/OllamaClient.php +++ b/src/OllamaClient.php @@ -5,8 +5,11 @@ declare(strict_types=1); namespace Distantmagic\Resonance; use CurlHandle; +use DateTimeImmutable; use Distantmagic\Resonance\Attribute\Singleton; use Generator; +use JsonSerializable; +use Psr\Log\LoggerInterface; use RuntimeException; use Swoole\Coroutine\Channel; @@ -17,6 +20,7 @@ readonly class OllamaClient public function __construct( private JsonSerializer $jsonSerializer, + private LoggerInterface $logger, private OllamaLinkBuilder $ollamaLinkBuilder, ) { $this->ch = curl_init(); @@ -33,53 +37,56 @@ readonly class OllamaClient } /** - * @return Generator<string> + * @return Generator<OllamaChatToken> */ - public function generateCompletion(OllamaCompletionRequest $request): Generator + public function generateChatCompletion(OllamaChatRequest $request): Generator { - $channel = new Channel(1); - $requestData = json_encode($request); - - $cid = go(function () use ($channel, $requestData) { - try { - curl_setopt($this->ch, CURLOPT_URL, $this->ollamaLinkBuilder->build('/api/generate')); - curl_setopt($this->ch, CURLOPT_POSTFIELDS, $requestData); - curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, false); - curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, function (CurlHandle $ch, string $data) use ($channel) { - if (!empty($data)) { - $channel->push( - $this - ->jsonSerializer - ->unserialize($data) - ); - } - - return strlen($data); - }); - - if (!curl_exec($this->ch)) { - throw new CurlException($this->ch); - } + $channel = $this->streamJson($request, '/api/chat'); - $this->assertStatusCode(200); - } finally { - curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, null); + /** + * @var SwooleChannelIterator<object{ error: string }|object{ + * created_at: string, + * message: object{ + * content: string, + * role: string, + * }, + * response: string, + * }> + */ + $swooleChannelIterator = new SwooleChannelIterator($channel); - $channel->close(); + foreach ($swooleChannelIterator as $data) { + if (isset($data->error)) { + $this->logger->error($data->error); + } else { + yield new OllamaChatToken( + createdAt: new DateTimeImmutable($data->created_at), + message: new OllamaChatMessage( + content: $data->message->content, + role: OllamaChatRole::from($data->message->role), + ) + ); } - }); - - if (!is_int($cid)) { - throw new RuntimeException('Unable to start a coroutine'); } + } + + /** + * @return Generator<OllamaCompletionToken> + */ + public function generateCompletion(OllamaCompletionRequest $request): Generator + { + $channel = $this->streamJson($request, '/api/generate'); /** - * @var SwooleChannelIterator<object{ response: string }> + * @var SwooleChannelIterator<object{ created_at: string, response: string }> */ $swooleChannelIterator = new SwooleChannelIterator($channel); foreach ($swooleChannelIterator as $token) { - yield $token->response; + yield new OllamaCompletionToken( + createdAt: new DateTimeImmutable($token->created_at), + response: $token->response, + ); } } @@ -129,4 +136,49 @@ readonly class OllamaClient $statusCode, )); } + + private function streamJson(JsonSerializable $request, string $path): Channel + { + $channel = new Channel(1); + $requestData = json_encode($request); + + $cid = go(function () use ($channel, $path, $requestData) { + try { + curl_setopt($this->ch, CURLOPT_URL, $this->ollamaLinkBuilder->build($path)); + curl_setopt($this->ch, CURLOPT_POSTFIELDS, $requestData); + curl_setopt($this->ch, CURLOPT_RETURNTRANSFER, false); + curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, function (CurlHandle $ch, string $data) use ($channel) { + $dataChunks = explode("\n", $data); + + foreach ($dataChunks as $dataChunk) { + if (!empty($dataChunk)) { + $channel->push( + $this + ->jsonSerializer + ->unserialize($dataChunk) + ); + } + } + + return strlen($data); + }); + + if (!curl_exec($this->ch)) { + throw new CurlException($this->ch); + } + + $this->assertStatusCode(200); + } finally { + curl_setopt($this->ch, CURLOPT_WRITEFUNCTION, null); + + $channel->close(); + } + }); + + if (!is_int($cid)) { + throw new RuntimeException('Unable to start a coroutine'); + } + + return $channel; + } } diff --git a/src/OllamaCompletionToken.php b/src/OllamaCompletionToken.php new file mode 100644 index 00000000..bb4947d2 --- /dev/null +++ b/src/OllamaCompletionToken.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +namespace Distantmagic\Resonance; + +use DateTimeImmutable; +use Stringable; + +readonly class OllamaCompletionToken implements Stringable +{ + public function __construct( + public DateTimeImmutable $createdAt, + public string $response, + ) {} + + public function __toString(): string + { + return $this->response; + } +} diff --git a/src/OllamaRequestOptions.php b/src/OllamaRequestOptions.php index 0f813607..24a5a62c 100644 --- a/src/OllamaRequestOptions.php +++ b/src/OllamaRequestOptions.php @@ -9,7 +9,8 @@ use JsonSerializable; readonly class OllamaRequestOptions implements JsonSerializable { public function __construct( - public float $temperature = 0.8, + public float $numPredict = -1, + public float $temperature = 0.5, public OllamaRequestStopDelimiter $stopDelimiter = new OllamaRequestStopDelimiter(), ) {} @@ -17,6 +18,7 @@ readonly class OllamaRequestOptions implements JsonSerializable { $ret = []; + $ret['num_predict'] = $this->numPredict; $ret['stop'] = $this->stopDelimiter; $ret['temperature'] = $this->temperature; -- GitLab