MDL-87513 AI: Integrate the Gemini AI provider plugin into core

Allows AI Subsystem to use Gemini models via
their API.

Co-authored by: Andrea Bertelli <[email protected]>
This commit is contained in:
Anupama Sarjoshi
2026-02-11 10:51:06 +05:30
parent 746815af49
commit bc49f9e918
31 changed files with 4456 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
define("aiprovider_gemini/modelchooser",["exports","core_ai/helper"],(function(_exports,_helper){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0;
/**
* AI provider model selection handler.
*
* @module aiprovider_gemini/modelchooser
* @copyright 2026 Anupama Sarjoshi <anupama.sarjoshi@moodle.com>
* @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"]',modelSettingsContainer:"id_modelsettingsheadercontainer",endpoint:'input[name="endpoint"]',model:'input[name="model"]',custommodel:'input[name="custommodel"]'};_exports.init=()=>{const modelSelector=document.querySelector(Selectors_fields.selector);if(modelSelector){const modelSettings=JSON.parse(modelSelector.getAttribute("data-storedmodelsettings"))[modelSelector.value],containerId=Selectors_fields.modelSettingsContainer;modelSettings?(0,_helper.populateFields)(modelSettings,containerId):(0,_helper.clearFields)(containerId),modelSelector.addEventListener("change",(e=>{modelSelector.options[e.target.selectedIndex].selected=!0;const selectedModel=e.target.value,modelField=document.querySelector(Selectors_fields.model);modelField&&(modelField.value="custom"!==selectedModel?selectedModel:document.querySelector(Selectors_fields.custommodel).value||""),(modelName=>{const endpointField=document.querySelector(Selectors_fields.endpoint),modelEndpoints=JSON.parse(endpointField.getAttribute("data-modelendpoints"));modelEndpoints[modelName]&&(endpointField.value=modelEndpoints[modelName])})(modelField.value);e.target.closest("form").querySelector(Selectors_fields.updateButton).click()}))}}}));
//# sourceMappingURL=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 <http://www.gnu.org/licenses/>.\n\n/**\n * AI provider model selection handler.\n *\n * @module aiprovider_gemini/modelchooser\n * @copyright 2026 Anupama Sarjoshi <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {populateFields, clearFields} from 'core_ai/helper';\n\nconst Selectors = {\n fields: {\n selector: '[data-modelchooser-field=\"selector\"]',\n updateButton: '[data-modelchooser-field=\"updateButton\"]',\n modelSettingsContainer: 'id_modelsettingsheadercontainer',\n endpoint: 'input[name=\"endpoint\"]',\n model: 'input[name=\"model\"]',\n custommodel: 'input[name=\"custommodel\"]',\n },\n};\n\n/**\n * Update the endpoint field based on the selected model.\n *\n * @param {string} modelName - The name of the selected model.\n */\nconst updateEndpoint = (modelName) => {\n const endpointField = document.querySelector(Selectors.fields.endpoint);\n const modelEndpoints = JSON.parse(endpointField.getAttribute('data-modelendpoints'));\n if (modelEndpoints[modelName]) {\n endpointField.value = modelEndpoints[modelName];\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 // If we have stored model settings, populate them in their respective fields.\n const storedModelSettings = JSON.parse(modelSelector.getAttribute('data-storedmodelsettings'));\n const modelSettings = storedModelSettings[modelSelector.value];\n const containerId = Selectors.fields.modelSettingsContainer;\n\n if (modelSettings) {\n populateFields(modelSettings, containerId);\n } else {\n clearFields(containerId);\n }\n\n modelSelector.addEventListener('change', e => {\n modelSelector.options[e.target.selectedIndex].selected = true;\n const selectedModel = e.target.value;\n\n // Update the hidden model field. For custom, custom model name comes from custommodel field.\n const modelField = document.querySelector(Selectors.fields.model);\n if (modelField) {\n if (selectedModel !== 'custom') {\n modelField.value = selectedModel;\n } else {\n // For custom model, get the model name from the custommodel input field.\n modelField.value = document.querySelector(Selectors.fields.custommodel).value || '';\n }\n }\n\n // Update the endpoint field based on the selected model.\n updateEndpoint(modelField.value);\n\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","modelSettingsContainer","endpoint","model","custommodel","modelSelector","document","querySelector","modelSettings","JSON","parse","getAttribute","value","containerId","addEventListener","e","options","target","selectedIndex","selected","selectedModel","modelField","modelName","endpointField","modelEndpoints","updateEndpoint","closest","click"],"mappings":";;;;;;;;MAwBMA,iBACM,CACJC,SAAU,uCACVC,aAAc,2CACdC,uBAAwB,kCACxBC,SAAU,yBACVC,MAAO,sBACPC,YAAa,2CAoBD,WACVC,cAAgBC,SAASC,cAAcT,iBAAiBC,aAC1DM,cAAe,OAGTG,cADsBC,KAAKC,MAAML,cAAcM,aAAa,6BACxBN,cAAcO,OAClDC,YAAcf,iBAAiBG,uBAEjCO,yCACeA,cAAeK,qCAElBA,aAGhBR,cAAcS,iBAAiB,UAAUC,IACrCV,cAAcW,QAAQD,EAAEE,OAAOC,eAAeC,UAAW,QACnDC,cAAgBL,EAAEE,OAAOL,MAGzBS,WAAaf,SAASC,cAAcT,iBAAiBK,OACvDkB,aAEIA,WAAWT,MADO,WAAlBQ,cACmBA,cAGAd,SAASC,cAAcT,iBAAiBM,aAAaQ,OAAS,IApC7EU,CAAAA,kBACdC,cAAgBjB,SAASC,cAAcT,iBAAiBI,UACxDsB,eAAiBf,KAAKC,MAAMa,cAAcZ,aAAa,wBACzDa,eAAeF,aACfC,cAAcX,MAAQY,eAAeF,aAqCjCG,CAAeJ,WAAWT,OAEbG,EAAEE,OAAOS,QAAQ,QACJnB,cAAcT,iBAAiBE,cAC5C2B"}
@@ -0,0 +1,89 @@
// 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 <http://www.gnu.org/licenses/>.
/**
* AI provider model selection handler.
*
* @module aiprovider_gemini/modelchooser
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {populateFields, clearFields} from 'core_ai/helper';
const Selectors = {
fields: {
selector: '[data-modelchooser-field="selector"]',
updateButton: '[data-modelchooser-field="updateButton"]',
modelSettingsContainer: 'id_modelsettingsheadercontainer',
endpoint: 'input[name="endpoint"]',
model: 'input[name="model"]',
custommodel: 'input[name="custommodel"]',
},
};
/**
* Update the endpoint field based on the selected model.
*
* @param {string} modelName - The name of the selected model.
*/
const updateEndpoint = (modelName) => {
const endpointField = document.querySelector(Selectors.fields.endpoint);
const modelEndpoints = JSON.parse(endpointField.getAttribute('data-modelendpoints'));
if (modelEndpoints[modelName]) {
endpointField.value = modelEndpoints[modelName];
}
};
/**
* Initialise the AI provider chooser.
*/
export const init = () => {
const modelSelector = document.querySelector(Selectors.fields.selector);
if (modelSelector) {
// If we have stored model settings, populate them in their respective fields.
const storedModelSettings = JSON.parse(modelSelector.getAttribute('data-storedmodelsettings'));
const modelSettings = storedModelSettings[modelSelector.value];
const containerId = Selectors.fields.modelSettingsContainer;
if (modelSettings) {
populateFields(modelSettings, containerId);
} else {
clearFields(containerId);
}
modelSelector.addEventListener('change', e => {
modelSelector.options[e.target.selectedIndex].selected = true;
const selectedModel = e.target.value;
// Update the hidden model field. For custom, custom model name comes from custommodel field.
const modelField = document.querySelector(Selectors.fields.model);
if (modelField) {
if (selectedModel !== 'custom') {
modelField.value = selectedModel;
} else {
// For custom model, get the model name from the custommodel input field.
modelField.value = document.querySelector(Selectors.fields.custommodel).value || '';
}
}
// Update the endpoint field based on the selected model.
updateEndpoint(modelField.value);
const form = e.target.closest('form');
const updateButton = form.querySelector(Selectors.fields.updateButton);
updateButton.click();
});
}
};
@@ -0,0 +1,163 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core\http_client;
use GuzzleHttp\Psr7\Uri;
use core_ai\process_base;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
/**
* Class process text generation.
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @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 {
return new Uri($this->provider->actionconfig[$this->action::class]['settings']['endpoint']);
}
/**
* 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['endpoint'],
$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 Gemini 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 Gemini 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 {
// Create the request object.
$request = $this->create_request_object(
userid: $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 \core_ai\error\factory::create($e->getCode(), $e->getMessage())->get_error_details();
}
// 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(),
];
$reason = $response->getReasonPhrase();
$status = $response->getStatusCode();
if ($status >= 500 && $status < 600) {
$responsearr['errormessage'] = $reason;
return \core_ai\error\factory::create($status, $responsearr['errormessage'])->get_error_details();
} else {
// Try to extract error details from the response body.
$bodyobj = json_decode($response->getBody()->getContents());
$responsearr['errormessage'] = $bodyobj->error->message ?? $reason;
$responsearr['error'] = $bodyobj->error->status;
return $responsearr;
}
}
}
@@ -0,0 +1,138 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\aimodel;
use core_ai\aimodel\base;
use MoodleQuickForm;
/**
* Gemini 2.5 Flash AI model.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class gemini25flash extends base implements gemini_base {
#[\Override]
public function get_model_name(): string {
return 'gemini-2.5-flash';
}
#[\Override]
public function get_model_display_name(): string {
return 'Gemini 2.5 Flash';
}
#[\Override]
public function get_model_settings(): array {
return [
// Temperature: controls how creative the AI responses are.
// 0.0 = very predictable, 2.0 = very creative.
'temperature' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_temperature',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT, // Float value from 0.0 to 2.0 controlling randomness/creativity.
'help' => [
'identifier' => 'settings_temperature',
'component' => 'aiprovider_gemini',
],
],
// Topp: controls randomness using nucleus sampling.
// 0.0 = most predictable, 1.0 = less restrictive.
'top_p' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_top_p',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT, // Float value from 0.0 to 1.0 controlling nucleus sampling.
'help' => [
'identifier' => 'settings_top_p',
'component' => 'aiprovider_gemini',
],
],
// Topk: maximum number of tokens considered when sampling.
'top_k' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_top_k',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT,
'help' => [
'identifier' => 'settings_top_k',
'component' => 'aiprovider_gemini',
],
],
// Max output tokens: limits the number of tokens the model will generate.
'max_output_tokens' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_max_output_tokens',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_INT,
'help' => [
'identifier' => 'settings_max_output_tokens',
'component' => 'aiprovider_gemini',
],
],
// Stop Sequences character sequences where the AI should stop generating text.
'stop_sequences' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_stop_sequences',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_TEXT, // String or comma-separated list of sequences.
'help' => [
'identifier' => 'settings_stop_sequences',
'component' => 'aiprovider_gemini',
],
],
];
}
#[\Override]
public function add_model_settings(MoodleQuickForm $mform): void {
$settings = $this->get_model_settings();
foreach ($settings as $key => $setting) {
$mform->addElement(
$setting['elementtype'],
$key,
get_string($setting['label']['identifier'], $setting['label']['component']),
);
$mform->setType($key, $setting['type']);
if (isset($setting['help'])) {
$mform->addHelpButton($key, $setting['help']['identifier'], $setting['help']['component']);
}
}
}
#[\Override]
public function model_type(): array {
return [self::MODEL_TYPE_TEXT];
}
#[\Override]
public function get_model_endpoint(): string {
return 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
}
}
@@ -0,0 +1,138 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\aimodel;
use core_ai\aimodel\base;
use MoodleQuickForm;
/**
* Gemini 2.5 Flashlite AI model.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class gemini25flashlite extends base implements gemini_base {
#[\Override]
public function get_model_name(): string {
return 'gemini-2.5-flash-lite';
}
#[\Override]
public function get_model_display_name(): string {
return 'Gemini 2.5 Flash lite';
}
#[\Override]
public function get_model_settings(): array {
return [
// Temperature: controls how creative the AI responses are.
// 0.0 = very predictable, 2.0 = very creative.
'temperature' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_temperature',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT, // Float value from 0.0 to 2.0 controlling randomness/creativity.
'help' => [
'identifier' => 'settings_temperature',
'component' => 'aiprovider_gemini',
],
],
// Topp: controls randomness using nucleus sampling.
// 0.0 = most predictable, 1.0 = less restrictive.
'top_p' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_top_p',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT, // Float value from 0.0 to 1.0 controlling nucleus sampling.
'help' => [
'identifier' => 'settings_top_p',
'component' => 'aiprovider_gemini',
],
],
// Topk: maximum number of tokens considered when sampling.
'top_k' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_top_k',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT,
'help' => [
'identifier' => 'settings_top_k',
'component' => 'aiprovider_gemini',
],
],
// Max output tokens: limits the number of tokens the model will generate.
'max_output_tokens' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_max_output_tokens',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_INT,
'help' => [
'identifier' => 'settings_max_output_tokens',
'component' => 'aiprovider_gemini',
],
],
// Stop Sequences character sequences where the AI should stop generating text.
'stop_sequences' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_stop_sequences',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_TEXT, // String or comma-separated list of sequences.
'help' => [
'identifier' => 'settings_stop_sequences',
'component' => 'aiprovider_gemini',
],
],
];
}
#[\Override]
public function add_model_settings(MoodleQuickForm $mform): void {
$settings = $this->get_model_settings();
foreach ($settings as $key => $setting) {
$mform->addElement(
$setting['elementtype'],
$key,
get_string($setting['label']['identifier'], $setting['label']['component']),
);
$mform->setType($key, $setting['type']);
if (isset($setting['help'])) {
$mform->addHelpButton($key, $setting['help']['identifier'], $setting['help']['component']);
}
}
}
#[\Override]
public function model_type(): array {
return [self::MODEL_TYPE_TEXT];
}
#[\Override]
public function get_model_endpoint(): string {
return 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent';
}
}
@@ -0,0 +1,138 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\aimodel;
use core_ai\aimodel\base;
use MoodleQuickForm;
/**
* Gemini 2.5 Flash Pro AI model.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class gemini25flashpro extends base implements gemini_base {
#[\Override]
public function get_model_name(): string {
return 'gemini-2.5-pro';
}
#[\Override]
public function get_model_display_name(): string {
return 'Gemini 2.5 Pro';
}
#[\Override]
public function get_model_settings(): array {
return [
// Temperature: controls how creative the AI responses are.
// 0.0 = very predictable, 2.0 = very creative.
'temperature' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_temperature',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT, // Float value from 0.0 to 2.0 controlling randomness/creativity.
'help' => [
'identifier' => 'settings_temperature',
'component' => 'aiprovider_gemini',
],
],
// Topp: controls randomness using nucleus sampling.
// 0.0 = most predictable, 1.0 = less restrictive.
'top_p' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_top_p',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT, // Float value from 0.0 to 1.0 controlling nucleus sampling.
'help' => [
'identifier' => 'settings_top_p',
'component' => 'aiprovider_gemini',
],
],
// Topk: maximum number of tokens considered when sampling.
'top_k' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_top_k',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_FLOAT,
'help' => [
'identifier' => 'settings_top_k',
'component' => 'aiprovider_gemini',
],
],
// Max output tokens: limits the number of tokens the model will generate.
'max_output_tokens' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_max_output_tokens',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_INT,
'help' => [
'identifier' => 'settings_max_output_tokens',
'component' => 'aiprovider_gemini',
],
],
// Stop sequences: character sequences where the AI should stop generating text.
'stop_sequences' => [
'elementtype' => 'text',
'label' => [
'identifier' => 'settings_stop_sequences',
'component' => 'aiprovider_gemini',
],
'type' => PARAM_TEXT, // String or comma-separated list of sequences.
'help' => [
'identifier' => 'settings_stop_sequences',
'component' => 'aiprovider_gemini',
],
],
];
}
#[\Override]
public function add_model_settings(MoodleQuickForm $mform): void {
$settings = $this->get_model_settings();
foreach ($settings as $key => $setting) {
$mform->addElement(
$setting['elementtype'],
$key,
get_string($setting['label']['identifier'], $setting['label']['component']),
);
$mform->setType($key, $setting['type']);
if (isset($setting['help'])) {
$mform->addHelpButton($key, $setting['help']['identifier'], $setting['help']['component']);
}
}
}
#[\Override]
public function model_type(): array {
return [self::MODEL_TYPE_TEXT];
}
#[\Override]
public function get_model_endpoint(): string {
return 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent';
}
}
@@ -0,0 +1,45 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\aimodel;
/**
* Gemini base AI model interface.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
interface gemini_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 types.
*
* @return array List of model types.
*/
public function model_type(): array;
/**
* Get model endpoint.
*
* @return string The endpoint URL.
*/
public function get_model_endpoint(): string;
}
@@ -0,0 +1,53 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\aimodel;
use core_ai\aimodel\base;
/**
* Imagen 4.0 Generate 001 AI model.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class imagen40generate001 extends base implements gemini_base {
#[\Override]
public function get_model_name(): string {
return 'imagen-4.0-generate-001';
}
#[\Override]
public function get_model_display_name(): string {
return 'Imagen 4.0 Generate 001';
}
#[\Override]
public function has_model_settings(): bool {
return false;
}
#[\Override]
public function model_type(): array {
return [self::MODEL_TYPE_IMAGE];
}
#[\Override]
public function get_model_endpoint(): string {
return 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
}
}
@@ -0,0 +1,279 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\form;
use aiprovider_gemini\helper;
use core_ai\form\action_settings_form;
/**
* Base action settings form for Gemini provider.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @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;
/**
* @var array Stored model settings.
*/
protected array $storedmodelsettings;
#[\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_gemini';
$this->storedmodelsettings = $this->_customdata['actionconfig']['modelsettings'] ?? [];
$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 (isset($data->model)) {
if ($data->modeltemplate === 'custom') {
$data->modelsettings['custom']['modelextraparams'] = $data->modelextraparams;
} else {
$modelclass = helper::get_model_class($data->model);
if ($modelclass) {
if ($modelclass->has_model_settings()) {
$modelsettings = $modelclass->get_model_settings();
$modelsettingskeys = array_keys($modelclass->get_model_settings());
// Process the model settings.
$modeldata = [];
foreach ($data as $key => $value) {
if (in_array($key, $modelsettingskeys)) {
$type = $modelsettings[$key]['type'];
// Cast form values to their intended types.
$modeldata[$key] = $this->cast_value_to_type($value, $type);
$data->$key = $this->cast_value_to_type($value, $type);
}
}
if (!empty($modeldata)) {
$data->modelsettings[$data->model] = $modeldata;
}
}
}
}
}
if (!empty($data)) {
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_gemini');
}
}
// 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_gemini/modelchooser', 'init');
$mform = $this->_form;
$actionname = $this->actionname;
// Determine which model to use as the default.
if (
!empty($this->actionconfig['model']) &&
(
!array_key_exists($this->actionconfig['model'], $this->get_model_list($modeltype)) ||
!empty($this->actionconfig['modelextraparams'])
)
) {
$defaultmodel = 'custom';
} else if (empty($this->actionconfig['model'])) {
$defaultmodel = ($actionname === 'generate_image') ? 'imagen-4.0-generate-001' : 'gemini-2.5-flash';
} else {
$defaultmodel = $this->actionconfig['model'];
}
// Get this model's stored values to assist model switching and value population in JS.
$modeltemplate = optional_param('modeltemplate', $defaultmodel, PARAM_TEXT);
if (isset($this->storedmodelsettings[$modeltemplate])) {
$this->storedmodelsettings = [$modeltemplate => $this->storedmodelsettings[$modeltemplate]];
}
// Model chooser.
$mform->addElement(
'select',
'modeltemplate',
get_string("action:{$this->actionname}:model", 'aiprovider_gemini'),
$this->get_model_list($modeltype),
['data-modelchooser-field' => 'selector', 'data-storedmodelsettings' => json_encode($this->storedmodelsettings)],
);
$mform->setType('modeltemplate', PARAM_TEXT);
$mform->addRule('modeltemplate', null, 'required', null, 'client');
$mform->setDefault('modeltemplate', $defaultmodel);
$mform->addHelpButton('modeltemplate', "action:{$this->actionname}:model", 'aiprovider_gemini');
$mform->addElement('hidden', 'model', $defaultmodel);
$mform->setType('model', PARAM_TEXT);
$mform->addElement('text', 'custommodel', get_string('custom_model_name', 'aiprovider_gemini'));
$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 (in_array($modeltype, $model->model_type())) {
$models[$model->get_model_name()] = $model->get_model_display_name();
}
}
return $models;
}
/**
* Cast a value to its intended type.
*
* When form values are submitted they come through as strings.
* We need to reassign the types for correct use with model requests.
*
* @param string $value Value to cast
* @param string $type Intended type
* @return mixed Cast value (string, int, float, null)
*/
protected function cast_value_to_type(string $value, string $type): mixed {
switch ($type) {
case PARAM_INT:
$castvalue = $value !== '' ? intval($value) : null;
break;
case PARAM_FLOAT:
case PARAM_RAW:
$castvalue = $value !== '' ? floatval($value) : null;
break;
default:
$castvalue = $value;
break;
}
return $castvalue;
}
/**
* Get all model endpoints.
*
* Gemini has a different endpoint for each model.
*
* @return array List of model endpoints.
*/
protected function get_model_endpoints(): array {
$modelendpoints = [];
foreach (helper::get_model_classes() as $class) {
$model = new $class();
$modelendpoints[$model->get_model_name()] = $model->get_model_endpoint();
}
return $modelendpoints;
}
}
@@ -0,0 +1,71 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\form;
use aiprovider_gemini\aimodel\gemini_base;
/**
* Generate image action provider settings form.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class action_generate_image_form extends action_form {
#[\Override]
protected function definition(): void {
parent::definition();
$mform = $this->_form;
$this->add_model_fields(gemini_base::MODEL_TYPE_IMAGE);
// API endpoint.
$defaultendpoint = $this->actionconfig['endpoint']
?? 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
$mform->addElement(
'text',
'endpoint',
[
'maxlength' => '255',
'size' => '30',
'data-modelendpoints' => json_encode($this->get_model_endpoints()),
],
);
$mform->setType('endpoint', PARAM_URL);
$mform->addRule('endpoint', null, 'required', null, 'client');
$mform->setDefault('endpoint', $defaultendpoint);
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);
}
}
@@ -0,0 +1,83 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\form;
use aiprovider_gemini\aimodel\gemini_base;
/**
* Generate text action provider settings form.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @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(gemini_base::MODEL_TYPE_TEXT);
// API endpoint.
$defaultendpoint = $this->actionconfig['endpoint']
?? 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent';
$mform->addElement(
'text',
'endpoint',
get_string("action:{$this->actionname}:endpoint", 'aiprovider_gemini'),
[
'maxlength' => '255',
'size' => '30',
'data-modelendpoints' => json_encode($this->get_model_endpoints()),
],
);
$mform->setType('endpoint', PARAM_URL);
$mform->addRule('endpoint', null, 'required', null, 'client');
$mform->setDefault('endpoint', $defaultendpoint);
// System Instructions.
$mform->addElement(
'textarea',
'systeminstruction',
get_string("action:{$this->actionname}:systeminstruction", 'aiprovider_gemini'),
'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_gemini');
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);
}
}
@@ -0,0 +1,61 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core_ai\aimodel\base;
/**
* Helper class for the Gemini provider.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @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_gemini', '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;
}
}
@@ -0,0 +1,95 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core_ai\hook\after_ai_action_settings_form_hook;
use core_ai\hook\after_ai_provider_form_hook;
/**
* Hook listener for Gemini provider.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class hook_listener {
/**
* Hook listener for the Gemini 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_gemini(after_ai_provider_form_hook $hook): void {
if ($hook->plugin !== 'aiprovider_gemini') {
return;
}
$mform = $hook->mform;
// Required setting to store Gemini API key.
$mform->addElement(
'passwordunmask',
'apikey',
get_string('apikey', 'aiprovider_gemini'),
['size' => 75],
);
$mform->addHelpButton('apikey', 'apikey', 'aiprovider_gemini');
$mform->addRule('apikey', get_string('required'), 'required', null, 'client');
}
/**
* Hook listener for the Gemini 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_gemini(after_ai_action_settings_form_hook $hook): void {
if ($hook->plugin !== 'aiprovider_gemini') {
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_gemini'));
$settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_gemini'));
$mform->addElement('html', $settingshelp);
$mform->addElement(
'textarea',
'modelextraparams',
get_string('extraparams', 'aiprovider_gemini'),
['rows' => 5, 'cols' => 20],
);
$mform->setType('modelextraparams', PARAM_TEXT);
$mform->addElement('static', 'modelextraparams_help', null, get_string('extraparams_help', 'aiprovider_gemini'));
} else {
$targetmodel = helper::get_model_class($model);
if ($targetmodel) {
if ($targetmodel->has_model_settings()) {
$mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_gemini'));
$settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_gemini'));
$mform->addElement('html', $settingshelp);
$targetmodel->add_model_settings($mform);
}
}
}
}
}
}
@@ -0,0 +1,77 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini\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 Gemini provider
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @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_gemini', [
'prompttext' => 'privacy:metadata:aiprovider_gemini:prompttext',
'model' => 'privacy:metadata:aiprovider_gemini:model',
'numberimages' => 'privacy:metadata:aiprovider_gemini:numberimages',
'responseformat' => 'privacy:metadata:aiprovider_gemini:responseformat',
], 'privacy:metadata:aiprovider_gemini: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) {
}
}
@@ -0,0 +1,27 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
/**
* Class process text explanation.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class process_explain_text extends process_generate_text {
}
@@ -0,0 +1,170 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core_ai\ai_image;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Class process image generation.
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class process_generate_image extends abstract_processor {
/** @var int The number of images to generate. */
private int $numberimages = 1;
#[\Override]
/**
* Create the request object for the Google Gemini API.
*
* API reference:
* (https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/imagen-api)
*
* @param string $userid User identifier.
* @return RequestInterface The request object to send to the Gemini API.
*/
protected function create_request_object(string $userid): RequestInterface {
// Create the request object.
$requestobj = new \stdClass();
$requestobj->instances = [
(object) [
'prompt' => $this->action->get_configuration('prompttext'),
],
];
$requestobj->parameters = (object) [
'sampleCount' => $this->numberimages,
'aspectRatio' => $this->calculate_aspect_ratio($this->action->get_configuration('aspectratio')),
'imageSize' => $this->calculate_image_quality($this->action->get_configuration('quality')),
'languageCode' => 'en', // Force English for best results.
];
return new Request(
method: 'POST',
uri: '',
body: json_encode($requestobj),
headers: [
'Content-Type' => 'application/json',
],
);
}
#[\Override]
protected function handle_api_success(ResponseInterface $response): array {
$responsebody = $response->getBody();
$bodyobj = json_decode($responsebody);
$predictions = $bodyobj->predictions;
// I have only one image.
$imagebase64 = $predictions[0]->bytesBase64Encoded;
$model = $this->get_model();
return [
'success' => true,
'model' => $model,
'draftfile' => $this->base64_to_file(
$this->action->get_configuration('userid'),
$imagebase64,
),
];
}
/**
* Convert a base64 image to a stored_file object.
* Google Gemini returns images as base64 encoded strings.
*
* @param int $userid The user id.
* @param string $base64image The base64 of the image.
* @return \stored_file The file object.
*/
private function base64_to_file(int $userid, string $base64image): \stored_file {
global $CFG;
require_once("{$CFG->libdir}/filelib.php");
// Decode the base64 image into a binary format we can use.
$binarydata = base64_decode($base64image);
// Construct a filename for the image, because we don't get one explicitly.
$imageinfo = getimagesizefromstring($binarydata);
$fileext = image_type_to_extension($imageinfo[2]);
$filename = substr(hash('sha512', ($base64image)), 0, 16) . $fileext;
// Save the image to a temp location and add the watermark.
$tempdst = make_request_directory() . DIRECTORY_SEPARATOR . $filename;
file_put_contents($tempdst, $binarydata);
$image = new ai_image($tempdst);
$image->add_watermark()->save();
// We put the file in the user draft area initially.
// Placements (on behalf of the user) can then move it to the correct location.
$fileinfo = new \stdClass();
$fileinfo->contextid = \context_user::instance($userid)->id;
$fileinfo->filearea = 'draft';
$fileinfo->component = 'user';
$fileinfo->itemid = file_get_unused_draft_itemid();
$fileinfo->filepath = '/';
$fileinfo->filename = $filename;
$fs = get_file_storage();
return $fs->create_file_from_string($fileinfo, file_get_contents($tempdst));
}
/**
* Convert the given quality to an image size that is compatible with the Gemini Imagen API.
*
* @param string $quality The quality of the image (supported values are "standard" and "hd").
* @return string The size of the image.
*/
private function calculate_image_quality(string $quality): string {
switch ($quality) {
case 'standard':
return '1k';
case 'hd':
return '2k';
default:
throw new \coding_exception('Invalid image quality: ' . $quality);
}
}
/**
* Convert the given orientation to a ratio.
*
* @param string $orientation The orientation of the image.
* @return string The aspect ratio of the image.
*/
private function calculate_aspect_ratio(string $orientation): string {
if ($orientation === 'square') {
$ratio = '1:1';
} else if ($orientation === 'landscape') {
$ratio = '16:9';
} else if ($orientation === 'portrait') {
$ratio = '9:16';
} else {
throw new \coding_exception('Invalid orientation: ' . $orientation);
}
return $ratio;
}
}
@@ -0,0 +1,122 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Class process text generation.
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @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'];
}
/**
* Create the request object for the Google Gemini (Generative Language) REST API.
*
* API reference:
* (https://ai.google.dev/gemini-api/docs/text-generation)
*
* @param string $userid User identifier
* @return RequestInterface
*/
protected function create_request_object(string $userid): RequestInterface {
// Create the user object.
$userobj = new \stdClass();
$userobj->role = 'user';
$userobj->parts = [
"text" => $this->action->get_configuration('prompttext'),
];
// Create the request object.
$requestobj = new \stdClass();
// Set model and system instruction.
$requestobj->model = $this->get_model();
// If there is a system string available, use it.
$systeminstruction = $this->get_system_instruction();
if (!empty($systeminstruction)) {
$systemobj = new \stdClass();
$systemobj->role = 'model';
$systemobj->parts = [
["text" => $systeminstruction],
];
$requestobj->system_instruction = $systemobj;
$requestobj->contents = $userobj;
} else {
$requestobj->contents = $userobj;
}
// Append the extra model settings.
$modelsettings = $this->get_model_settings();
if (!empty($modelsettings)) {
$generationconfig = new \stdClass();
foreach ($modelsettings as $setting => $value) {
$processedvalue = $value;
if ($setting === 'stop_sequences') {
// Gemini API expects an array, but the UI stores this as a comma-separated string.
$processedvalue = array_map('trim', explode(',', $value));
}
$generationconfig->$setting = $processedvalue;
}
$requestobj->generationConfig = $generationconfig;
}
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 {
$bodystring = (string) $response->getBody();
$responsebody = json_decode($bodystring);
$usagemetadata = $responsebody->usageMetadata;
$bodycandidate = $responsebody->candidates[0] ?? null;
return [
'success' => true,
'id' => $responsebody->responseId,
'generatedcontent' => $bodycandidate->content->parts[0]->text,
'finishreason' => $bodycandidate->finishReason ?? 'unknown',
'prompttokens' => $usagemetadata->promptTokenCount,
'completiontokens' => $usagemetadata->totalTokenCount,
'model' => $responsebody->modelVersion ?? $this->get_model(), // Fallback to config model.
];
}
}
@@ -0,0 +1,28 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
/**
* Class process text summarisation.
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class process_summarise_text extends process_generate_text {
}
@@ -0,0 +1,92 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use Psr\Http\Message\RequestInterface;
use core_ai\form\action_settings_form;
/**
* Class provider.
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @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\generate_image::class,
\core_ai\aiactions\summarise_text::class,
\core_ai\aiactions\explain_text::class,
];
}
#[\Override]
public function add_authentication_headers(RequestInterface $request): RequestInterface {
return $request
->withAddedHeader('x-goog-api-key', $this->config['apikey']);
}
#[\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;
if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') {
return new form\action_generate_text_form(customdata: $customdata);
} else if ($actionname === 'generate_image') {
return new form\action_generate_image_form(customdata: $customdata);
}
return false;
}
#[\Override]
public static function get_action_setting_defaults(string $action): array {
$actionname = substr($action, (strrpos($action, '\\') + 1));
$customdata = [
'actionname' => $actionname,
'action' => $action,
'providername' => 'aiprovider_gemini',
];
if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') {
$mform = new form\action_generate_text_form(customdata: $customdata);
return $mform->get_defaults();
} else if ($actionname === 'generate_image') {
$mform = new form\action_generate_image_form(customdata: $customdata);
return $mform->get_defaults();
}
return [];
}
/**
* Check this provider has the minimal configuration to work.
*
* @return bool Return true if configured.
*/
public function is_provider_configured(): bool {
return !empty($this->config['apikey']);
}
}
+37
View File
@@ -0,0 +1,37 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Hook listener callbacks for the Gemini provider.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @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_gemini\hook_listener::class . '::set_form_definition_for_aiprovider_gemini',
],
[
'hook' => \core_ai\hook\after_ai_action_settings_form_hook::class,
'callback' => \aiprovider_gemini\hook_listener::class . '::set_model_form_definition_for_aiprovider_gemini',
],
];
@@ -0,0 +1,77 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Strings for component aiprovider_gemini, language 'en'.
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
$string['action:explain_text:endpoint'] = 'API endpoint';
$string['action:explain_text:model'] = 'AI 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_image:endpoint'] = 'API endpoint';
$string['action:generate_image:model'] = 'AI model';
$string['action:generate_image:model_help'] = 'The model used to generate images from user prompts.';
$string['action:generate_image:systeminstruction'] = 'System instruction';
$string['action:generate_image: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:endpoint'] = 'API endpoint';
$string['action:generate_text:model'] = 'AI 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:endpoint'] = 'API endpoint';
$string['action:summarise_text:model'] = 'AI 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['apikey'] = 'Gemini API key';
$string['apikey_help'] = 'Get a key from <a href="https://aistudio.google.com/apikey" target="_blank">Google AI Studio website API keys</a>.';
$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:
<pre>
{
"temperature": 0.5,
"max_output_tokens": 100
}
</pre>';
$string['getallmodels_error'] = 'You need to insert API key before.';
$string['invalidjson'] = 'Invalid JSON string';
$string['pluginname'] = 'Gemini API provider';
$string['privacy:metadata'] = 'The Gemini API provider plugin does not store any personal data.';
$string['privacy:metadata:aiprovider_gemini:externalpurpose'] = 'This information is sent to the Gemini API in order for a response to be generated. Your Gemini account settings may change how Gemini stores and retains this data. No user data is explicitly sent to Gemini or stored in Moodle LMS by this plugin.';
$string['privacy:metadata:aiprovider_gemini:model'] = 'The model used to generate the response.';
$string['privacy:metadata:aiprovider_gemini:numberimages'] = 'When generating images the number of images used in the response.';
$string['privacy:metadata:aiprovider_gemini:prompttext'] = 'The user entered text prompt used to generate the response.';
$string['privacy:metadata:aiprovider_gemini:responseformat'] = 'When generating images the format of the response.';
$string['settings'] = 'Settings';
$string['settings_help'] = 'Adjust the settings below to customise how requests are sent to Gemini.';
$string['settings_max_output_tokens'] = 'Max tokens';
$string['settings_max_output_tokens_help'] = 'The maximum number of tokens used in the generated text.';
$string['settings_stop_sequences'] = 'Stop sequences';
$string['settings_stop_sequences_help'] = 'Specify one or more character sequences (comma-separated) where the model should stop generating text.';
$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. This setting works alongside other options like topP and topK to generate the output.';
$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.';
$string['settings_top_p'] = 'Top p';
$string['settings_top_p_help'] = 'Top p (nucleus sampling) determines how many possible words to consider. A high value (e.g. 0.9) means the model looks at more words, which makes the generated text more diverse.';
@@ -0,0 +1,8 @@
{
"predictions": [
{
"bytesBase64Encoded": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
"mimeType": "image/png"
}
]
}
@@ -0,0 +1,30 @@
{
"candidates": [
{
"content": {
"parts": [
{
"text": "Photosynthesis is the process plants use to make their own food. They take in sunlight, water from the soil, and carbon dioxide from the air. Inside their leaves, a green substance called chlorophyll captures the sunlight's energy. This energy is then used to convert the water and carbon dioxide into sugar, which is the plant's food, and oxygen, which the plant releases into the air as a byproduct. Essentially, plants are using light energy to transform simple ingredients into food for themselves and oxygen for us to breathe."
}
],
"role": "model"
},
"finishReason": "STOP",
"index": 0
}
],
"usageMetadata": {
"promptTokenCount": 65,
"candidatesTokenCount": 105,
"totalTokenCount": 316,
"promptTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 65
}
],
"thoughtsTokenCount": 146
},
"modelVersion": "gemini-2.5-flash",
"responseId": "9r5Uaa7hEarkg8UPndKw4AY"
}
@@ -0,0 +1,526 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core_ai\aiactions\base;
use core_ai\provider;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\CoversClass;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/testcase_helper_trait.php');
/**
* Test Explain text provider class for Gemini provider methods.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\aiprovider_gemini\provider::class)]
#[CoversClass(\aiprovider_gemini\process_explain_text::class)]
#[CoversClass(\aiprovider_gemini\abstract_processor::class)]
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 */
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_gemini', '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(get_string('action_explain_text_instruction', 'core_ai'), $body->system_instruction->parts[0]->text);
$this->assertEquals('This is a test prompt', $body->contents->parts->text);
$this->assertEquals('user', $body->contents->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\explain_text::class,
actionconfig: [
'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'),
'temperature' => '0.5',
'max_output_tokens' => '100',
'stop_sequences' => 'Title1, Title2',
],
);
$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('gemini-2.5-flash', $body->model);
$this->assertEquals('0.5', $body->generationConfig->temperature);
$this->assertEquals('100', $body->generationConfig->max_output_tokens);
$this->assertEquals('Title1', $body->generationConfig->stop_sequences[0]);
$this->assertEquals('Title2', $body->generationConfig->stop_sequences[1]);
$this->provider = $this->create_provider(
actionclass: \core_ai\aiactions\explain_text::class,
actionconfig: [
'model' => 'my-custom-gpt',
'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'),
'modelextraparams' => '{"temperature": 0.5,"max_output_tokens": 100}',
'stop_sequences' => 'Title',
],
);
$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-gpt', $body->model);
$this->assertEquals('0.5', $body->generationConfig->temperature);
$this->assertEquals('100', $body->generationConfig->max_output_tokens);
$this->assertEquals('Title', $body->generationConfig->stop_sequences[0]);
}
/**
* Test the API error response handler method.
*/
public function test_handle_api_error(): void {
$responses = $this->get_error_responses();
$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->assertEquals('9r5Uaa7hEarkg8UPndKw4AY', $result['id']);
$this->assertStringContainsString('Photosynthesis is the process plants use', $result['generatedcontent']);
$this->assertEqualsIgnoringCase('STOP', $result['finishreason']);
$this->assertEquals('65', $result['prompttokens']);
$this->assertEquals('316', $result['completiontokens']);
$this->assertEquals('gemini-2.5-flash', $result['model']);
}
/**
* 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 Gemini.
$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->assertEquals('9r5Uaa7hEarkg8UPndKw4AY', $result['id']);
$this->assertStringContainsString('Photosynthesis is the process plants use', $result['generatedcontent']);
$this->assertEqualsIgnoringCase('stop', $result['finishreason']);
$this->assertEquals('65', $result['prompttokens']);
$this->assertEquals('316', $result['completiontokens']);
$this->assertEquals('gemini-2.5-flash', $result['model']);
}
/**
* 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' => '9r5Uaa7hEarkg8UPndKw4AY',
'generatedcontent' => 'Sure, here is some sample text',
'finishreason' => 'stop',
'prompttokens' => '11',
'completiontokens' => '568',
'model' => 'gemini-2.5-flash',
];
$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']);
$this->assertEquals($response['model'], $result->get_response_data()['model']);
}
/**
* 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,
'error' => 'Internal server error',
'errormessage' => 'Try again later',
];
$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 {
// 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 Gemini.
$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 {
// 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 Gemini.
$mock->append(new Response(
401,
['Content-Type' => 'application/json'],
json_encode([
'error' => [
'code' => 401,
'message' => 'Invalid Authentication',
'status' => '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 {
// 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 = [
'apikey' => '123',
'enableuserratelimit' => true,
'userratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\explain_text::class => [
'settings' => [
'model' => 'gemini-2.5-flash',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
'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 Gemini.
$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 Gemini.
$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(
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
actual: $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 Gemini.
$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 Gemini.
$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 {
// 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 = [
'apikey' => '123',
'enableglobalratelimit' => true,
'globalratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\explain_text::class => [
'settings' => [
'model' => 'gemini-2.5-flash',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
'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 Gemini.
$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 Gemini.
$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(
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
actual: $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 Gemini.
$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 Gemini.
$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());
}
}
@@ -0,0 +1,515 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core_ai\aiactions\base;
use core_ai\provider;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\CoversClass;
use GuzzleHttp\Psr7\Utils;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/testcase_helper_trait.php');
/**
* Test Generate image provider class for Gemini provider methods.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\aiprovider_gemini\provider::class)]
#[CoversClass(\aiprovider_gemini\process_generate_image::class)]
#[CoversClass(\aiprovider_gemini\abstract_processor::class)]
final class process_generate_image_test extends \advanced_testcase {
use testcase_helper_trait;
/** @var string A successful response in JSON format. */
protected string $responsebodyjson;
/** @var \core_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;
/** @var \stored_file Test stored file. */
protected \stored_file $testfile;
/**
* 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_gemini', 'image_request_success.json'));
$this->manager = \core\di::get(\core_ai\manager::class);
$this->provider = $this->create_provider(
actionclass: \core_ai\aiactions\generate_image::class,
actionconfig: [
'model' => 'imagen-4.0-generate-001',
],
);
$this->create_action();
$this->testfile = $this->create_test_file();
}
/**
* 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_image(
contextid: 1,
userid: $userid,
prompttext: 'This is a test prompt',
quality: 'hd',
aspectratio: 'square',
numimages: 1,
style: 'vivid',
);
}
/**
* Create a mocked Gemini response.
*
* @param bool $success Whether to mock a successful response or not.
*/
private function mock_gemini_response(bool $success = true): void {
// Mock the http client to return a response.
['mock' => $mock] = $this->get_mocked_http_client();
// Create a PSR-7 Stream for the response body.
$stream = Utils::streamFor($this->responsebodyjson);
$responsesuccess = new Response(
200,
['Content-Type' => 'application/json'],
$stream,
);
$responseserror = new Response(
401,
['Content-Type' => 'application/json'],
json_encode([
'error' => [
'code' => 401,
'message' => 'Invalid Authentication',
'status' => 'UNAUTHENTICATED',
],
]),
);
if ($success) {
$mock->append($responsesuccess);
return;
} else {
$mock->append($responseserror);
return;
}
}
/**
* Test calculate_aspect_ratio.
*/
public function test_calculate_aspect_ratio(): void {
$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, 'calculate_aspect_ratio');
$orientation = 'square';
$ratio = $method->invoke($processor, $orientation);
$this->assertEquals('1:1', $ratio);
$orientation = 'portrait';
$ratio = $method->invoke($processor, $orientation);
$this->assertEquals('9:16', $ratio);
$orientation = 'landscape';
$ratio = $method->invoke($processor, $orientation);
$this->assertEquals('16:9', $ratio);
}
/**
* Test calculate_image_quality.
*/
public function test_calculate_image_quality(): void {
$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, 'calculate_image_quality');
$quality = 'standard';
$size = $method->invoke($processor, $quality);
$this->assertEquals('1k', $size);
$quality = 'hd';
$size = $method->invoke($processor, $quality);
$this->assertEquals('2k', $size);
}
/**
* Test create_request_object.
*/
public function test_create_request_object(): void {
$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);
$requestdata = (object) json_decode($request->getBody()->getContents());
$this->assertEquals('This is a test prompt', $requestdata->instances[0]->prompt);
$this->assertEquals('1', $requestdata->parameters->sampleCount);
$this->assertEquals('2k', $requestdata->parameters->imageSize);
$this->assertEquals('1:1', $requestdata->parameters->aspectRatio);
}
/**
* Test the API error response handler method.
*/
public function test_handle_api_error(): void {
$responses = $this->get_error_responses();
$processor = new process_generate_image($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 {
// Create a PSR-7 Stream for the response body.
$stream = Utils::streamFor($this->responsebodyjson);
$response = new Response(
200,
['Content-Type' => 'application/json'],
$stream,
);
// Log in user.
$this->setAdminUser();
// We're testing a private method, so we need to setup reflector magic.
$processor = new process_generate_image($this->provider, $this->action);
$method = new \ReflectionMethod($processor, 'handle_api_success');
$result = $method->invoke($processor, $response);
$this->assertTrue($result['success']);
$this->assertEquals('7cc6025f8a6ce71a.png', $result['draftfile']->get_filename());
}
/**
* Test query_ai_api for a successful call.
*/
public function test_query_ai_api_success(): void {
// Mock successful response from Gemini.
$this->mock_gemini_response();
// Log in user.
$this->setAdminUser();
$processor = new process_generate_image($this->provider, $this->action);
$method = new \ReflectionMethod($processor, 'query_ai_api');
$result = $method->invoke($processor);
$this->assertTrue($result['success']);
$this->assertEquals('7cc6025f8a6ce71a.png', $result['draftfile']->get_filename());
}
/**
* Test prepare_response success.
*/
public function test_prepare_response_success(): void {
$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, 'prepare_response');
$response = [
'success' => true,
'draftfile' => $this->testfile,
];
$result = $method->invoke($processor, $response);
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
$this->assertTrue($result->get_success());
$this->assertEquals($response['success'], $result->get_success());
$this->assertEquals('testfile.txt', $result->get_response_data()['draftfile']->get_filename());
}
/**
* Test prepare_response error.
*/
public function test_prepare_response_error(): void {
$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, 'prepare_response');
$response = [
'success' => false,
'errorcode' => 500,
'error' => 'Internal server error',
'errormessage' => 'Try again later',
];
$result = $method->invoke($processor, $response);
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
$this->assertFalse($result->get_success());
$this->assertEquals('generate_image', $result->get_actionname());
$this->assertEquals($response['errorcode'], $result->get_errorcode());
$this->assertEquals($response['errormessage'], $result->get_errormessage());
}
/**
* Test process.
*/
public function test_process(): void {
// Log in user.
$this->setUser($this->getDataGenerator()->create_user());
// Mock successful response from Gemini.
$this->mock_gemini_response();
// Create a request object.
$contextid = 1;
$userid = 1;
$prompttext = 'This is a test prompt';
$aspectratio = 'square';
$quality = 'hd';
$numimages = 1;
$style = 'vivid';
$this->action = new \core_ai\aiactions\generate_image(
contextid: $contextid,
userid: $userid,
prompttext: $prompttext,
quality: $quality,
aspectratio: $aspectratio,
numimages: $numimages,
style: $style,
);
$processor = new process_generate_image($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_image', $result->get_actionname());
$this->assertEquals('7cc6025f8a6ce71a.png', $result->get_response_data()['draftfile']->get_filename());
}
/**
* Test process method with error.
*/
public function test_process_error(): void {
// Log in user.
$this->setUser($this->getDataGenerator()->create_user());
// Mock error response from Gemini.
$this->mock_gemini_response(false);
$processor = new process_generate_image($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_image', $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 {
// 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 = [
'apikey' => '123',
'enableuserratelimit' => true,
'userratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\generate_image::class => [
'settings' => [
'model' => 'imagen-4.0-generate-001',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict",
],
],
],
);
// Case 1: User rate limit has not been reached.
$this->create_action($user1->id);
// Mock response from Gemini.
$this->mock_gemini_response();
$processor = new process_generate_image($provider, $this->action);
$result = $processor->process();
$this->assertTrue($result->get_success());
// Case 2: User rate limit has been reached.
$clock->bump(HOURSECS - 10);
// Mock response from Gemini.
$this->mock_gemini_response();
$this->create_action($user1->id);
$processor = new process_generate_image($provider, $this->action);
$result = $processor->process();
$this->assertEquals(429, $result->get_errorcode());
$this->assertEquals(
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
actual: $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);
// Mock response from Gemini.
$this->mock_gemini_response();
$processor = new process_generate_image($this->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);
// Mock response from Gemini.
$this->mock_gemini_response();
$this->create_action($user1->id);
$processor = new process_generate_image($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 {
// 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 = [
'apikey' => '123',
'enableglobalratelimit' => true,
'globalratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\generate_image::class => [
'settings' => [
'model' => 'imagen-4.0-generate-001',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict",
],
],
],
);
// Case 1: Global rate limit has not been reached.
$this->create_action($user1->id);
// Mock response from Gemini.
$this->mock_gemini_response();
$processor = new process_generate_image($provider, $this->action);
$result = $processor->process();
$this->assertTrue($result->get_success());
// Case 2: Global rate limit has been reached.
$clock->bump(HOURSECS - 10);
// Mock response from Gemini.
$this->mock_gemini_response();
$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();
$this->assertEquals(429, $result->get_errorcode());
$this->assertEquals(
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
actual: $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->provider = $this->create_provider(\core_ai\aiactions\generate_image::class);
$this->create_action($user2->id);
// Mock response from Gemini.
$this->mock_gemini_response();
$processor = new process_generate_image($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);
// Mock response from Gemini.
$this->mock_gemini_response();
$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();
$this->assertTrue($result->get_success());
}
}
@@ -0,0 +1,527 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core_ai\aiactions\base;
use core_ai\provider;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\CoversClass;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/testcase_helper_trait.php');
/**
* Test Generate text provider class for Gemini provider methods.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\aiprovider_gemini\provider::class)]
#[CoversClass(\aiprovider_gemini\process_generate_text::class)]
#[CoversClass(\aiprovider_gemini\abstract_processor::class)]
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 */
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_gemini', '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->contents->parts->text);
$this->assertEquals('user', $body->contents->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_output_tokens' => '100',
'stop_sequences' => 'Title1, Title2',
],
);
$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('gemini-2.5-flash', $body->model);
$this->assertEquals('0.5', $body->generationConfig->temperature);
$this->assertEquals('100', $body->generationConfig->max_output_tokens);
$this->assertEquals('Title1', $body->generationConfig->stop_sequences[0]);
$this->assertEquals('Title2', $body->generationConfig->stop_sequences[1]);
$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_output_tokens": 100}',
'stop_sequences' => 'Title',
],
);
$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->generationConfig->temperature);
$this->assertEquals('100', $body->generationConfig->max_output_tokens);
$this->assertEquals('Title', $body->generationConfig->stop_sequences[0]);
}
/**
* Test the API error response handler method.
*/
public function test_handle_api_error(): void {
$responses = $this->get_error_responses();
$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->assertEquals('9r5Uaa7hEarkg8UPndKw4AY', $result['id']);
$this->assertStringContainsString('Photosynthesis is the process plants use', $result['generatedcontent']);
$this->assertEqualsIgnoringCase('stop', $result['finishreason']);
$this->assertEquals('65', $result['prompttokens']);
$this->assertEquals('316', $result['completiontokens']);
$this->assertEquals('gemini-2.5-flash', $result['model']);
}
/**
* 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 Gemini.
$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->assertEquals('9r5Uaa7hEarkg8UPndKw4AY', $result['id']);
$this->assertStringContainsString('Photosynthesis is the process plants use', $result['generatedcontent']);
$this->assertEqualsIgnoringCase('stop', $result['finishreason']);
$this->assertEquals('65', $result['prompttokens']);
$this->assertEquals('316', $result['completiontokens']);
$this->assertEquals('gemini-2.5-flash', $result['model']);
}
/**
* 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' => '9r5Uaa7hEarkg8UPndKw4AY',
'generatedcontent' => 'Sure, here is some sample text',
'finishreason' => 'STOP',
'prompttokens' => '11',
'completiontokens' => '568',
'model' => 'gemini-2.5-flash',
];
$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']);
$this->assertEquals($response['model'], $result->get_response_data()['model']);
}
/**
* 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,
'error' => 'Internal server error',
'errormessage' => 'Try again later',
];
$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['error'], $result->get_error());
$this->assertEquals($response['errormessage'], $result->get_errormessage());
}
/**
* Test process method.
*/
public function test_process(): void {
// 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 Gemini.
$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 {
// 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 Gemini.
$mock->append(new Response(
401,
['Content-Type' => 'application/json'],
json_encode([
'error' => [
'code' => 401,
'message' => 'Invalid Authentication',
'status' => 'UNAUTHENTICATED',
],
]),
));
$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 {
// 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 = [
'apikey' => '123',
'enableuserratelimit' => true,
'userratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\generate_text::class => [
'settings' => [
'model' => 'gemini-2.5-flash',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
'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 Gemini.
$mock->append(new Response(
200,
['Content-Type' => 'application/json'],
$this->responsebodyjson,
));
$processor = new process_generate_text($this->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 Gemini.
$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(
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
actual: $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 Gemini.
$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 Gemini.
$mock->append(new Response(
200,
['Content-Type' => 'application/json'],
$this->responsebodyjson,
));
$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();
$this->assertTrue($result->get_success());
}
/**
* Test process method with global rate limiter.
*/
public function test_process_with_global_rate_limiter(): void {
// 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 = [
'apikey' => '123',
'enableglobalratelimit' => true,
'globalratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\generate_text::class => [
'settings' => [
'model' => 'gemini-2.5-flash',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
'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 Gemini.
$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 Gemini.
$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(
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
actual: $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 Gemini.
$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 Gemini.
$mock->append(new Response(
200,
['Content-Type' => 'application/json'],
$this->responsebodyjson,
));
$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();
$this->assertTrue($result->get_success());
}
}
@@ -0,0 +1,526 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use core_ai\aiactions\base;
use core_ai\provider;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\CoversClass;
defined('MOODLE_INTERNAL') || die();
require_once(__DIR__ . '/testcase_helper_trait.php');
/**
* Test Summarise text provider class for Gemini provider methods.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\aiprovider_gemini\provider::class)]
#[CoversClass(\aiprovider_gemini\process_summarise_text::class)]
#[CoversClass(\aiprovider_gemini\abstract_processor::class)]
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 */
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_gemini', '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(get_string('action_summarise_text_instruction', 'core_ai'), $body->system_instruction->parts[0]->text);
$this->assertEquals('This is a test prompt', $body->contents->parts->text);
$this->assertEquals('user', $body->contents->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_output_tokens' => '100',
'stop_sequences' => 'Title1, Title2',
],
);
$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('gemini-2.5-flash', $body->model);
$this->assertEquals('0.5', $body->generationConfig->temperature);
$this->assertEquals('100', $body->generationConfig->max_output_tokens);
$this->assertEquals('Title1', $body->generationConfig->stop_sequences[0]);
$this->assertEquals('Title2', $body->generationConfig->stop_sequences[1]);
$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_output_tokens": 100}',
'stop_sequences' => 'Title',
],
);
$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->generationConfig->temperature);
$this->assertEquals('100', $body->generationConfig->max_output_tokens);
$this->assertEquals('Title', $body->generationConfig->stop_sequences[0]);
}
/**
* Test the API error response handler method.
*/
public function test_handle_api_error(): void {
$responses = $this->get_error_responses();
$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->assertEquals('9r5Uaa7hEarkg8UPndKw4AY', $result['id']);
$this->assertStringContainsString('Photosynthesis is the process plants use', $result['generatedcontent']);
$this->assertEqualsIgnoringCase('STOP', $result['finishreason']);
$this->assertEquals('65', $result['prompttokens']);
$this->assertEquals('316', $result['completiontokens']);
$this->assertEquals('gemini-2.5-flash', $result['model']);
}
/**
* 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 Gemini.
$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->assertEquals('9r5Uaa7hEarkg8UPndKw4AY', $result['id']);
$this->assertStringContainsString('Photosynthesis is the process plants use', $result['generatedcontent']);
$this->assertEqualsIgnoringCase('stop', $result['finishreason']);
$this->assertEquals('65', $result['prompttokens']);
$this->assertEquals('316', $result['completiontokens']);
$this->assertEquals('gemini-2.5-flash', $result['model']);
}
/**
* 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' => '9r5Uaa7hEarkg8UPndKw4AY',
'generatedcontent' => 'Sure, here is some sample text',
'finishreason' => 'stop',
'prompttokens' => '11',
'completiontokens' => '568',
'model' => 'gemini-2.5-flash',
];
$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']);
$this->assertEquals($response['model'], $result->get_response_data()['model']);
}
/**
* 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,
'error' => 'Internal server error',
'errormessage' => 'Try again later',
];
$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 {
// 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 Gemini.
$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 {
// 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 Gemini.
$mock->append(new Response(
401,
['Content-Type' => 'application/json'],
json_encode([
'error' => [
'code' => 401,
'message' => 'Invalid Authentication',
'status' => '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 {
// 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 = [
'apikey' => '123',
'enableuserratelimit' => true,
'userratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\summarise_text::class => [
'settings' => [
'model' => 'gemini-2.5-flash',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
'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 Gemini.
$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 Gemini.
$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(
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
actual: $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 Gemini.
$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 Gemini.
$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 {
// 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 = [
'apikey' => '123',
'enableglobalratelimit' => true,
'globalratelimit' => 1,
];
$provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: [
\core_ai\aiactions\summarise_text::class => [
'settings' => [
'model' => 'gemini-2.5-flash',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
'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 Gemini.
$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 Gemini.
$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(
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
actual: $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 Gemini.
$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 Gemini.
$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());
}
}
@@ -0,0 +1,161 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use PHPUnit\Framework\Attributes\CoversClass;
/**
* Test Gemini provider methods.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
#[CoversClass(\aiprovider_gemini\provider::class)]
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);
$this->provider = $this->manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
);
}
/**
* Test get_action_list
*/
public function test_get_action_list(): void {
$actionlist = $this->provider->get_action_list();
$this->assertIsArray($actionlist);
$this->assertCount(4, $actionlist);
$this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist);
$this->assertContains(\core_ai\aiactions\generate_image::class, $actionlist);
$this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist);
$this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist);
}
/**
* Test generate_userid.
*/
public function test_generate_userid(): void {
$userid = $this->provider->generate_userid(1);
// Assert that the generated userid is a string of proper length.
$this->assertIsString($userid);
$this->assertEquals(64, strlen($userid));
}
/**
* 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_gemini\provider',
name: 'dummy',
config: $config,
);
$contextid = 1;
$userid = 1;
$prompttext = 'This is a test prompt';
$aspectratio = 'square';
$quality = 'hd';
$numimages = 1;
$style = 'vivid';
$action = new \core_ai\aiactions\generate_image(
contextid: $contextid,
userid: $userid,
prompttext: $prompttext,
quality: $quality,
aspectratio: $aspectratio,
numimages: $numimages,
style: $style,
);
// 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(
'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
$result['errormessage']
);
// Change user id to make a request for a different user, should pass (4 requests for global rate).
$action = new \core_ai\aiactions\generate_image(
contextid: $contextid,
userid: 2,
prompttext: $prompttext,
quality: $quality,
aspectratio: $aspectratio,
numimages: $numimages,
style: $style,
);
$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(
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
actual: $result['errormessage'],
);
}
/**
* Test is_provider_configured.
*/
public function test_is_provider_configured(): void {
// No configured values.
$this->assertFalse($this->provider->is_provider_configured());
// Properly configured values.
$updatedprovider = $this->manager->update_provider_instance(
provider: $this->provider,
config: ['apikey' => '123'],
);
$this->assertTrue($updatedprovider->is_provider_configured());
}
}
@@ -0,0 +1,137 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
namespace aiprovider_gemini;
use GuzzleHttp\Psr7\Response;
/**
* Trait for test cases.
*
* @package aiprovider_gemini
* @copyright 2026 Anupama Sarjoshi <[email protected]>
* @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' => 'gemini-2.5-flash',
'endpoint' => "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
],
],
];
foreach ($actionconfig as $key => $value) {
$defaultactionconfig[$actionclass]['settings'][$key] = $value;
}
$provider = $manager->create_provider_instance(
classname: '\aiprovider_gemini\provider',
name: 'dummy',
config: $config,
actionconfig: $defaultactionconfig,
);
return $provider;
}
/**
* Create a test file.
*
* @return \stored_file The test file.
*/
private function create_test_file(): \stored_file {
$fs = get_file_storage();
$fileinfo = [
'contextid' => 1,
'component' => 'draft',
'filearea' => 'user',
'itemid' => 0,
'filepath' => '/',
'filename' => 'testfile.txt',
];
$testfile = $fs->create_file_from_string($fileinfo, 'This is test file content.');
return $testfile;
}
/**
* Get the error responses.
*
* @return array The error responses.
*/
private function get_error_responses(): array {
return [
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' => [
'code' => 401,
'message' => 'Invalid Authentication',
'status' => 'UNAUTHENTICATED',
],
]),
),
404 => new Response(
404,
['Content-Type' => 'application/json'],
json_encode([
'error' => [
'code' => 404,
'message' => 'You must be a member of an organization to use the API',
'status' => 'NOT_FOUND',
],
]),
),
429 => new Response(
429,
['Content-Type' => 'application/json'],
json_encode([
'error' => [
'code' => 429,
'message' => 'Rate limit reached for requests',
'status' => 'RESOURCE_EXHAUSTED',
],
]),
),
];
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Version information for aiprovider_gemini.
*
* @package aiprovider_gemini
* @copyright 2025 University of Ferrara, Italy
* @author Andrea Bertelli <[email protected]>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'aiprovider_gemini';
$plugin->version = 2026012200;
$plugin->requires = 2026011600;
$plugin->maturity = MATURITY_STABLE;