ÿØÿà JFIF    ÿÛ „  ( %!1"%)+...383-7(-.+  -%%--------+------------/----------------------------ÿÀ  ¶" ÿÄ     ÿÄ @    !1AQaq‘¡"±ÁÑð2’BRráS‚ñ3b¢CT²Ò #cÿÄ    ÿÄ '   1!AQ2a"‘±#ÿÚ   ? ò(/p‘—–¢·,M|¯G»R£üešT. ôŸEoCFñ‡ 4ÔkåóxR|µÁ¨ÓDà!Ç´µ˜‡p ÍQ‹§!þž^õWsŒy  åÁ§$á«%ºq˜™JyyË0y«^Ϗ -3ÃÏËÈ£Æ\P’ä„s&•‹Üs üô'àWt¹pñCp$bÚF`·7.^)¾!)ÒÙ˜–ÅÏš^h¾ø³·ìe±ŒaeÖ—Í,e=B}Jéߦ$$½—šÙÜCC¯¶è8β–Åkèwx LÉngT±æ¹£Ócœ¿Í/©Éÿ ‰ÚCÒ­mq;Tt¶–}0"zÖPcd1š­¤tˆ„ÐÓâqú[™öUCŠ2޿د­É(¸ßúçšmW,Ò ºf‹Ån™ÄLêÁ8üÁkÁ€L¥ÍuJ1œôrÂSŒÓöt!7EUíïb)AËi¬â-ÙHk8òÁiXYwÅ)O^"ÇØriÛô{»wRUKÙÏÚlÓ¬€‚’שT ] ªb—ÊîKC^×M“xùäñz¬zJ× {4[¹M'IÆeH1LCWÕ]œûº WDæà°OÅ‚¹c²ÞuA–rL`HšpCez‡V’— Ž‚Ë„–ñ­'µR0ÕÖ=À:±G»C\nÆÉ5*¢ †3šhQ4ýÒi$Õ1"Ü]¢p-q3zcQ$ªö¨¥æf›‘»´ÝÚHá„e²JÇ–iÊ:·à¬Øc5Åk»Laª*wi [0Ówk ¦a¦0Õ²ÅÅ‚T¸’³q:Æ<¹ú Ò˜Æ}2VE•Ѐp¡Ô3Úº …h³^LJºçì¥Ánó|˜ÖˆÎˆ+« ¦dø‘Mº–d\ªDðÛ„Ì×LÝ——Ð9Ç|•FèçVm™¨€™3õS–9Äå¢<̉Ìm¢±`·º˜&Z²ž Ë®D«¶»6™‰©­HÈ Àá«h­i³04æ’hxœi3ï¨(ÓL·†‹¶ËpŒ ƒÜÒ? :õdsÀ –3æÓ·Üjâ¬9—]8n¼&]I F£¼…ì–—Œ0v°LǐÅfÛ2TÖ’aµ˜N@ÖBSÏ^à´4-±°b×nÈÈœÆD=KdŒø…ÄÈK,›ÖR§fjü$^ÔÎ!„6^ s:Üun\ôkCÞò÷<—Lúl Í›G¹ÀPá¼Nc¡šÒ…ÙWͳœ±vÊáЄ×9‹P‰cEØ‘™›µŸP<×g1 º©Ø¬wiÁ^l5× I/';Ë+´Z‡ÓÄFÕp[Èð’ HÄ ó 9¬Dl4“é±Ï”V=^Hð;žLädÉa‚°Ë,#/Ž~²BÔÛ QãñQtIeórVB,ÏÂf‹4#Μc‹É3ZIÖ Œ¢¥·üxa‡Ãçè„jŠ[5 Ä!U?,3žÎ×…ðdÓ÷háŠA‰ìÄ4÷ÃÜZÃE{‰®+W\ZÍE[‰»µk»MÝ­`¢©†˜ÃV‹-f¢¡†¢a«eŠ%«Y¨©q%jâd,Ôq`) ¤¦…šˆ ¦E>0Ùb²t–Ÿ  ÷jsςҒKÈcø(é›;k(’#ôŒQ ™C> õ³M9óºÆµ¤çâ;uÀ)5>RÔ¸fÓ~Ȧ—’-$fe«"FâUˆÊwµPÏu w+"åÉó¨6s™Û9(À¨ù©hE°½†n`m~“@©›p!Vkhµ Á1šbEqqÊ@ºèl›2 –Yã%‘‚hÛ{a¶‡ÅÞM²¬çL34®Çl]®‹ŽØŒm'RÜe35æöˆ­Á­ cP9šeÁköo´L³Ì=p8]”‡B¶,šº|ɏdzXˆØk*ÇÚ{#ñ‰pã(€¶‡9ý'šÜ³½¯kƒ†Â’ëSOƒ•Å®H6+a¢µˆ‹Y¨b˜b+XˆÖ!°u!¢"µˆˆn2ˆ Å0Ä`Å Ô®c(ÓÜGº•Ä»Œ âWî%q.áÐÄ÷î%umÍ£+ÜQ,Vn¦-GphU¸¢X­&,Gt ·K¢ÅÅ·¥RÄÊÉbKn N­R2g$VµÕó Qr#–ÓZUÏð0ÎtÙÀœz ëöo“Îk¯n„T°"SÞ+³/…€½"ižãO%Í%&Δâ‘ËØ´IˆÓ°Ë‰ÞM3.™yçÁzit›`4C`k¢¼á!álÄï sÂ[×!¤l¾æo"ƒIÔ'@ªu)3›ô®“@Z à¾] üœ'‡4¸PÞkÁ-qÄH‰ÓÍBd´ÿ —¹°ÄA&ÊUC„öz‚‚RæŠtf¬>˜ÑidšEçÑL÷áý¡V±h§­c¿¥¨ƒ@g¼…Ýö^ÞËc.ž„æ“I[ ª+·zÐÒÚ,½Ð»¶€C›7jk$๖€Ž¾ÅÙpp¶ÍþéΤ ½ØÎŒq ñpæžÅ¥#Øâ49ÃÂK^MYV“,¶Éz_òæ:…;Õ«¦I3âJ£¤û5$7Èxün\â]Êe¯(Ÿ&Ž‹¶2<6Ćf× æ¢ƱqWı[(ÙÂŽç‚T7¨@ÈÉÍß‚ïÙ"&3TYlGށ†)†#5ŠA©^T2ÆÁ†)©†©†¤yGXÁ†©"†)©¼ÅV WS†#©]R–r‹q5ÕbâWþBh¯u+ªÅÄ×ü„nÑ^êkªÁj‰jeœWˆÕÕ`µDµ2Ì+Ä µDµµ5Ô{¤Þ0RF’H÷AÛ<ÊÁ§ ƈæ0Ñ£ê4 öÚµ[¬/1²€â<-¬šö²r×0o×w ´xÐÒË„ g01¬„åEM蛇Ã\6µE›‘fÙO k÷CºnÓ1`iîÒ² áÂ!ÑLÄÿ ³¬\õ*:M=Ç»c‹2[‰ØsÕLò\ŒK L^™üÄN\¶#» Ç\……ßïIœÉ$¸ÎñÍ®ÔHÇ Ûí/´>Li”„†g^$ 7NÍÑÑ2M2ªNg=k½Ð}Ÿl6‡ —JWˆ”ÞéT·+µ¦üÂFè¢Vs6ýÆ4»2+2IÝ9ô9®ÇGÙÇ1+Ä8;*Õ§ž½D*îÑWÚçá3Ma¤·ˆ¥t}š²Ü…Q+Æüµ8HíS”üxGéÁˆ¦Ël„éÈ 5 µÄƒ6äÜ(kIš¯S†fÖ&¸®×v|Äyˆ*禌Éù@7-®Ä[]ÎÖ¿ê`º…=:…žJVnݳvðÖKE¼‚ »mà ÷f®:Ë ™–4rð¡4` ܹçž~‹C=œ…¾Ïj|VDtK.ƒ"9 Ï ­–éXá³6WÒïIL-Ї Ç db/hooks.php000064400000002527152267354220007003 0ustar00. /** * 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', ], ]; lang/en/aiprovider_ollama.php000064400000011723152267354220012305 0ustar00. /** * Strings for component aiprovider_ollama, language 'en'. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ $string['action:explain_text:model'] = 'Text explanation model'; $string['action:explain_text:model_help'] = 'The model used to explain the provided text.'; $string['action:explain_text:systeminstruction'] = 'System instruction'; $string['action:explain_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:generate_text:model'] = 'Text generation model'; $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_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_help'] = 'The model used to summarise the provided text.'; $string['action:summarise_text:systeminstruction'] = 'System instruction'; $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_help'] = 'Enable basic authentication for the Ollama API provider.'; $string['endpoint'] = 'API endpoint'; $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_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'] = 'Adjust the settings below to customise how requests are sent to Ollama.'; $string['settings_mirostat'] = 'Mirostat'; $string['settings_mirostat_help'] = 'Mirostat is a neural text decoding algorithm for controlling perplexity. 0 = disabled, 1 = Mirostat, 2 = Mirostat 2.0. (Default: 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'] = 'Temperature influences whether the output is more random and creative or more predictable. 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_help'] = 'The username used for basic authentication.'; version.php000064400000002104152267354220006747 0ustar00. /** * Version information for aiprovider_ollama. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiprovider_ollama'; $plugin->version = 2025041400; $plugin->requires = 2025040800; $plugin->maturity = MATURITY_STABLE; classes/form/action_form.php000064400000015014152267354220012166 0ustar00. 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; } } classes/form/action_generate_text_form.php000064400000004761152267354220015113 0ustar00. 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); } } classes/privacy/provider.php000064400000005106152267354220012233 0ustar00. namespace aiprovider_ollama\privacy; use core_privacy\local\metadata\collection; use core_privacy\local\request\approved_contextlist; use core_privacy\local\request\approved_userlist; use core_privacy\local\request\contextlist; use core_privacy\local\request\userlist; /** * Privacy provider implementation for Ollama provider * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @codeCoverageIgnore */ class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\core_userlist_provider, \core_privacy\local\request\plugin\provider { #[\Override] public static function get_metadata(collection $collection): collection { $collection->add_external_location_link('aiprovider_ollama', [ 'prompttext' => 'privacy:metadata:aiprovider_ollama:prompttext', 'model' => 'privacy:metadata:aiprovider_ollama:model', ], 'privacy:metadata:aiprovider_ollama:externalpurpose'); return $collection; } #[\Override] public static function get_contexts_for_userid(int $userid): contextlist { return new contextlist(); } #[\Override] public static function get_users_in_context(userlist $userlist) { } #[\Override] public static function export_user_data(approved_contextlist $contextlist) { } #[\Override] public static function delete_data_for_all_users_in_context(\context $context) { } /** * Delete multiple users within a single context. * * @param approved_userlist $userlist The approved context and user information to delete information for. */ public static function delete_data_for_users(approved_userlist $userlist) { } #[\Override] public static function delete_data_for_user(approved_contextlist $contextlist) { } } classes/aimodel/llama33.php000064400000005531152267354220011574 0ustar00. 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; } } classes/aimodel/ollama_base.php000064400000002357152267354220012602 0ustar00. 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; } classes/process_summarise_text.php000064400000002231152267354220013527 0ustar00. namespace aiprovider_ollama; /** * Class process text summarisation. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @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_system_instruction(): string { return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } } classes/hook_listener.php000064400000012043152267354220011567 0ustar00. 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')); $settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_ollama')); $mform->addElement('html', $settingshelp); $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')); $settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_ollama')); $mform->addElement('html', $settingshelp); $targetmodel->add_model_settings($mform); } } } } } } classes/helper.php000064400000003714152267354220010206 0ustar00. 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; } } classes/abstract_processor.php000064400000012125152267354220012625 0ustar00. namespace aiprovider_ollama; use core\http_client; 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; use Psr\Http\Message\UriInterface; /** * Class process text generation. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ abstract class abstract_processor extends process_base { /** * Get the endpoint URI. * * @return 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 */ 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. * * @return string */ protected function get_system_instruction(): string { 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(string $userid): RequestInterface; /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ abstract protected function handle_api_success(ResponseInterface $response): array; #[\Override] protected function query_ai_api(): array { $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); try { // Call the external AI service. $response = $client->send($request, [ 'base_uri' => $this->get_endpoint(), RequestOptions::HTTP_ERRORS => false, ]); } catch (RequestException $e) { // Handle any exceptions. return [ 'success' => false, 'errorcode' => $e->getCode(), 'errormessage' => $e->getMessage(), ]; } // Double-check the response codes, in case of a non 200 that didn't throw an error. $status = $response->getStatusCode(); if ($status === 200) { return $this->handle_api_success($response); } else { return $this->handle_api_error($response); } } /** * Handle an error from the external AI api. * * @param ResponseInterface $response The response object. * @return array The error response. */ protected function handle_api_error(ResponseInterface $response): array { $responsearr = [ 'success' => false, 'errorcode' => $response->getStatusCode(), ]; $status = $response->getStatusCode(); if ($status >= 500 && $status < 600) { $responsearr['errormessage'] = $response->getReasonPhrase(); } else { $bodyobj = json_decode($response->getBody()->getContents()); $responsearr['errormessage'] = $bodyobj->error->message; } return $responsearr; } } classes/provider.php000064400000006065152267354220010563 0ustar00. namespace aiprovider_ollama; use core_ai\form\action_settings_form; use Psr\Http\Message\RequestInterface; /** * Class provider. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class provider extends \core_ai\provider { #[\Override] public static function get_action_list(): array { return [ \core_ai\aiactions\generate_text::class, \core_ai\aiactions\summarise_text::class, \core_ai\aiactions\explain_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' || $actionname === 'explain_text') { return new form\action_generate_text_form(customdata: $customdata); } return false; } #[\Override] public function add_authentication_headers(RequestInterface $request): RequestInterface { if (empty($this->config['basicauthenabled'])) { return $request; } else { // Add the Authorization header for basic auth. $authheader = 'Basic ' . base64_encode($this->config['username'] . ':' . $this->config['password']); return $request->withAddedHeader('Authorization', $authheader); } } #[\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' || $actionname === 'explain_text') { $mform = new form\action_generate_text_form(customdata: $customdata); return $mform->get_defaults(); } return []; } #[\Override] public function is_provider_configured(): bool { return !empty($this->config['endpoint']); } } classes/process_explain_text.php000064400000001734152267354220013171 0ustar00. namespace aiprovider_ollama; /** * Class process text explanation. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_explain_text extends process_generate_text { } classes/process_generate_text.php000064400000006074152267354220013325 0ustar00. namespace aiprovider_ollama; use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; /** * Class process text generation. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class process_generate_text extends abstract_processor { #[\Override] protected function get_system_instruction(): string { return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction']; } #[\Override] 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(); if (!empty($systeminstruction)) { $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: '', body: json_encode($requestobj), headers: [ 'Content-Type' => 'application/json', ], ); } /** * Handle a successful response from the external AI api. * * @param ResponseInterface $response The response object. * @return array The response. */ protected function handle_api_success(ResponseInterface $response): array { $responsebody = $response->getBody(); $bodyobj = json_decode($responsebody->getContents()); return [ 'success' => true, 'generatedcontent' => $bodyobj->response, 'finishreason' => $bodyobj->done_reason, 'prompttokens' => $bodyobj->prompt_eval_count, 'completiontokens' => $bodyobj->eval_count, 'model' => $bodyobj->model ?? $this->get_model(), // Fallback to config model. ]; } } amd/src/modelchooser.js000064400000003050152267354220011123 0ustar00// 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(); }); } }; amd/build/modelchooser.min.js.map000064400000004113152267354220012772 0ustar00{"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"}amd/build/modelchooser.min.js000064400000001525152267354220012222 0ustar00define("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.maptests/fixtures/text_request_success.json000064400000005430152267354220014750 0ustar00{ "model":"mistral", "created_at":"2024-09-09T01:54:37.479368Z", "response":" World War II was a global conflict that lasted from 1939 to 1945. It involved the majority of the world's countries—organized into two opposing military alliances: the Allies and the Axis powers. The main spark for the war was Adolf Hitler's invasion of Poland, which led to declarations of war on Germany by Britain and France. The war concluded with the unconditional surrender of the Axis powers. It resulted in 70-85 million fatalities, making it one of the deadliest conflicts in human history.", "done":true, "done_reason":"stop", "context":[ 3, 29473, 1763, 1390, 6324, 1032, 3013, 3555, 1245, 1040, 2956, 29491, 4372, 4406, 1117, 1066, 9038, 3013, 3586, 1124, 1420, 2927, 29491, 10590, 1935, 3046, 12150, 8998, 781, 1028, 29508, 29491, 5339, 1040, 14828, 1065, 11603, 3013, 1633, 6256, 781, 1028, 29518, 29491, 3146, 1227, 3792, 1475, 2484, 4022, 1989, 15526, 29493, 4424, 1067, 1510, 29493, 1210, 25242, 16555, 29491, 781, 781, 6006, 29473, 29550, 29502, 3853, 1124, 1043, 29495, 29518, 4, 1027, 4072, 4041, 4485, 1171, 1032, 4294, 11086, 1137, 24121, 1245, 29473, 29508, 29542, 29538, 29542, 1066, 29473, 29508, 29542, 29549, 29550, 29491, 1429, 6058, 1040, 8525, 1070, 1040, 2294, 29510, 29481, 6548, 29589, 9742, 2100, 1546, 1757, 6561, 1056, 6237, 1312, 21672, 29515, 1040, 2450, 1265, 1072, 1040, 1098, 13426, 12518, 29491, 1183, 2959, 14891, 1122, 1040, 2264, 1171, 2732, 5977, 15624, 29510, 29481, 19561, 1070, 18580, 29493, 1458, 4931, 1066, 18995, 1465, 1070, 2264, 1124, 8061, 1254, 10942, 1072, 5611, 29491, 1183, 2264, 17257, 1163, 1040, 1289, 1981, 3350, 26652, 1070, 1040, 1098, 13426, 12518, 29491, 1429, 17728, 1065, 29473, 29555, 29502, 29501, 29551, 29550, 4609, 20942, 1986, 29493, 3260, 1146, 1392, 1070, 1040, 4462, 18835, 24897, 1065, 3698, 4108, 29491 ], "total_duration":63199305763, "load_duration":28276495, "prompt_eval_count":75, "prompt_eval_duration":6918955000, "eval_count":123, "eval_duration":56249258000 } tests/process_generate_text_test.php000064400000047406152267354220014075 0ustar00. namespace aiprovider_ollama; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/testcase_helper_trait.php'); /** * Test Generate text provider class for Ollama provider methods. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_ollama\provider * @covers \aiprovider_ollama\process_generate_text * @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; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ 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->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 action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\generate_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $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('This is a test prompt', $body->prompt); $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); } /** * Test the API error response handler method. */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), ), 404 => new Response( 404, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'You must be a member of an organization to use the API']]), ), 429 => new Response( 429, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Rate limit reached for requests']]), ), ]; $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to setup reflector magic. $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $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, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $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, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_generate_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('generate_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $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_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $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()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $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_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $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()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } tests/testcase_helper_trait.php000064400000004133152267354220013005 0ustar00. 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; } } tests/process_summarise_text_test.php000064400000047166152267354220014313 0ustar00. namespace aiprovider_ollama; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/testcase_helper_trait.php'); /** * Test Generate text provider class for Ollama provider methods. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_ollama\provider * @covers \aiprovider_ollama\process_summarise_text * @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; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ 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->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 action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\summarise_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $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('This is a test prompt', $body->prompt); $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); } /** * Test the API error response handler method. * */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to set up reflector magic. $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $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, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $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, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_summarise_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('summarise_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $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_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $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()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $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_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $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()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_summarise_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } } tests/provider_test.php000064400000007772152267354220011335 0ustar00. namespace aiprovider_ollama; /** * Test Ollama provider methods. * * @package aiprovider_ollama * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @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 { $actionlist = $this->provider->get_action_list(); $this->assertIsArray($actionlist); $this->assertCount(3, $actionlist); $this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist); } /** * Test is_request_allowed. */ public function test_is_request_allowed(): void { // Create the provider instance. $config = [ 'enableuserratelimit' => true, 'userratelimit' => 3, 'enableglobalratelimit' => true, 'globalratelimit' => 5, ]; $provider = $this->manager->create_provider_instance( classname: '\aiprovider_ollama\provider', name: 'dummy', config: $config, ); $contextid = 1; $userid = 1; $prompttext = 'This is a test prompt'; $action = new \core_ai\aiactions\generate_text( contextid: $contextid, userid: $userid, prompttext: $prompttext, ); // Make 3 requests, all should be allowed. for ($i = 0; $i < 3; $i++) { $this->assertTrue($provider->is_request_allowed($action)); } // The 4th request for the same user should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $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_text( contextid: $contextid, userid: 2, prompttext: $prompttext, ); $this->assertTrue($provider->is_request_allowed($action)); // Make a 5th request for the global rate limit, it should be allowed. $this->assertTrue($provider->is_request_allowed($action)); // The 6th request should be denied. $result = $provider->is_request_allowed($action); $this->assertFalse($result['success']); $this->assertEquals('Global rate limit exceeded', $result['errormessage']); } } tests/process_explain_text_test.php000064400000047053152267354220013741 0ustar00. namespace aiprovider_ollama; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; defined('MOODLE_INTERNAL') || die(); require_once(__DIR__ . '/testcase_helper_trait.php'); /** * Test Explain text provider class for Ollama provider methods. * * @package aiprovider_ollama * @copyright 2025 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @covers \aiprovider_ollama\provider * @covers \aiprovider_ollama\process_explain_text * @covers \aiprovider_ollama\abstract_processor */ final class process_explain_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; /** @var base The action to process. */ protected base $action; /** * Set up the test. */ 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->manager = \core\di::get(\core_ai\manager::class); $this->provider = $this->create_provider( actionclass: \core_ai\aiactions\explain_text::class, actionconfig: [ 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), ], ); $this->create_action(); } /** * Create the action object. * @param int $userid The user id to use in the action. */ private function create_action(int $userid = 1): void { $this->action = new \core_ai\aiactions\explain_text( contextid: 1, userid: $userid, prompttext: 'This is a test prompt', ); } /** * Test create_request_object */ public function test_create_request_object(): void { $processor = new process_explain_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('This is a test prompt', $body->prompt); $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\explain_text::class, actionconfig: [ 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), 'temperature' => '0.5', 'mirostat' => '1', 'seed' => '50', ], ); $processor = new process_explain_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\explain_text::class, actionconfig: [ 'model' => 'my-custom-ollama', 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), 'modelextraparams' => '{"temperature": 0.5,"mirostat": 1,"seed": "50"}', ], ); $processor = new process_explain_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); } /** * Test the API error response handler method. * */ public function test_handle_api_error(): void { $responses = [ 500 => new Response(500, ['Content-Type' => 'application/json']), 503 => new Response(503, ['Content-Type' => 'application/json']), 401 => new Response(401, ['Content-Type' => 'application/json'], '{"error": {"message": "Invalid Authentication"}}'), 404 => new Response(404, ['Content-Type' => 'application/json'], '{"error": {"message": "You must be a member of an organization to use the API"}}'), 429 => new Response(429, ['Content-Type' => 'application/json'], '{"error": {"message": "Rate limit reached for requests"}}'), ]; $processor = new process_explain_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_error'); foreach ($responses as $status => $response) { $result = $method->invoke($processor, $response); $this->assertEquals($status, $result['errorcode']); if ($status == 500) { $this->assertEquals('Internal Server Error', $result['errormessage']); } else if ($status == 503) { $this->assertEquals('Service Unavailable', $result['errormessage']); } else { $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); } } } /** * Test the API success response handler method. */ public function test_handle_api_success(): void { $response = new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, ); // We're testing a private method, so we need to set up reflector magic. $processor = new process_explain_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'handle_api_success'); $result = $method->invoke($processor, $response); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test query_ai_api for a successful call. */ public function test_query_ai_api_success(): void { // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($this->provider, $this->action); $method = new \ReflectionMethod($processor, 'query_ai_api'); $result = $method->invoke($processor); $this->assertTrue($result['success']); $this->assertStringContainsString('World War II was a global conflict', $result['generatedcontent']); $this->assertEquals('stop', $result['finishreason']); $this->assertEquals('75', $result['prompttokens']); $this->assertEquals('123', $result['completiontokens']); } /** * Test prepare_response success. */ public function test_prepare_response_success(): void { $processor = new process_explain_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => true, 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', 'fingerprint' => 'fp_c4e5b6fa31', 'generatedcontent' => 'Sure, here is some sample text', 'finishreason' => 'stop', 'prompttokens' => '11', 'completiontokens' => '568', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); $this->assertEquals($response['success'], $result->get_success()); $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); } /** * Test prepare_response error. */ public function test_prepare_response_error(): void { $processor = new process_explain_text($this->provider, $this->action); // We're working with a private method here, so we need to use reflection. $method = new \ReflectionMethod($processor, 'prepare_response'); $response = [ 'success' => false, 'errorcode' => 500, 'errormessage' => 'Internal server error.', ]; $result = $method->invoke($processor, $response); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); $this->assertEquals($response['errorcode'], $result->get_errorcode()); $this->assertEquals($response['errormessage'], $result->get_errormessage()); } /** * Test process method. */ public function test_process(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertTrue($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); } /** * Test process method with error. */ public function test_process_error(): void { $this->resetAfterTest(); // Log in user. $this->setUser($this->getDataGenerator()->create_user()); // Mock the http client to return a successful response. ['mock' => $mock] = $this->get_mocked_http_client(); // The response from Ollama. $mock->append(new Response( 401, ['Content-Type' => 'application/json'], json_encode(['error' => ['message' => 'Invalid Authentication']]), )); $processor = new process_explain_text($this->provider, $this->action); $result = $processor->process(); $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); $this->assertFalse($result->get_success()); $this->assertEquals('explain_text', $result->get_actionname()); $this->assertEquals(401, $result->get_errorcode()); $this->assertEquals('Invalid Authentication', $result->get_errormessage()); } /** * Test process method with user rate limiter. */ public function test_process_with_user_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the user rate limiter. $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\explain_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_explain_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_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: User rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: User rate limit has not been reached for a different user. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 4: Time window has passed, user rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } /** * Test process method with global rate limiter. */ public function test_process_with_global_rate_limiter(): void { $this->resetAfterTest(); // Create users. $user1 = $this->getDataGenerator()->create_user(); $user2 = $this->getDataGenerator()->create_user(); // Log in user1. $this->setUser($user1); // Mock clock. $clock = $this->mock_clock_with_frozen(); // Set the global rate limiter. $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\explain_text::class => [ 'settings' => [ 'model' => 'llama3.2', 'systeminstruction' => get_string('action_explain_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_action($user1->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); // Case 2: Global rate limit has been reached. $clock->bump(HOURSECS - 10); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertEquals(429, $result->get_errorcode()); $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); $this->assertFalse($result->get_success()); // Case 3: Global rate limit has been reached for a different user too. // Log in user2. $this->setUser($user2); $this->create_action($user2->id); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertFalse($result->get_success()); // Case 4: Time window has passed, global rate limit should be reset. $clock->bump(11); // Log in user1. $this->setUser($user1); // The response from Ollama. $mock->append(new Response( 200, ['Content-Type' => 'application/json'], $this->responsebodyjson, )); $this->create_action($user1->id); $processor = new process_explain_text($provider, $this->action); $result = $processor->process(); $this->assertTrue($result->get_success()); } }