From 5fe9bf8bbd1d3a0fb014b2e98dfba0d1d28b808f Mon Sep 17 00:00:00 2001 From: Huong Nguyen Date: Tue, 28 Jan 2025 12:02:35 +0700 Subject: [PATCH] MDL-83006 AI: Provider Plugin - Ollama Make Ollama compatible with new features: - AI Provider instance - AI Model settings - Behat tests --- ai/classes/provider.php | 27 +++ ai/provider/azureai/classes/provider.php | 22 +- .../ollama/amd/build/modelchooser.min.js | 11 + .../ollama/amd/build/modelchooser.min.js.map | 1 + ai/provider/ollama/amd/src/modelchooser.js | 43 ++++ .../ollama/classes/abstract_processor.php | 45 +++- .../ollama/classes/aimodel/llama33.php | 93 ++++++++ .../ollama/classes/aimodel/ollama_base.php | 39 ++++ .../ollama/classes/form/action_form.php | 198 ++++++++++++++++++ .../form/action_generate_text_form.php | 66 ++++++ ai/provider/ollama/classes/helper.php | 62 ++++++ ai/provider/ollama/classes/hook_listener.php | 127 +++++++++++ .../ollama/classes/privacy/provider.php | 3 +- .../ollama/classes/process_generate_text.php | 27 +-- .../ollama/classes/process_summarise_text.php | 10 +- ai/provider/ollama/classes/provider.php | 172 ++++----------- ai/provider/ollama/db/hooks.php | 36 ++++ .../ollama/lang/en/aiprovider_ollama.php | 48 +++-- ai/provider/ollama/settings.php | 96 --------- .../tests/process_generate_text_test.php | 141 ++++++++++--- .../tests/process_summarise_text_test.php | 141 ++++++++++--- ai/provider/ollama/tests/provider_test.php | 62 ++++-- .../ollama/tests/testcase_helper_trait.php | 65 ++++++ ai/provider/openai/classes/provider.php | 22 +- ai/tests/behat/admin.feature | 15 ++ lib/apis.json | 5 + 26 files changed, 1172 insertions(+), 405 deletions(-) create mode 100644 ai/provider/ollama/amd/build/modelchooser.min.js create mode 100644 ai/provider/ollama/amd/build/modelchooser.min.js.map create mode 100644 ai/provider/ollama/amd/src/modelchooser.js create mode 100644 ai/provider/ollama/classes/aimodel/llama33.php create mode 100644 ai/provider/ollama/classes/aimodel/ollama_base.php create mode 100644 ai/provider/ollama/classes/form/action_form.php create mode 100644 ai/provider/ollama/classes/form/action_generate_text_form.php create mode 100644 ai/provider/ollama/classes/helper.php create mode 100644 ai/provider/ollama/classes/hook_listener.php create mode 100644 ai/provider/ollama/db/hooks.php delete mode 100644 ai/provider/ollama/settings.php create mode 100644 ai/provider/ollama/tests/testcase_helper_trait.php diff --git a/ai/classes/provider.php b/ai/classes/provider.php index 07ccecf877a..c28ddc3bdcb 100644 --- a/ai/classes/provider.php +++ b/ai/classes/provider.php @@ -17,6 +17,7 @@ namespace core_ai; use core_ai\form\action_settings_form; +use Psr\Http\Message\RequestInterface; use Spatie\Cloneable\Cloneable; /** @@ -187,6 +188,32 @@ abstract class provider { return false; } + /** + * Update a request to add any headers required by the provider (if needed). + * AI providers will need to override this method to add their own headers. + * + * @param RequestInterface $request + * @return RequestInterface + */ + public function add_authentication_headers(RequestInterface $request): RequestInterface { + return $request; + } + + /** + * Generate a user id. + * + * This is a hash of the site id and user id, + * this means we can determine who made the request + * but don't pass any personal data to the AI provider. + * + * @param string $userid The user id. + * @return string The generated user id. + */ + public function generate_userid(string $userid): string { + global $CFG; + return hash('sha256', $CFG->siteidentifier . $userid); + } + /** * Convert this object to a stdClass, suitable for saving to the database. * diff --git a/ai/provider/azureai/classes/provider.php b/ai/provider/azureai/classes/provider.php index a05ac39dfc4..5bd6a1a1e19 100644 --- a/ai/provider/azureai/classes/provider.php +++ b/ai/provider/azureai/classes/provider.php @@ -41,27 +41,7 @@ class provider extends \core_ai\provider { ]; } - /** - * Generate a user id. - * - * This is a hash of the site id and user id, - * this means we can determine who made the request - * but don't pass any personal data to AzureAI. - * - * @param string $userid The user id. - * @return string The generated user id. - */ - public function generate_userid(string $userid): string { - global $CFG; - return hash('sha256', $CFG->siteidentifier . $userid); - } - - /** - * Update a request to add any headers required by the provider. - * - * @param RequestInterface $request - * @return RequestInterface - */ + #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { return $request ->withAddedHeader('api-key', $this->config['apikey']); diff --git a/ai/provider/ollama/amd/build/modelchooser.min.js b/ai/provider/ollama/amd/build/modelchooser.min.js new file mode 100644 index 00000000000..80b4b31312b --- /dev/null +++ b/ai/provider/ollama/amd/build/modelchooser.min.js @@ -0,0 +1,11 @@ +define("aiprovider_ollama/modelchooser",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; +/** + * AI provider model selection handler. + * + * @module aiprovider_ollama/modelchooser + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +const Selectors_fields={selector:'[data-modelchooser-field="selector"]',updateButton:'[data-modelchooser-field="updateButton"]'};_exports.init=()=>{const modelSelector=document.querySelector(Selectors_fields.selector);modelSelector&&modelSelector.addEventListener("change",(e=>{modelSelector.options[e.target.selectedIndex].selected=!0;e.target.closest("form").querySelector(Selectors_fields.updateButton).click()}))}})); + +//# sourceMappingURL=modelchooser.min.js.map \ No newline at end of file diff --git a/ai/provider/ollama/amd/build/modelchooser.min.js.map b/ai/provider/ollama/amd/build/modelchooser.min.js.map new file mode 100644 index 00000000000..e2dad856fc8 --- /dev/null +++ b/ai/provider/ollama/amd/build/modelchooser.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"modelchooser.min.js","sources":["../src/modelchooser.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/ //\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * AI provider model selection handler.\n *\n * @module aiprovider_ollama/modelchooser\n * @copyright 2025 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst Selectors = {\n fields: {\n selector: '[data-modelchooser-field=\"selector\"]',\n updateButton: '[data-modelchooser-field=\"updateButton\"]',\n },\n};\n\n/**\n * Initialise the AI provider chooser.\n */\nexport const init = () => {\n const modelSelector = document.querySelector(Selectors.fields.selector);\n if (modelSelector) {\n modelSelector.addEventListener('change', e => {\n modelSelector.options[e.target.selectedIndex].selected = true;\n const form = e.target.closest('form');\n const updateButton = form.querySelector(Selectors.fields.updateButton);\n updateButton.click();\n });\n }\n};\n"],"names":["Selectors","selector","updateButton","modelSelector","document","querySelector","addEventListener","e","options","target","selectedIndex","selected","closest","click"],"mappings":";;;;;;;;MAsBMA,iBACM,CACJC,SAAU,uCACVC,aAAc,0DAOF,WACVC,cAAgBC,SAASC,cAAcL,iBAAiBC,UAC1DE,eACAA,cAAcG,iBAAiB,UAAUC,IACrCJ,cAAcK,QAAQD,EAAEE,OAAOC,eAAeC,UAAW,EAC5CJ,EAAEE,OAAOG,QAAQ,QACJP,cAAcL,iBAAiBE,cAC5CW"} \ No newline at end of file diff --git a/ai/provider/ollama/amd/src/modelchooser.js b/ai/provider/ollama/amd/src/modelchooser.js new file mode 100644 index 00000000000..2e10eb74556 --- /dev/null +++ b/ai/provider/ollama/amd/src/modelchooser.js @@ -0,0 +1,43 @@ +// This file is part of Moodle - http://moodle.org/ // +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * AI provider model selection handler. + * + * @module aiprovider_ollama/modelchooser + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +const Selectors = { + fields: { + selector: '[data-modelchooser-field="selector"]', + updateButton: '[data-modelchooser-field="updateButton"]', + }, +}; + +/** + * Initialise the AI provider chooser. + */ +export const init = () => { + const modelSelector = document.querySelector(Selectors.fields.selector); + if (modelSelector) { + modelSelector.addEventListener('change', e => { + modelSelector.options[e.target.selectedIndex].selected = true; + const form = e.target.closest('form'); + const updateButton = form.querySelector(Selectors.fields.updateButton); + updateButton.click(); + }); + } +}; diff --git a/ai/provider/ollama/classes/abstract_processor.php b/ai/provider/ollama/classes/abstract_processor.php index 21544afc138..181f485f2e4 100644 --- a/ai/provider/ollama/classes/abstract_processor.php +++ b/ai/provider/ollama/classes/abstract_processor.php @@ -17,9 +17,9 @@ namespace aiprovider_ollama; use core\http_client; -use core_ai\aiactions\responses\response_base; use core_ai\process_base; use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Uri; use GuzzleHttp\RequestOptions; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -38,14 +38,46 @@ abstract class abstract_processor extends process_base { * * @return UriInterface */ - abstract protected function get_endpoint(): UriInterface; + protected function get_endpoint(): UriInterface { + $url = rtrim($this->provider->config['endpoint'], '/') + . '/api/generate/'; + + return new Uri($url); + } /** * Get the name of the model to use. * * @return string */ - abstract protected function get_model(): string; + protected function get_model(): string { + return $this->provider->actionconfig[$this->action::class]['settings']['model']; + } + + /** + * Get the model settings. + * + * @return array + */ + protected function get_model_settings(): array { + $settings = $this->provider->actionconfig[$this->action::class]['settings']; + if (!empty($settings['modelextraparams'])) { + // Custom model settings. + $params = json_decode($settings['modelextraparams'], true); + foreach ($params as $key => $param) { + $settings[$key] = $param; + } + } + + // Unset unnecessary settings. + unset( + $settings['model'], + $settings['systeminstruction'], + $settings['providerid'], + $settings['modelextraparams'], + ); + return $settings; + } /** * Get the system instructions. @@ -53,16 +85,17 @@ abstract class abstract_processor extends process_base { * @return string */ protected function get_system_instruction(): string { - return $this->action->get_system_instruction(); + return $this->action::get_system_instruction(); } /** * Create the request object to send to the Ollama API. * This object contains all the required parameters for the request. * + * @param string $userid The user id. * @return RequestInterface The request object to send to the Ollama API. */ - abstract protected function create_request_object(): RequestInterface; + abstract protected function create_request_object(string $userid): RequestInterface; /** * Handle a successful response from the external AI api. @@ -74,7 +107,7 @@ abstract class abstract_processor extends process_base { #[\Override] protected function query_ai_api(): array { - $request = $this->create_request_object(); + $request = $this->create_request_object($this->provider->generate_userid($this->action->get_configuration('userid'))); $request = $this->provider->add_authentication_headers($request); $client = \core\di::get(http_client::class); diff --git a/ai/provider/ollama/classes/aimodel/llama33.php b/ai/provider/ollama/classes/aimodel/llama33.php new file mode 100644 index 00000000000..308908b8afe --- /dev/null +++ b/ai/provider/ollama/classes/aimodel/llama33.php @@ -0,0 +1,93 @@ +. + +namespace aiprovider_ollama\aimodel; + +use core_ai\aimodel\base; +use MoodleQuickForm; + +/** + * Llama 3.3 AI model. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class llama33 extends base implements ollama_base { + + #[\Override] + public function get_model_name(): string { + return 'llama3.3'; + } + + #[\Override] + public function get_model_display_name(): string { + return 'Llama 3.3'; + } + + #[\Override] + public function has_model_settings(): bool { + return true; + } + + #[\Override] + public function add_model_settings(MoodleQuickForm $mform): void { + $mform->addElement( + 'text', + 'mirostat', + get_string('settings_mirostat', 'aiprovider_ollama'), + ); + $mform->setType('mirostat', PARAM_INT); + $mform->addHelpButton('mirostat', 'settings_mirostat', 'aiprovider_ollama'); + + $mform->addElement( + 'text', + 'temperature', + get_string('settings_temperature', 'aiprovider_ollama'), + ); + $mform->setType('temperature', PARAM_FLOAT); + $mform->addHelpButton('temperature', 'settings_temperature', 'aiprovider_ollama'); + + $mform->addElement( + 'text', + 'seed', + get_string('settings_seed', 'aiprovider_ollama'), + ); + $mform->setType('seed', PARAM_INT); + $mform->addHelpButton('seed', 'settings_seed', 'aiprovider_ollama'); + + $mform->addElement( + 'text', + 'top_k', + get_string('settings_top_k', 'aiprovider_ollama'), + ); + $mform->setType('top_k', PARAM_FLOAT); + $mform->addHelpButton('top_k', 'settings_top_k', 'aiprovider_ollama'); + + $mform->addElement( + 'text', + 'top_p', + get_string('settings_top_p', 'aiprovider_ollama'), + ); + $mform->setType('top_p', PARAM_FLOAT); + $mform->addHelpButton('top_p', 'settings_top_p', 'aiprovider_ollama'); + } + + #[\Override] + public function model_type(): int { + return self::MODEL_TYPE_TEXT; + } +} diff --git a/ai/provider/ollama/classes/aimodel/ollama_base.php b/ai/provider/ollama/classes/aimodel/ollama_base.php new file mode 100644 index 00000000000..ca107a15bbe --- /dev/null +++ b/ai/provider/ollama/classes/aimodel/ollama_base.php @@ -0,0 +1,39 @@ +. + +namespace aiprovider_ollama\aimodel; + +/** + * Ollama base AI model interface. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface ollama_base { + + /** @var int MODEL_TYPE_TEXT Text model type. */ + public const MODEL_TYPE_TEXT = 1; + /** @var int MODEL_TYPE_IMAGE Image model type. */ + public const MODEL_TYPE_IMAGE = 2; + + /** + * Get model type. + * + * @return int Model type. + */ + public function model_type(): int; +} diff --git a/ai/provider/ollama/classes/form/action_form.php b/ai/provider/ollama/classes/form/action_form.php new file mode 100644 index 00000000000..aa6669111ef --- /dev/null +++ b/ai/provider/ollama/classes/form/action_form.php @@ -0,0 +1,198 @@ +. + +namespace aiprovider_ollama\form; + +use aiprovider_ollama\helper; +use core_ai\form\action_settings_form; + +/** + * Base action settings form for Ollama provider. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class action_form extends action_settings_form { + /** + * @var array Action configuration. + */ + protected array $actionconfig; + /** + * @var string|null Return URL. + */ + protected ?string $returnurl; + /** + * @var string Action name. + */ + protected string $actionname; + /** + * @var string Action class. + */ + protected string $action; + /** + * @var int Provider ID. + */ + protected int $providerid; + /** + * @var string Provider name. + */ + protected string $providername; + + #[\Override] + protected function definition(): void { + $mform = $this->_form; + $this->actionconfig = $this->_customdata['actionconfig']['settings'] ?? []; + $this->returnurl = $this->_customdata['returnurl'] ?? null; + $this->actionname = $this->_customdata['actionname']; + $this->action = $this->_customdata['action']; + $this->providerid = $this->_customdata['providerid'] ?? 0; + $this->providername = $this->_customdata['providername'] ?? 'aiprovider_ollama'; + + $mform->addElement('header', 'generalsettingsheader', get_string('general', 'core')); + } + + #[\Override] + public function set_data($data): void { + if (!empty($data['modelextraparams'])) { + $data['modelextraparams'] = json_encode(json_decode($data['modelextraparams']), JSON_PRETTY_PRINT); + } + parent::set_data($data); + } + + #[\Override] + public function get_data(): ?\stdClass { + $data = parent::get_data(); + + if ($data) { + if (isset($data->modeltemplate)) { + if ($data->modeltemplate === 'custom') { + $data->model = $data->custommodel; + } else { + // Set the model to the selected model template. + $data->model = $data->modeltemplate; + } + + } + // Unset the model template. + unset($data->custommodel); + unset($data->modeltemplate); + + // Unset any false-y values. + $data = (object) array_filter((array) $data); + } + + return $data; + } + + #[\Override] + public function validation($data, $files): array { + $errors = parent::validation($data, $files); + + // Validate the extra parameters. + if (!empty($data['modelextraparams'])) { + json_decode($data['modelextraparams']); + if (json_last_error() !== JSON_ERROR_NONE) { + $errors['modelextraparams'] = get_string('invalidjson', 'aiprovider_ollama'); + } + } + + // Validate the model. + if ($data['modeltemplate'] === 'custom' && empty($data['custommodel'])) { + $errors['custommodel'] = get_string('required'); + } + + return $errors; + } + + #[\Override] + public function get_defaults(): array { + $data = parent::get_defaults(); + + unset( + $data['modeltemplate'], + $data['custommodel'], + $data['modelextraparams'], + ); + + return $data; + } + + /** + * Add model fields to the form. + * + * @param int $modeltype Model type. + */ + protected function add_model_fields(int $modeltype): void { + global $PAGE; + $PAGE->requires->js_call_amd('aiprovider_ollama/modelchooser', 'init'); + $mform = $this->_form; + + // Action model to use. + $mform->addElement( + 'select', + 'modeltemplate', + get_string("action:{$this->actionname}:model", 'aiprovider_ollama'), + $this->get_model_list($modeltype), + ['data-modelchooser-field' => 'selector'], + ); + $mform->setType('modeltemplate', PARAM_TEXT); + $mform->addRule('modeltemplate', null, 'required', null, 'client'); + if (!empty($this->actionconfig['model']) && + (!array_key_exists($this->actionconfig['model'], $this->get_model_list($modeltype)) || + !empty($this->actionconfig['modelextraparams']))) { + $defaultmodel = 'custom'; + } else { + $defaultmodel = $this->actionconfig['model'] ?? 'llama3.3'; + } + $mform->setDefault('modeltemplate', $defaultmodel); + $mform->addHelpButton('modeltemplate', "action:{$this->actionname}:model", 'aiprovider_ollama'); + + $mform->addElement('hidden', 'model', $this->actionconfig['model'] ?? 'llama3.3'); + $mform->setType('model', PARAM_TEXT); + + $mform->addElement('text', 'custommodel', get_string('custom_model_name', 'aiprovider_ollama')); + $mform->setType('custommodel', PARAM_TEXT); + $mform->setDefault('custommodel', $this->actionconfig['model'] ?? ''); + $mform->hideIf('custommodel', 'modeltemplate', 'neq', 'custom'); + + $mform->registerNoSubmitButton('updateactionsettings'); + $mform->addElement( + 'submit', + 'updateactionsettings', + 'updateactionsettings', + ['data-modelchooser-field' => 'updateButton', 'class' => 'd-none'] + ); + } + + /** + * Get the list of models. + * + * @param int $modeltype Model type. + * @return array List of models. + */ + protected function get_model_list(int $modeltype): array { + $models = []; + $models['custom'] = get_string('custom', 'core_form'); + foreach (helper::get_model_classes() as $class) { + $model = new $class(); + if ($model->model_type() == $modeltype) { + $models[$model->get_model_name()] = $model->get_model_display_name(); + } + } + return $models; + } +} diff --git a/ai/provider/ollama/classes/form/action_generate_text_form.php b/ai/provider/ollama/classes/form/action_generate_text_form.php new file mode 100644 index 00000000000..0582d8ded2b --- /dev/null +++ b/ai/provider/ollama/classes/form/action_generate_text_form.php @@ -0,0 +1,66 @@ +. + +namespace aiprovider_ollama\form; + +use aiprovider_ollama\aimodel\ollama_base; + +/** + * Base action settings form for Ollama provider. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class action_generate_text_form extends action_form { + #[\Override] + protected function definition(): void { + parent::definition(); + $mform = $this->_form; + + $this->add_model_fields(ollama_base::MODEL_TYPE_TEXT); + + // System Instructions. + $mform->addElement( + 'textarea', + 'systeminstruction', + get_string("action:{$this->actionname}:systeminstruction", 'aiprovider_ollama'), + 'wrap="virtual" rows="5" cols="20"', + ); + $mform->setType('systeminstruction', PARAM_TEXT); + $mform->setDefault('systeminstruction', $actionconfig['systeminstruction'] ?? $this->action::get_system_instruction()); + $mform->addHelpButton('systeminstruction', "action:{$this->actionname}:systeminstruction", 'aiprovider_ollama'); + + if ($this->returnurl) { + $mform->addElement('hidden', 'returnurl', $this->returnurl); + $mform->setType('returnurl', PARAM_LOCALURL); + } + + // Add the action class as a hidden field. + $mform->addElement('hidden', 'action', $this->action); + $mform->setType('action', PARAM_TEXT); + + // Add the provider class as a hidden field. + $mform->addElement('hidden', 'provider', $this->providername); + $mform->setType('provider', PARAM_TEXT); + + // Add the provider id as a hidden field. + $mform->addElement('hidden', 'providerid', $this->providerid); + $mform->setType('providerid', PARAM_INT); + + $this->set_data($this->actionconfig); + } +} diff --git a/ai/provider/ollama/classes/helper.php b/ai/provider/ollama/classes/helper.php new file mode 100644 index 00000000000..afbbffc8844 --- /dev/null +++ b/ai/provider/ollama/classes/helper.php @@ -0,0 +1,62 @@ +. + +namespace aiprovider_ollama; + +use core_ai\aimodel\base; + +/** + * Helper class for the Ollama provider. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class helper { + + /** + * Get all model classes. + * + * @return array Array of model classes. + */ + public static function get_model_classes(): array { + $models = []; + $modelclasses = \core_component::get_component_classes_in_namespace('aiprovider_ollama', 'aimodel'); + foreach ($modelclasses as $class => $path) { + if (!class_exists($class) || !is_a($class, base::class, true)) { + throw new \coding_exception("Model class not valid: {$class}"); + } + $models[] = $class; + } + return $models; + } + + /** + * Get model class by name. + * + * @param string $modelname Model name. + * @return base|null + */ + public static function get_model_class(string $modelname): ?base { + foreach (static::get_model_classes() as $classname) { + $model = new $classname(); + if ($model->get_model_name() === $modelname) { + return $model; + } + } + return null; + } +} diff --git a/ai/provider/ollama/classes/hook_listener.php b/ai/provider/ollama/classes/hook_listener.php new file mode 100644 index 00000000000..ec9b7e313ec --- /dev/null +++ b/ai/provider/ollama/classes/hook_listener.php @@ -0,0 +1,127 @@ +. + +namespace aiprovider_ollama; + +use core_ai\hook\after_ai_action_settings_form_hook; +use core_ai\hook\after_ai_provider_form_hook; + +/** + * Hook listener for Ollama Provider. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_listener { + + /** + * Hook listener for the Ollama instance setup form. + * + * @param after_ai_provider_form_hook $hook The hook to add to the AI instance setup. + */ + public static function set_form_definition_for_aiprovider_ollama(after_ai_provider_form_hook $hook): void { + if ($hook->plugin !== 'aiprovider_ollama') { + return; + } + + $mform = $hook->mform; + + // Setting to store Ollama endpoint URL. + $mform->addElement( + 'text', + 'endpoint', + get_string('endpoint', 'aiprovider_ollama'), + ['size' => 25], + ); + $mform->setType('endpoint', PARAM_URL); + $mform->addHelpButton('endpoint', 'endpoint', 'aiprovider_ollama'); + $mform->addRule('endpoint', get_string('required'), 'required', null, 'client'); + $mform->setDefault('endpoint', 'http://localhost:11434'); + + // Checkbox to enable basic auth settings. + $mform->addElement( + 'checkbox', + 'enablebasicauth', + get_string('enablebasicauth', 'aiprovider_ollama') + ); + $mform->setType('enablebasicauth', PARAM_INT); + $mform->addHelpButton('enablebasicauth', 'enablebasicauth', 'aiprovider_ollama'); + $mform->setDefault('enablebasicauth', 0); + + // Username for basic auth. + $mform->addElement( + 'text', + 'username', + get_string('username', 'aiprovider_ollama'), + ); + $mform->setType('username', PARAM_TEXT); + $mform->addHelpButton('username', 'username', 'aiprovider_ollama'); + $mform->hideIf('username', 'enablebasicauth', 'notchecked'); + + // Password for basic auth. + // Username for basic auth. + $mform->addElement( + 'passwordunmask', + 'password', + get_string('password', 'aiprovider_ollama'), + ); + $mform->setType('password', PARAM_TEXT); + $mform->addHelpButton('password', 'password', 'aiprovider_ollama'); + $mform->hideIf('password', 'enablebasicauth', 'notchecked'); + } + + /** + * Hook listener for the Ollama action settings form. + * + * @param after_ai_action_settings_form_hook $hook The hook to add to config action settings. + */ + public static function set_model_form_definition_for_aiprovider_ollama(after_ai_action_settings_form_hook $hook): void { + if ($hook->plugin !== 'aiprovider_ollama') { + return; + } + + $mform = $hook->mform; + if (isset($mform->_elementIndex['modeltemplate'])) { + $model = $mform->getElementValue('modeltemplate'); + if (is_array($model)) { + $model = $model[0]; + } + + if ($model == 'custom') { + $mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_ollama')); + $mform->addElement('html', get_string('settings_help', 'aiprovider_ollama')); + $mform->addElement( + 'textarea', + 'modelextraparams', + get_string('extraparams', 'aiprovider_ollama'), + ['rows' => 5, 'cols' => 20], + ); + $mform->setType('modelextraparams', PARAM_TEXT); + $mform->addElement('static', 'modelextraparams_help', null, get_string('extraparams_help', 'aiprovider_ollama')); + } else { + $targetmodel = helper::get_model_class($model); + if ($targetmodel) { + if ($targetmodel->has_model_settings()) { + $mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_ollama')); + $mform->addElement('html', get_string('settings_help', 'aiprovider_ollama')); + $targetmodel->add_model_settings($mform); + } + } + } + } + } +} diff --git a/ai/provider/ollama/classes/privacy/provider.php b/ai/provider/ollama/classes/privacy/provider.php index 7156eeda8dc..a92646028fd 100644 --- a/ai/provider/ollama/classes/privacy/provider.php +++ b/ai/provider/ollama/classes/privacy/provider.php @@ -33,8 +33,7 @@ use core_privacy\local\request\userlist; class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, - \core_privacy\local\request\plugin\provider -{ + \core_privacy\local\request\plugin\provider { #[\Override] public static function get_metadata(collection $collection): collection { $collection->add_external_location_link('aiprovider_ollama', [ diff --git a/ai/provider/ollama/classes/process_generate_text.php b/ai/provider/ollama/classes/process_generate_text.php index ff3389a9ddc..085013d6398 100644 --- a/ai/provider/ollama/classes/process_generate_text.php +++ b/ai/provider/ollama/classes/process_generate_text.php @@ -17,10 +17,8 @@ namespace aiprovider_ollama; use GuzzleHttp\Psr7\Request; -use GuzzleHttp\Psr7\Uri; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\UriInterface; /** * Class process text generation. @@ -30,30 +28,20 @@ use Psr\Http\Message\UriInterface; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_generate_text extends abstract_processor { - #[\Override] - protected function get_endpoint(): UriInterface { - $url = rtrim(get_config('aiprovider_ollama', 'endpoint'), '/') - . '/api/generate'; - return new Uri($url); - } - - #[\Override] - protected function get_model(): string { - return get_config('aiprovider_ollama', 'action_generate_text_model'); - } - #[\Override] protected function get_system_instruction(): string { - return get_config('aiprovider_ollama', 'action_generate_text_systeminstruction'); + return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } #[\Override] - protected function create_request_object(): RequestInterface { + protected function create_request_object(string $userid): RequestInterface { // Create the request object. $requestobj = new \stdClass(); $requestobj->model = $this->get_model(); $requestobj->stream = false; $requestobj->prompt = $this->action->get_configuration('prompttext'); + $requestobj->user = $userid; + $requestobj->options = new \stdClass(); // If there is a system string available, use it. $systeminstruction = $this->get_system_instruction(); @@ -61,6 +49,12 @@ class process_generate_text extends abstract_processor { $requestobj->system = $systeminstruction; } + // Append the extra model settings. + $modelsettings = $this->get_model_settings(); + foreach ($modelsettings as $setting => $value) { + $requestobj->options->$setting = $value; + } + return new Request( method: 'POST', uri: '', @@ -87,6 +81,7 @@ class process_generate_text extends abstract_processor { 'finishreason' => $bodyobj->done_reason, 'prompttokens' => $bodyobj->prompt_eval_count, 'completiontokens' => $bodyobj->eval_count, + 'model' => $bodyobj->model ?? $this->get_model(), // Fallback to config model. ]; } } diff --git a/ai/provider/ollama/classes/process_summarise_text.php b/ai/provider/ollama/classes/process_summarise_text.php index 006b738e051..2268121d407 100644 --- a/ai/provider/ollama/classes/process_summarise_text.php +++ b/ai/provider/ollama/classes/process_summarise_text.php @@ -16,9 +16,6 @@ namespace aiprovider_ollama; -use GuzzleHttp\Psr7\Uri; -use Psr\Http\Message\UriInterface; - /** * Class process text summarisation. * @@ -27,13 +24,8 @@ use Psr\Http\Message\UriInterface; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_summarise_text extends process_generate_text { - #[\Override] - protected function get_model(): string { - return get_config('aiprovider_ollama', 'action_summarise_text_model'); - } - #[\Override] protected function get_system_instruction(): string { - return get_config('aiprovider_ollama', 'action_summarise_text_systeminstruction'); + return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } } diff --git a/ai/provider/ollama/classes/provider.php b/ai/provider/ollama/classes/provider.php index d96ef6180bd..c0e4f54ff2c 100644 --- a/ai/provider/ollama/classes/provider.php +++ b/ai/provider/ollama/classes/provider.php @@ -16,8 +16,7 @@ namespace aiprovider_ollama; -use core_ai\aiactions; -use core_ai\rate_limiter; +use core_ai\form\action_settings_form; use Psr\Http\Message\RequestInterface; /** @@ -28,153 +27,60 @@ use Psr\Http\Message\RequestInterface; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider extends \core_ai\provider { - /** @var bool Is basic authentication enabled. */ - private bool $basicauthenabled; - /** @var string The basic auth username. */ - private string $username; - - /** @var string The basic auth password. */ - private string $password; - - /** @var bool Is global rate limiting for the API enabled. */ - private bool $enableglobalratelimit; - - /** @var int The global rate limit. */ - private int $globalratelimit; - - /** @var bool Is user rate limiting for the API enabled */ - private bool $enableuserratelimit; - - /** @var int The user rate limit. */ - private int $userratelimit; - - /** - * Class constructor. - */ - public function __construct() { - // Basic auth enabled. - $this->basicauthenabled = get_config('aiprovider_ollama', 'enablebasicauth'); - // Get basic auth username from config. - $this->username = get_config('aiprovider_ollama', 'username'); - // Get basic auth password from config. - $this->password = get_config('aiprovider_ollama', 'password'); - // Get global rate limit from config. - $this->enableglobalratelimit = get_config('aiprovider_ollama', 'enableglobalratelimit'); - $this->globalratelimit = get_config('aiprovider_ollama', 'globalratelimit'); - // Get user rate limit from config. - $this->enableuserratelimit = get_config('aiprovider_ollama', 'enableuserratelimit'); - $this->userratelimit = get_config('aiprovider_ollama', 'userratelimit'); - } - - /** - * Get the list of actions that this provider supports. - * - * @return array An array of action class names. - */ - public function get_action_list(): array { + #[\Override] + public static function get_action_list(): array { return [ \core_ai\aiactions\generate_text::class, \core_ai\aiactions\summarise_text::class, ]; } + #[\Override] + public static function get_action_settings( + string $action, + array $customdata = [], + ): action_settings_form|bool { + $actionname = substr($action, (strrpos($action, '\\') + 1)); + $customdata['actionname'] = $actionname; + $customdata['action'] = $action; + $customdata['providername'] = 'aiprovider_ollama'; + if ($actionname === 'generate_text' || $actionname === 'summarise_text') { + return new form\action_generate_text_form(customdata: $customdata); + } - /** - * Update a request to add any headers required by the provider. - * - * @param \Psr\Http\Message\RequestInterface $request - * @return \Psr\Http\Message\RequestInterface - */ + return false; + } + + #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { - if (!$this->basicauthenabled) { + if (empty($this->config['basicauthenabled'])) { return $request; } else { - // Add the Authorization header for basic auth - $authHeader = 'Basic ' . base64_encode($this->username . ':' . $this->password); - return $request - ->withAddedHeader('Authorization', $authHeader); + // Add the Authorization header for basic auth. + $authheader = 'Basic ' . base64_encode($this->config['username'] . ':' . $this->config['password']); + return $request->withAddedHeader('Authorization', $authheader); } } - /** - * Check if the request is allowed by the rate limiter. - * - * @param aiactions\base $action The action to check. - * @return array|bool True on success, array of error details on failure. - */ - public function is_request_allowed(aiactions\base $action): array|bool { - $ratelimiter = \core\di::get(rate_limiter::class); - $component = \core\component::get_component_from_classname(get_class($this)); - - // Check the user rate limit. - if ($this->enableuserratelimit) { - if (!$ratelimiter->check_user_rate_limit( - component: $component, - ratelimit: $this->userratelimit, - userid: $action->get_configuration('userid') - )) { - return [ - 'success' => false, - 'errorcode' => 429, - 'errormessage' => 'User rate limit exceeded', - ]; - } - } - - // Check the global rate limit. - if ($this->enableglobalratelimit) { - if (!$ratelimiter->check_global_rate_limit( - component: $component, - ratelimit: $this->globalratelimit - )) { - return [ - 'success' => false, - 'errorcode' => 429, - 'errormessage' => 'Global rate limit exceeded', - ]; - } - } - - return true; - } - - /** - * Get any action settings for this provider. - * - * @param string $action The action class name. - * @param \admin_root $ADMIN The admin root object. - * @param string $section The section name. - * @param bool $hassiteconfig Whether the current user has moodle/site:config capability. - * @return array An array of settings. - */ - public function get_action_settings( - string $action, - \admin_root $ADMIN, - string $section, - bool $hassiteconfig - ): array { - $actionname = substr($action, (strrpos($action, '\\') + 1)); - $settings = []; + #[\Override] + public static function get_action_setting_defaults(string $action): array { + $actionname = substr($action, (strrpos($action, '\\') + 1)); + $customdata = [ + 'actionname' => $actionname, + 'action' => $action, + 'providername' => 'aiprovider_ollama', + ]; if ($actionname === 'generate_text' || $actionname === 'summarise_text') { - // Add the model setting. - $settings[] = new \admin_setting_configtext( - "aiprovider_ollama/action_{$actionname}_model", - new \lang_string("action:{$actionname}:model", 'aiprovider_ollama'), - new \lang_string("action:{$actionname}:model_desc", 'aiprovider_ollama'), - 'llama3.1:8b', - PARAM_TEXT, - ); - // Add system instruction settings. - $settings[] = new \admin_setting_configtextarea( - "aiprovider_ollama/action_{$actionname}_systeminstruction", - new \lang_string("action:{$actionname}:systeminstruction", 'aiprovider_ollama'), - new \lang_string("action:{$actionname}:systeminstruction_desc", 'aiprovider_ollama'), - $action::get_system_instruction(), - PARAM_TEXT - ); + $mform = new form\action_generate_text_form(customdata: $customdata); + return $mform->get_defaults(); } - return $settings; + return []; + } + + #[\Override] + public function is_provider_configured(): bool { + return !empty($this->config['endpoint']); } } diff --git a/ai/provider/ollama/db/hooks.php b/ai/provider/ollama/db/hooks.php new file mode 100644 index 00000000000..26943a8855e --- /dev/null +++ b/ai/provider/ollama/db/hooks.php @@ -0,0 +1,36 @@ +. + +/** + * Hook listener callbacks for the Ollama Provider. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => \core_ai\hook\after_ai_provider_form_hook::class, + 'callback' => \aiprovider_ollama\hook_listener::class . '::set_form_definition_for_aiprovider_ollama', + ], + [ + 'hook' => \core_ai\hook\after_ai_action_settings_form_hook::class, + 'callback' => \aiprovider_ollama\hook_listener::class . '::set_model_form_definition_for_aiprovider_ollama', + ], +]; diff --git a/ai/provider/ollama/lang/en/aiprovider_ollama.php b/ai/provider/ollama/lang/en/aiprovider_ollama.php index d48152b7d2b..7a82db26c1a 100644 --- a/ai/provider/ollama/lang/en/aiprovider_ollama.php +++ b/ai/provider/ollama/lang/en/aiprovider_ollama.php @@ -22,34 +22,46 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -$string['action:generate_text:endpoint'] = 'API endpoint'; -$string['action:generate_text:endpoint_desc'] = 'The API endpoint for the provider uses for this action.'; $string['action:generate_text:model'] = 'Text generation model'; -$string['action:generate_text:model_desc'] = 'The model used to generate the text response.'; +$string['action:generate_text:model_help'] = 'The model used to generate the text response.'; $string['action:generate_text:systeminstruction'] = 'System instruction'; -$string['action:generate_text:systeminstruction_desc'] = 'This instruction is provided together with the user prompt for this action. It provides information to the AI model on how to generate the response.'; +$string['action:generate_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:summarise_text:model'] = 'Text summarisation model'; -$string['action:summarise_text:model_desc'] = 'The model used to summarise the provided text.'; +$string['action:summarise_text:model_help'] = 'The model used to summarise the provided text.'; $string['action:summarise_text:systeminstruction'] = 'System instruction'; -$string['action:summarise_text:systeminstruction_desc'] = 'This instruction is provided together with the user prompt for this action. It provides information to the AI model on how to generate the response.'; +$string['action:summarise_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; +$string['custom_model_name'] = 'Custom model name'; $string['enablebasicauth'] = 'Enable basic authentication'; -$string['enablebasicauth_desc'] = 'Enable basic authentication for the Ollama API provider.'; -$string['enableglobalratelimit'] = 'Enable global rate limiting'; -$string['enableglobalratelimit_desc'] = 'Enable global rate limiting for the Ollama API provider.'; -$string['enableuserratelimit'] = 'Enable user rate limiting'; -$string['enableuserratelimit_desc'] = 'Enable user rate limiting for the Ollama API provider.'; +$string['enablebasicauth_help'] = 'Enable basic authentication for the Ollama API provider.'; $string['endpoint'] = 'API endpoint'; -$string['endpoint_desc'] = 'The API endpoint for the Ollama API server.'; -$string['globalratelimit'] = 'Global rate limit'; -$string['globalratelimit_desc'] = 'Set the number of requests per hour allowed for the global rate limit.'; +$string['endpoint_help'] = 'The API endpoint for the Ollama API server.'; +$string['extraparams'] = 'Extra parameters'; +$string['extraparams_help'] = 'Extra parameters can be configured here. We support JSON format. For example: +
+{
+    "temperature": 0.5,
+    "max_tokens": 100
+}
+
'; +$string['invalidjson'] = 'Invalid JSON string'; $string['password'] = 'Password'; -$string['password_desc'] = 'The password used for basic authentication.'; +$string['password_help'] = 'The password used for basic authentication.'; $string['pluginname'] = 'Ollama API Provider'; $string['privacy:metadata'] = 'The Ollama API provider plugin does not store any personal data.'; $string['privacy:metadata:aiprovider_ollama:externalpurpose'] = 'This information is sent to the Ollama API in order for a response to be generated. Your Ollama account settings may change how Ollama stores and retains this data. No user data is explicitly sent to Ollama or stored in Moodle LMS by this plugin.'; $string['privacy:metadata:aiprovider_ollama:model'] = 'The model used to generate the response.'; $string['privacy:metadata:aiprovider_ollama:prompttext'] = 'The user entered text prompt used to generate the response.'; +$string['settings'] = 'Settings'; +$string['settings_help'] = 'You can adjust the settings below to customize how requests are sent to Ollama. Update the values as needed, ensuring they align with your requirements.

'; +$string['settings_mirostat'] = 'mirostat'; +$string['settings_mirostat_help'] = 'Enable Mirostat sampling for controlling perplexity. (default: 0, 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0)'; +$string['settings_seed'] = 'seed'; +$string['settings_seed_help'] = 'Sets the random number seed to use for generation. Setting this to a specific number will make the model generate the same text for the same prompt. (Default: 0)'; +$string['settings_temperature'] = 'temperature'; +$string['settings_temperature_help'] = 'The temperature of the model. Increasing the temperature will make the model answer more creatively. (Default: 0.8)'; +$string['settings_top_k'] = 'top_k'; +$string['settings_top_k_help'] = 'Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more diverse answers, while a lower value (e.g. 10) will be more conservative. (Default: 40)'; +$string['settings_top_p'] = 'top_p'; +$string['settings_top_p_help'] = 'Works together with top-k. 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)'; $string['username'] = 'Username'; -$string['username_desc'] = 'The username used for basic authentication.'; -$string['userratelimit'] = 'User rate limit'; -$string['userratelimit_desc'] = 'Set the number of requests per hour allowed for the user rate limit.'; +$string['username_help'] = 'The username used for basic authentication.'; diff --git a/ai/provider/ollama/settings.php b/ai/provider/ollama/settings.php deleted file mode 100644 index 98e259241a0..00000000000 --- a/ai/provider/ollama/settings.php +++ /dev/null @@ -1,96 +0,0 @@ -. - -/** - * Plugin administration pages are defined here. - * - * @package aiprovider_ollama - * @copyright 2024 Matt Porritt - * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -use core_ai\admin\admin_settingspage_provider; - -defined('MOODLE_INTERNAL') || die(); - -if ($hassiteconfig) { - // Provider specific settings heading. - $settings = new admin_settingspage_provider('aiprovider_ollama', - new lang_string('pluginname', 'aiprovider_ollama'), 'moodle/site:config', true); - - $settings->add(new admin_setting_heading('aiprovider_ollama/general', - new lang_string('providersettings', 'core_ai'), - new lang_string('providersettings_desc', 'core_ai'))); - - // Setting to store Ollama API URL endpoint. - $settings->add(new admin_setting_configtext('aiprovider_ollama/endpoint', - new lang_string('endpoint', 'aiprovider_ollama'), - new lang_string('endpoint_desc', 'aiprovider_ollama'), - 'http://localhost:11434', - PARAM_URL)); - - // Checkbox to enable basic auth settings. - $settings->add(new admin_setting_configcheckbox('aiprovider_ollama/enablebasicauth', - new lang_string('enablebasicauth', 'aiprovider_ollama'), - new lang_string('enablebasicauth_desc', 'aiprovider_ollama'), - 0)); - - // Username for basic auth. - $settings->add(new admin_setting_configtext('aiprovider_ollama/username', - new lang_string('username', 'aiprovider_ollama'), - new lang_string('username_desc', 'aiprovider_ollama'), - '', - PARAM_TEXT)); - $settings->hide_if('aiprovider_ollama/username', 'aiprovider_ollama/enablebasicauth', 'eq', 0); - - // Password for basic auth. - $settings->add(new admin_setting_configpasswordunmask('aiprovider_ollama/password', - new lang_string('password', 'aiprovider_ollama'), - new lang_string('password_desc', 'aiprovider_ollama'), - '' - )); - $settings->hide_if('aiprovider_ollama/password', 'aiprovider_ollama/enablebasicauth', 'eq', 0); - - // Setting to enable/disable global rate limiting. - $settings->add(new admin_setting_configcheckbox('aiprovider_ollama/enableglobalratelimit', - new lang_string('enableglobalratelimit', 'aiprovider_ollama'), - new lang_string('enableglobalratelimit_desc', 'aiprovider_ollama'), - 0)); - - // Setting to set how many requests per hour are allowed for the global rate limit. - // Should only be enabled when global rate limiting is enabled. - $settings->add(new admin_setting_configtext('aiprovider_ollama/globalratelimit', - new lang_string('globalratelimit', 'aiprovider_ollama'), - new lang_string('globalratelimit_desc', 'aiprovider_ollama'), - 100, - PARAM_INT)); - $settings->hide_if('aiprovider_ollama/globalratelimit', 'aiprovider_ollama/enableglobalratelimit', 'eq', 0); - - // Setting to enable/disable user rate limiting. - $settings->add(new admin_setting_configcheckbox('aiprovider_ollama/enableuserratelimit', - new lang_string('enableuserratelimit', 'aiprovider_ollama'), - new lang_string('enableuserratelimit_desc', 'aiprovider_ollama'), - 0)); - - // Setting to set how many requests per hour are allowed for the user rate limit. - // Should only be enabled when user rate limiting is enabled. - $settings->add(new admin_setting_configtext('aiprovider_ollama/userratelimit', - new lang_string('userratelimit', 'aiprovider_ollama'), - new lang_string('userratelimit_desc', 'aiprovider_ollama'), - 10, - PARAM_INT)); - $settings->hide_if('aiprovider_ollama/userratelimit', 'aiprovider_ollama/enableuserratelimit', 'eq', 0); -} diff --git a/ai/provider/ollama/tests/process_generate_text_test.php b/ai/provider/ollama/tests/process_generate_text_test.php index 0c0fc774c18..90c4f31bee7 100644 --- a/ai/provider/ollama/tests/process_generate_text_test.php +++ b/ai/provider/ollama/tests/process_generate_text_test.php @@ -16,11 +16,12 @@ namespace aiprovider_ollama; -use aiprovider_ollama\process_generate_text; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; +require_once(__DIR__ . '/testcase_helper_trait.php'); + /** * Test Generate text provider class for Ollama provider methods. * @@ -32,9 +33,15 @@ use GuzzleHttp\Psr7\Response; * @covers \aiprovider_ollama\abstract_processor */ final class process_generate_text_test extends \advanced_testcase { + + use testcase_helper_trait; + /** @var string A successful response in JSON format. */ protected string $responsebodyjson; + /** @var \core_ai\manager AI Manager. */ + private $manager; + /** @var provider The provider that will process the action. */ protected provider $provider; @@ -46,19 +53,19 @@ final class process_generate_text_test extends \advanced_testcase { */ protected function setUp(): void { parent::setUp(); + $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_ollama', 'text_request_success.json')); - $this->create_provider(); + $this->manager = \core\di::get(\core_ai\manager::class); + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\generate_text::class, + actionconfig: [ + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + ], + ); $this->create_action(); } - /** - * Create the provider object. - */ - private function create_provider(): void { - $this->provider = new \aiprovider_ollama\provider(); - } - /** * Create the action object. * @param int $userid The user id to use in the action. @@ -84,7 +91,55 @@ final class process_generate_text_test extends \advanced_testcase { $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $body->prompt); - $this->assertEquals('llama3.1:8b', $body->model); + $this->assertEquals('llama3.2', $body->model); + } + + /** + * Test create_request_object with extra model settings. + */ + public function test_create_request_object_with_model_settings(): void { + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\generate_text::class, + actionconfig: [ + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + 'temperature' => '0.5', + 'mirostat' => '1', + 'seed' => '50', + ], + ); + $processor = new process_generate_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'create_request_object'); + $request = $method->invoke($processor, 1); + + $body = (object) json_decode($request->getBody()->getContents()); + + $this->assertEquals('llama3.2', $body->model); + $this->assertEquals('0.5', $body->options->temperature); + $this->assertEquals('1', $body->options->mirostat); + $this->assertEquals('50', $body->options->seed); + + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\generate_text::class, + actionconfig: [ + 'model' => 'my-custom-ollama', + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + 'modelextraparams' => '{"temperature": 0.5,"mirostat": 1,"seed": "50"}', + ], + ); + $processor = new process_generate_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'create_request_object'); + $request = $method->invoke($processor, 1); + + $body = (object) json_decode($request->getBody()->getContents()); + + $this->assertEquals('my-custom-ollama', $body->model); + $this->assertEquals('0.5', $body->options->temperature); + $this->assertEquals('1', $body->options->mirostat); + $this->assertEquals('50', $body->options->seed); } /** @@ -296,14 +351,30 @@ final class process_generate_text_test extends \advanced_testcase { $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. - set_config('enableuserratelimit', 1, 'aiprovider_ollama'); - set_config('userratelimit', 1, 'aiprovider_ollama'); + $config = [ + 'enableuserratelimit' => true, + 'userratelimit' => 1, + 'endpoint' => "http://localhost:11434/", + ]; + + $provider = $this->manager->create_provider_instance( + classname: '\aiprovider_ollama\provider', + name: 'dummy', + config: $config, + actionconfig: [ + \core_ai\aiactions\generate_text::class => [ + 'settings' => [ + 'model' => 'llama3.2', + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + ], + ], + ], + ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. - $this->create_provider(); $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( @@ -311,7 +382,7 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); @@ -323,9 +394,8 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); @@ -334,7 +404,6 @@ final class process_generate_text_test extends \advanced_testcase { // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); - $this->create_provider(); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( @@ -342,7 +411,7 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); @@ -356,9 +425,8 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } @@ -377,14 +445,30 @@ final class process_generate_text_test extends \advanced_testcase { $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. - set_config('enableglobalratelimit', 1, 'aiprovider_ollama'); - set_config('globalratelimit', 1, 'aiprovider_ollama'); + $config = [ + 'enableglobalratelimit' => true, + 'globalratelimit' => 1, + 'endpoint' => "http://localhost:11434/", + ]; + + $provider = $this->manager->create_provider_instance( + classname: '\aiprovider_ollama\provider', + name: 'dummy', + config: $config, + actionconfig: [ + \core_ai\aiactions\generate_text::class => [ + 'settings' => [ + 'model' => 'llama3.2', + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + ], + ], + ], + ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. - $this->create_provider(); $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( @@ -392,7 +476,7 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); @@ -404,9 +488,8 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); @@ -415,7 +498,6 @@ final class process_generate_text_test extends \advanced_testcase { // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); - $this->create_provider(); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( @@ -423,7 +505,7 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); @@ -437,9 +519,8 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_generate_text($this->provider, $this->action); + $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } diff --git a/ai/provider/ollama/tests/process_summarise_text_test.php b/ai/provider/ollama/tests/process_summarise_text_test.php index cb4413c701f..cb9ac3b6a23 100644 --- a/ai/provider/ollama/tests/process_summarise_text_test.php +++ b/ai/provider/ollama/tests/process_summarise_text_test.php @@ -16,11 +16,12 @@ namespace aiprovider_ollama; -use aiprovider_ollama\process_summarise_text; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; +require_once(__DIR__ . '/testcase_helper_trait.php'); + /** * Test Generate text provider class for Ollama provider methods. * @@ -32,9 +33,15 @@ use GuzzleHttp\Psr7\Response; * @covers \aiprovider_ollama\abstract_processor */ final class process_summarise_text_test extends \advanced_testcase { + + use testcase_helper_trait; + /** @var string A successful response in JSON format. */ protected string $responsebodyjson; + /** @var \core_ai\manager AI Manager. */ + private $manager; + /** @var provider The provider that will process the action. */ protected provider $provider; @@ -46,19 +53,19 @@ final class process_summarise_text_test extends \advanced_testcase { */ protected function setUp(): void { parent::setUp(); + $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_ollama', 'text_request_success.json')); - $this->create_provider(); + $this->manager = \core\di::get(\core_ai\manager::class); + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\summarise_text::class, + actionconfig: [ + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + ], + ); $this->create_action(); } - /** - * Create the provider object. - */ - private function create_provider(): void { - $this->provider = new \aiprovider_ollama\provider(); - } - /** * Create the action object. * @param int $userid The user id to use in the action. @@ -84,7 +91,55 @@ final class process_summarise_text_test extends \advanced_testcase { $body = (object) json_decode($request->getBody()->getContents()); $this->assertEquals('This is a test prompt', $body->prompt); - $this->assertEquals('llama3.1:8b', $body->model); + $this->assertEquals('llama3.2', $body->model); + } + + /** + * Test create_request_object with extra model settings. + */ + public function test_create_request_object_with_model_settings(): void { + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\summarise_text::class, + actionconfig: [ + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + 'temperature' => '0.5', + 'mirostat' => '1', + 'seed' => '50', + ], + ); + $processor = new process_summarise_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'create_request_object'); + $request = $method->invoke($processor, 1); + + $body = (object) json_decode($request->getBody()->getContents()); + + $this->assertEquals('llama3.2', $body->model); + $this->assertEquals('0.5', $body->options->temperature); + $this->assertEquals('1', $body->options->mirostat); + $this->assertEquals('50', $body->options->seed); + + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\summarise_text::class, + actionconfig: [ + 'model' => 'my-custom-ollama', + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + 'modelextraparams' => '{"temperature": 0.5,"mirostat": 1,"seed": "50"}', + ], + ); + $processor = new process_summarise_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'create_request_object'); + $request = $method->invoke($processor, 1); + + $body = (object) json_decode($request->getBody()->getContents()); + + $this->assertEquals('my-custom-ollama', $body->model); + $this->assertEquals('0.5', $body->options->temperature); + $this->assertEquals('1', $body->options->mirostat); + $this->assertEquals('50', $body->options->seed); } /** @@ -287,14 +342,30 @@ final class process_summarise_text_test extends \advanced_testcase { $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. - set_config('enableuserratelimit', 1, 'aiprovider_ollama'); - set_config('userratelimit', 1, 'aiprovider_ollama'); + $config = [ + 'enableuserratelimit' => true, + 'userratelimit' => 1, + 'endpoint' => "http://localhost:11434/", + ]; + + $provider = $this->manager->create_provider_instance( + classname: '\aiprovider_ollama\provider', + name: 'dummy', + config: $config, + actionconfig: [ + \core_ai\aiactions\summarise_text::class => [ + 'settings' => [ + 'model' => 'llama3.2', + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + ], + ], + ], + ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: User rate limit has not been reached. - $this->create_provider(); $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( @@ -302,7 +373,7 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); @@ -314,9 +385,8 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); @@ -325,7 +395,6 @@ final class process_summarise_text_test extends \advanced_testcase { // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); - $this->create_provider(); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( @@ -333,7 +402,7 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); @@ -347,9 +416,8 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } @@ -368,14 +436,30 @@ final class process_summarise_text_test extends \advanced_testcase { $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. - set_config('enableglobalratelimit', 1, 'aiprovider_ollama'); - set_config('globalratelimit', 1, 'aiprovider_ollama'); + $config = [ + 'enableglobalratelimit' => true, + 'globalratelimit' => 1, + 'endpoint' => "http://localhost:11434/", + ]; + + $provider = $this->manager->create_provider_instance( + classname: '\aiprovider_ollama\provider', + name: 'dummy', + config: $config, + actionconfig: [ + \core_ai\aiactions\summarise_text::class => [ + 'settings' => [ + 'model' => 'llama3.2', + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + ], + ], + ], + ); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // Case 1: Global rate limit has not been reached. - $this->create_provider(); $this->create_action($user1->id); // The response from Ollama. $mock->append(new Response( @@ -383,7 +467,7 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); @@ -395,9 +479,8 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); @@ -406,7 +489,6 @@ final class process_summarise_text_test extends \advanced_testcase { // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); - $this->create_provider(); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( @@ -414,7 +496,7 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); @@ -428,9 +510,8 @@ final class process_summarise_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); $this->create_action($user1->id); - $processor = new process_summarise_text($this->provider, $this->action); + $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } diff --git a/ai/provider/ollama/tests/provider_test.php b/ai/provider/ollama/tests/provider_test.php index 8ef492a8e95..65eab9dec2b 100644 --- a/ai/provider/ollama/tests/provider_test.php +++ b/ai/provider/ollama/tests/provider_test.php @@ -25,12 +25,35 @@ namespace aiprovider_ollama; * @covers \aiprovider_ollama\provider */ final class provider_test extends \advanced_testcase { + + /** @var \core_ai\manager */ + private $manager; + + /** @var \core_ai\provider */ + private $provider; + + /** + * Overriding setUp() function to always reset after tests. + */ + public function setUp(): void { + parent::setUp(); + $this->resetAfterTest(); + + // Create the provider instance. + $this->manager = \core\di::get(\core_ai\manager::class); + $config = ['data' => 'goeshere']; + $this->provider = $this->manager->create_provider_instance( + classname: '\aiprovider_ollama\provider', + name: 'dummy', + config: $config, + ); + } + /** * Test get_action_list */ public function test_get_action_list(): void { - $provider = new provider(); - $actionlist = $provider->get_action_list(); + $actionlist = $this->provider->get_action_list(); $this->assertIsArray($actionlist); $this->assertCount(2, $actionlist); $this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist); @@ -41,31 +64,28 @@ final class provider_test extends \advanced_testcase { * Test is_request_allowed. */ public function test_is_request_allowed(): void { - $this->resetAfterTest(); + // Create the provider instance. + $config = [ + 'enableuserratelimit' => true, + 'userratelimit' => 3, + 'enableglobalratelimit' => true, + 'globalratelimit' => 5, + ]; - // Set plugin config rate limiter settings. - set_config('enableglobalratelimit', 1, 'aiprovider_ollama'); - set_config('globalratelimit', 5, 'aiprovider_ollama'); - set_config('enableuserratelimit', 1, 'aiprovider_ollama'); - set_config('userratelimit', 3, 'aiprovider_ollama'); + $provider = $this->manager->create_provider_instance( + classname: '\aiprovider_ollama\provider', + name: 'dummy', + config: $config, + ); $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; - $aspectratio = 'square'; - $quality = 'hd'; - $numimages = 1; - $style = 'vivid'; - $action = new \core_ai\aiactions\generate_image( + $action = new \core_ai\aiactions\generate_text( contextid: $contextid, userid: $userid, prompttext: $prompttext, - quality: $quality, - aspectratio: $aspectratio, - numimages: $numimages, - style: $style, ); - $provider = new provider(); // Make 3 requests, all should be allowed. for ($i = 0; $i < 3; $i++) { @@ -78,14 +98,10 @@ final class provider_test extends \advanced_testcase { $this->assertEquals('User rate limit exceeded', $result['errormessage']); // Change user id to make a request for a different user, should pass (4 requests for global rate). - $action = new \core_ai\aiactions\generate_image( + $action = new \core_ai\aiactions\generate_text( contextid: $contextid, userid: 2, prompttext: $prompttext, - quality: $quality, - aspectratio: $aspectratio, - numimages: $numimages, - style: $style, ); $this->assertTrue($provider->is_request_allowed($action)); diff --git a/ai/provider/ollama/tests/testcase_helper_trait.php b/ai/provider/ollama/tests/testcase_helper_trait.php new file mode 100644 index 00000000000..a630e61b552 --- /dev/null +++ b/ai/provider/ollama/tests/testcase_helper_trait.php @@ -0,0 +1,65 @@ +. + +namespace aiprovider_ollama; + +/** + * Trait for test cases. + * + * @package aiprovider_ollama + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait testcase_helper_trait { + + /** + * Create the provider object. + * + * @param string $actionclass The action class to use. + * @param array $actionconfig The action configuration to use. + */ + public function create_provider( + string $actionclass, + array $actionconfig = [], + ): \core_ai\provider { + $manager = \core\di::get(\core_ai\manager::class); + $config = [ + 'enableuserratelimit' => true, + 'userratelimit' => 1, + 'enableglobalratelimit' => true, + 'globalratelimit' => 1, + 'endpoint' => "http://localhost:11434/", + ]; + $defaultactionconfig = [ + $actionclass => [ + 'settings' => [ + 'model' => 'llama3.2', + ], + ], + ]; + foreach ($actionconfig as $key => $value) { + $defaultactionconfig[$actionclass]['settings'][$key] = $value; + } + $provider = $manager->create_provider_instance( + classname: '\aiprovider_ollama\provider', + name: 'dummy', + config: $config, + actionconfig: $defaultactionconfig, + ); + + return $provider; + } +} diff --git a/ai/provider/openai/classes/provider.php b/ai/provider/openai/classes/provider.php index 033588f8446..0c8092313fb 100644 --- a/ai/provider/openai/classes/provider.php +++ b/ai/provider/openai/classes/provider.php @@ -41,27 +41,7 @@ class provider extends \core_ai\provider { ]; } - /** - * Generate a user id. - * - * This is a hash of the site id and user id, - * this means we can determine who made the request - * but don't pass any personal data to OpenAI. - * - * @param string $userid The user id. - * @return string The generated user id. - */ - public function generate_userid(string $userid): string { - global $CFG; - return hash('sha256', $CFG->siteidentifier . $userid); - } - - /** - * Update a request to add any headers required by the provider. - * - * @param RequestInterface $request - * @return RequestInterface - */ + #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { if (isset($this->config['orgid'])) { return $request diff --git a/ai/tests/behat/admin.feature b/ai/tests/behat/admin.feature index c7b5c407d7c..4e17ed3880b 100644 --- a/ai/tests/behat/admin.feature +++ b/ai/tests/behat/admin.feature @@ -130,3 +130,18 @@ Feature: An administrator can manage AI subsystem settings And I click on the "Settings" link in the table row containing "Text editor placement" And I should see "This action is unavailable." in the table row containing "Generate text" Then I should not see "This action is unavailable." in the table row containing "Generate image" + + @javascript + Scenario: An administrator can control the enabled state of AI placement actions using JavaScript + Given the following "core_ai > ai providers" exist: + | provider | name | enabled | endpoint | + | aiprovider_ollama | Ollama API test | 1 | http://localhost:11434 | + And I am logged in as "admin" + And I navigate to "AI > AI placements" in site administration + When I click on the "Settings" link in the table row containing "Text editor placement" + Then I should see "Generate text" in the "flexible" "table" + And I should see "Generate image" in the "flexible" "table" + And I should not see "This action is unavailable. No AI providers are configured for this action." in the "Generate text" "table_row" + And I should see "This action is unavailable. No AI providers are configured for this action." in the "Generate image" "table_row" + And I toggle the "Generate text" admin switch "off" + And I should see "Generate text disabled." diff --git a/lib/apis.json b/lib/apis.json index 6ee8b57894a..a765940c784 100644 --- a/lib/apis.json +++ b/lib/apis.json @@ -19,6 +19,11 @@ "allowedlevel2": true, "allowedspread": false }, + "aimodel": { + "component": "core_ai", + "allowedlevel2": true, + "allowedspread": false + }, "analytics": { "component": "core_analytics", "allowedlevel2": true,