diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a4b263e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +#### Link to ticket + +Please add a link to the ticket being addressed by this change. + +#### Description + +Please include a short description of the suggested change and the reasoning behind the approach you have chosen. + +#### Screenshot of the result + +If your change affects the user interface you should include a screenshot of the result with the pull request. + +#### Checklist + +- [ ] My code passes our static analysis suite. +- [ ] My code passes our continuous integration process. + +If your code does not pass all the requirements on the checklist you have to add a comment explaining why this change +should be exempt from the list. + +#### Additional comments or questions + +If you have any further comments or questions for the reviewer please add them here. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..e82f27e --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,154 @@ +on: pull_request +name: Review +jobs: + changelog: + runs-on: ubuntu-latest + name: Changelog should be updated + strategy: + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 2 + + - name: Git fetch + run: git fetch + + - name: Check that changelog has been updated. + run: git diff --exit-code origin/${{ github.base_ref }} -- CHANGELOG.md && exit 1 || exit 0 + + test-composer-files: + name: Validate composer + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.3' ] + dependency-version: [ prefer-lowest, prefer-stable ] + steps: + - uses: actions/checkout@master + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Validate composer files + run: | + composer validate --strict composer.json + # Check that dependencies resolve. + composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction + + php-coding-standards: + name: PHP coding standards + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.3' ] + steps: + - uses: actions/checkout@master + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Install Dependencies + run: | + composer install --no-interaction --no-progress + + - name: PHPCS + run: | + composer coding-standards-check/phpcs + + php-code-analysis: + name: PHP code analysis + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: [ '8.3' ] + steps: + - uses: actions/checkout@master + + - name: Setup PHP, with composer and extensions + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + extensions: json + coverage: none + tools: composer:v2 + # https://github.com/shivammathur/setup-php#cache-composer-dependencies + + - name: Get composer cache directory + id: composer-cache + run: echo "::set-output name=dir::$(composer config cache-files-dir)" + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: ${{ runner.os }}-composer- + + - name: Code analysis + run: | + ./scripts/code-analysis + + markdownlint: + runs-on: ubuntu-latest + name: markdownlint + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + + - name: Cache yarn packages + uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: Yarn install + uses: actions/setup-node@v2 + with: + node-version: '20' + - run: yarn install + + - name: markdownlint + run: yarn coding-standards-check/markdownlint diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7579f74 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +vendor +composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ad9812a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ + +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +- First version of the module + +[Unreleased]: https://github.com/itk-dev/llm_services/compare/develop...HEAD diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3d12d81 --- /dev/null +++ b/composer.json @@ -0,0 +1,64 @@ +{ + "name": "itkdev/llm_services", + "type": "drupal-module", + "description": "Drupal large language module service integration module", + "minimum-stability": "dev", + "prefer-stable": true, + "license": "EUPL-1.2", + "repositories": { + "drupal": { + "type": "composer", + "url": "https://packages.drupal.org/8" + }, + "assets": { + "type": "composer", + "url": "https://asset-packagist.org" + } + }, + "require": { + "php": "^8.3", + "openai-php/client": "^0.8.5", + "theodo-group/llphant": "^0.6.10" + }, + "require-dev": { + "drupal/coder": "^8.3", + "mglaman/phpstan-drupal": "^1.2", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan-deprecation-rules": "^1.2", + "vincentlanglet/twig-cs-fixer": "^2.9" + }, + "extra" : { + "composer-exit-on-patch-failure": false, + "enable-patching" : true, + "patches": { + } + }, + "scripts": { + "code-analysis/phpstan": [ + "phpstan analyse" + ], + "code-analysis": [ + "@code-analysis/phpstan" + ], + "coding-standards-check/phpcs": [ + "phpcs --standard=phpcs.xml.dist" + ], + "coding-standards-check": [ + "@coding-standards-check/phpcs" + ], + "coding-standards-apply/phpcs": [ + "phpcbf --standard=phpcs.xml.dist" + ], + "coding-standards-apply": [ + "@coding-standards-apply/phpcs" + ] + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "phpstan/extension-installer": true, + "dealerdirect/phpcodesniffer-composer-installer": true, + "php-http/discovery": true + } + } +} diff --git a/drush.services.yml b/drush.services.yml new file mode 100644 index 0000000..21e70be --- /dev/null +++ b/drush.services.yml @@ -0,0 +1,28 @@ +services: + llm.service.completion.command: + class: Drupal\llm_services\Commands\ModelCompletionCommand + arguments: + - '@plugin.manager.llm_services' + tags: + - { name: console.command } + + llm.service.install.command: + class: Drupal\llm_services\Commands\ProviderInstallCommand + arguments: + - '@plugin.manager.llm_services' + tags: + - { name: console.command } + + llm.service.list.command: + class: Drupal\llm_services\Commands\ProviderListCommand + arguments: + - '@plugin.manager.llm_services' + tags: + - { name: console.command } + + llm.service.char.command: + class: Drupal\llm_services\Commands\ModelChatCommand + arguments: + - '@plugin.manager.llm_services' + tags: + - { name: console.command } diff --git a/llm_services.info.yml b/llm_services.info.yml new file mode 100644 index 0000000..84b3da7 --- /dev/null +++ b/llm_services.info.yml @@ -0,0 +1,5 @@ +name: "Large language model services" +description: 'Large language module services to communicate with the models.' +type: module +core_version_requirement: ^10 +configure: llm_services.plugin_settings_local_tasks diff --git a/llm_services.links.menu.yml b/llm_services.links.menu.yml new file mode 100644 index 0000000..a2f58cc --- /dev/null +++ b/llm_services.links.menu.yml @@ -0,0 +1,5 @@ +llm_services.admin_settings: + title: 'LLM services settings' + parent: system.admin_config_system + description: 'Settings for the LLM services' + route_name: llm_services.plugin_settings_local_tasks diff --git a/llm_services.links.task.yml b/llm_services.links.task.yml new file mode 100644 index 0000000..3755a6b --- /dev/null +++ b/llm_services.links.task.yml @@ -0,0 +1,5 @@ +llm_services.plugin_settings_tasks: + title: 'LLM services settings' + route_name: llm_services.plugin_settings_local_tasks + base_route: llm_services.plugin_settings_local_tasks + deriver: Drupal\llm_services\Plugin\Derivative\LocalTask diff --git a/llm_services.routing.yml b/llm_services.routing.yml new file mode 100644 index 0000000..2bc1326 --- /dev/null +++ b/llm_services.routing.yml @@ -0,0 +1,8 @@ +llm_services.plugin_settings_local_tasks: + path: '/admin/config/llm_services/settings/{type}' + defaults: + _controller: '\Drupal\llm_services\Controller\LocalTasksController::dynamicTasks' + _title: 'LLM services settings' + type: '' + requirements: + _permission: 'administer site' diff --git a/llm_services.services.yml b/llm_services.services.yml new file mode 100644 index 0000000..ba82f17 --- /dev/null +++ b/llm_services.services.yml @@ -0,0 +1,6 @@ +services: + plugin.manager.llm_services: + class: Drupal\llm_services\Plugin\LLModelProviderManager + parent: default_plugin_manager + arguments: + - '@config.factory' diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec3c847 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "license": "UNLICENSED", + "private": true, + "devDependencies": { + "markdownlint-cli": "^0.32.2" + }, + "scripts": { + "coding-standards-check/markdownlint": "yarn markdownlint --ignore LICENSE.md --ignore vendor --ignore node_modules '*.md' 'modules/llm_services/**/*.md'", + "coding-standards-check": "yarn coding-standards-check/markdownlint", + "coding-standards-apply/markdownlint": "yarn markdownlint --ignore LICENSE.md --ignore vendor --ignore node_modules '*.md' 'modules/llm_services/**/*.md' --fix", + "coding-standards-apply": "yarn coding-standards-apply/markdownlint" + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..9d4bd68 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,23 @@ + + + LLM services PHP Code Sniffer configuration + + . + vendor/ + node_modules/ + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..29de114 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,31 @@ +parameters: + level: 6 + paths: + - ./ + excludePaths: + # @see https://github.com/mglaman/drupal-check/issues/261#issuecomment-1030141772/ + - vendor + - '*/node_modules/*' + ignoreErrors: + # This is how drupal works.... + - '#Unsafe usage of new static\(\).#' + - '#\Drupal calls should be avoided in classes, use dependency injection instead#' + - '#getEditableConfigNames\(\) return type has no value type specified in iterable type array#' + - '#buildForm\(\) has parameter \$form with no value type specified in iterable type array.#' + - '#buildForm\(\) return type has no value type specified in iterable type array.#' + - '#validateForm\(\) has parameter \$form with no value type specified in iterable type array.#' + - '#submitForm\(\) has parameter \$form with no value type specified in iterable type array.#' + - '#getDerivativeDefinitions\(\) has parameter \$base_plugin_definition with no value type specified in iterable type array.#' + - '#getDerivativeDefinitions\(\) return type has no value type specified in iterable type array.#' + - '#__construct\(\) has parameter \$configuration with no value type specified in iterable type array.#' + - '#getConfiguration\(\) return type has no value type specified in iterable type array.#' + - '#setConfiguration\(\) has parameter \$configuration with no value type specified in iterable type array.#' + - '#buildConfigurationForm\(\) has parameter \$form with no value type specified in iterable type array.#' + - '#buildConfigurationForm\(\) return type has no value type specified in iterable type array.#' + - '#validateConfigurationForm\(\) has parameter \$form with no value type specified in iterable type array.#' + - '#submitConfigurationForm\(\) has parameter \$form with no value type specified in iterable type array.#' + - '#getForm\(\) invoked with 2 parameters, 1 required.#' + - '#While loop condition is always true.#' + - '#has parameter \$configuration with no value type specified in iterable type array.#' + - '#has parameter \$namespaces with no value type specified in iterable type Traversable.#' + - '#Call to an undefined method Symfony\\Component\\Console\\Helper\\HelperInterface::ask\(\).#' diff --git a/scripts/code-analysis b/scripts/code-analysis new file mode 100755 index 0000000..7977367 --- /dev/null +++ b/scripts/code-analysis @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +script_dir=$(pwd) +module_name=$(basename "$script_dir") +drupal_dir=vendor/drupal-module-code-analysis +# Relative to $drupal_dir +module_path=web/modules/contrib/$module_name + +cd "$script_dir" || exit + +drupal_composer() { + composer --working-dir="$drupal_dir" --no-interaction "$@" +} + +# Create new Drupal 9 project +if [ ! -f "$drupal_dir/composer.json" ]; then + composer --no-interaction create-project drupal/recommended-project:^10 "$drupal_dir" +fi +# Copy our code into the modules folder +mkdir -p "$drupal_dir/$module_path" +# https://stackoverflow.com/a/15373763 +rsync --archive --compress . --filter=':- .gitignore' --exclude "$drupal_dir" --exclude .git "$drupal_dir/$module_path" + +drupal_composer config minimum-stability dev + +# Allow ALL plugins +# https://getcomposer.org/doc/06-config.md#allow-plugins +drupal_composer config --no-plugins allow-plugins true + +drupal_composer require wikimedia/composer-merge-plugin +drupal_composer config extra.merge-plugin.include "$module_path/composer.json" +# https://www.drupal.org/project/drupal/issues/3220043#comment-14845434 +drupal_composer require --dev symfony/phpunit-bridge + +# Run PHPStan +(cd "$drupal_dir" && vendor/bin/phpstan --configuration="$module_path/phpstan.neon") diff --git a/src/Annotation/LLModelProvider.php b/src/Annotation/LLModelProvider.php new file mode 100644 index 0000000..2f8ad2b --- /dev/null +++ b/src/Annotation/LLModelProvider.php @@ -0,0 +1,42 @@ +> + * Basic information about the models. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + public function listLocalModels(): array { + $response = $this->call(method: 'get', uri: '/api/tags'); + $data = $response->getBody()->getContents(); + $data = json_decode($data, TRUE); + + $models = []; + foreach ($data['models'] as $item) { + $models[$item['model']] = [ + 'name' => $item['name'], + 'size' => $item['size'], + 'modified' => $item['modified_at'], + 'digest' => $item['digest'], + ]; + } + + return $models; + } + + /** + * Install/update model in Ollama. + * + * @param string $modelName + * Name of the model. + * + * @return \Generator + * The progress of installation. + * + * @see https://ollama.com/library + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + * @throws \JsonException + */ + public function install(string $modelName): \Generator { + $response = $this->call(method: 'post', uri: '/api/pull', options: [ + 'json' => [ + 'name' => $modelName, + 'stream' => TRUE, + ], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + RequestOptions::CONNECT_TIMEOUT => 10, + RequestOptions::TIMEOUT => 300, + RequestOptions::STREAM => TRUE, + ]); + + $body = $response->getBody(); + while (!$body->eof()) { + $data = $body->read(1024); + yield from $this->parse($data); + } + } + + /** + * Ask a question to the model. + * + * @param \Drupal\llm_services\Model\Payload $payload + * The question to ask the module. + * + * @return \Generator + * The response from the model as it completes. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + * @throws \JsonException + * + * @see https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-completion + */ + public function completion(Payload $payload): \Generator { + $response = $this->call(method: 'post', uri: '/api/generate', options: [ + 'json' => [ + 'model' => $payload->model, + 'prompt' => $payload->messages[0]->content, + 'stream' => TRUE, + ], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + RequestOptions::CONNECT_TIMEOUT => 10, + RequestOptions::TIMEOUT => 300, + RequestOptions::STREAM => TRUE, + ]); + + $body = $response->getBody(); + while (!$body->eof()) { + $data = $body->read(1024); + yield from $this->parse($data); + } + } + + /** + * Chat with a model. + * + * @param \Drupal\llm_services\Model\Payload $payload + * The question to ask the module and the chat history. + * + * @return \Generator + * The response from the model as it completes it. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + * @throws \JsonException + */ + public function chat(Payload $payload): \Generator { + $response = $this->call(method: 'post', uri: '/api/chat', options: [ + 'json' => [ + 'model' => $payload->model, + 'messages' => $this->chatMessagesAsArray($payload), + 'stream' => TRUE, + ], + 'headers' => [ + 'Content-Type' => 'application/json', + ], + RequestOptions::CONNECT_TIMEOUT => 10, + RequestOptions::TIMEOUT => 300, + RequestOptions::STREAM => TRUE, + ]); + + $body = $response->getBody(); + while (!$body->eof()) { + $data = $body->read(1024); + yield from $this->parse($data); + } + } + + /** + * Take all payload messages and change them into an array. + * + * This array of messages is used to give the model some chat context to make + * the interaction appear more like real char with a person. + * + * @param \Drupal\llm_services\Model\Payload $payload + * The payload sent to the chat function. + * + * @return array{content: string, role: string}[] + * Array of messages to send to Ollama. + * + * @see https://github.com/ollama/ollama/blob/main/docs/api.md#chat-request-with-history + */ + private function chatMessagesAsArray(Payload $payload): array { + $messages = []; + foreach ($payload->messages as $message) { + $messages[] = [ + 'content' => $message->content, + 'role' => $message->role->value, + ]; + } + + return $messages; + } + + /** + * Parse LLM stream. + * + * As the LLM streams the response, and we read them in chunks and given chunk + * of data may not be complete json object. So this function parses the data + * and joins chunks to make it valid parsable json. But at the same time + * yield back json results as soon as possible to make the stream seam as live + * response. + * + * @param string $data + * The data chunk to parse. + * + * @return \Generator + * Yield back json objects. + * + * @throws \JsonException + */ + private function parse(string $data): \Generator { + // Split on new-lines. + $strings = explode("\n", $data); + + foreach ($strings as $str) { + if (json_validate($str)) { + // Valid json string lets decode an yield it. + yield json_decode($str, TRUE, flags: JSON_THROW_ON_ERROR); + } + else { + // Ignore empty strings. + if (!empty($str)) { + // If cached partial json object: append else store. + if (empty($this->parserCache)) { + $this->parserCache = $str; + } + else { + $str = $this->parserCache . $str; + if (!json_validate($str)) { + // Still not json, just append until it becomes json. + $this->parserCache .= $str; + + // Nothing to yield, no complet json string yet. + return; + } + // Valid json string, yield, reset cache. + yield json_decode($str, TRUE, flags: JSON_THROW_ON_ERROR); + $this->parserCache = ''; + } + } + } + } + } + + /** + * Make request to Ollama. + * + * @param string $method + * The method to use (GET/POST). + * @param string $uri + * The API endpoint to call. + * @param array $options + * Extra options and/or payload to post. + * + * @return \Psr\Http\Message\ResponseInterface + * The response object. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + private function call(string $method, string $uri, array $options = []): ResponseInterface { + try { + $response = $this->client->request($method, $this->getUrl($uri), $options); + if ($response->getStatusCode() !== 200) { + throw new CommunicationException('Request failed', $response->getStatusCode()); + } + } + catch (GuzzleException $exception) { + throw new CommunicationException('Request failed', $exception->getCode(), $exception); + } + + return $response; + } + + /** + * Returns a URL string with the given URI appended to the base URL. + * + * @param string $uri + * The URI to append to the base URL. Default is an empty string. + * + * @return string + * The complete URL string. + */ + private function getUrl(string $uri = ''): string { + return $this->url . ':' . $this->port . ($uri ? '/' . ltrim($uri, '/') : ''); + } + +} diff --git a/src/Client/OllamaChatResponse.php b/src/Client/OllamaChatResponse.php new file mode 100644 index 0000000..229a80a --- /dev/null +++ b/src/Client/OllamaChatResponse.php @@ -0,0 +1,71 @@ + $images + * Base64 encoded array of images. + * @param bool $done + * The module completion state. + */ + public function __construct( + private string $model, + private string $content, + private MessageRoles $role, + private array $images, + private bool $done, + ) { + } + + /** + * {@inheritdoc} + */ + public function getModel(): string { + return $this->model; + } + + /** + * {@inheritdoc} + */ + public function getStatus(): bool { + return $this->done; + } + + /** + * {@inheritdoc} + */ + public function getContent(): string { + return $this->content; + } + + /** + * {@inheritdoc} + */ + public function getRole(): MessageRoles { + return $this->role; + } + + /** + * {@inheritdoc} + */ + public function getImages(): array { + return $this->images; + } + +} diff --git a/src/Client/OllamaCompletionResponse.php b/src/Client/OllamaCompletionResponse.php new file mode 100644 index 0000000..3158ca3 --- /dev/null +++ b/src/Client/OllamaCompletionResponse.php @@ -0,0 +1,63 @@ + $context + * The generated context when completed. + */ + public function __construct( + private string $model, + private string $response, + private bool $done, + private array $context, + ) { + } + + /** + * {@inheritdoc} + */ + public function getModel(): string { + return $this->model; + } + + /** + * {@inheritdoc} + */ + public function getResponse(): string { + return $this->response; + } + + /** + * {@inheritdoc} + */ + public function getStatus(): bool { + return $this->done; + } + + /** + * {@inheritdoc} + * + * @return array + * The context given from the model. + */ + public function getContext(): array { + return $this->context; + } + +} diff --git a/src/Commands/ModelChatCommand.php b/src/Commands/ModelChatCommand.php new file mode 100644 index 0000000..0c8b148 --- /dev/null +++ b/src/Commands/ModelChatCommand.php @@ -0,0 +1,141 @@ +setName('llm:model:chat') + ->setDescription('Chat with model (use ctrl+c to stop chatting)') + ->addUsage('llm:model:chat ollama llama3') + ->addArgument( + name: 'provider', + mode: InputArgument::REQUIRED, + description: 'Name of the provider (plugin).' + ) + ->addArgument( + name: 'name', + mode: InputArgument::REQUIRED, + description: 'Name of the model to use.' + ) + ->addOption( + name: 'system-prompt', + mode: InputOption::VALUE_REQUIRED, + description: 'System message to instruct the llm have to behave.', + default: 'Use the following pieces of context to answer the users question. If you don\'t know the answer, just say that you don\'t know, don\'t try to make up an answer.' + ) + ->addOption( + name: 'temperature', + mode: InputOption::VALUE_REQUIRED, + description: 'The temperature of the model. Increasing the temperature will make the model answer more creatively.', + default: '0.8' + ) + ->addOption( + name: 'top-k', + mode: InputOption::VALUE_REQUIRED, + description: 'Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers.', + default: '40' + ) + ->addOption( + name: 'top-p', + mode: InputOption::VALUE_REQUIRED, + description: 'A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.', + default: '0.9' + ); + } + + /** + * {@inheritDoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $providerName = $input->getArgument('provider'); + $name = $input->getArgument('name'); + + $systemPrompt = $input->getOption('system-prompt'); + $temperature = $input->getOption('temperature'); + $topK = $input->getOption('top-k'); + $topP = $input->getOption('top-p'); + + if (!is_numeric($temperature) || !is_numeric($topK) || !is_numeric($topP)) { + $output->writeln('Invalid input. Temperature, top-k, and top-p must be numeric values.'); + + return Command::FAILURE; + } + + $provider = $this->providerManager->createInstance($providerName); + + // Build configuration. + $payLoad = new Payload(); + $payLoad->model = $name; + $payLoad->options = [ + 'temperature' => $temperature, + 'top_k' => $topK, + 'top_p' => $topP, + ]; + $msg = new Message(); + $msg->role = MessageRoles::System; + $msg->content = $systemPrompt; + $payLoad->messages[] = $msg; + + /** @var \Symfony\Component\Console\Helper\HelperInterface $helper */ + $helper = $this->getHelper('question'); + $question = new Question('Message: ', ''); + + // Keep chatting with the user. Not optimal, but okay for now. + while (TRUE) { + // Query the next question. + $output->write("\n"); + $msg = new Message(); + $msg->role = MessageRoles::User; + $msg->content = $helper->ask($input, $output, $question); + $payLoad->messages[] = $msg; + $output->write("\n"); + + $answer = ''; + foreach ($provider->chat($payLoad) as $res) { + $output->write($res->getContent()); + $answer .= $res->getContent(); + } + $output->write("\n"); + + // Add answer as context to the next question. + $msg = new Message(); + $msg->role = MessageRoles::Assistant; + $msg->content = $answer; + $payLoad->messages[] = $msg; + } + } + +} diff --git a/src/Commands/ModelCompletionCommand.php b/src/Commands/ModelCompletionCommand.php new file mode 100644 index 0000000..3352967 --- /dev/null +++ b/src/Commands/ModelCompletionCommand.php @@ -0,0 +1,118 @@ +setName('llm:model:completion') + ->setDescription('Make a completion request to a model') + ->addUsage('llm:model:completion ollama llama3 "Why is the sky blue?') + ->addArgument( + name: 'provider', + mode: InputArgument::REQUIRED, + description: 'Name of the provider (plugin).' + ) + ->addArgument( + name: 'name', + mode: InputArgument::REQUIRED, + description: 'Name of the model to use.' + ) + ->addArgument( + name: 'prompt', + mode: InputArgument::REQUIRED, + description: 'The prompt to generate a response for.' + ) + ->addOption( + name: 'temperature', + mode: InputOption::VALUE_REQUIRED, + description: 'The temperature of the model. Increasing the temperature will make the model answer more creatively.', + default: '0.8' + ) + ->addOption( + name: 'top-k', + mode: InputOption::VALUE_REQUIRED, + description: 'Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers.', + default: '40' + ) + ->addOption( + name: 'top-p', + mode: InputOption::VALUE_REQUIRED, + description: 'A higher value (e.g., 0.95) will lead to more diverse text, while a lower value (e.g., 0.5) will generate more focused and conservative text.', + default: '0.9' + ); + } + + /** + * {@inheritDoc} + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $providerName = $input->getArgument('provider'); + $name = $input->getArgument('name'); + $prompt = $input->getArgument('prompt'); + $temperature = $input->getOption('temperature'); + $topK = $input->getOption('top-k'); + $topP = $input->getOption('top-p'); + + if (!is_numeric($temperature) || !is_numeric($topK) || !is_numeric($topP)) { + $output->writeln('Invalid input. Temperature, top-k, and top-p must be numeric values.'); + + return Command::FAILURE; + } + + // Build configuration. + $provider = $this->providerManager->createInstance($providerName); + $payLoad = new Payload(); + $payLoad->model = $name; + $payLoad->options = [ + 'temperature' => $temperature, + 'top_k' => $topK, + 'top_p' => $topP, + ]; + + // Create a completion message. + $msg = new Message(); + $msg->content = $prompt; + $payLoad->messages[] = $msg; + + foreach ($provider->completion($payLoad) as $res) { + $output->write($res->getResponse()); + } + $output->write("\n"); + + return Command::SUCCESS; + } + +} diff --git a/src/Commands/ProviderInstallCommand.php b/src/Commands/ProviderInstallCommand.php new file mode 100644 index 0000000..f0c336a --- /dev/null +++ b/src/Commands/ProviderInstallCommand.php @@ -0,0 +1,76 @@ +setName('llm:provider:install') + ->setDescription('Install model in provider') + ->addUsage('llm:provider:install ollama llama3') + ->addArgument( + name: 'provider', + mode: InputArgument::REQUIRED, + description: 'Name of the provider (plugin).' + ) + ->addArgument( + name: 'name', + mode: InputArgument::REQUIRED, + description: 'Name of the model to use.' + ); + } + + /** + * {@inheritDoc} + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $providerName = $input->getArgument('provider'); + $name = $input->getArgument('name'); + + $provider = $this->providerManager->createInstance($providerName); + + foreach ($provider->installModel($name) as $progress) { + if (isset($progress['total']) && isset($progress['completed'])) { + $percent = ($progress['completed'] / $progress['total']) * 100; + $output->writeln(sprintf('%s (%0.2f%% downloaded)', $progress['status'], $percent)); + } + else { + $output->writeln($progress['status']); + } + } + $output->write("\n"); + + return Command::SUCCESS; + } + +} diff --git a/src/Commands/ProviderListCommand.php b/src/Commands/ProviderListCommand.php new file mode 100644 index 0000000..5050619 --- /dev/null +++ b/src/Commands/ProviderListCommand.php @@ -0,0 +1,64 @@ +setName('llm:provider:list') + ->setDescription('Lists installed models in provider') + ->addUsage('llm:provider:list ollama') + ->addArgument( + name: 'provider', + mode: InputArgument::REQUIRED, + description: 'Name of the provider (plugin).' + ); + } + + /** + * {@inheritDoc} + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $providerName = $input->getArgument('provider'); + + $provider = $this->providerManager->createInstance($providerName); + $models = $provider->listModels(); + + foreach ($models as $model) { + $output->writeln($model['name'] . ' (' . $model['modified'] . ')'); + } + + return Command::SUCCESS; + } + +} diff --git a/src/Controller/LocalTasksController.php b/src/Controller/LocalTasksController.php new file mode 100644 index 0000000..43d617e --- /dev/null +++ b/src/Controller/LocalTasksController.php @@ -0,0 +1,58 @@ +formBuilder = $formBuilder; + $this->configFactory = $configFactory; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): LocalTasksController|static { + return new static( + $container->get('form_builder'), + $container->get('config.factory'), + ); + } + + /** + * Get dynamic tasks. + * + * @param string|null $type + * The type of form to retrieve. Default to NULL. + * + * @return array + * An array containing the form definition. + */ + public function dynamicTasks(string $type = NULL): array { + if (empty($type)) { + return $this->formBuilder->getForm('\Drupal\llm_services\Form\SettingsForm'); + } + + return $this->formBuilder->getForm('\Drupal\llm_services\Form\PluginSettingsForm', $type); + } + +} diff --git a/src/Exceptions/CommunicationException.php b/src/Exceptions/CommunicationException.php new file mode 100644 index 0000000..8226609 --- /dev/null +++ b/src/Exceptions/CommunicationException.php @@ -0,0 +1,10 @@ +manager = $manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container): static { + return new static( + $container->get('config.factory'), + $container->get('plugin.manager.llm_services') + ); + } + + /** + * {@inheritdoc} + */ + public static function getConfigName(): string { + return 'llm_services.plugin_settings'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames(): array { + return [$this->getConfigName()]; + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return $this->getConfigName() . '_settings_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $plugin_id = $form_state->getBuildInfo()['args'][0]; + $instance = $this->getPluginInstance($plugin_id); + $form = $instance->buildConfigurationForm($form, $form_state); + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state): void { + $plugin_id = $form_state->getBuildInfo()['args'][0]; + $instance = $this->getPluginInstance($plugin_id); + $instance->validateConfigurationForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + $plugin_id = $form_state->getBuildInfo()['args'][0]; + $instance = $this->getPluginInstance($plugin_id); + $instance->submitConfigurationForm($form, $form_state); + + $config = $this->config($this->getConfigName()); + $config->set($plugin_id, $instance->getConfiguration()); + $config->save(); + + parent::submitForm($form, $form_state); + } + + /** + * Returns plugin instance for a given plugin id. + * + * @param string $plugin_id + * The plugin_id for the plugin instance. + * + * @return object + * Plugin instance. + * + * @throws \Drupal\Component\Plugin\Exception\PluginException + */ + public function getPluginInstance(string $plugin_id): object { + $configuration = $this->config($this->getConfigName())->get($plugin_id); + + return $this->manager->createInstance($plugin_id, $configuration ?? []); + } + +} diff --git a/src/Form/PluginSettingsFormInterface.php b/src/Form/PluginSettingsFormInterface.php new file mode 100644 index 0000000..8383199 --- /dev/null +++ b/src/Form/PluginSettingsFormInterface.php @@ -0,0 +1,20 @@ +get('config.factory'), + $container->get('plugin.manager.llm_services') + ); + } + + /** + * The name of the configuration setting. + * + * @var string + */ + public static string $configName = 'llm_services.settings'; + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames(): array { + return [self::$configName]; + } + + /** + * {@inheritdoc} + */ + public function getFormId(): string { + return 'llm_services_admin_form'; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state): array { + $config = $this->config(self::$configName); + + $plugins = $this->providerManager->getDefinitions(); + ksort($plugins); + $options = array_map(function ($plugin) { + /** @var \Drupal\Core\StringTranslation\TranslatableMarkup $title */ + $title = $plugin['title']; + return $title->render(); + }, $plugins); + + $form['provider'] = [ + '#type' => 'select', + '#title' => $this->t('Provider'), + '#description' => $this->t('Select the provider you wish to use'), + '#options' => $options, + '#default_value' => $config->get('provider'), + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state): void { + parent::submitForm($form, $form_state); + + $this->config(self::$configName) + ->set('provider', $form_state->getValue('provider')) + ->save(); + } + +} diff --git a/src/Model/ChatResponseInterface.php b/src/Model/ChatResponseInterface.php new file mode 100644 index 0000000..503deb7 --- /dev/null +++ b/src/Model/ChatResponseInterface.php @@ -0,0 +1,55 @@ + + * String of base64 encoded images. + */ + public function getImages(): array; + + /** + * The completion status. + * + * @return bool + * If false, the model has more to say. + */ + public function getStatus(): bool; + +} diff --git a/src/Model/CompletionResponseInterface.php b/src/Model/CompletionResponseInterface.php new file mode 100644 index 0000000..f2aa649 --- /dev/null +++ b/src/Model/CompletionResponseInterface.php @@ -0,0 +1,45 @@ + + */ + public array $images; + +} diff --git a/src/Model/MessageRoles.php b/src/Model/MessageRoles.php new file mode 100644 index 0000000..a27e909 --- /dev/null +++ b/src/Model/MessageRoles.php @@ -0,0 +1,19 @@ + + * + * @see https://github.com/ollama/ollama/blob/main/docs/api.md#parameters-1 + */ + public array $messages; + + /** + * Additional model parameters. + * + * @var array + * + * @see https://github.com/ollama/ollama/blob/main/docs/modelfile.md#valid-parameters-and-values + */ + public array $options; + +} diff --git a/src/Plugin/Derivative/LocalTask.php b/src/Plugin/Derivative/LocalTask.php new file mode 100644 index 0000000..e034754 --- /dev/null +++ b/src/Plugin/Derivative/LocalTask.php @@ -0,0 +1,64 @@ +get('plugin.manager.llm_services') + ); + } + + /** + * {@inheritdoc} + * + * @throws \ReflectionException + */ + public function getDerivativeDefinitions($base_plugin_definition): array { + $plugins = $this->providerManager->getDefinitions(); + ksort($plugins); + + // Sadly, it seems that it is not possible to just invalidate the + // deriver/menu cache stuff. To get the local tasks menu links. So instead + // of clearing all caches on settings save to only show selected plugins, we + // show em all. + $options = array_map(function ($plugin) { + // Only the plugins that provide configuration options. + $reflector = new \ReflectionClass($plugin['class']); + if ($reflector->implementsInterface('Drupal\Component\Plugin\ConfigurableInterface')) { + /** @var \Drupal\Core\StringTranslation\TranslatableMarkup $title */ + $title = $plugin['title']; + return $title->render(); + } + }, $plugins); + + foreach (['settings' => 'Settings'] + $options as $plugin => $title) { + $this->derivatives[$plugin] = $base_plugin_definition; + $this->derivatives[$plugin]['title'] = $title; + $this->derivatives[$plugin]['route_parameters'] = ['type' => $plugin]; + if ($plugin === 'settings') { + $this->derivatives[$plugin]['route_parameters']['type'] = ''; + } + } + + return $this->derivatives; + } + +} diff --git a/src/Plugin/LLModelProviderManager.php b/src/Plugin/LLModelProviderManager.php new file mode 100644 index 0000000..84301f8 --- /dev/null +++ b/src/Plugin/LLModelProviderManager.php @@ -0,0 +1,60 @@ +configFactory = $configFactory; + + parent::__construct( + 'Plugin/LLModelProviders', + $namespaces, + $module_handler, + 'Drupal\llm_services\Plugin\LLModelProviders\LLMProviderInterface', + 'Drupal\llm_services\Annotation\LLModelProvider', + ); + + $this->alterInfo('llm_services_providers_info'); + $this->setCacheBackend($cache_backend, 'llm_services_providers_plugins'); + } + + /** + * {@inheritdoc} + */ + public function createInstance($plugin_id, array $configuration = []): LLMProviderInterface { + if (empty($configuration)) { + $configuration = $this->configFactory->get(PluginSettingsForm::getConfigName())->get($plugin_id); + } + + /** @var \Drupal\llm_services\Plugin\LLModelProviders\LLMProviderInterface $provider */ + $provider = parent::createInstance($plugin_id, $configuration); + + return $provider; + } + +} diff --git a/src/Plugin/LLModelProviders/LLMProviderInterface.php b/src/Plugin/LLModelProviders/LLMProviderInterface.php new file mode 100644 index 0000000..225ffaf --- /dev/null +++ b/src/Plugin/LLModelProviders/LLMProviderInterface.php @@ -0,0 +1,63 @@ +> + * List of supported language models. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + public function listModels(): array; + + /** + * Installs a model. + * + * @param string $modelName + * The name of the model to install. + * + * @return mixed + * The result of installing the model. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + public function installModel(string $modelName): mixed; + + /** + * Performs a completion process. + * + * @param \Drupal\llm_services\Model\Payload $payload + * The body of the completion request. It should contain the necessary data + * for completion. + * + * @return \Generator<\Drupal\llm_services\Model\CompletionResponseInterface> + * The result of the completion process. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + public function completion(Payload $payload): \Generator; + + /** + * Initiates a chat. + * + * @param \Drupal\llm_services\Model\Payload $payload + * The body of the chat request. + * + * @return \Generator<\Drupal\llm_services\Model\ChatResponseInterface> + * The result of the chat. + * + * @throws \Drupal\llm_services\Exceptions\CommunicationException + */ + public function chat(Payload $payload): \Generator; + +} diff --git a/src/Plugin/LLModelProviders/Ollama.php b/src/Plugin/LLModelProviders/Ollama.php new file mode 100644 index 0000000..ba10782 --- /dev/null +++ b/src/Plugin/LLModelProviders/Ollama.php @@ -0,0 +1,191 @@ +setConfiguration($configuration); + } + + /** + * {@inheritdoc} + * + * @return array + * List of models. + */ + public function listModels(): array { + return $this->getClient()->listLocalModels(); + } + + /** + * {@inheritdoc} + * + * @throws \JsonException + */ + public function installModel(string $modelName): \Generator|string { + return $this->getClient()->install($modelName); + } + + /** + * {@inheritdoc} + * + * @throws \JsonException + * + * @see https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-completion + */ + public function completion(Payload $payload): \Generator { + foreach ($this->getClient()->completion($payload) as $chunk) { + yield new OllamaCompletionResponse( + model: $chunk['model'], + response: $chunk['response'], + done: $chunk['done'], + context: $chunk['context'] ?? [], + ); + } + } + + /** + * {@inheritdoc} + * + * @throws \JsonException + * + * @see https://github.com/ollama/ollama/blob/main/docs/api.md#generate-a-chat-completion + */ + public function chat(Payload $payload): \Generator { + foreach ($this->getClient()->chat($payload) as $chunk) { + yield new OllamaChatResponse( + model: $chunk['model'], + content: $chunk['message']['content'] ?? '', + role: $chunk['message']['role'] ? MessageRoles::from($chunk['message']['role']) : MessageRoles::Assistant, + images: $chunk['message']['images'] ?? [], + done: $chunk['done'], + ); + } + } + + /** + * {@inheritdoc} + */ + public function getConfiguration(): array { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration): static { + $this->configuration = $configuration + $this->defaultConfiguration(); + return $this; + } + + /** + * {@inheritdoc} + * + * @return array + * Default configuration array. + */ + public function defaultConfiguration(): array { + return [ + 'url' => 'http://ollama', + 'port' => '11434', + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state): array { + $form['url'] = [ + '#type' => 'textfield', + '#title' => $this->t('The URL to connect to the Ollama API.'), + '#default_value' => $this->configuration['url'], + ]; + + $form['port'] = [ + '#type' => 'textfield', + '#title' => $this->t('The port that Ollama runs on.'), + '#default_value' => $this->configuration['port'], + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state): void { + $values = $form_state->getValues(); + + if (filter_var($values['url'], FILTER_VALIDATE_URL) === FALSE) { + $form_state->setErrorByName('url', $this->t('Invalid URL.')); + } + + $filter_options = [ + 'options' => [ + 'min_range' => 1, + 'max_range' => 65535, + ], + ]; + if (filter_var($values['port'], FILTER_VALIDATE_INT, $filter_options) === FALSE) { + $form_state->setErrorByName('port', $this->t('Invalid port range. Should be between 1 and 65535.')); + } + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void { + if (!$form_state->getErrors()) { + $values = $form_state->getValues(); + $configuration = [ + 'url' => $values['url'], + 'port' => $values['port'], + ]; + $this->setConfiguration($configuration); + } + + // Try to connect to Ollama to test the connection. + try { + $this->listModels(); + $this->messenger->addMessage('Successfully connected to Ollama'); + } + catch (\Exception $exception) { + $this->messenger->addMessage('Error communication with Ollama: ' . $exception->getMessage(), 'error'); + } + } + + /** + * Get a client. + * + * @return \Drupal\llm_services\Client\Ollama + * Client to communicate with Ollama. + */ + public function getClient(): ClientOllama { + return new ClientOllama($this->configuration['url'], $this->configuration['port'], \Drupal::httpClient()); + } + +}