diff --git a/.upgradenotes/MDL-82980-2025021903294284.yml b/.upgradenotes/MDL-82980-2025021903294284.yml new file mode 100644 index 00000000000..b43436862d7 --- /dev/null +++ b/.upgradenotes/MDL-82980-2025021903294284.yml @@ -0,0 +1,15 @@ +issueNumber: MDL-82980 +notes: + core_ai: + - message: |2 + - The `\core_ai\form\action_settings_form` class has been updated to automatically include action buttons such as Save and Cancel. + - AI provider plugins should update their form classes by removing the `$this->add_action_buttons();` call, as it is no longer required. + type: changed + - message: > + - A new hook, `\core_ai\hook\after_ai_action_settings_form_hook`, has + been introduced. It will allows AI provider plugins to add additional + form elements for action settings configuration. + type: improved + - message: |2 + - AI provider plugins that want to implement `pre-defined models` and display additional settings for models must now extend the `\core_ai\aimodel\base` class. + type: improved diff --git a/ai/classes/aimodel/base.php b/ai/classes/aimodel/base.php new file mode 100644 index 00000000000..b9eb29a3d98 --- /dev/null +++ b/ai/classes/aimodel/base.php @@ -0,0 +1,61 @@ +. + +namespace core_ai\aimodel; + +use MoodleQuickForm; + +/** + * Base Model class. + * + * @package core_ai + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class base { + /** + * Get the display name of the model. + * This name is used to display the model in the UI. + * + * @return string The display name of the model. + */ + abstract public function get_model_display_name(): string; + + /** + * Get the name of the model. + * This name is used to identify the model. The system will use this model name to make the request to the AI services. + * + * @return string The name of the model. + */ + abstract public function get_model_name(): string; + + /** + * Add the model settings to the form. + * + * @param MoodleQuickForm $mform The form to add the model settings to. + */ + public function add_model_settings(MoodleQuickForm $mform): void { + } + + /** + * Check if the model has settings. + * + * @return bool Whether the model has settings. + */ + public function has_model_settings(): bool { + return false; + } +} diff --git a/ai/classes/form/action_settings_form.php b/ai/classes/form/action_settings_form.php index c2502799f62..ee10b8ef6ed 100644 --- a/ai/classes/form/action_settings_form.php +++ b/ai/classes/form/action_settings_form.php @@ -34,6 +34,24 @@ class action_settings_form extends moodleform { protected function definition() { } + #[\Override] + protected function after_definition() { + parent::after_definition(); + $this->_form->_registerCancelButton('cancel'); + } + + #[\Override] + public function definition_after_data() { + // Dispatch a hook for plugins to add their fields. + $hook = new \core_ai\hook\after_ai_action_settings_form_hook( + mform: $this->_form, + plugin: $this->_customdata['providername'], + ); + \core\di::get(\core\hook\manager::class)->dispatch($hook); + // Add action buttons. + $this->add_action_buttons(); + } + /** * Get the default values for the form. * diff --git a/ai/classes/hook/after_ai_action_settings_form_hook.php b/ai/classes/hook/after_ai_action_settings_form_hook.php new file mode 100644 index 00000000000..c863ac13de5 --- /dev/null +++ b/ai/classes/hook/after_ai_action_settings_form_hook.php @@ -0,0 +1,48 @@ +. + +namespace core_ai\hook; + +use MoodleQuickForm; + +/** + * Hook after AI action settings form is initiated. + * + * @package core_ai + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @property-read MoodleQuickForm $mform The form element + * @property-read ?string $plugin Name of the model + */ +#[\core\attribute\label('Allows plugins to add form elements for action settings setup.')] +#[\core\attribute\tags('ai')] +class after_ai_action_settings_form_hook { + + /** + * Constructor for the hook. + * + * @param MoodleQuickForm $mform The moodle form instance. + * @param ?string $plugin The name of the plugin + */ + public function __construct( + /** @var MoodleQuickForm The moodle form instance. */ + public readonly MoodleQuickForm $mform, + + /** @var ?string The name of the plugin */ + public readonly ?string $plugin, + ) { + } +} diff --git a/ai/classes/manager.php b/ai/classes/manager.php index 5bb1469ae96..78f94cfd1c1 100644 --- a/ai/classes/manager.php +++ b/ai/classes/manager.php @@ -366,6 +366,7 @@ class manager { * @param string $name The name of the provider config. * @param bool $enabled The enabled state of the provider. * @param array|null $config The config json. + * @param array|null $actionconfig The action config json. * @return provider */ public function create_provider_instance( @@ -373,6 +374,7 @@ class manager { string $name, bool $enabled = false, ?array $config = null, + ?array $actionconfig = null, ): provider { if (!class_exists($classname) || !is_a($classname, provider::class, true)) { throw new \coding_exception("Provider class not valid: {$classname}"); @@ -381,6 +383,7 @@ class manager { enabled: $enabled, name: $name, config: json_encode($config ?? []), + actionconfig: $actionconfig ? json_encode($actionconfig) : '', ); $id = $this->db->insert_record('ai_providers', $provider->to_record()); diff --git a/ai/configure_actions.php b/ai/configure_actions.php index ad28bfb9633..e0bca5b26b4 100644 --- a/ai/configure_actions.php +++ b/ai/configure_actions.php @@ -32,7 +32,7 @@ $provider = required_param('provider', PARAM_PLUGIN); $action = required_param('action', PARAM_TEXT); $id = required_param('providerid', PARAM_INT); $returnurl = optional_param('returnurl', null, PARAM_LOCALURL); -$data = ['providerid' => $id]; +$customdata = ['providerid' => $id]; // Handle return URL. if (empty($returnurl)) { @@ -43,7 +43,7 @@ if (empty($returnurl)) { } else { $returnurl = new moodle_url($returnurl); } -$data['returnurl'] = $returnurl; +$customdata['returnurl'] = $returnurl; $manager = \core\di::get(\core_ai\manager::class); $providerrecord = $manager->get_provider_records(['id' => $id]); @@ -52,7 +52,8 @@ $providerrecord = reset($providerrecord); $actionconfig = json_decode($providerrecord->actionconfig, true, 512, JSON_THROW_ON_ERROR); $actionconfig = $actionconfig[$action]; -$data['actionconfig'] = $actionconfig; +$customdata['actionconfig'] = $actionconfig; +$customdata['providername'] = $provider; $urlparams = [ 'provider' => $provider, @@ -69,7 +70,7 @@ $PAGE->set_title($title); $PAGE->set_heading($title); $providerclass = "\\$provider\\provider"; -$mform = $providerclass::get_action_settings($action, $data); +$mform = $providerclass::get_action_settings($action, $customdata); if ($mform->is_cancelled()) { $data = $mform->get_data(); diff --git a/ai/provider/azureai/classes/form/action_generate_image_form.php b/ai/provider/azureai/classes/form/action_generate_image_form.php index de79912ef39..4e34a3bcc59 100644 --- a/ai/provider/azureai/classes/form/action_generate_image_form.php +++ b/ai/provider/azureai/classes/form/action_generate_image_form.php @@ -75,8 +75,6 @@ class action_generate_image_form extends action_settings_form { $mform->addElement('hidden', 'providerid', $providerid); $mform->setType('providerid', PARAM_INT); - $this->add_action_buttons(); - $this->set_data($actionconfig); } } diff --git a/ai/provider/azureai/classes/form/action_generate_text_form.php b/ai/provider/azureai/classes/form/action_generate_text_form.php index 23905514171..cc2636d5637 100644 --- a/ai/provider/azureai/classes/form/action_generate_text_form.php +++ b/ai/provider/azureai/classes/form/action_generate_text_form.php @@ -86,8 +86,6 @@ class action_generate_text_form extends action_settings_form { $mform->addElement('hidden', 'providerid', $providerid); $mform->setType('providerid', PARAM_INT); - $this->add_action_buttons(); - $this->set_data($actionconfig); } } diff --git a/ai/provider/openai/amd/build/modelchooser.min.js b/ai/provider/openai/amd/build/modelchooser.min.js new file mode 100644 index 00000000000..e1b062eff6b --- /dev/null +++ b/ai/provider/openai/amd/build/modelchooser.min.js @@ -0,0 +1,11 @@ +define("aiprovider_openai/modelchooser",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0; +/** + * AI provider model selection handler. + * + * @module aiprovider_openai/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/openai/amd/build/modelchooser.min.js.map b/ai/provider/openai/amd/build/modelchooser.min.js.map new file mode 100644 index 00000000000..01eeacac9f7 --- /dev/null +++ b/ai/provider/openai/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_openai/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/openai/amd/src/modelchooser.js b/ai/provider/openai/amd/src/modelchooser.js new file mode 100644 index 00000000000..32cb77bf246 --- /dev/null +++ b/ai/provider/openai/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_openai/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/openai/classes/abstract_processor.php b/ai/provider/openai/classes/abstract_processor.php index 4cc614b1fd6..72abb10244c 100644 --- a/ai/provider/openai/classes/abstract_processor.php +++ b/ai/provider/openai/classes/abstract_processor.php @@ -51,6 +51,32 @@ abstract class abstract_processor extends process_base { 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['endpoint'], + $settings['systeminstruction'], + $settings['providerid'], + $settings['modelextraparams'], + ); + return $settings; + } + /** * Get the system instructions. * diff --git a/ai/provider/openai/classes/aimodel/dalle3.php b/ai/provider/openai/classes/aimodel/dalle3.php new file mode 100644 index 00000000000..f35124448df --- /dev/null +++ b/ai/provider/openai/classes/aimodel/dalle3.php @@ -0,0 +1,49 @@ +. + +namespace aiprovider_openai\aimodel; + +use core_ai\aimodel\base; + +/** + * DALL-e-3 AI model. + * + * @package aiprovider_openai + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class dalle3 extends base implements openai_base { + + #[\Override] + public function get_model_name(): string { + return 'dall-e-3'; + } + + #[\Override] + public function get_model_display_name(): string { + return 'DALL-e-3'; + } + + #[\Override] + public function has_model_settings(): bool { + return false; + } + + #[\Override] + public function model_type(): int { + return self::MODEL_TYPE_IMAGE; + } +} diff --git a/ai/provider/openai/classes/aimodel/gpt4o.php b/ai/provider/openai/classes/aimodel/gpt4o.php new file mode 100644 index 00000000000..fceb5118264 --- /dev/null +++ b/ai/provider/openai/classes/aimodel/gpt4o.php @@ -0,0 +1,87 @@ +. + +namespace aiprovider_openai\aimodel; + +use core_ai\aimodel\base; +use MoodleQuickForm; + +/** + * GPT-4o AI model. + * + * @package aiprovider_openai + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class gpt4o extends base implements openai_base { + + #[\Override] + public function get_model_name(): string { + return 'gpt-4o'; + } + + #[\Override] + public function get_model_display_name(): string { + return 'GPT-4o'; + } + + #[\Override] + public function has_model_settings(): bool { + return true; + } + + #[\Override] + public function add_model_settings(MoodleQuickForm $mform): void { + $mform->addElement( + 'text', + 'top_p', + get_string('settings_top_p', 'aiprovider_openai'), + ); + $mform->setType('top_p', PARAM_FLOAT); + $mform->addHelpButton('top_p', 'settings_top_p', 'aiprovider_openai'); + + $mform->addElement( + 'text', + 'max_tokens', + get_string('settings_max_tokens', 'aiprovider_openai'), + ); + $mform->setType('max_tokens', PARAM_INT); + $mform->addHelpButton('max_tokens', 'settings_max_tokens', 'aiprovider_openai'); + + $mform->addElement( + 'text', + 'frequency_penalty', + get_string('settings_frequency_penalty', 'aiprovider_openai'), + ); + // This is a raw value because it can be a float from -2.0 to 2.0. + $mform->setType('frequency_penalty', PARAM_RAW); + $mform->addHelpButton('frequency_penalty', 'settings_frequency_penalty', 'aiprovider_openai'); + + $mform->addElement( + 'text', + 'presence_penalty', + get_string('settings_presence_penalty', 'aiprovider_openai'), + ); + // This is a raw value because it can be a float from -2.0 to 2.0. + $mform->setType('presence_penalty', PARAM_RAW); + $mform->addHelpButton('presence_penalty', 'settings_presence_penalty', 'aiprovider_openai'); + } + + #[\Override] + public function model_type(): int { + return self::MODEL_TYPE_TEXT; + } +} diff --git a/ai/provider/openai/classes/aimodel/o1.php b/ai/provider/openai/classes/aimodel/o1.php new file mode 100644 index 00000000000..34560e7cdfa --- /dev/null +++ b/ai/provider/openai/classes/aimodel/o1.php @@ -0,0 +1,47 @@ +. + +namespace aiprovider_openai\aimodel; + +/** + * O1 AI model. + * + * @package aiprovider_openai + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class o1 extends gpt4o { + + #[\Override] + public function get_model_name(): string { + return 'o1'; + } + + #[\Override] + public function get_model_display_name(): string { + return 'O1'; + } + + #[\Override] + public function has_model_settings(): bool { + return true; + } + + #[\Override] + public function model_type(): int { + return self::MODEL_TYPE_TEXT; + } +} diff --git a/ai/provider/openai/classes/aimodel/openai_base.php b/ai/provider/openai/classes/aimodel/openai_base.php new file mode 100644 index 00000000000..e81464a9d4a --- /dev/null +++ b/ai/provider/openai/classes/aimodel/openai_base.php @@ -0,0 +1,39 @@ +. + +namespace aiprovider_openai\aimodel; + +/** + * OpenAI base AI model interface. + * + * @package aiprovider_openai + * @copyright 2025 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface openai_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/openai/classes/form/action_form.php b/ai/provider/openai/classes/form/action_form.php new file mode 100644 index 00000000000..fdc1ab14b84 --- /dev/null +++ b/ai/provider/openai/classes/form/action_form.php @@ -0,0 +1,198 @@ +. + +namespace aiprovider_openai\form; + +use aiprovider_openai\helper; +use core_ai\form\action_settings_form; + +/** + * Base action settings form for OpenAI provider. + * + * @package aiprovider_openai + * @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_openai'; + + $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_openai'); + } + } + + // 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_openai/modelchooser', 'init'); + $mform = $this->_form; + + // Action model to use. + $mform->addElement( + 'select', + 'modeltemplate', + get_string("action:{$this->actionname}:model", 'aiprovider_openai'), + $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'] ?? 'gpt-4o'; + } + $mform->setDefault('modeltemplate', $defaultmodel); + $mform->addHelpButton('modeltemplate', "action:{$this->actionname}:model", 'aiprovider_openai'); + + $mform->addElement('hidden', 'model', $this->actionconfig['model'] ?? 'gpt-4o'); + $mform->setType('model', PARAM_TEXT); + + $mform->addElement('text', 'custommodel', get_string('custom_model_name', 'aiprovider_openai')); + $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/openai/classes/form/action_generate_image_form.php b/ai/provider/openai/classes/form/action_generate_image_form.php index 220bf8dd96c..743ed79c5cc 100644 --- a/ai/provider/openai/classes/form/action_generate_image_form.php +++ b/ai/provider/openai/classes/form/action_generate_image_form.php @@ -16,7 +16,7 @@ namespace aiprovider_openai\form; -use core_ai\form\action_settings_form; +use aiprovider_openai\aimodel\openai_base; /** * Generate image action provider settings form. @@ -25,58 +25,42 @@ use core_ai\form\action_settings_form; * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class action_generate_image_form extends action_settings_form { +class action_generate_image_form extends action_form { #[\Override] - protected function definition() { + protected function definition(): void { + parent::definition(); $mform = $this->_form; - $actionconfig = $this->_customdata['actionconfig']['settings'] ?? []; - $returnurl = $this->_customdata['returnurl'] ?? null; - $actionname = $this->_customdata['actionname']; - $action = $this->_customdata['action']; - $providerid = $this->_customdata['providerid'] ?? 0; - // Action model to use. - $mform->addElement( - 'text', - 'model', - get_string("action:{$actionname}:model", 'aiprovider_openai'), - 'maxlength="255" size="20"', - ); - $mform->setType('model', PARAM_TEXT); - $mform->addRule('model', null, 'required', null, 'client'); - $mform->setDefault('model', $actionconfig['model'] ?? 'dall-e-3'); - $mform->addHelpButton('model', "action:{$actionname}:model", 'aiprovider_openai'); + $this->add_model_fields(openai_base::MODEL_TYPE_IMAGE); // API endpoint. $mform->addElement( 'text', 'endpoint', - get_string("action:{$actionname}:endpoint", 'aiprovider_openai'), + get_string("action:{$this->actionname}:endpoint", 'aiprovider_openai'), 'maxlength="255" size="30"', ); $mform->setType('endpoint', PARAM_URL); $mform->addRule('endpoint', null, 'required', null, 'client'); - $mform->setDefault('endpoint', $actionconfig['endpoint'] ?? 'https://api.openai.com/v1/images/generations'); + $mform->setDefault('endpoint', $this->actionconfig['endpoint'] ?? 'https://api.openai.com/v1/images/generations'); - if ($returnurl) { - $mform->addElement('hidden', 'returnurl', $returnurl); + 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', $action); + $mform->addElement('hidden', 'action', $this->action); $mform->setType('action', PARAM_TEXT); // Add the provider class as a hidden field. - $mform->addElement('hidden', 'provider', 'aiprovider_openai'); + $mform->addElement('hidden', 'provider', $this->providername); $mform->setType('provider', PARAM_TEXT); // Add the provider id as a hidden field. - $mform->addElement('hidden', 'providerid', $providerid); + $mform->addElement('hidden', 'providerid', $this->providerid); $mform->setType('providerid', PARAM_INT); - $this->add_action_buttons(); - - $this->set_data($actionconfig); + $this->set_data($this->actionconfig); } } diff --git a/ai/provider/openai/classes/form/action_generate_text_form.php b/ai/provider/openai/classes/form/action_generate_text_form.php index 3f2410ee01e..6af85fad047 100644 --- a/ai/provider/openai/classes/form/action_generate_text_form.php +++ b/ai/provider/openai/classes/form/action_generate_text_form.php @@ -16,7 +16,7 @@ namespace aiprovider_openai\form; -use core_ai\form\action_settings_form; +use aiprovider_openai\aimodel\openai_base; /** * Generate text action provider settings form. @@ -25,33 +25,19 @@ use core_ai\form\action_settings_form; * @copyright 2024 Matt Porritt * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class action_generate_text_form extends action_settings_form { +class action_generate_text_form extends action_form { #[\Override] - protected function definition() { + protected function definition(): void { + parent::definition(); $mform = $this->_form; - $actionconfig = $this->_customdata['actionconfig']['settings'] ?? []; - $returnurl = $this->_customdata['returnurl'] ?? null; - $actionname = $this->_customdata['actionname']; - $action = $this->_customdata['action']; - $providerid = $this->_customdata['providerid'] ?? 0; - // Action model to use. - $mform->addElement( - 'text', - 'model', - get_string("action:{$actionname}:model", 'aiprovider_openai'), - 'maxlength="255" size="20"', - ); - $mform->setType('model', PARAM_TEXT); - $mform->addRule('model', null, 'required', null, 'client'); - $mform->setDefault('model', $actionconfig['model'] ?? 'gpt-4o'); - $mform->addHelpButton('model', "action:{$actionname}:model", 'aiprovider_openai'); + $this->add_model_fields(openai_base::MODEL_TYPE_TEXT); // API endpoint. $mform->addElement( 'text', 'endpoint', - get_string("action:{$actionname}:endpoint", 'aiprovider_openai'), + get_string("action:{$this->actionname}:endpoint", 'aiprovider_openai'), 'maxlength="255" size="30"', ); $mform->setType('endpoint', PARAM_URL); @@ -62,32 +48,31 @@ class action_generate_text_form extends action_settings_form { $mform->addElement( 'textarea', 'systeminstruction', - get_string("action:{$actionname}:systeminstruction", 'aiprovider_openai'), + get_string("action:{$this->actionname}:systeminstruction", 'aiprovider_openai'), 'wrap="virtual" rows="5" cols="20"', ); $mform->setType('systeminstruction', PARAM_TEXT); - $mform->setDefault('systeminstruction', $actionconfig['systeminstruction'] ?? $action::get_system_instruction()); - $mform->addHelpButton('systeminstruction', "action:{$actionname}:systeminstruction", 'aiprovider_openai'); + $mform->setDefault('systeminstruction', $actionconfig['systeminstruction'] ?? $this->action::get_system_instruction()); + $mform->addHelpButton('systeminstruction', "action:{$this->actionname}:systeminstruction", 'aiprovider_openai'); - if ($returnurl) { - $mform->addElement('hidden', 'returnurl', $returnurl); + 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', $action); + $mform->addElement('hidden', 'action', $this->action); $mform->setType('action', PARAM_TEXT); // Add the provider class as a hidden field. - $mform->addElement('hidden', 'provider', 'aiprovider_openai'); + $mform->addElement('hidden', 'provider', $this->providername); $mform->setType('provider', PARAM_TEXT); // Add the provider id as a hidden field. - $mform->addElement('hidden', 'providerid', $providerid); + $mform->addElement('hidden', 'providerid', $this->providerid); $mform->setType('providerid', PARAM_INT); - $this->add_action_buttons(); - - $this->set_data($actionconfig); + $this->set_data($this->actionconfig); } + } diff --git a/ai/provider/openai/classes/helper.php b/ai/provider/openai/classes/helper.php new file mode 100644 index 00000000000..869852d090a --- /dev/null +++ b/ai/provider/openai/classes/helper.php @@ -0,0 +1,62 @@ +. + +namespace aiprovider_openai; + +use core_ai\aimodel\base; + +/** + * Helper class for the OpenAI provider. + * + * @package aiprovider_openai + * @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_openai', '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/openai/classes/hook_listener.php b/ai/provider/openai/classes/hook_listener.php index e8adca5ca62..eed5d0bd750 100644 --- a/ai/provider/openai/classes/hook_listener.php +++ b/ai/provider/openai/classes/hook_listener.php @@ -16,6 +16,8 @@ namespace aiprovider_openai; +use aiprovider_openai\model\base; +use core_ai\hook\after_ai_action_settings_form_hook; use core_ai\hook\after_ai_provider_form_hook; /** @@ -61,4 +63,44 @@ class hook_listener { } + /** + * Hook listener for the Open AI 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_openai(after_ai_action_settings_form_hook $hook): void { + if ($hook->plugin !== 'aiprovider_openai') { + 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_openai')); + $mform->addElement('html', get_string('settings_help', 'aiprovider_openai')); + $mform->addElement( + 'textarea', + 'modelextraparams', + get_string('extraparams', 'aiprovider_openai'), + ['rows' => 5, 'cols' => 20], + ); + $mform->setType('modelextraparams', PARAM_TEXT); + $mform->addElement('static', 'modelextraparams_help', null, get_string('extraparams_help', 'aiprovider_openai')); + } else { + $targetmodel = helper::get_model_class($model); + if ($targetmodel) { + if ($targetmodel->has_model_settings()) { + $mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_openai')); + $mform->addElement('html', get_string('settings_help', 'aiprovider_openai')); + $targetmodel->add_model_settings($mform); + } + } + } + } + } } diff --git a/ai/provider/openai/classes/process_generate_image.php b/ai/provider/openai/classes/process_generate_image.php index 83ec91063f1..3fa55153b79 100644 --- a/ai/provider/openai/classes/process_generate_image.php +++ b/ai/provider/openai/classes/process_generate_image.php @@ -75,22 +75,28 @@ class process_generate_image extends abstract_processor { #[\Override] protected function create_request_object(string $userid): RequestInterface { + // Create the request object. + $requestobj = new \stdClass(); + $requestobj->model = $this->get_model(); + $requestobj->user = $userid; + $requestobj->prompt = $this->action->get_configuration('prompttext'); + $requestobj->n = $this->numberimages; + $requestobj->quality = $this->action->get_configuration('quality'); + $requestobj->response_format = $this->responseformat; + $requestobj->size = $this->calculate_size($this->action->get_configuration('aspectratio')); + $requestobj->style = $this->action->get_configuration('style'); + // Append the extra model settings. + $modelsettings = $this->get_model_settings(); + foreach ($modelsettings as $setting => $value) { + $requestobj->$setting = $value; + } return new Request( method: 'POST', - uri: '', - headers: [ - 'Content-Type' => 'application/json', - ], - body: json_encode((object) [ - 'prompt' => $this->action->get_configuration('prompttext'), - 'model' => $this->get_model(), - 'n' => $this->numberimages, - 'quality' => $this->action->get_configuration('quality'), - 'response_format' => $this->responseformat, - 'size' => $this->calculate_size($this->action->get_configuration('aspectratio')), - 'style' => $this->action->get_configuration('style'), - 'user' => $userid, - ]), + uri: '', + headers: [ + 'Content-Type' => 'application/json', + ], + body: json_encode($requestobj), ); } diff --git a/ai/provider/openai/classes/process_generate_text.php b/ai/provider/openai/classes/process_generate_text.php index 2743e431d3d..d21647d734d 100644 --- a/ai/provider/openai/classes/process_generate_text.php +++ b/ai/provider/openai/classes/process_generate_text.php @@ -57,6 +57,12 @@ class process_generate_text extends abstract_processor { $requestobj->messages = [$userobj]; } + // Append the extra model settings. + $modelsettings = $this->get_model_settings(); + foreach ($modelsettings as $setting => $value) { + $requestobj->$setting = $value; + } + return new Request( method: 'POST', uri: '', diff --git a/ai/provider/openai/classes/provider.php b/ai/provider/openai/classes/provider.php index ac98c233de6..62067f29227 100644 --- a/ai/provider/openai/classes/provider.php +++ b/ai/provider/openai/classes/provider.php @@ -94,6 +94,7 @@ class provider extends \core_ai\provider { $customdata = [ 'actionname' => $actionname, 'action' => $action, + 'providername' => 'aiprovider_openai', ]; if ($actionname === 'generate_text' || $actionname === 'summarise_text') { $mform = new form\action_generate_text_form(customdata: $customdata); diff --git a/ai/provider/openai/classes/test/testcase_helper_trait.php b/ai/provider/openai/classes/test/testcase_helper_trait.php new file mode 100644 index 00000000000..1a968595fcf --- /dev/null +++ b/ai/provider/openai/classes/test/testcase_helper_trait.php @@ -0,0 +1,66 @@ +. + +namespace aiprovider_openai\test; + +/** + * Trait for test cases. + * + * @package aiprovider_openai + * @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 = [ + 'apikey' => '123', + 'enableuserratelimit' => true, + 'userratelimit' => 1, + 'enableglobalratelimit' => true, + 'globalratelimit' => 1, + ]; + $defaultactionconfig = [ + $actionclass => [ + 'settings' => [ + 'model' => 'gpt-4o', + 'endpoint' => "https://api.openai.com/v1/chat/completions", + ], + ], + ]; + foreach ($actionconfig as $key => $value) { + $defaultactionconfig[$actionclass]['settings'][$key] = $value; + } + $provider = $manager->create_provider_instance( + classname: '\aiprovider_openai\provider', + name: 'dummy', + config: $config, + actionconfig: $defaultactionconfig, + ); + + return $provider; + } +} diff --git a/ai/provider/openai/db/hooks.php b/ai/provider/openai/db/hooks.php index c6b06d2833c..f4c6c671b25 100644 --- a/ai/provider/openai/db/hooks.php +++ b/ai/provider/openai/db/hooks.php @@ -29,4 +29,9 @@ $callbacks = [ 'hook' => \core_ai\hook\after_ai_provider_form_hook::class, 'callback' => \aiprovider_openai\hook_listener::class . '::set_form_definition_for_aiprovider_openai', ], + + [ + 'hook' => \core_ai\hook\after_ai_action_settings_form_hook::class, + 'callback' => \aiprovider_openai\hook_listener::class . '::set_model_form_definition_for_aiprovider_openai', + ], ]; diff --git a/ai/provider/openai/lang/en/aiprovider_openai.php b/ai/provider/openai/lang/en/aiprovider_openai.php index 61bbdc26c97..7a0c135c692 100644 --- a/ai/provider/openai/lang/en/aiprovider_openai.php +++ b/ai/provider/openai/lang/en/aiprovider_openai.php @@ -37,6 +37,16 @@ $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['apikey'] = 'OpenAI API key'; $string['apikey_help'] = 'Get a key from your OpenAI API keys.'; +$string['custom_model_name'] = 'Custom model name'; +$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['orgid'] = 'OpenAI organization ID'; $string['orgid_help'] = 'Get your OpenAI organization ID from your OpenAI account.'; $string['pluginname'] = 'OpenAI API provider'; @@ -46,6 +56,16 @@ $string['privacy:metadata:aiprovider_openai:model'] = 'The model used to generat $string['privacy:metadata:aiprovider_openai:numberimages'] = 'When generating images the number of images used in the response.'; $string['privacy:metadata:aiprovider_openai:prompttext'] = 'The user entered text prompt used to generate the response.'; $string['privacy:metadata:aiprovider_openai:responseformat'] = 'The format of the response. When generating images.'; +$string['settings'] = 'Settings'; +$string['settings_frequency_penalty'] = 'frequency_penalty'; +$string['settings_frequency_penalty_help'] = 'Penalizes new tokens based on their frequency in the text so far'; +$string['settings_help'] = 'You can adjust the settings below to customize how requests are sent to OpenAI. Update the values as needed, ensuring they align with your requirements.

'; +$string['settings_max_tokens'] = 'max_tokens'; +$string['settings_max_tokens_help'] = 'The maximum number of tokens to generate in the response'; +$string['settings_presence_penalty'] = 'presence_penalty'; +$string['settings_presence_penalty_help'] = 'Penalizes new tokens based on whether they appear in the text so far'; +$string['settings_top_p'] = 'top_p'; +$string['settings_top_p_help'] = 'Controls nucleus sampling'; // Deprecated since Moodle 5.0. $string['action:generate_image:model_desc'] = 'The model used to generate images from user prompts.'; diff --git a/ai/provider/openai/tests/process_generate_image_test.php b/ai/provider/openai/tests/process_generate_image_test.php index cdcc06726fa..78620fffb3b 100644 --- a/ai/provider/openai/tests/process_generate_image_test.php +++ b/ai/provider/openai/tests/process_generate_image_test.php @@ -16,6 +16,7 @@ namespace aiprovider_openai; +use aiprovider_openai\test\testcase_helper_trait; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; @@ -31,6 +32,9 @@ use GuzzleHttp\Psr7\Response; * @covers \aiprovider_openai\abstract_processor */ final class process_generate_image_test extends \advanced_testcase { + + use testcase_helper_trait; + /** @var string A successful response in JSON format. */ protected string $responsebodyjson; @@ -51,27 +55,14 @@ final class process_generate_image_test extends \advanced_testcase { $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_openai', 'image_request_success.json')); - $this->create_provider(); - $this->create_action(); - } - - /** - * Create the provider object. - */ - private function create_provider(): void { $this->manager = \core\di::get(\core_ai\manager::class); - $config = [ - 'apikey' => '123', - 'enableuserratelimit' => true, - 'userratelimit' => 1, - 'enableglobalratelimit' => true, - 'globalratelimit' => 1, - ]; - $this->provider = $this->manager->create_provider_instance( - classname: '\aiprovider_openai\provider', - name: 'dummy', - config: $config, + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\generate_image::class, + actionconfig: [ + 'model' => 'dall-e-3', + ], ); + $this->create_action(); } /** @@ -132,6 +123,50 @@ final class process_generate_image_test extends \advanced_testcase { $this->assertEquals('1024x1024', $requestdata->size); } + /** + * 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_image::class, + actionconfig: [ + 'model' => 'dall-e-3', + 'temperature' => '0.5', + 'max_tokens' => '100', + ], + ); + $processor = new process_generate_image($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('dall-e-3', $body->model); + $this->assertEquals('0.5', $body->temperature); + $this->assertEquals('100', $body->max_tokens); + + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\generate_image::class, + actionconfig: [ + 'model' => 'my-custom-gpt', + 'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}', + ], + ); + $processor = new process_generate_image($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-gpt', $body->model); + $this->assertEquals('0.5', $body->temperature); + $this->assertEquals('100', $body->max_tokens); + } + /** * Test the API error response handler method. */ @@ -397,6 +432,14 @@ final class process_generate_image_test extends \advanced_testcase { classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, + actionconfig: [ + \core_ai\aiactions\generate_image::class => [ + 'settings' => [ + 'model' => 'dall-e-3', + 'endpoint' => "https://api.openai.com/v1/chat/completions", + ], + ], + ], ); // Mock the http client to return a successful response. @@ -538,6 +581,14 @@ final class process_generate_image_test extends \advanced_testcase { classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, + actionconfig: [ + \core_ai\aiactions\generate_image::class => [ + 'settings' => [ + 'model' => 'dall-e-3', + 'endpoint' => "https://api.openai.com/v1/chat/completions", + ], + ], + ], ); // Mock the http client to return a successful response. @@ -592,7 +643,7 @@ final class process_generate_image_test extends \advanced_testcase { ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); - $this->create_provider(); + $this->provider = $this->create_provider(\core_ai\aiactions\generate_image::class); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); @@ -603,7 +654,7 @@ final class process_generate_image_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->provider = $this->create_provider(\core_ai\aiactions\generate_image::class); $this->create_action($user2->id); // The response from OpenAI. $mock->append(new Response( @@ -653,7 +704,7 @@ final class process_generate_image_test extends \advanced_testcase { ['Content-Type' => 'image/jpeg'], \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_openai', 'test.jpg'), 'r')), )); - $this->create_provider(); + $this->provider = $this->create_provider(\core_ai\aiactions\generate_image::class); $this->create_action($user1->id); $processor = new process_generate_image($provider, $this->action); $result = $processor->process(); diff --git a/ai/provider/openai/tests/process_generate_text_test.php b/ai/provider/openai/tests/process_generate_text_test.php index ca7857dc542..c98284b12fc 100644 --- a/ai/provider/openai/tests/process_generate_text_test.php +++ b/ai/provider/openai/tests/process_generate_text_test.php @@ -16,7 +16,7 @@ namespace aiprovider_openai; -use aiprovider_openai\process_generate_text; +use aiprovider_openai\test\testcase_helper_trait; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; @@ -32,6 +32,9 @@ use GuzzleHttp\Psr7\Response; * @covers \aiprovider_openai\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; @@ -52,27 +55,14 @@ final class process_generate_text_test extends \advanced_testcase { $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_openai', 'text_request_success.json')); - $this->create_provider(); - $this->create_action(); - } - - /** - * Create the provider object. - */ - private function create_provider(): void { $this->manager = \core\di::get(\core_ai\manager::class); - $config = [ - 'apikey' => '123', - 'enableuserratelimit' => true, - 'userratelimit' => 1, - 'enableglobalratelimit' => true, - 'globalratelimit' => 1, - ]; - $this->provider = $this->manager->create_provider_instance( - classname: '\aiprovider_openai\provider', - name: 'dummy', - config: $config, + $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(); } /** @@ -103,6 +93,51 @@ final class process_generate_text_test extends \advanced_testcase { $this->assertEquals('user', $body->messages[1]->role); } + /** + * 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', + 'max_tokens' => '100', + ], + ); + $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('gpt-4o', $body->model); + $this->assertEquals('0.5', $body->temperature); + $this->assertEquals('100', $body->max_tokens); + + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\generate_text::class, + actionconfig: [ + 'model' => 'my-custom-gpt', + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + 'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}', + ], + ); + $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-gpt', $body->model); + $this->assertEquals('0.5', $body->temperature); + $this->assertEquals('100', $body->max_tokens); + } + /** * Test the API error response handler method. */ @@ -325,6 +360,15 @@ final class process_generate_text_test extends \advanced_testcase { classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, + actionconfig: [ + \core_ai\aiactions\generate_text::class => [ + 'settings' => [ + 'model' => 'gpt-4o', + 'endpoint' => "https://api.openai.com/v1/chat/completions", + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + ], + ], + ], ); // Mock the http client to return a successful response. @@ -381,7 +425,7 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); + $this->provider = $this->create_provider(\core_ai\aiactions\generate_text::class); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); @@ -410,6 +454,15 @@ final class process_generate_text_test extends \advanced_testcase { classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, + actionconfig: [ + \core_ai\aiactions\generate_text::class => [ + 'settings' => [ + 'model' => 'gpt-4o', + 'endpoint' => "https://api.openai.com/v1/chat/completions", + 'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'), + ], + ], + ], ); // Mock the http client to return a successful response. @@ -466,7 +519,7 @@ final class process_generate_text_test extends \advanced_testcase { ['Content-Type' => 'application/json'], $this->responsebodyjson, )); - $this->create_provider(); + $this->provider = $this->create_provider(\core_ai\aiactions\generate_text::class); $this->create_action($user1->id); $processor = new process_generate_text($provider, $this->action); $result = $processor->process(); diff --git a/ai/provider/openai/tests/process_summarise_text_test.php b/ai/provider/openai/tests/process_summarise_text_test.php index 7216c68c469..06d3004a478 100644 --- a/ai/provider/openai/tests/process_summarise_text_test.php +++ b/ai/provider/openai/tests/process_summarise_text_test.php @@ -16,7 +16,7 @@ namespace aiprovider_openai; -use aiprovider_openai\process_summarise_text; +use aiprovider_openai\test\testcase_helper_trait; use core_ai\aiactions\base; use core_ai\provider; use GuzzleHttp\Psr7\Response; @@ -32,6 +32,9 @@ use GuzzleHttp\Psr7\Response; * @covers \aiprovider_openai\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; @@ -52,27 +55,14 @@ final class process_summarise_text_test extends \advanced_testcase { $this->resetAfterTest(); // Load a response body from a file. $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_openai', 'text_request_success.json')); - $this->create_provider(); - $this->create_action(); - } - - /** - * Create the provider object. - */ - private function create_provider(): void { $this->manager = \core\di::get(\core_ai\manager::class); - $config = [ - 'apikey' => '123', - 'enableuserratelimit' => true, - 'userratelimit' => 1, - 'enableglobalratelimit' => true, - 'globalratelimit' => 1, - ]; - $this->provider = $this->manager->create_provider_instance( - classname: '\aiprovider_openai\provider', - name: 'dummy', - config: $config, + $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(); } /** @@ -105,6 +95,51 @@ final class process_summarise_text_test extends \advanced_testcase { $this->assertEquals('user', $body->messages[1]->role); } + /** + * 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', + 'max_tokens' => '100', + ], + ); + $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('gpt-4o', $body->model); + $this->assertEquals('0.5', $body->temperature); + $this->assertEquals('100', $body->max_tokens); + + $this->provider = $this->create_provider( + actionclass: \core_ai\aiactions\summarise_text::class, + actionconfig: [ + 'model' => 'my-custom-gpt', + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + 'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}', + ], + ); + $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-gpt', $body->model); + $this->assertEquals('0.5', $body->temperature); + $this->assertEquals('100', $body->max_tokens); + } + /** * Test the API error response handler method. * @@ -319,6 +354,15 @@ final class process_summarise_text_test extends \advanced_testcase { classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, + actionconfig: [ + \core_ai\aiactions\summarise_text::class => [ + 'settings' => [ + 'model' => 'gpt-4o', + 'endpoint' => "https://api.openai.com/v1/chat/completions", + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + ], + ], + ], ); // Mock the http client to return a successful response. @@ -403,6 +447,15 @@ final class process_summarise_text_test extends \advanced_testcase { classname: '\aiprovider_openai\provider', name: 'dummy', config: $config, + actionconfig: [ + \core_ai\aiactions\summarise_text::class => [ + 'settings' => [ + 'model' => 'gpt-4o', + 'endpoint' => "https://api.openai.com/v1/chat/completions", + 'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'), + ], + ], + ], ); // Mock the http client to return a successful response. diff --git a/ai/tests/behat/admin.feature b/ai/tests/behat/admin.feature index 55c6d023867..c7b5c407d7c 100644 --- a/ai/tests/behat/admin.feature +++ b/ai/tests/behat/admin.feature @@ -64,9 +64,10 @@ Feature: An administrator can manage AI subsystem settings And I should see "Configure provider instance" And I click on the "Settings" link in the table row containing "Generate text" And I should see "Generate text action settings" + And I set the field "AI model" to "Custom" And I set the following fields to these values: - | AI model | gpt-3 | - | API endpoint | https://api.openai.com/v1/engines/gpt-3/completions | + | Custom model name | gpt-3 | + | API endpoint | https://api.openai.com/v1/engines/gpt-3/completions | And I click on "Save changes" "button" Then I should see "Generate text action settings updated"