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());
+ }
+
+}