MDL-82731 AI: Provider Plugin - AWS Bedrock
Add a provider plugin that supports AI models from AWS bedrock. Supports both text and image models. Co-authored-by: Matt Porritt <[email protected]> Co-authored-by: Safat Shahin <[email protected]>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
define("aiprovider_awsbedrock/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_awsbedrock/modelchooser
|
||||
* @copyright 2025 Matt Porritt <matt.porritt@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"};_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;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_awsbedrock/modelchooser\n * @copyright 2025 Matt Porritt <[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';\nconst Selectors = {\n fields: {\n selector: '[data-modelchooser-field=\"selector\"]',\n updateButton: '[data-modelchooser-field=\"updateButton\"]',\n modelSettingsContainer: 'id_modelsettingsheadercontainer',\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 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","modelSelector","document","querySelector","modelSettings","JSON","parse","getAttribute","value","containerId","addEventListener","e","options","target","selectedIndex","selected","closest","click"],"mappings":";;;;;;;;MAuBMA,iBACM,CACJC,SAAU,uCACVC,aAAc,2CACdC,uBAAwB,iDAOZ,WACVC,cAAgBC,SAASC,cAAcN,iBAAiBC,aAC1DG,cAAe,OAGTG,cADsBC,KAAKC,MAAML,cAAcM,aAAa,6BACxBN,cAAcO,OAClDC,YAAcZ,iBAAiBG,uBAEjCI,yCACeA,cAAeK,qCAElBA,aAGhBR,cAAcS,iBAAiB,UAAUC,IACrCV,cAAcW,QAAQD,EAAEE,OAAOC,eAAeC,UAAW,EAC5CJ,EAAEE,OAAOG,QAAQ,QACJb,cAAcN,iBAAiBE,cAC5CkB"}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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_awsbedrock/modelchooser
|
||||
* @copyright 2025 Matt Porritt <[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',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 form = e.target.closest('form');
|
||||
const updateButton = form.querySelector(Selectors.fields.updateButton);
|
||||
updateButton.click();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\Exception\AwsException;
|
||||
use Aws\Result;
|
||||
use core_ai\process_base;
|
||||
|
||||
/**
|
||||
* Class process text generation.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
abstract class abstract_processor extends process_base {
|
||||
/**
|
||||
* 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 AWS region this model uses.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_region(): string {
|
||||
return $this->provider->actionconfig[$this->action::class]['settings']['awsregion'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cross region inference if applicable.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
protected function get_cross_region_inference(): string|null {
|
||||
return $this->provider->actionconfig[$this->action::class]['settings']['cross_region_inference'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($params)) {
|
||||
foreach ($params as $key => $param) {
|
||||
$settings[$key] = $param;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unset unnecessary settings.
|
||||
unset(
|
||||
$settings['model'],
|
||||
$settings['awsregion'],
|
||||
$settings['cross_region_inference'],
|
||||
$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 to send to the external AI API.
|
||||
* This object contains all the required parameters for the request.
|
||||
*
|
||||
* @return array The request to send to the external AI API.
|
||||
*/
|
||||
abstract protected function create_request(): array;
|
||||
|
||||
/**
|
||||
* Handle a successful result from the external AI api.
|
||||
*
|
||||
* @param Result $result The result object.
|
||||
* @return array The response.
|
||||
*/
|
||||
abstract protected function handle_api_success(Result $result): array;
|
||||
|
||||
#[\Override]
|
||||
protected function query_ai_api(): array {
|
||||
$request = $this->create_request();
|
||||
$client = $this->provider->create_bedrock_client(
|
||||
region: $this->get_region(),
|
||||
);
|
||||
try {
|
||||
// Call the external AI service.
|
||||
$response = $client->invokeModel($request);
|
||||
} catch (AwsException $exception) {
|
||||
// Handle any exceptions.
|
||||
return $this->handle_api_error($exception);
|
||||
}
|
||||
return $this->handle_api_success($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error from the external AI api.
|
||||
*
|
||||
* @param AwsException $exception The response object.
|
||||
* @return array The error response.
|
||||
*/
|
||||
protected function handle_api_error(AwsException $exception): array {
|
||||
return [
|
||||
'success' => false,
|
||||
'errorcode' => $exception->getStatusCode(),
|
||||
'error' => $exception->getAwsErrorCode(),
|
||||
'errormessage' => $exception->getAwsErrorMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?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_awsbedrock\aimodel;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* AI21 model catalog.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class ai21 extends awsbedrock_base {
|
||||
/**
|
||||
* Get AI21 models.
|
||||
*
|
||||
* @return array<string, \aiprovider_awsbedrock\model_definition>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$settings = self::get_settings();
|
||||
return [
|
||||
'ai21.jamba-1-5-large-v1:0' => self::create_model(
|
||||
'ai21.jamba-1-5-large-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$settings,
|
||||
),
|
||||
'ai21.jamba-1-5-mini-v1:0' => self::create_model(
|
||||
'ai21.jamba-1-5-mini-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$settings,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* AI21 Jamba settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_settings(): array {
|
||||
return [
|
||||
// Temperature – Use a lower value to decrease randomness in responses.
|
||||
'temperature' => self::setting(
|
||||
'settings_temperature',
|
||||
PARAM_FLOAT,
|
||||
'settings_temperature',
|
||||
['min' => 0, 'max' => 2, 'default' => 1.0],
|
||||
),
|
||||
// Top P – Limit the pool of next tokens in each step to the top N percentile of possible tokens.
|
||||
'top_p' => self::setting(
|
||||
'settings_top_p',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_p',
|
||||
['min' => 0, 'max' => 1.0, 'default' => 1.0],
|
||||
),
|
||||
// Max token – The maximum number of tokens to generate in the response. Maximum token limits are strictly enforced.
|
||||
'max_tokens' => self::setting(
|
||||
'settings_max_tokens',
|
||||
PARAM_INT,
|
||||
'settings_max_tokens',
|
||||
['min' => 0, 'max' => 4096, 'default' => 4096],
|
||||
),
|
||||
// Stop Sequences – Specify a character sequence to indicate where the model should stop.
|
||||
'stop' => self::setting(
|
||||
'settings_stop_sequences',
|
||||
PARAM_TEXT,
|
||||
'settings_stop_sequences',
|
||||
),
|
||||
// Frequency Penalty - Reduce frequency of repeated words within a single response message by increasing this number.
|
||||
// This penalty gradually increases the more times a word appears during response generation.
|
||||
'frequency_penalty' => self::setting(
|
||||
'settings_frequency_penalty',
|
||||
PARAM_FLOAT,
|
||||
'settings_frequency_penalty',
|
||||
['min' => 0, 'max' => 2.0, 'default' => 0],
|
||||
),
|
||||
// Presence Penalty - Reduce the frequency of repeated words within a single message by increasing this number.
|
||||
// Unlike frequency penalty, presence penalty is the same no matter how many times a word appears.
|
||||
'presence_penalty' => self::setting(
|
||||
'settings_presence_penalty',
|
||||
PARAM_FLOAT,
|
||||
'settings_presence_penalty',
|
||||
['min' => 0, 'max' => 5.0, 'default' => 0],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?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_awsbedrock\aimodel;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Amazon model catalog.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class amazon extends awsbedrock_base {
|
||||
/**
|
||||
* Get Amazon models.
|
||||
*
|
||||
* @return array<string, \aiprovider_awsbedrock\model_definition>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$textsettings = self::get_text_settings();
|
||||
$image = self::get_image_settings();
|
||||
|
||||
return [
|
||||
'amazon.nova-pro-v1:0' => self::create_model(
|
||||
'amazon.nova-pro-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$textsettings,
|
||||
),
|
||||
'amazon.nova-lite-v1:0' => self::create_model(
|
||||
'amazon.nova-lite-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$textsettings,
|
||||
),
|
||||
'amazon.nova-micro-v1:0' => self::create_model(
|
||||
'amazon.nova-micro-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$textsettings,
|
||||
),
|
||||
'amazon.nova-canvas-v1:0' => self::create_model(
|
||||
'amazon.nova-canvas-v1:0',
|
||||
model_definition::MODEL_TYPE_IMAGE,
|
||||
$image
|
||||
),
|
||||
'amazon.titan-image-generator-v2:0' => self::create_model(
|
||||
'amazon.titan-image-generator-v2:0',
|
||||
model_definition::MODEL_TYPE_IMAGE,
|
||||
self::get_image_settings(8.0, 42),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Amazon Nova text settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_text_settings(): array {
|
||||
return [
|
||||
// Temperature – Use a lower value to decrease randomness in responses.
|
||||
'temperature' => self::setting(
|
||||
'settings_temperature',
|
||||
PARAM_FLOAT,
|
||||
'settings_temperature',
|
||||
['min' => 0, 'max' => 1, 'default' => 0.7],
|
||||
),
|
||||
// Top_p – Use a lower value to ignore less probable options and decrease the diversity of responses.
|
||||
'topP' => self::setting(
|
||||
'settings_top_p',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_p',
|
||||
['min' => 0, 'max' => 1, 'default' => 0.9],
|
||||
),
|
||||
// Top_k – Use to remove long tail low probability responses.
|
||||
'topK' => self::setting(
|
||||
'settings_top_k',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_k',
|
||||
['min' => 0, 'max' => 128, 'default' => get_string('none', 'aiprovider_awsbedrock')],
|
||||
),
|
||||
// Max token - The maximum number of tokens to generate in the response. Maximum token limits are strictly enforced.
|
||||
'maxTokens' => self::setting(
|
||||
'settings_max_tokens',
|
||||
PARAM_INT,
|
||||
'settings_max_tokens',
|
||||
['min' => 0, 'max' => 5000, 'default' => get_string('none', 'aiprovider_awsbedrock')],
|
||||
),
|
||||
// Schema version to use for the request.
|
||||
'schemaVersion' => self::setting(
|
||||
'settings_schema_version',
|
||||
PARAM_TEXT,
|
||||
'settings_schema_version',
|
||||
['default' => 'messages-v1'],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Amazon image settings.
|
||||
*
|
||||
* @param float $cfgscaledefault cfgScale default value.
|
||||
* @param int $seeddefault Seed default value.
|
||||
* @return array
|
||||
*/
|
||||
private static function get_image_settings(float $cfgscaledefault = 6.5, int $seeddefault = 12): array {
|
||||
return [
|
||||
// Specifies how strongly the generated image should adhere to the prompt.
|
||||
// Use a lower value to introduce more randomness in the generation.
|
||||
'cfgScale' => self::setting(
|
||||
'settings_cfg_scale',
|
||||
PARAM_FLOAT,
|
||||
'settings_cfg_scale',
|
||||
['min' => 1.1, 'max' => 10, 'default' => $cfgscaledefault],
|
||||
),
|
||||
// Determines the initial noise setting for the generation process.
|
||||
// Changing the seed value while leaving all other parameters the same will
|
||||
// produce a totally new image that still adheres to your prompt, dimensions, and other settings.
|
||||
'seed' => self::setting(
|
||||
'settings_seed_img',
|
||||
PARAM_INT,
|
||||
'settings_seed_img',
|
||||
['min' => 0, 'max' => 2147483646, 'default' => $seeddefault],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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_awsbedrock\aimodel;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Anthropic model catalog.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class anthropic extends awsbedrock_base {
|
||||
/**
|
||||
* Get Anthropic models.
|
||||
*
|
||||
* @return array<string, \aiprovider_awsbedrock\model_definition>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$default = self::get_settings();
|
||||
$v45 = self::get_settings(65536);
|
||||
|
||||
return [
|
||||
'anthropic.claude-3-5-sonnet-20240620-v1:0' => self::create_model(
|
||||
'anthropic.claude-3-5-sonnet-20240620-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$default,
|
||||
),
|
||||
'anthropic.claude-3-5-sonnet-20241022-v2:0' => self::create_model(
|
||||
'anthropic.claude-3-5-sonnet-20241022-v2:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($default, 'us.anthropic.claude-3-5-sonnet-20241022-v2:0'),
|
||||
),
|
||||
'anthropic.claude-3-7-sonnet-20250219-v1:0' => self::create_model(
|
||||
'anthropic.claude-3-7-sonnet-20250219-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($default, 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'),
|
||||
),
|
||||
'anthropic.claude-3-haiku-20240307-v1:0' => self::create_model(
|
||||
'anthropic.claude-3-haiku-20240307-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$default,
|
||||
),
|
||||
'anthropic.claude-sonnet-4-20250514-v1:0' => self::create_model(
|
||||
'anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($default, 'global.anthropic.claude-sonnet-4-20250514-v1:0'),
|
||||
),
|
||||
'anthropic.claude-haiku-4-5-20251001-v1:0' => self::create_model(
|
||||
'anthropic.claude-haiku-4-5-20251001-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($v45, 'global.anthropic.claude-haiku-4-5-20251001-v1:0'),
|
||||
),
|
||||
'anthropic.claude-sonnet-4-5-20250929-v1:0' => self::create_model(
|
||||
'anthropic.claude-sonnet-4-5-20250929-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($v45, 'global.anthropic.claude-sonnet-4-5-20250929-v1:0'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic settings.
|
||||
*
|
||||
* @param int $maxtokensmax Max allowed max_tokens value.
|
||||
* @return array
|
||||
*/
|
||||
private static function get_settings(int $maxtokensmax = 4096): array {
|
||||
return [
|
||||
// Temperature – Use a lower value to decrease randomness in responses.
|
||||
'temperature' => self::setting(
|
||||
'settings_temperature',
|
||||
PARAM_FLOAT,
|
||||
'settings_temperature',
|
||||
['min' => 0, 'max' => 1, 'default' => 1],
|
||||
),
|
||||
// Top_p – Use a lower value to ignore less probable options and decrease the diversity of responses.
|
||||
'top_p' => self::setting(
|
||||
'settings_top_p',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_p',
|
||||
['min' => 0, 'max' => 1, 'default' => 0.999],
|
||||
),
|
||||
// Top_k – Use to remove long tail low probability responses.
|
||||
'top_k' => self::setting(
|
||||
'settings_top_k',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_k',
|
||||
['min' => 0, 'max' => 500, 'default' => 250],
|
||||
),
|
||||
// Max token – The maximum number of tokens to generate in the response. Maximum token limits are strictly enforced.
|
||||
'max_tokens' => self::setting(
|
||||
'settings_max_tokens',
|
||||
PARAM_INT,
|
||||
'settings_max_tokens',
|
||||
['min' => 0, 'max' => $maxtokensmax, 'default' => 4096],
|
||||
true,
|
||||
),
|
||||
// Stop Sequences – Specify a character sequence to indicate where the model should stop.
|
||||
'stop_sequences' => self::setting(
|
||||
'settings_stop_sequences',
|
||||
PARAM_TEXT,
|
||||
'settings_stop_sequences',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?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_awsbedrock\aimodel;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Shared helpers for Bedrock model catalogs.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
abstract class awsbedrock_base {
|
||||
/**
|
||||
* Build one setting definition.
|
||||
*
|
||||
* @param string $labelidentifier Label lang string identifier.
|
||||
* @param mixed $type PARAM_* type.
|
||||
* @param string|null $helpidentifier Help lang string identifier.
|
||||
* @param array $helpa Help placeholders.
|
||||
* @param bool $required Whether field is required.
|
||||
* @return array
|
||||
*/
|
||||
protected static function setting(
|
||||
string $labelidentifier,
|
||||
mixed $type,
|
||||
?string $helpidentifier = null,
|
||||
array $helpa = [],
|
||||
bool $required = false,
|
||||
): array {
|
||||
$setting = [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => $labelidentifier,
|
||||
'component' => 'aiprovider_awsbedrock',
|
||||
],
|
||||
'type' => $type,
|
||||
];
|
||||
|
||||
if ($helpidentifier !== null) {
|
||||
$setting['help'] = [
|
||||
'identifier' => $helpidentifier,
|
||||
'component' => 'aiprovider_awsbedrock',
|
||||
];
|
||||
if (!empty($helpa)) {
|
||||
$setting['help']['a'] = $helpa;
|
||||
}
|
||||
}
|
||||
|
||||
if ($required) {
|
||||
$setting['required'] = true;
|
||||
}
|
||||
|
||||
return $setting;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build cross region inference setting.
|
||||
*
|
||||
* @param string $defaultvalue Default inference profile value.
|
||||
* @return array
|
||||
*/
|
||||
protected static function get_cross_region_inference_setting(string $defaultvalue): array {
|
||||
return self::setting(
|
||||
'settings_cross_region_inference',
|
||||
PARAM_TEXT,
|
||||
'settings_cross_region_inference',
|
||||
['default' => $defaultvalue],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cross-region setting to a settings array.
|
||||
*
|
||||
* @param array $settings Base settings.
|
||||
* @param string $defaultvalue Default inference profile value.
|
||||
* @return array
|
||||
*/
|
||||
protected static function with_cross_region(array $settings, string $defaultvalue): array {
|
||||
$settings['cross_region_inference'] = self::get_cross_region_inference_setting($defaultvalue);
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a model instance.
|
||||
*
|
||||
* @param string $name Model id.
|
||||
* @param int $type Model type.
|
||||
* @param array $settings Model settings.
|
||||
* @return model_definition
|
||||
*/
|
||||
protected static function create_model(string $name, int $type, array $settings): model_definition {
|
||||
return new model_definition($name, $type, $settings);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<?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_awsbedrock\aimodel;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Meta model catalog.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class meta extends awsbedrock_base {
|
||||
/**
|
||||
* Get Meta models.
|
||||
*
|
||||
* @return array<string, \aiprovider_awsbedrock\model_definition>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$settings = self::get_settings();
|
||||
|
||||
return [
|
||||
'meta.llama3-8b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-8b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$settings,
|
||||
),
|
||||
'meta.llama3-70b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-70b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$settings,
|
||||
),
|
||||
'meta.llama3-1-8b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-1-8b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-1-8b-instruct-v1:0'),
|
||||
),
|
||||
'meta.llama3-1-70b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-1-70b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-1-70b-instruct-v1:0'),
|
||||
),
|
||||
'meta.llama3-1-405b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-1-405b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-1-405b-instruct-v1:0'),
|
||||
),
|
||||
'meta.llama3-2-1b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-2-1b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-2-1b-instruct-v1:0'),
|
||||
),
|
||||
'meta.llama3-2-3b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-2-3b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-2-3b-instruct-v1:0'),
|
||||
),
|
||||
'meta.llama3-2-11b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-2-11b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-2-11b-instruct-v1:0'),
|
||||
),
|
||||
'meta.llama3-2-90b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-2-90b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-2-90b-instruct-v1:0'),
|
||||
),
|
||||
'meta.llama3-3-70b-instruct-v1:0' => self::create_model(
|
||||
'meta.llama3-3-70b-instruct-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
self::with_cross_region($settings, 'us.meta.llama3-3-70b-instruct-v1:0'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_settings(): array {
|
||||
return [
|
||||
// Temperature – Use a lower value to decrease randomness in responses.
|
||||
'temperature' => self::setting(
|
||||
'settings_temperature',
|
||||
PARAM_FLOAT,
|
||||
'settings_temperature',
|
||||
['min' => 0, 'max' => 1, 'default' => 0.5],
|
||||
),
|
||||
// Top_p – Use a lower value to ignore less probable options and decrease the diversity of responses.
|
||||
'top_p' => self::setting(
|
||||
'settings_top_p',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_p',
|
||||
['min' => 0, 'max' => 1, 'default' => 0.9],
|
||||
),
|
||||
// Max token – The maximum number of tokens to generate in the response. Maximum token limits are strictly enforced.
|
||||
'max_gen_len' => self::setting(
|
||||
'settings_max_tokens',
|
||||
PARAM_INT,
|
||||
'settings_max_tokens',
|
||||
['min' => 1, 'max' => 2048, 'default' => 512],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?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_awsbedrock\aimodel;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Mistral model catalog.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class mistral extends awsbedrock_base {
|
||||
/**
|
||||
* Get Mistral models.
|
||||
*
|
||||
* @return array<string, \aiprovider_awsbedrock\model_definition>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$base = self::get_settings();
|
||||
|
||||
$mixtral = $base;
|
||||
if (isset($mixtral['max_tokens']['help']['a']['max'])) {
|
||||
$mixtral['max_tokens']['help']['a']['max'] = 4096;
|
||||
}
|
||||
|
||||
$large = $base;
|
||||
if (isset($large['temperature']['help']['a']['default'])) {
|
||||
$large['temperature']['help']['a']['default'] = 0.7;
|
||||
}
|
||||
if (isset($large['top_p']['help']['a']['default'])) {
|
||||
$large['top_p']['help']['a']['default'] = 1;
|
||||
}
|
||||
if (isset($large['top_k']['help']['a']['default'])) {
|
||||
$large['top_k']['help']['a']['default'] = get_string('none', 'aiprovider_awsbedrock');
|
||||
}
|
||||
if (isset($large['max_tokens']['help']['a']['default'])) {
|
||||
$large['max_tokens']['help']['a']['default'] = 8192;
|
||||
}
|
||||
|
||||
$small = $large;
|
||||
|
||||
return [
|
||||
'mistral.mistral-7b-instruct-v0:2' => self::create_model(
|
||||
'mistral.mistral-7b-instruct-v0:2',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$base,
|
||||
),
|
||||
'mistral.mixtral-8x7b-instruct-v0:1' => self::create_model(
|
||||
'mistral.mixtral-8x7b-instruct-v0:1',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$mixtral,
|
||||
),
|
||||
'mistral.mistral-large-2402-v1:0' => self::create_model(
|
||||
'mistral.mistral-large-2402-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$large,
|
||||
),
|
||||
'mistral.mistral-small-2402-v1:0' => self::create_model(
|
||||
'mistral.mistral-small-2402-v1:0',
|
||||
model_definition::MODEL_TYPE_TEXT,
|
||||
$small,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mistral settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_settings(): array {
|
||||
return [
|
||||
// Temperature – Use a lower value to decrease randomness in responses.
|
||||
'temperature' => self::setting(
|
||||
'settings_temperature',
|
||||
PARAM_FLOAT,
|
||||
'settings_temperature',
|
||||
['min' => 0, 'max' => 1, 'default' => 0.5],
|
||||
),
|
||||
// Top_p – Use a lower value to ignore less probable options and decrease the diversity of responses.
|
||||
'top_p' => self::setting(
|
||||
'settings_top_p',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_p',
|
||||
['min' => 0, 'max' => 1, 'default' => 0.9],
|
||||
),
|
||||
// Top_k – Use to remove long tail low probability responses.
|
||||
'top_k' => self::setting(
|
||||
'settings_top_k',
|
||||
PARAM_FLOAT,
|
||||
'settings_top_k',
|
||||
['min' => 1, 'max' => 200, 'default' => 50],
|
||||
),
|
||||
// Max token – The maximum number of tokens to generate in the response. Maximum token limits are strictly enforced.
|
||||
'max_tokens' => self::setting(
|
||||
'settings_max_tokens',
|
||||
PARAM_INT,
|
||||
'settings_max_tokens',
|
||||
['min' => 1, 'max' => 8192, 'default' => 512],
|
||||
),
|
||||
// Stop Sequences – Specify a character sequence to indicate where the model should stop.
|
||||
'stop' => self::setting(
|
||||
'settings_stop_sequences',
|
||||
PARAM_TEXT,
|
||||
'settings_stop_sequences',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?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_awsbedrock\aimodel;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Stability model catalog.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class stability extends awsbedrock_base {
|
||||
/**
|
||||
* Get Stability models.
|
||||
*
|
||||
* @return array<string, \aiprovider_awsbedrock\model_definition>
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
$settings = self::get_settings();
|
||||
|
||||
return [
|
||||
'stability.stable-image-core-v1:1' => self::create_model(
|
||||
'stability.stable-image-core-v1:1',
|
||||
model_definition::MODEL_TYPE_IMAGE,
|
||||
$settings,
|
||||
),
|
||||
'stability.stable-image-ultra-v1:1' => self::create_model(
|
||||
'stability.stable-image-ultra-v1:1',
|
||||
model_definition::MODEL_TYPE_IMAGE,
|
||||
$settings,
|
||||
),
|
||||
'stability.sd3-5-large-v1:0' => self::create_model(
|
||||
'stability.sd3-5-large-v1:0',
|
||||
model_definition::MODEL_TYPE_IMAGE,
|
||||
$settings,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stability image settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function get_settings(): array {
|
||||
return [
|
||||
// A specific value that is used to guide the 'randomness' of the generation.
|
||||
'seed' => self::setting(
|
||||
'settings_seed_img',
|
||||
PARAM_INT,
|
||||
'settings_seed_img',
|
||||
['min' => 0, 'max' => 4294967295, 'default' => 0],
|
||||
),
|
||||
// Keywords of what you do not wish to see in the output image. Max: 10.000 characters.
|
||||
'negative_prompt' => self::setting(
|
||||
'settings_negative_prompt_img',
|
||||
PARAM_TEXT,
|
||||
'settings_negative_prompt_img',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\BedrockRuntime\BedrockRuntimeClient;
|
||||
|
||||
/**
|
||||
* AWS Bedrock client factory.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class bedrock_client_factory {
|
||||
/**
|
||||
* Create an instance of BedrockRuntimeClient.
|
||||
*
|
||||
* @param string $region The AWS region.
|
||||
* @param string $key The AWS IAM API key.
|
||||
* @param string $secret The AWS IAM API secret.
|
||||
* @param string $version The API version.
|
||||
* @return BedrockRuntimeClient.
|
||||
*/
|
||||
public function create_client(
|
||||
string $region,
|
||||
string $key,
|
||||
string $secret,
|
||||
string $version = 'latest'
|
||||
): BedrockRuntimeClient {
|
||||
$credentials = [
|
||||
'key' => $key,
|
||||
'secret' => $secret,
|
||||
];
|
||||
|
||||
return new BedrockRuntimeClient([
|
||||
'region' => $region,
|
||||
'version' => $version,
|
||||
'credentials' => $credentials,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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_awsbedrock\form;
|
||||
|
||||
use aiprovider_awsbedrock\helper;
|
||||
use core_ai\form\action_settings_form;
|
||||
|
||||
/**
|
||||
* Base action settings form for AWS Bedrock provider.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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_awsbedrock';
|
||||
$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 = array_keys($modelclass->get_model_settings());
|
||||
// Process the model settings.
|
||||
$modeldata = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, $modelsettings)) {
|
||||
$modeldata[$key] = $value;
|
||||
}
|
||||
}
|
||||
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_awsbedrock');
|
||||
}
|
||||
}
|
||||
|
||||
// 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_awsbedrock/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') ? 'amazon.nova-canvas-v1:0' : 'amazon.nova-pro-v1:0';
|
||||
} 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_awsbedrock'),
|
||||
$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_awsbedrock');
|
||||
|
||||
$mform->addElement('hidden', 'model', $defaultmodel);
|
||||
$mform->setType('model', PARAM_TEXT);
|
||||
|
||||
$mform->addElement('text', 'custommodel', get_string('custom_model_name', 'aiprovider_awsbedrock'));
|
||||
$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_models() as $model) {
|
||||
if ($model->model_type() == $modeltype) {
|
||||
$models[$model->get_model_name()] = $model->get_model_display_name();
|
||||
}
|
||||
}
|
||||
return $models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of AWS regions mapped to their corresponding language strings.
|
||||
*
|
||||
* @return array An associative array where keys represent AWS region codes and values
|
||||
* are language string objects for the respective region.
|
||||
*/
|
||||
protected function get_regions(): array {
|
||||
$regioncodes = [
|
||||
'af-south-1',
|
||||
'ap-east-2',
|
||||
'ap-northeast-1',
|
||||
'ap-northeast-2',
|
||||
'ap-northeast-3',
|
||||
'ap-south-1',
|
||||
'ap-south-2',
|
||||
'ap-southeast-1',
|
||||
'ap-southeast-2',
|
||||
'ap-southeast-3',
|
||||
'ap-southeast-4',
|
||||
'ap-southeast-5',
|
||||
'ap-southeast-6',
|
||||
'ap-southeast-7',
|
||||
'ca-central-1',
|
||||
'ca-west-1',
|
||||
'eu-central-1',
|
||||
'eu-central-2',
|
||||
'eu-north-1',
|
||||
'eu-south-1',
|
||||
'eu-south-2',
|
||||
'eu-west-1',
|
||||
'eu-west-2',
|
||||
'eu-west-3',
|
||||
'il-central-1',
|
||||
'me-central-1',
|
||||
'me-south-1',
|
||||
'mx-central-1',
|
||||
'sa-east-1',
|
||||
'us-east-1',
|
||||
'us-east-2',
|
||||
'us-gov-east-1',
|
||||
'us-gov-west-1',
|
||||
'us-west-1',
|
||||
'us-west-2',
|
||||
];
|
||||
|
||||
$regions = [];
|
||||
foreach ($regioncodes as $region) {
|
||||
$regions[$region] = new \lang_string('awsregion:' . $region, 'aiprovider_awsbedrock');
|
||||
}
|
||||
|
||||
return $regions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?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_awsbedrock\form;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Generate image action provider settings form.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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(model_definition::MODEL_TYPE_IMAGE);
|
||||
|
||||
$mform->addElement(
|
||||
'select',
|
||||
'awsregion',
|
||||
get_string('awsregion', 'aiprovider_awsbedrock'),
|
||||
$this->get_regions(),
|
||||
);
|
||||
$mform->setDefault('awsregion', 'ap-southeast-2');
|
||||
$mform->addHelpButton('awsregion', 'awsregion', 'aiprovider_awsbedrock');
|
||||
|
||||
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,75 @@
|
||||
<?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_awsbedrock\form;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* Generate text action provider settings form.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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(model_definition::MODEL_TYPE_TEXT);
|
||||
|
||||
$mform->addElement(
|
||||
'select',
|
||||
'awsregion',
|
||||
get_string('awsregion', 'aiprovider_awsbedrock'),
|
||||
$this->get_regions(),
|
||||
);
|
||||
$mform->setDefault('awsregion', 'ap-southeast-2');
|
||||
$mform->addHelpButton('awsregion', 'awsregion', 'aiprovider_awsbedrock');
|
||||
|
||||
// System Instructions.
|
||||
$mform->addElement(
|
||||
'textarea',
|
||||
'systeminstruction',
|
||||
get_string("action:{$this->actionname}:systeminstruction", 'aiprovider_awsbedrock'),
|
||||
'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_awsbedrock');
|
||||
|
||||
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,57 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
use core_ai\aimodel\base;
|
||||
|
||||
/**
|
||||
* Helper class for the AWS Bedrock provider.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class helper {
|
||||
/**
|
||||
* Get all model definitions.
|
||||
*
|
||||
* @return model_definition[] Array of model definitions.
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
return model_registry::get_models();
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible alias for model definitions.
|
||||
*
|
||||
* @return model_definition[] Array of model definitions.
|
||||
*/
|
||||
public static function get_model_classes(): array {
|
||||
return static::get_models();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model definition by name.
|
||||
*
|
||||
* @param string $modelname Model name.
|
||||
* @return base|null
|
||||
*/
|
||||
public static function get_model_class(string $modelname): ?base {
|
||||
return model_registry::get_model($modelname);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use core\output\html_writer;
|
||||
use core_ai\hook\after_ai_action_settings_form_hook;
|
||||
use core_ai\hook\after_ai_provider_form_hook;
|
||||
|
||||
/**
|
||||
* Hook listener for AWS Bedrock Provider.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class hook_listener {
|
||||
/**
|
||||
* Hook listener for the Bedrock AI 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_awsbedrock(after_ai_provider_form_hook $hook): void {
|
||||
if ($hook->plugin !== 'aiprovider_awsbedrock') {
|
||||
return;
|
||||
}
|
||||
|
||||
$mform = $hook->mform;
|
||||
// Required setting to store AWS API key.
|
||||
$mform->addElement(
|
||||
'text',
|
||||
'apikey',
|
||||
get_string('apikey', 'aiprovider_awsbedrock'),
|
||||
['size' => 32],
|
||||
);
|
||||
$mform->setType('apikey', PARAM_TEXT);
|
||||
$mform->addHelpButton('apikey', 'apikey', 'aiprovider_awsbedrock');
|
||||
$mform->addRule('apikey', get_string('required'), 'required', null, 'client');
|
||||
|
||||
// Setting to store AWS API secret.
|
||||
$mform->addElement(
|
||||
'passwordunmask',
|
||||
'apisecret',
|
||||
get_string('apisecret', 'aiprovider_awsbedrock'),
|
||||
['size' => 32],
|
||||
);
|
||||
$mform->addHelpButton('apisecret', 'apisecret', 'aiprovider_awsbedrock');
|
||||
$mform->addRule('apisecret', get_string('required'), 'required', null, 'client');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook listener for the Bedrock 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_awsbedrock(after_ai_action_settings_form_hook $hook): void {
|
||||
if ($hook->plugin !== 'aiprovider_awsbedrock') {
|
||||
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_awsbedrock'));
|
||||
$settingshelp = html_writer::tag('p', get_string('settings_help', 'aiprovider_awsbedrock'));
|
||||
$mform->addElement('html', $settingshelp);
|
||||
$mform->addElement(
|
||||
'textarea',
|
||||
'modelextraparams',
|
||||
get_string('extraparams', 'aiprovider_awsbedrock'),
|
||||
['rows' => 5, 'cols' => 20],
|
||||
);
|
||||
$mform->setType('modelextraparams', PARAM_TEXT);
|
||||
$mform->addElement(
|
||||
'static',
|
||||
'modelextraparams_help',
|
||||
null,
|
||||
get_string('extraparams_help', 'aiprovider_awsbedrock')
|
||||
);
|
||||
} else {
|
||||
$targetmodel = helper::get_model_class($model);
|
||||
if ($targetmodel) {
|
||||
if ($targetmodel->has_model_settings()) {
|
||||
$mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_awsbedrock'));
|
||||
$settingshelp = html_writer::tag('p', get_string('settings_help', 'aiprovider_awsbedrock'));
|
||||
$mform->addElement('html', $settingshelp);
|
||||
$targetmodel->add_model_settings($mform);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use core_ai\aimodel\base;
|
||||
use MoodleQuickForm;
|
||||
|
||||
/**
|
||||
* Generic AWS Bedrock model definition.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class model_definition extends 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;
|
||||
/** @var string Model id. */
|
||||
private readonly string $modelname;
|
||||
/** @var int Model type. */
|
||||
private readonly int $modeltype;
|
||||
/** @var array Model settings schema. */
|
||||
private readonly array $settings;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $modelname Model id.
|
||||
* @param int $modeltype Model type.
|
||||
* @param array $settings Model settings schema.
|
||||
*/
|
||||
public function __construct(
|
||||
string $modelname,
|
||||
int $modeltype,
|
||||
array $settings = [],
|
||||
) {
|
||||
$this->modelname = $modelname;
|
||||
$this->modeltype = $modeltype;
|
||||
$this->settings = $settings;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_model_name(): string {
|
||||
return $this->modelname;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_model_display_name(): string {
|
||||
return get_string("model_{$this->modelname}", 'aiprovider_awsbedrock');
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_model_settings(): array {
|
||||
return $this->settings;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function add_model_settings(MoodleQuickForm $mform): void {
|
||||
foreach ($this->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(
|
||||
elementname: $key,
|
||||
identifier: $setting['help']['identifier'],
|
||||
component: $setting['help']['component'],
|
||||
a: !empty($setting['help']['a']) ? $setting['help']['a'] : [],
|
||||
);
|
||||
}
|
||||
if (!empty($setting['required'])) {
|
||||
$mform->addRule($key, get_string('required'), 'required', null, 'client');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model type.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function model_type(): int {
|
||||
return $this->modeltype;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use aiprovider_awsbedrock\aimodel\ai21;
|
||||
use aiprovider_awsbedrock\aimodel\amazon;
|
||||
use aiprovider_awsbedrock\aimodel\anthropic;
|
||||
use aiprovider_awsbedrock\aimodel\meta;
|
||||
use aiprovider_awsbedrock\aimodel\mistral;
|
||||
use aiprovider_awsbedrock\aimodel\stability;
|
||||
use aiprovider_awsbedrock\model_definition;
|
||||
|
||||
/**
|
||||
* AWS Bedrock model registry.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Safat Shahin <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class model_registry {
|
||||
/** @var array<string, model_definition>|null Cached model map. */
|
||||
private static ?array $modelmap = null;
|
||||
|
||||
/**
|
||||
* Get all registered models.
|
||||
*
|
||||
* @return model_definition[]
|
||||
*/
|
||||
public static function get_models(): array {
|
||||
return array_values(self::get_model_map());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a model by model id.
|
||||
*
|
||||
* @param string $modelname Model id.
|
||||
* @return model_definition|null
|
||||
*/
|
||||
public static function get_model(string $modelname): ?model_definition {
|
||||
return self::get_model_map()[$modelname] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model map keyed by model id.
|
||||
*
|
||||
* @return array<string, model_definition>
|
||||
*/
|
||||
private static function get_model_map(): array {
|
||||
if (self::$modelmap !== null) {
|
||||
return self::$modelmap;
|
||||
}
|
||||
|
||||
$catalogs = [
|
||||
ai21::class,
|
||||
amazon::class,
|
||||
anthropic::class,
|
||||
meta::class,
|
||||
mistral::class,
|
||||
stability::class,
|
||||
];
|
||||
|
||||
$modelmap = [];
|
||||
foreach ($catalogs as $catalog) {
|
||||
foreach ($catalog::get_models() as $modelname => $modeldefinition) {
|
||||
if (isset($modelmap[$modelname])) {
|
||||
throw new \coding_exception("Duplicate model definition for '{$modelname}'.");
|
||||
}
|
||||
$modelmap[$modelname] = $modeldefinition;
|
||||
}
|
||||
}
|
||||
|
||||
self::$modelmap = $modelmap;
|
||||
return self::$modelmap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?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_awsbedrock\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 AWS Bedrock provider.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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_awsbedrock', [
|
||||
'prompttext' => 'privacy:metadata:aiprovider_awsbedrock:prompttext',
|
||||
'model' => 'privacy:metadata:aiprovider_awsbedrock:model',
|
||||
'numberimages' => 'privacy:metadata:aiprovider_awsbedrock:numberimages',
|
||||
'responseformat' => 'privacy:metadata:aiprovider_awsbedrock:responseformat',
|
||||
], 'privacy:metadata:aiprovider_awsbedrock: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_awsbedrock;
|
||||
|
||||
/**
|
||||
* Class process text explanation.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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,280 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\Result;
|
||||
use core_ai\ai_image;
|
||||
|
||||
/**
|
||||
* Class process image generation.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class process_generate_image extends abstract_processor {
|
||||
/**
|
||||
* Create the request object for the Amazon models.
|
||||
*
|
||||
* @param \stdClass $requestobj The base request object to extend.
|
||||
* @param array $modelsettings The model settings to append to the request object.
|
||||
* @return \stdClass $requestobj The extended request object.
|
||||
*/
|
||||
private function create_amazon_request(\stdClass $requestobj, array $modelsettings): \stdClass {
|
||||
$requestobj->taskType = 'TEXT_IMAGE';
|
||||
$aspectratio = $this->action->get_configuration('aspectratio');
|
||||
$quality = $this->action->get_configuration('quality') == 'hd' ? 'premium' : 'standard';
|
||||
$model = $this->get_model();
|
||||
|
||||
// Calculate the dimensions based on the aspect ratio and quality.
|
||||
if (str_contains($model, 'amazon.nova')) {
|
||||
$dimensions = [
|
||||
'premium' => [
|
||||
'square' => ['width' => 1792, 'height' => 1792],
|
||||
'portrait' => ['width' => 1024, 'height' => 1792],
|
||||
'landscape' => ['width' => 1792, 'height' => 1024],
|
||||
],
|
||||
'standard' => [
|
||||
'square' => ['width' => 1024, 'height' => 1024],
|
||||
'portrait' => ['width' => 592, 'height' => 1024],
|
||||
'landscape' => ['width' => 1024, 'height' => 592],
|
||||
],
|
||||
];
|
||||
} else {
|
||||
// Titan models have different dimensions.
|
||||
$dimensions = [
|
||||
'premium' => [
|
||||
'square' => ['width' => 1024, 'height' => 1024],
|
||||
'portrait' => ['width' => 768, 'height' => 1408],
|
||||
'landscape' => ['width' => 1173, 'height' => 640],
|
||||
],
|
||||
'standard' => [
|
||||
'square' => ['width' => 768, 'height' => 768],
|
||||
'portrait' => ['width' => 384, 'height' => 704],
|
||||
'landscape' => ['width' => 1173, 'height' => 640],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($dimensions[$quality][$aspectratio])) {
|
||||
$width = $dimensions[$quality][$aspectratio]['width'];
|
||||
$height = $dimensions[$quality][$aspectratio]['height'];
|
||||
} else {
|
||||
throw new \coding_exception('Unknown aspect ratio or quality.');
|
||||
}
|
||||
|
||||
// Create the prompt object.
|
||||
$promptobj = new \stdClass();
|
||||
$promptobj->text = $this->action->get_configuration('prompttext');
|
||||
$requestobj->textToImageParams = $promptobj;
|
||||
|
||||
// Create the image generation config object.
|
||||
$imggenconfig = new \stdClass();
|
||||
$imggenconfig->numberOfImages = $this->action->get_configuration('numimages');
|
||||
$imggenconfig->width = $width;
|
||||
$imggenconfig->height = $height;
|
||||
$imggenconfig->quality = $quality;
|
||||
|
||||
// Add the model settings to the request object.
|
||||
foreach ($modelsettings as $setting => $value) {
|
||||
$imggenconfig->$setting = is_numeric($value) ? ($value + 0) : $value;
|
||||
}
|
||||
|
||||
$requestobj->imageGenerationConfig = $imggenconfig;
|
||||
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the request object for Stability AI models.
|
||||
*
|
||||
* @param \stdClass $requestobj The base request object to extend.
|
||||
* @param array $modelsettings The model settings to append to the request object.
|
||||
* @return \stdClass $requestobj The extended request object.
|
||||
*/
|
||||
private function create_stability_request(\stdClass $requestobj, array $modelsettings): \stdClass {
|
||||
$requestobj->prompt = $this->action->get_configuration('prompttext');
|
||||
$requestobj->aspect_ratio = $this->get_stability_aspect_ratio(
|
||||
$this->action->get_configuration('aspectratio'),
|
||||
$this->action->get_configuration('quality'),
|
||||
);
|
||||
// Add the model settings to the request object.
|
||||
foreach ($modelsettings as $setting => $value) {
|
||||
$requestobj->$setting = is_numeric($value) ? ($value + 0) : $value;
|
||||
}
|
||||
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function create_request(): array {
|
||||
$requestobj = new \stdClass();
|
||||
$modelsettings = $this->get_model_settings();
|
||||
$model = $this->get_model();
|
||||
|
||||
if (str_contains($model, 'amazon')) {
|
||||
$requestobj = $this->create_amazon_request($requestobj, $modelsettings);
|
||||
} else if (str_contains($model, 'stability')) {
|
||||
$requestobj = $this->create_stability_request($requestobj, $modelsettings);
|
||||
} else {
|
||||
throw new \coding_exception('Unknown model class type.');
|
||||
}
|
||||
|
||||
return [
|
||||
'ContentType' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'modelId' => $model,
|
||||
'body' => json_encode($requestobj),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function handle_api_success(Result $result): array {
|
||||
$bodyobj = json_decode($result['body']->getContents());
|
||||
$responseheaders = $result['@metadata']['headers'] ?? [];
|
||||
$statuscode = (int)($result['@metadata']['statusCode'] ?? 500);
|
||||
$model = $this->get_model();
|
||||
|
||||
// Base response with common fields.
|
||||
$response = [
|
||||
'success' => true,
|
||||
'fingerprint' => (string)($responseheaders['x-amzn-requestid'] ?? ''),
|
||||
'prompttokens' => (string)($responseheaders['x-amzn-bedrock-input-token-count'] ?? '0'),
|
||||
'completiontokens' => (string)($responseheaders['x-amzn-bedrock-output-token-count'] ?? '0'),
|
||||
'revisedprompt' => $this->action->get_configuration('prompttext'), // No revised prompt in AWS Bedrock.
|
||||
'model' => $model,
|
||||
];
|
||||
|
||||
// Handle image generation models.
|
||||
if (!str_contains($model, 'amazon') && !str_contains($model, 'stability')) {
|
||||
throw new \coding_exception('Unknown model class type.');
|
||||
}
|
||||
|
||||
$imagebase64 = $bodyobj->images[0] ?? null;
|
||||
if (!is_string($imagebase64) || $imagebase64 === '') {
|
||||
return [
|
||||
'success' => false,
|
||||
'errorcode' => $statuscode,
|
||||
'error' => 'InvalidResponseException',
|
||||
'errormessage' => 'AWS Bedrock response did not include a valid image payload.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$response['draftfile'] = $this->base64_to_file($imagebase64);
|
||||
} catch (\Throwable $exception) {
|
||||
return [
|
||||
'success' => false,
|
||||
'errorcode' => $statuscode,
|
||||
'error' => 'InvalidResponseException',
|
||||
'errormessage' => 'AWS Bedrock image payload could not be processed.',
|
||||
];
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the base64 for the image to a file.
|
||||
*
|
||||
* Placements can't interact with the provider AI directly,
|
||||
* therefore we need to provide the image file in a format that can
|
||||
* be used by placements. So we use the file API.
|
||||
*
|
||||
* @param string $base64 The base64 encoded image.
|
||||
* @return \stored_file The file object.
|
||||
*/
|
||||
protected function base64_to_file(string $base64): \stored_file {
|
||||
global $CFG, $USER;
|
||||
require_once("{$CFG->libdir}/filelib.php");
|
||||
|
||||
$base64 = ltrim($base64, "\xEF\xBB\xBF");
|
||||
$base64 = preg_replace('/\s+/', '', $base64) ?? '';
|
||||
|
||||
// Decode the base64 image into a binary format we can use.
|
||||
$binarydata = base64_decode($base64, true);
|
||||
if ($binarydata === false) {
|
||||
throw new \coding_exception('Invalid image data returned by AWS Bedrock.');
|
||||
}
|
||||
|
||||
// Construct a filename for the image, because we don't get one explicitly.
|
||||
$imageinfo = getimagesizefromstring($binarydata);
|
||||
if ($imageinfo === false || empty($imageinfo[2])) {
|
||||
throw new \coding_exception('Invalid image binary returned by AWS Bedrock.');
|
||||
}
|
||||
$fileext = image_type_to_extension($imageinfo[2]);
|
||||
if ($fileext === false) {
|
||||
throw new \coding_exception('Unsupported image format returned by AWS Bedrock.');
|
||||
}
|
||||
$filename = substr(hash('sha512', ($base64)), 0, 16) . $fileext;
|
||||
|
||||
// Save the image to a temp location and add the watermark.
|
||||
$tempdst = make_request_directory() . DIRECTORY_SEPARATOR . $filename;
|
||||
if (file_put_contents($tempdst, $binarydata) === false) {
|
||||
throw new \coding_exception('Unable to write temporary image file.');
|
||||
}
|
||||
$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($USER->id)->id;
|
||||
$fileinfo->filearea = 'draft';
|
||||
$fileinfo->component = 'user';
|
||||
$fileinfo->itemid = file_get_unused_draft_itemid();
|
||||
$fileinfo->filepath = '/';
|
||||
$fileinfo->filename = $filename;
|
||||
|
||||
$fs = get_file_storage();
|
||||
$filecontent = file_get_contents($tempdst);
|
||||
if ($filecontent === false) {
|
||||
throw new \coding_exception('Unable to read temporary image file.');
|
||||
}
|
||||
return $fs->create_file_from_string($fileinfo, $filecontent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Moodle aspect ratio and quality to Stability AI compatible aspect ratio
|
||||
*
|
||||
* @param string $aspectratio The aspect ratio from Moodle form ('square', 'landscape', 'portrait')
|
||||
* @param string $quality The quality level ('standard', 'high')
|
||||
* @return string The aspect ratio string accepted by Stable Image Core
|
||||
*/
|
||||
private function get_stability_aspect_ratio(string $aspectratio, string $quality) {
|
||||
$mapping = [
|
||||
'square' => [
|
||||
'standard' => '1:1',
|
||||
'hd' => '1:1',
|
||||
],
|
||||
'landscape' => [
|
||||
'standard' => '16:9', // Widescreen - good for standard.
|
||||
'hd' => '3:2', // Classic photo ratio - better for high quality.
|
||||
],
|
||||
'portrait' => [
|
||||
'standard' => '9:16', // Standard portrait orientation.
|
||||
'hd' => '4:5', // Better composition for high quality portraits.
|
||||
],
|
||||
];
|
||||
|
||||
if (!isset($mapping[$aspectratio][$quality])) {
|
||||
return '1:1';
|
||||
}
|
||||
|
||||
return $mapping[$aspectratio][$quality];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\Result;
|
||||
|
||||
/**
|
||||
* Class process text generation.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to iterate over model settings.
|
||||
*
|
||||
* @param \stdClass $requestobj The request object to extend.
|
||||
* @param array $modelsettings The model settings to append.
|
||||
* @param array $stopkeys Keys for which the value should be wrapped in an array.
|
||||
* @param bool $handlepenalty Whether to handle any keys containing "Penalty" specially.
|
||||
* @param callable|null $processor Optional custom processor callback.
|
||||
* @return \stdClass The extended request object.
|
||||
*/
|
||||
private function apply_model_settings(
|
||||
\stdClass $requestobj,
|
||||
array $modelsettings,
|
||||
array $stopkeys = [],
|
||||
bool $handlepenalty = false,
|
||||
callable|null $processor = null
|
||||
): \stdClass {
|
||||
foreach ($modelsettings as $setting => $value) {
|
||||
if ($processor !== null) {
|
||||
$processor($requestobj, $setting, $value);
|
||||
} else {
|
||||
if (in_array($setting, $stopkeys)) {
|
||||
$requestobj->$setting = [$value];
|
||||
} else if ($handlepenalty && str_contains($setting, 'Penalty')) {
|
||||
$scale = new \stdClass();
|
||||
$scale->scale = $value + 0;
|
||||
$requestobj->$setting = $scale;
|
||||
} else {
|
||||
$requestobj->$setting = is_numeric($value) ? ($value + 0) : $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the request object for the AI21 Jamba models.
|
||||
*
|
||||
* @param \stdClass $requestobj The base request object to extend.
|
||||
* @param string $systeminstruction The system instruction to append to the request object.
|
||||
* @param array $modelsettings The model settings to append to the request object.
|
||||
* @return \stdClass $requestobj The extended request object.
|
||||
*/
|
||||
private function create_ai21_jamba_request(
|
||||
\stdClass $requestobj,
|
||||
string $systeminstruction,
|
||||
array $modelsettings
|
||||
): \stdClass {
|
||||
$requestobj->n = 1;
|
||||
|
||||
// Create user message object.
|
||||
$messageobj = new \stdClass();
|
||||
$messageobj->role = 'user';
|
||||
$messageobj->content = $this->action->get_configuration('prompttext');
|
||||
|
||||
if (!empty($systeminstruction)) {
|
||||
// Create system message object.
|
||||
$systemobj = new \stdClass();
|
||||
$systemobj->role = 'system';
|
||||
$systemobj->content = $systeminstruction;
|
||||
$requestobj->messages = [$systemobj, $messageobj];
|
||||
} else {
|
||||
$requestobj->messages = [$messageobj];
|
||||
}
|
||||
|
||||
// For AI21 Jamba, wrap 'stop' values.
|
||||
$this->apply_model_settings($requestobj, $modelsettings, ['stop']);
|
||||
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the request object for the Amazon models.
|
||||
*
|
||||
* @param \stdClass $requestobj The base request object to extend.
|
||||
* @param string $systeminstruction The system instruction to append to the request object.
|
||||
* @param array $modelsettings The model settings to append to the request object.
|
||||
* @return \stdClass $requestobj The extended request object.
|
||||
*/
|
||||
private function create_amazon_request(
|
||||
\stdClass $requestobj,
|
||||
string $systeminstruction,
|
||||
array $modelsettings
|
||||
): \stdClass {
|
||||
if (!empty($systeminstruction)) {
|
||||
$systemobj = new \stdClass();
|
||||
$systemobj->text = $systeminstruction;
|
||||
$requestobj->system = [$systemobj];
|
||||
}
|
||||
|
||||
// Create message object.
|
||||
$messageobj = new \stdClass();
|
||||
$messageobj->text = $this->action->get_configuration('prompttext');
|
||||
|
||||
// Create the user object.
|
||||
$userobj = new \stdClass();
|
||||
$userobj->role = 'user';
|
||||
$userobj->content = [$messageobj];
|
||||
|
||||
$requestobj->messages = [$userobj];
|
||||
|
||||
// Amazon requires model settings to be grouped in a separate object.
|
||||
$modelobj = new \stdClass();
|
||||
foreach ($modelsettings as $setting => $value) {
|
||||
if ($setting === 'schemaVersion') {
|
||||
$requestobj->schemaVersion = $value;
|
||||
} else {
|
||||
$modelobj->$setting = is_numeric($value) ? ($value + 0) : $value;
|
||||
}
|
||||
}
|
||||
if (!empty((array)$modelobj)) {
|
||||
$requestobj->inferenceConfig = $modelobj;
|
||||
}
|
||||
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the request object for the Anthropic models.
|
||||
*
|
||||
* @param \stdClass $requestobj The base request object to extend.
|
||||
* @param string $systeminstruction The system instruction to append to the request object.
|
||||
* @param array $modelsettings The model settings to append to the request object.
|
||||
* @return \stdClass $requestobj The extended request object.
|
||||
*/
|
||||
private function create_anthropic_request(
|
||||
\stdClass $requestobj,
|
||||
string $systeminstruction,
|
||||
array $modelsettings
|
||||
): \stdClass {
|
||||
$requestobj->anthropic_version = "bedrock-2023-05-31";
|
||||
if (!empty($systeminstruction)) {
|
||||
$requestobj->system = $systeminstruction;
|
||||
}
|
||||
|
||||
// Create message object.
|
||||
$messageobj = new \stdClass();
|
||||
$messageobj->type = 'text';
|
||||
$messageobj->text = $this->action->get_configuration('prompttext');
|
||||
|
||||
// Create the user object.
|
||||
$userobj = new \stdClass();
|
||||
$userobj->role = 'user';
|
||||
$userobj->content = [$messageobj];
|
||||
|
||||
$requestobj->messages = [$userobj];
|
||||
|
||||
// For Anthropic, wrap 'stop_sequences' values.
|
||||
$this->apply_model_settings($requestobj, $modelsettings, ['stop_sequences']);
|
||||
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the request object for the Meta models.
|
||||
*
|
||||
* @param \stdClass $requestobj The base request object to extend.
|
||||
* @param string $systeminstruction The system instruction to append to the request object.
|
||||
* @param array $modelsettings The model settings to append to the request object.
|
||||
* @return \stdClass $requestobj The extended request object.
|
||||
*/
|
||||
private function create_meta_request(
|
||||
\stdClass $requestobj,
|
||||
string $systeminstruction,
|
||||
array $modelsettings
|
||||
): \stdClass {
|
||||
$prompt = '<|begin_of_text|>';
|
||||
if (!empty($systeminstruction)) {
|
||||
$prompt .= '<|start_header_id|>system<|end_header_id|>' . $systeminstruction . '<|eot_id|>';
|
||||
}
|
||||
$prompt .= '<|start_header_id|>user<|end_header_id|>'
|
||||
. $this->action->get_configuration('prompttext')
|
||||
. '<|eot_id|><|start_header_id|>assistant<|end_header_id|>';
|
||||
$requestobj->prompt = $prompt;
|
||||
|
||||
// Default processing.
|
||||
$this->apply_model_settings($requestobj, $modelsettings);
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the request object for the Mistral models.
|
||||
*
|
||||
* @param \stdClass $requestobj The base request object to extend.
|
||||
* @param string $systeminstruction The system instruction to append to the request object.
|
||||
* @param array $modelsettings The model settings to append to the request object.
|
||||
* @return \stdClass $requestobj The extended request object.
|
||||
*/
|
||||
private function create_mistral_request(
|
||||
\stdClass $requestobj,
|
||||
string $systeminstruction,
|
||||
array $modelsettings
|
||||
): \stdClass {
|
||||
$prompttext = $this->action->get_configuration('prompttext');
|
||||
if (!empty($systeminstruction)) {
|
||||
$requestobj->prompt = '<s>[INST] System: ' . $systeminstruction . ' User: ' . $prompttext . ' [/INST]';
|
||||
} else {
|
||||
$requestobj->prompt = '<s>[INST] ' . $prompttext . ' [/INST]';
|
||||
}
|
||||
|
||||
// For Mistral, wrap 'stop' values.
|
||||
$this->apply_model_settings($requestobj, $modelsettings, ['stop']);
|
||||
|
||||
return $requestobj;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function create_request(): array {
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = $this->get_system_instruction();
|
||||
$modelsettings = $this->get_model_settings();
|
||||
$model = $this->get_model();
|
||||
|
||||
if (str_contains($model, 'ai21.jamba')) {
|
||||
$requestobj = $this->create_ai21_jamba_request($requestobj, $systeminstruction, $modelsettings);
|
||||
} else if (str_contains($model, 'amazon')) {
|
||||
$requestobj = $this->create_amazon_request($requestobj, $systeminstruction, $modelsettings);
|
||||
} else if (str_contains($model, 'anthropic')) {
|
||||
$requestobj = $this->create_anthropic_request($requestobj, $systeminstruction, $modelsettings);
|
||||
} else if (str_contains($model, 'meta')) {
|
||||
$requestobj = $this->create_meta_request($requestobj, $systeminstruction, $modelsettings);
|
||||
} else if (str_contains($model, 'mistral')) {
|
||||
$requestobj = $this->create_mistral_request($requestobj, $systeminstruction, $modelsettings);
|
||||
} else {
|
||||
throw new \coding_exception('Unknown model class type.');
|
||||
}
|
||||
|
||||
return [
|
||||
'ContentType' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
'modelId' => $this->get_cross_region_inference() ?? $model,
|
||||
'body' => json_encode($requestobj),
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
protected function handle_api_success(Result $result): array {
|
||||
$bodyobj = json_decode($result['body']->getContents());
|
||||
$responseheaders = $result['@metadata']['headers'] ?? [];
|
||||
$response = [
|
||||
'success' => true,
|
||||
'fingerprint' => (string)($responseheaders['x-amzn-requestid'] ?? ''),
|
||||
'prompttokens' => (string)($responseheaders['x-amzn-bedrock-input-token-count'] ?? '0'),
|
||||
'completiontokens' => (string)($responseheaders['x-amzn-bedrock-output-token-count'] ?? '0'),
|
||||
];
|
||||
|
||||
$model = $this->get_model();
|
||||
if (str_contains($model, 'ai21.jamba')) {
|
||||
$response['generatedcontent'] = ltrim($bodyobj->choices[0]->message->content);
|
||||
$response['finishreason'] = $bodyobj->choices[0]->finish_reason;
|
||||
$response['model'] = $bodyobj->model;
|
||||
} else if (str_contains($model, 'amazon')) {
|
||||
$response['generatedcontent'] = $bodyobj->output->message->content[0]->text;
|
||||
$response['finishreason'] = $bodyobj->stopReason;
|
||||
$response['model'] = $model;
|
||||
} else if (str_contains($model, 'anthropic')) {
|
||||
$response['generatedcontent'] = $bodyobj->content[0]->text;
|
||||
$response['finishreason'] = $bodyobj->stop_reason;
|
||||
$response['model'] = $bodyobj->model;
|
||||
} else if (str_contains($model, 'meta')) {
|
||||
$response['generatedcontent'] = ltrim($bodyobj->generation);
|
||||
$response['finishreason'] = $bodyobj->stop_reason;
|
||||
$response['model'] = $model;
|
||||
} else if (str_contains($model, 'mistral')) {
|
||||
$response['generatedcontent'] = ltrim($bodyobj->outputs[0]->text);
|
||||
$response['finishreason'] = $bodyobj->outputs[0]->stop_reason;
|
||||
$response['model'] = $model;
|
||||
} else {
|
||||
throw new \coding_exception('Unknown model class type.');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -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_awsbedrock;
|
||||
|
||||
/**
|
||||
* Class process text summarisation.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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,101 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\BedrockRuntime\BedrockRuntimeClient;
|
||||
use core_ai\form\action_settings_form;
|
||||
|
||||
/**
|
||||
* Class provider.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the Bedrock API client.
|
||||
*
|
||||
* @param string $region The AWS region the model is hosted in
|
||||
* @param string $version The version of the webservice to utilize.
|
||||
* @return BedrockRuntimeClient The client used to make requests.
|
||||
*/
|
||||
public function create_bedrock_client(
|
||||
string $region,
|
||||
string $version = 'latest'
|
||||
): BedrockRuntimeClient {
|
||||
$factory = \core\di::get(bedrock_client_factory::class);
|
||||
return $factory->create_client(
|
||||
region: $region,
|
||||
key: $this->config['apikey'],
|
||||
secret: $this->config['apisecret'],
|
||||
version: $version
|
||||
);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function get_action_settings(
|
||||
string $action,
|
||||
array $customdata = [],
|
||||
): action_settings_form|bool {
|
||||
$actionname = substr($action, (strrpos($action, '\\') + 1));
|
||||
$customdata['actionname'] = $actionname;
|
||||
$customdata['action'] = $action;
|
||||
$customdata['providername'] = 'aiprovider_awsbedrock';
|
||||
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_awsbedrock',
|
||||
];
|
||||
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 [];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function is_provider_configured(): bool {
|
||||
return !empty($this->config['apikey']) && !empty($this->config['apisecret']);
|
||||
}
|
||||
}
|
||||
@@ -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 AWS Bedrock Provider.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[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_awsbedrock\hook_listener::class . '::set_form_definition_for_aiprovider_awsbedrock',
|
||||
],
|
||||
|
||||
[
|
||||
'hook' => \core_ai\hook\after_ai_action_settings_form_hook::class,
|
||||
'callback' => \aiprovider_awsbedrock\hook_listener::class . '::set_model_form_definition_for_aiprovider_awsbedrock',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,156 @@
|
||||
<?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_awsbedrock, language 'en'.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
$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:model'] = 'AI model';
|
||||
$string['action:generate_image:model_help'] = 'The model used to generate images from user prompts. <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html" target="_blank">Supported models</a>.';
|
||||
$string['action:generate_text:model'] = 'AI model';
|
||||
$string['action:generate_text:model_help'] = 'The model used to generate the text response. <a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html" target="_blank">Supported models</a>';
|
||||
$string['action:generate_text:systeminstruction'] = 'System instruction';
|
||||
$string['action:generate_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.';
|
||||
$string['action:summarise_text:model'] = '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'] = 'Amazon API key credential';
|
||||
$string['apikey_help'] = 'Generate a key using IAM in the AWS console or using the AWS CLI.';
|
||||
$string['apisecret'] = 'Amazon API secret credential';
|
||||
$string['apisecret_help'] = 'Generate a secret using IAM in the AWS console or using the AWS CLI.';
|
||||
$string['awsregion'] = 'Region';
|
||||
$string['awsregion:af-south-1'] = 'Africa (Cape Town)';
|
||||
$string['awsregion:ap-east-2'] = 'Asia Pacific (Taipei)';
|
||||
$string['awsregion:ap-northeast-1'] = 'Asia Pacific (Tokyo)';
|
||||
$string['awsregion:ap-northeast-2'] = 'Asia Pacific (Seoul)';
|
||||
$string['awsregion:ap-northeast-3'] = 'Asia Pacific (Osaka)';
|
||||
$string['awsregion:ap-south-1'] = 'Asia Pacific (Mumbai)';
|
||||
$string['awsregion:ap-south-2'] = 'Asia Pacific (Hyderabad)';
|
||||
$string['awsregion:ap-southeast-1'] = 'Asia Pacific (Singapore)';
|
||||
$string['awsregion:ap-southeast-2'] = 'Asia Pacific (Sydney)';
|
||||
$string['awsregion:ap-southeast-3'] = 'Asia Pacific (Jakarta)';
|
||||
$string['awsregion:ap-southeast-4'] = 'Asia Pacific (Melbourne)';
|
||||
$string['awsregion:ap-southeast-5'] = 'Asia Pacific (Malaysia)';
|
||||
$string['awsregion:ap-southeast-6'] = 'Asia Pacific (New Zealand)';
|
||||
$string['awsregion:ap-southeast-7'] = 'Asia Pacific (Thailand)';
|
||||
$string['awsregion:ca-central-1'] = 'Canada (Central)';
|
||||
$string['awsregion:ca-west-1'] = 'Canada West (Calgary)';
|
||||
$string['awsregion:eu-central-1'] = 'Europe (Frankfurt)';
|
||||
$string['awsregion:eu-central-2'] = 'Europe (Zurich)';
|
||||
$string['awsregion:eu-north-1'] = 'Europe (Stockholm)';
|
||||
$string['awsregion:eu-south-1'] = 'Europe (Milan)';
|
||||
$string['awsregion:eu-south-2'] = 'Europe (Spain)';
|
||||
$string['awsregion:eu-west-1'] = 'Europe (Ireland)';
|
||||
$string['awsregion:eu-west-2'] = 'Europe (London)';
|
||||
$string['awsregion:eu-west-3'] = 'Europe (Paris)';
|
||||
$string['awsregion:il-central-1'] = 'Israel (Tel Aviv)';
|
||||
$string['awsregion:me-central-1'] = 'Middle East (UAE)';
|
||||
$string['awsregion:me-south-1'] = 'Middle East (Bahrain)';
|
||||
$string['awsregion:mx-central-1'] = 'Mexico (Central)';
|
||||
$string['awsregion:sa-east-1'] = 'South America (São Paulo)';
|
||||
$string['awsregion:us-east-1'] = 'US East (N. Virginia)';
|
||||
$string['awsregion:us-east-2'] = 'US East (Ohio)';
|
||||
$string['awsregion:us-gov-east-1'] = 'AWS GovCloud (US-East)';
|
||||
$string['awsregion:us-gov-west-1'] = 'AWS GovCloud (US-West)';
|
||||
$string['awsregion:us-west-1'] = 'US West (N. California)';
|
||||
$string['awsregion:us-west-2'] = 'US West (Oregon)';
|
||||
$string['awsregion_help'] = 'The AWS region where the AI model is hosted.';
|
||||
$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_tokens": 100
|
||||
}
|
||||
</pre>';
|
||||
$string['invalidjson'] = 'Invalid JSON string';
|
||||
$string['model_ai21.jamba-1-5-large-v1:0'] = 'AI21 Labs Jamba 1.5 Large';
|
||||
$string['model_ai21.jamba-1-5-mini-v1:0'] = 'AI21 Labs Jamba 1.5 Mini';
|
||||
$string['model_amazon.nova-canvas-v1:0'] = 'Amazon Nova Canvas';
|
||||
$string['model_amazon.nova-lite-v1:0'] = 'Amazon Nova Lite';
|
||||
$string['model_amazon.nova-micro-v1:0'] = 'Amazon Nova Micro';
|
||||
$string['model_amazon.nova-pro-v1:0'] = 'Amazon Nova Pro';
|
||||
$string['model_amazon.titan-image-generator-v2:0'] = 'Amazon Titan Image Generator V2';
|
||||
$string['model_anthropic.claude-3-5-sonnet-20240620-v1:0'] = 'Claude 3.5 Sonnet V1';
|
||||
$string['model_anthropic.claude-3-5-sonnet-20241022-v2:0'] = 'Claude 3.5 Sonnet V2';
|
||||
$string['model_anthropic.claude-3-7-sonnet-20250219-v1:0'] = 'Claude 3.7 Sonnet V1';
|
||||
$string['model_anthropic.claude-3-haiku-20240307-v1:0'] = 'Claude 3 Haiku';
|
||||
$string['model_anthropic.claude-haiku-4-5-20251001-v1:0'] = 'Claude 4.5 Haiku';
|
||||
$string['model_anthropic.claude-sonnet-4-20250514-v1:0'] = 'Claude 4.0 Sonnet V1';
|
||||
$string['model_anthropic.claude-sonnet-4-5-20250929-v1:0'] = 'Claude 4.5 Sonnet V1';
|
||||
$string['model_meta.llama3-1-405b-instruct-v1:0'] = 'Meta Llama 3.1 405B Instruct';
|
||||
$string['model_meta.llama3-1-70b-instruct-v1:0'] = 'Meta Llama 3.1 70B Instruct';
|
||||
$string['model_meta.llama3-1-8b-instruct-v1:0'] = 'Meta Llama 3.1 8B Instruct';
|
||||
$string['model_meta.llama3-2-11b-instruct-v1:0'] = 'Meta Llama 3.2 11B Instruct';
|
||||
$string['model_meta.llama3-2-1b-instruct-v1:0'] = 'Meta Llama 3.2 1B Instruct';
|
||||
$string['model_meta.llama3-2-3b-instruct-v1:0'] = 'Meta Llama 3.2 3B Instruct';
|
||||
$string['model_meta.llama3-2-90b-instruct-v1:0'] = 'Meta Llama 3.2 90B Instruct';
|
||||
$string['model_meta.llama3-3-70b-instruct-v1:0'] = 'Meta Llama 3.3 70B Instruct';
|
||||
$string['model_meta.llama3-70b-instruct-v1:0'] = 'Meta Llama 3 70B Instruct';
|
||||
$string['model_meta.llama3-8b-instruct-v1:0'] = 'Meta Llama 3 8B Instruct';
|
||||
$string['model_mistral.mistral-7b-instruct-v0:2'] = 'Mistral 7B Instruct';
|
||||
$string['model_mistral.mistral-large-2402-v1:0'] = 'Mistral Large Instruct';
|
||||
$string['model_mistral.mistral-small-2402-v1:0'] = 'Mistral Small Instruct';
|
||||
$string['model_mistral.mixtral-8x7b-instruct-v0:1'] = 'Mixtral 8X7B Instruct';
|
||||
$string['model_stability.sd3-5-large-v1:0'] = 'Stability AI Stable Diffusion 3.5 Large';
|
||||
$string['model_stability.stable-image-core-v1:1'] = 'Stability AI Stable Image Core 1.1';
|
||||
$string['model_stability.stable-image-ultra-v1:1'] = 'Stability AI Stable Image Ultra 1.1';
|
||||
$string['none'] = 'None';
|
||||
$string['pluginname'] = 'AWS Bedrock Provider';
|
||||
$string['privacy:metadata'] = 'The AWS Bedrock provider plugin does not store any personal data.';
|
||||
$string['privacy:metadata:aiprovider_awsbedrock:externalpurpose'] = 'This information is sent to AWS in order for a response to be generated. Your AWS account settings may change how AWS stores and retains this data. No user data is explicitly sent to AWS or stored in Moodle LMS by this plugin.';
|
||||
$string['privacy:metadata:aiprovider_awsbedrock:model'] = 'The model used to generate the response.';
|
||||
$string['privacy:metadata:aiprovider_awsbedrock:numberimages'] = 'When generating images the number of images used in the response.';
|
||||
$string['privacy:metadata:aiprovider_awsbedrock:prompttext'] = 'The user entered text prompt used to generate the response.';
|
||||
$string['privacy:metadata:aiprovider_awsbedrock:responseformat'] = 'The format of the response. When generating images.';
|
||||
$string['settings'] = 'Settings';
|
||||
$string['settings_cfg_scale'] = 'CFG scale';
|
||||
$string['settings_cfg_scale_help'] = 'Specifies how strongly the generated image should adhere to the prompt. Use a lower value to introduce more randomness in the generation. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_cross_region_inference'] = 'Cross region inference';
|
||||
$string['settings_cross_region_inference_help'] = 'The inference profile ID for this model. Default: {$a->default}';
|
||||
$string['settings_frequency_penalty'] = 'Frequency penalty';
|
||||
$string['settings_frequency_penalty_help'] = 'Penalizes new tokens based on their frequency in the text so far. Resulting in fewer repeated words. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_help'] = 'You can adjust the settings below to customize how requests are sent to AWS. Update the values as needed, ensuring they align with your requirements.';
|
||||
$string['settings_max_tokens'] = 'Max Tokens';
|
||||
$string['settings_max_tokens_help'] = 'The maximum number of tokens to generate in the response. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_negative_prompt_img'] = 'Negative prompt';
|
||||
$string['settings_negative_prompt_img_help'] = 'Specifies keywords describing elements that should be excluded from the generated image. Enter words or short phrases separated by commas. Maximum length: 10,000 characters';
|
||||
$string['settings_presence_penalty'] = 'Presence penalty';
|
||||
$string['settings_presence_penalty_help'] = 'Reduce the frequency of repeated words within a single message by increasing this number. Unlike frequency penalty, presence penalty is the same no matter how many times a word appears. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_schema_version'] = 'Schema Version';
|
||||
$string['settings_schema_version_help'] = 'Schema version to use for the request. Default: {$a->default}.';
|
||||
$string['settings_seed'] = 'Seed';
|
||||
$string['settings_seed_help'] = 'If specified, the backend will make a best effort to sample tokens deterministically, such that repeated requests with the same seed and parameters should return the same result. However, determinism cannot be totally guaranteed. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_seed_img'] = 'Seed';
|
||||
$string['settings_seed_img_help'] = 'Determines the initial noise setting for the generation process. Changing the seed value while leaving all other parameters the same will produce a totally new image that still adheres to your prompt, dimensions, and other settings. It is common to experiment with a variety of seed values to find the perfect image. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_stop_sequences'] = 'Stop Sequence';
|
||||
$string['settings_stop_sequences_help'] = 'Specify a character sequence to indicate where the model should stop';
|
||||
$string['settings_temperature'] = 'Temperature';
|
||||
$string['settings_temperature_help'] = 'Use a lower value to decrease randomness in responses. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_top_k'] = 'Top K';
|
||||
$string['settings_top_k_help'] = 'Only sample from the top K options for each subsequent token. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
$string['settings_top_p'] = 'Top P';
|
||||
$string['settings_top_p_help'] = 'Use a lower value to ignore less probable options and decrease the diversity of responses. Min: {$a->min}, Max: {$a->max}, Default: {$a->default}.';
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,700 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\BedrockRuntime\BedrockRuntimeClient;
|
||||
use Aws\Exception\AwsException;
|
||||
use Aws\Result;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use core_ai\aiactions\base;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once(__DIR__ . '/testcase_helper_trait.php');
|
||||
|
||||
/**
|
||||
* Test Explain text provider class for AWS Bedrock provider methods.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2026 Raquel Ortega <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \aiprovider_awsbedrock\provider
|
||||
* @covers \aiprovider_awsbedrock\process_explain_text
|
||||
* @covers \aiprovider_awsbedrock\abstract_processor
|
||||
* @group aiprovider_awsbedrock
|
||||
*/
|
||||
final class process_explain_text_test extends \advanced_testcase {
|
||||
use testcase_helper_trait;
|
||||
|
||||
/** @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 string The action name for assertions. */
|
||||
protected string $actionname = 'explain_text';
|
||||
|
||||
/** @var string The system instruction language key. */
|
||||
protected string $systeminstructionkey = 'action_explain_text_instruction';
|
||||
|
||||
/**
|
||||
* Set up the test.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->resetAfterTest();
|
||||
$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($this->systeminstructionkey, '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(): 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');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertStringContainsString('This is a test prompt', $body->messages[0]->content[0]->text);
|
||||
$this->assertStringContainsString('You will receive a text input from the user.', $body->system[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request with extra model settings.
|
||||
*/
|
||||
public function test_create_request_with_model_settings(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\explain_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string($this->systeminstructionkey, 'core_ai'),
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
],
|
||||
);
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a protected method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->inferenceConfig->temperature);
|
||||
$this->assertEquals('100', $body->inferenceConfig->max_tokens);
|
||||
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\explain_text::class,
|
||||
actionconfig: [
|
||||
'model' => 'amazon.nova-pro-v2',
|
||||
'systeminstruction' => get_string($this->systeminstructionkey, 'core_ai'),
|
||||
'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}',
|
||||
],
|
||||
);
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v2', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->inferenceConfig->temperature);
|
||||
$this->assertEquals('100', $body->inferenceConfig->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request with cross region inference.
|
||||
*/
|
||||
public function test_create_request_with_region_inference(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\explain_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string($this->systeminstructionkey, 'core_ai'),
|
||||
'model' => 'meta.llama3-2-90b-instruct-v1:0',
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
'cross_region_inference' => 'us.meta.llama3-2-90b-instruct-v1:0',
|
||||
],
|
||||
);
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a protected method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('us.meta.llama3-2-90b-instruct-v1:0', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for AWS API error responses.
|
||||
*
|
||||
* @return array The test cases from aws_api_error_provider_helper().
|
||||
*/
|
||||
public static function aws_api_error_provider(): array {
|
||||
return self::aws_api_error_provider_helper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handling of various API errors from AWS Bedrock.
|
||||
*
|
||||
* @dataProvider aws_api_error_provider
|
||||
* @param AwsException $exception The AWS exception to simulate.
|
||||
* @param int $expectedstatus The expected HTTP status code in the error response.
|
||||
*/
|
||||
public function test_handle_api_error(AwsException $exception, int $expectedstatus): void {
|
||||
// Create an instance of the class that processes API errors.
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_error');
|
||||
|
||||
$result = $method->invoke($processor, $exception);
|
||||
// Assert that the returned error code matches the expected HTTP status.
|
||||
$this->assertEquals($expectedstatus, $result['errorcode'], "Failed asserting for status $expectedstatus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the API success response handler method.
|
||||
*/
|
||||
public function test_handle_api_success(): void {
|
||||
// We're testing a protected method, so we need to setup reflector magic.
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
|
||||
$result = $method->invoke($processor, $this->get_mocked_aws_result());
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('end_turn', $result['finishreason']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API success response handling when Bedrock metadata headers are missing.
|
||||
* Ensures defaults are applied when usage headers are absent.
|
||||
*/
|
||||
public function test_handle_api_success_with_missing_headers(): void {
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
$stream = Utils::streamFor('{
|
||||
"output":{
|
||||
"message":{
|
||||
"content":[{"text":"The capital of Australia is Canberra."}],
|
||||
"role":"assistant"
|
||||
}
|
||||
},
|
||||
"stopReason":"end_turn"
|
||||
}');
|
||||
$resultobj = new Result([
|
||||
'body' => $stream,
|
||||
'contentType' => 'application/json',
|
||||
'@metadata' => [
|
||||
'statusCode' => 200,
|
||||
'headers' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($processor, $resultobj);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('', $result['fingerprint']);
|
||||
$this->assertEquals('0', $result['prompttokens']);
|
||||
$this->assertEquals('0', $result['completiontokens']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query_ai_api for a successful call.
|
||||
*/
|
||||
public function test_query_ai_api_success(): void {
|
||||
// Create a mock of the Bedrock client.
|
||||
$mockclient = $this->createMock(BedrockRuntimeClient::class);
|
||||
|
||||
// Properly mock the invokeModel call using __call.
|
||||
$mockclient->expects($this->any())
|
||||
->method('__call')
|
||||
->with('invokeModel', $this->anything()) // AWS SDK calls are dynamic via __call.
|
||||
->willReturn($this->get_mocked_aws_result());
|
||||
|
||||
// Now properly mock the provider while calling its constructor.
|
||||
$mockprovider = $this->getMockBuilder(get_class($this->provider))
|
||||
->setConstructorArgs([
|
||||
true, // Enable the provider.
|
||||
'mockprovider', // Provider name.
|
||||
'{}', // Empty config is ok here.
|
||||
])
|
||||
->onlyMethods(['create_bedrock_client']) // Only mock this method.
|
||||
->getMock();
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
// Create an instance of the processor with the mocked provider.
|
||||
$processor = new process_explain_text($mockprovider, $this->action);
|
||||
|
||||
// We're testing a protected method, so we need to setup reflector magic.
|
||||
$method = new \ReflectionMethod($processor, 'query_ai_api');
|
||||
|
||||
// Invoke the query_ai_api method.
|
||||
$result = $method->invoke($processor);
|
||||
|
||||
// Assertions.
|
||||
$this->assertIsArray($result);
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('end_turn', $result['finishreason']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response success.
|
||||
*/
|
||||
public function test_prepare_response_success(): void {
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'fingerprint' => 'fp_c4e5b6fa31',
|
||||
'generatedcontent' => 'Sure, here is some sample text',
|
||||
'finishreason' => 'stop',
|
||||
'prompttokens' => '11',
|
||||
'completiontokens' => '568',
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals($this->actionname, $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' => 'Internal server error.',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals($this->actionname, $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());
|
||||
|
||||
$processor = $this->get_mocked_query_ai_api_result($this->provider);
|
||||
$result = $processor->process();
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals($this->actionname, $result->get_actionname());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with error.
|
||||
*/
|
||||
public function test_process_error(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
$processor = $this->get_mocked_query_ai_api_result($this->provider, false);
|
||||
$result = $processor->process();
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals($this->actionname, $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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\explain_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'endpoint' => "https://api.awsbedrock.com/v1/chat/completions",
|
||||
'systeminstruction' => get_string($this->systeminstructionkey, 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: User rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: User rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
|
||||
$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);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->provider = $this->create_provider(\core_ai\aiactions\explain_text::class);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\explain_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'endpoint' => "https://api.awsbedrock.com/v1/chat/completions",
|
||||
'systeminstruction' => get_string($this->systeminstructionkey, 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: Global rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: Global rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_a21_jamba_request method.
|
||||
*/
|
||||
public function test_create_a21_jamba_request(): 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_ai21_jamba_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'frequency_penalty' => 0.5,
|
||||
'presence_penalty' => 0.5,
|
||||
'max_tokens' => 100,
|
||||
'stop' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals(0.5, $result->frequency_penalty);
|
||||
$this->assertEquals(0.5, $result->presence_penalty);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop);
|
||||
$this->assertEquals('user', $result->messages[1]->role);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[1]->content);
|
||||
$this->assertEquals('system', $result->messages[0]->role);
|
||||
$this->assertEquals('This is a test system instruction', $result->messages[0]->content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_amazon_request method.
|
||||
*/
|
||||
public function test_create_amazon_request(): 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_amazon_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'maxTokens' => 100,
|
||||
'topP' => 0.9,
|
||||
'topK' => 100,
|
||||
'schemaVersion' => 'messages-v1',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals('This is a test system instruction', $result->system[0]->text);
|
||||
$this->assertEquals(0.5, $result->inferenceConfig->temperature);
|
||||
$this->assertEquals(100, $result->inferenceConfig->maxTokens);
|
||||
$this->assertEquals(0.9, $result->inferenceConfig->topP);
|
||||
$this->assertEquals(100, $result->inferenceConfig->topK);
|
||||
$this->assertEquals('messages-v1', $result->schemaVersion);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[0]->content[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_anthropic_request method.
|
||||
*/
|
||||
public function test_create_anthropic_request(): 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_anthropic_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'top_p' => 0.9,
|
||||
'top_k' => 100,
|
||||
'max_tokens' => 100,
|
||||
'stop_sequences' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals('bedrock-2023-05-31', $result->anthropic_version);
|
||||
$this->assertEquals('This is a test system instruction', $result->system);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertEquals(100, $result->top_k);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop_sequences);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[0]->content[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_mistral_request method.
|
||||
*/
|
||||
public function test_create_mistral_request(): 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_mistral_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'top_p' => 0.9,
|
||||
'top_k' => 100,
|
||||
'max_tokens' => 100,
|
||||
'stop' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals(
|
||||
'<s>[INST] System: This is a test system instruction User: This is a test prompt [/INST]',
|
||||
$result->prompt
|
||||
);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertEquals(100, $result->top_k);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_meta_request method.
|
||||
*/
|
||||
public function test_create_meta_request(): 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_meta_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'max_gen_len' => 100,
|
||||
'top_p' => 0.9,
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertStringContainsString('This is a test system instruction', $result->prompt);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(100, $result->max_gen_len);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertStringContainsString('This is a test prompt', $result->prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request when modelextraparams is valid JSON, but not an object/array.
|
||||
*/
|
||||
public function test_create_request_ignores_non_array_modelextraparams(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\explain_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '1',
|
||||
],
|
||||
);
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $request['modelId']);
|
||||
$this->assertObjectNotHasProperty('inferenceConfig', $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\BedrockRuntime\BedrockRuntimeClient;
|
||||
use Aws\Command;
|
||||
use Aws\Exception\AwsException;
|
||||
use Aws\Result;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use core_ai\aiactions\base;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once(__DIR__ . '/testcase_helper_trait.php');
|
||||
|
||||
/**
|
||||
* Test generate image provider class for AWS Bedrock provider methods.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \aiprovider_awsbedrock\provider
|
||||
* @covers \aiprovider_awsbedrock\process_generate_image
|
||||
* @covers \aiprovider_awsbedrock\abstract_processor
|
||||
*/
|
||||
final class process_generate_image_test extends \advanced_testcase {
|
||||
use testcase_helper_trait;
|
||||
|
||||
/** @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();
|
||||
$this->manager = \core\di::get(\core_ai\manager::class);
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_image::class,
|
||||
actionconfig: [
|
||||
'model' => 'amazon.nova-canvas-v1:0',
|
||||
],
|
||||
);
|
||||
$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 Aws\Result.
|
||||
*
|
||||
* @param bool $success Whether the result should be successful or not.
|
||||
* @return Result| AwsException The mocked result.
|
||||
*/
|
||||
private function get_mocked_aws_result(bool $success): Result| AwsException {
|
||||
|
||||
if ($success) {
|
||||
$mockresponsebody = '{
|
||||
"images":[
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="
|
||||
]
|
||||
}';
|
||||
|
||||
// Create a PSR-7 Stream for the response body.
|
||||
$stream = Utils::streamFor($mockresponsebody);
|
||||
|
||||
// Create a mocked Aws\Result.
|
||||
return new Result([
|
||||
'body' => $stream, // Simulate AWS SDK response body.
|
||||
'contentType' => 'application/json',
|
||||
'@metadata' => [
|
||||
'statusCode' => 200,
|
||||
'headers' => [
|
||||
'x-amzn-requestid' => 'mock-request-id',
|
||||
'x-amzn-bedrock-input-token-count' => '11',
|
||||
'x-amzn-bedrock-output-token-count' => '568',
|
||||
],
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
return new AwsException(
|
||||
'AccessDeniedException: You do not have permission to access this resource',
|
||||
new Command('InvokeModel'),
|
||||
[
|
||||
'code' => 'AccessDeniedException',
|
||||
'message' => 'You do not have permission to invoke this model',
|
||||
'response' => new Response(403),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mocked AWS invokeModel result.
|
||||
*
|
||||
* @param bool $success Whether the result should be successful or not.
|
||||
* @return MockObject The mocked provider.
|
||||
* @throws \PHPUnit\Framework\MockObject\Exception
|
||||
*/
|
||||
private function get_mocked_aws_invoke_model_result(bool $success = true): MockObject {
|
||||
$actionconfig = $this->get_action_config(\core_ai\aiactions\generate_image::class);
|
||||
|
||||
// Create a mock of the Bedrock client.
|
||||
$mockclient = $this->createMock(BedrockRuntimeClient::class);
|
||||
|
||||
// Properly mock the invokeModel call using __call.
|
||||
if ($success) {
|
||||
$mockclient->expects($this->any())
|
||||
->method('__call')
|
||||
->with('invokeModel', $this->anything()) // AWS SDK calls are dynamic via __call.
|
||||
->willReturn($this->get_mocked_aws_result($success));
|
||||
} else {
|
||||
$mockclient->expects($this->any())
|
||||
->method('__call')
|
||||
->with('invokeModel', $this->anything()) // AWS SDK calls are dynamic via __call.
|
||||
->willThrowException($this->get_mocked_aws_result($success));
|
||||
};
|
||||
|
||||
// Now properly mock the provider while calling its constructor.
|
||||
$mockprovider = $this->getMockBuilder(get_class($this->provider))
|
||||
->setConstructorArgs([
|
||||
true, // Enable the provider.
|
||||
'mockprovider', // Provider name.
|
||||
'{}', // Empty config is ok here.
|
||||
json_encode($actionconfig), // Action config.
|
||||
])
|
||||
->onlyMethods(['create_bedrock_client']) // Only mock this method.
|
||||
->getMock();
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
return $mockprovider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the result from query_ai_api().
|
||||
* Returns a MockBuilder object for a processor.
|
||||
*
|
||||
* @param provider $provider The provider to use.
|
||||
* @param bool $success Whether the query was successful.
|
||||
* @return MockObject The mocked processor object.
|
||||
*/
|
||||
private function get_mocked_query_ai_api_result(
|
||||
provider $provider,
|
||||
bool $success = true
|
||||
): MockObject {
|
||||
// Define the mock successful response from query_ai_api().
|
||||
$responsesuccess = [
|
||||
'success' => true,
|
||||
'fingerprint' => 'mock-request-id',
|
||||
'prompttokens' => '11',
|
||||
'completiontokens' => '568',
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'revisedprompt' => 'This is a test prompt',
|
||||
'draftfile' => $this->testfile,
|
||||
];
|
||||
|
||||
$responseerror = [
|
||||
'success' => false,
|
||||
'errorcode' => 401,
|
||||
'error' => 'Internal server error',
|
||||
'errormessage' => 'Invalid Authentication',
|
||||
];
|
||||
|
||||
$processor = $this->getMockBuilder(process_generate_image::class)
|
||||
->setConstructorArgs([$provider, $this->action])
|
||||
->onlyMethods(['query_ai_api'])
|
||||
->getMock();
|
||||
|
||||
// Mock `query_ai_api()` to return the predefined response.
|
||||
if ($success) {
|
||||
$processor->method('query_ai_api')
|
||||
->willReturn($responsesuccess);
|
||||
} else {
|
||||
$processor->method('query_ai_api')
|
||||
->willReturn($responseerror);
|
||||
}
|
||||
|
||||
return $processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request
|
||||
*/
|
||||
public function test_create_request(): 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');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-canvas-v1:0', $request['modelId']);
|
||||
$this->assertnotNull($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request with extra model settings.
|
||||
*/
|
||||
public function test_create_request_with_model_settings(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_image::class,
|
||||
actionconfig: [
|
||||
'model' => 'amazon.nova-canvas-v1:0',
|
||||
'cfgScale' => 6.5,
|
||||
'seed' => 12,
|
||||
],
|
||||
);
|
||||
$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');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-canvas-v1:0', $request['modelId']);
|
||||
$this->assertEquals('6.5', $body->imageGenerationConfig->cfgScale);
|
||||
$this->assertEquals('12', $body->imageGenerationConfig->seed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for AWS API error responses.
|
||||
*
|
||||
* @return array The test cases from aws_api_error_provider_helper().
|
||||
*/
|
||||
public static function aws_api_error_provider(): array {
|
||||
return self::aws_api_error_provider_helper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handling of various API errors from AWS Bedrock.
|
||||
*
|
||||
* @dataProvider aws_api_error_provider
|
||||
* @param AwsException $exception The AWS exception to simulate.
|
||||
* @param int $expectedstatus The expected HTTP status code in the error response.
|
||||
*/
|
||||
public function test_handle_api_error(AwsException $exception, int $expectedstatus): void {
|
||||
// Create an instance of the class that processes API errors.
|
||||
$processor = new process_generate_image($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_error');
|
||||
|
||||
$result = $method->invoke($processor, $exception);
|
||||
// Assert that the returned error code matches the expected HTTP status.
|
||||
$this->assertEquals($expectedstatus, $result['errorcode'], "Failed asserting for status $expectedstatus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the API success response handler method.
|
||||
*/
|
||||
public function test_handle_api_success(): void {
|
||||
// Mock the response from the base64_to_file method.
|
||||
$processor = $this->getMockBuilder(process_generate_image::class)
|
||||
->setConstructorArgs([$this->provider, $this->action])
|
||||
->onlyMethods(['base64_to_file'])
|
||||
->getMock();
|
||||
$processor->method('base64_to_file')
|
||||
->willReturn($this->testfile);
|
||||
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
$result = $method->invoke($processor, $this->get_mocked_aws_result(true));
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
$this->assertEquals('amazon.nova-canvas-v1:0', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API success response handling when Bedrock metadata headers are missing.
|
||||
*/
|
||||
public function test_handle_api_success_with_missing_headers(): void {
|
||||
$processor = $this->getMockBuilder(process_generate_image::class)
|
||||
->setConstructorArgs([$this->provider, $this->action])
|
||||
->onlyMethods(['base64_to_file'])
|
||||
->getMock();
|
||||
$processor->method('base64_to_file')
|
||||
->willReturn($this->testfile);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
$stream = Utils::streamFor('{
|
||||
"images":[
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="
|
||||
]
|
||||
}');
|
||||
$resultobj = new Result([
|
||||
'body' => $stream,
|
||||
'contentType' => 'application/json',
|
||||
'@metadata' => [
|
||||
'statusCode' => 200,
|
||||
'headers' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($processor, $resultobj);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('', $result['fingerprint']);
|
||||
$this->assertEquals('0', $result['prompttokens']);
|
||||
$this->assertEquals('0', $result['completiontokens']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API success handling when Bedrock response does not contain an image payload.
|
||||
*/
|
||||
public function test_handle_api_success_with_missing_image_payload(): void {
|
||||
$processor = new process_generate_image($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
$stream = Utils::streamFor('{}');
|
||||
$resultobj = new Result([
|
||||
'body' => $stream,
|
||||
'contentType' => 'application/json',
|
||||
'@metadata' => [
|
||||
'statusCode' => 200,
|
||||
'headers' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($processor, $resultobj);
|
||||
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals(200, $result['errorcode']);
|
||||
$this->assertEquals('InvalidResponseException', $result['error']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test base64_to_file.
|
||||
*/
|
||||
public function test_base64_to_file(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
$base64 = file_get_contents(self::get_fixture_path('aiprovider_awsbedrock', 'test_image.base64'));
|
||||
|
||||
$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, 'base64_to_file');
|
||||
|
||||
$filenobj = $method->invoke($processor, $base64);
|
||||
|
||||
$this->assertEquals('aa7ff9e16b9cdafd.jpeg', $filenobj->get_filename());
|
||||
$this->assertEquals('image/jpeg', $filenobj->get_mimetype());
|
||||
$this->assertgreaterThan(0, $filenobj->get_filesize());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test base64_to_file with invalid base64 payload.
|
||||
*/
|
||||
public function test_base64_to_file_with_invalid_base64(): void {
|
||||
$processor = new process_generate_image($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'base64_to_file');
|
||||
|
||||
$this->expectException(\coding_exception::class);
|
||||
$method->invoke($processor, '#invalid-base64#');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query_ai_api for a successful call.
|
||||
*/
|
||||
public function test_query_ai_api_success(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
// Create an instance of the processor with the mocked provider.
|
||||
$mockclient = $this->get_mocked_aws_invoke_model_result();
|
||||
$processor = new process_generate_image($mockclient, $this->action);
|
||||
|
||||
// We're testing a protected method, so we need to setup reflector magic.
|
||||
$method = new \ReflectionMethod($processor, 'query_ai_api');
|
||||
|
||||
// Invoke the query_ai_api method.
|
||||
$result = $method->invoke($processor);
|
||||
|
||||
// Assertions.
|
||||
$this->assertIsArray($result);
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('This is a test prompt', $result['revisedprompt']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
$this->assertEquals('15a539ba5aa81f35.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,
|
||||
'revisedprompt' => 'An image that represents the concept of a \'test\'.',
|
||||
'imageurl' => 'oaidalleapiprodscus.blob.core.windows.net',
|
||||
'model' => 'amazon.nova-canvas-v1:0',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals('generate_image', $result->get_actionname());
|
||||
$this->assertEquals($response['success'], $result->get_success());
|
||||
$this->assertEquals($response['revisedprompt'], $result->get_response_data()['revisedprompt']);
|
||||
$this->assertEquals($response['model'], $result->get_response_data()['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' => 'Internal server error.',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals('generate_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());
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
$mockclient = $this->get_mocked_aws_invoke_model_result();
|
||||
$processor = new process_generate_image($mockclient, $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('This is a test prompt', $result->get_response_data()['revisedprompt']);
|
||||
$this->assertEquals('15a539ba5aa81f35.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());
|
||||
|
||||
$mockclient = $this->get_mocked_aws_invoke_model_result(false);
|
||||
$processor = new process_generate_image($mockclient, $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(403, $result->get_errorcode());
|
||||
$this->assertEquals('You do not have permission to invoke this model', $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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\generate_image::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-canvas-v1:0',
|
||||
'cfgScale' => 6.5,
|
||||
'seed' => 12,
|
||||
'awsregion' => 'us-east-1',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: User rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: User rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
|
||||
$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);
|
||||
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->provider = $this->create_provider(\core_ai\aiactions\generate_image::class);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\generate_image::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-canvas-v1:0',
|
||||
'cfgScale' => 6.5,
|
||||
'seed' => 12,
|
||||
'awsregion' => 'us-east-1',
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: Global rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: Global rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_amazon_request method.
|
||||
*/
|
||||
public function test_create_amazon_request(): 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_amazon_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$modelsettings = [
|
||||
'model' => 'amazon.nova-canvas-v1:0',
|
||||
'cfgScale' => 6.5,
|
||||
'seed' => 12,
|
||||
];
|
||||
$requestobj = $method->invoke($processor, $requestobj, $modelsettings);
|
||||
|
||||
$this->assertEquals('6.5', $requestobj->imageGenerationConfig->cfgScale);
|
||||
$this->assertEquals('12', $requestobj->imageGenerationConfig->seed);
|
||||
$this->assertEquals('TEXT_IMAGE', $requestobj->taskType);
|
||||
$this->assertEquals('This is a test prompt', $requestobj->textToImageParams->text);
|
||||
$this->assertEquals(1792, $requestobj->imageGenerationConfig->width);
|
||||
$this->assertEquals(1792, $requestobj->imageGenerationConfig->height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the create_stability_request() method.
|
||||
*
|
||||
* Verifies that the request object is correctly populated with the expected values based on the provided model settings.
|
||||
*/
|
||||
public function test_create_stability_request(): 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_stability_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$modelsettings = [
|
||||
'model' => 'stability.stable-image-core-v1:1',
|
||||
'seed' => 12,
|
||||
'negative_prompt' => 'pink',
|
||||
];
|
||||
$requestobj = $method->invoke($processor, $requestobj, $modelsettings);
|
||||
$this->assertEquals('This is a test prompt', $requestobj->prompt);
|
||||
$this->assertEquals('12', $requestobj->seed);
|
||||
$this->assertEquals('1:1', $requestobj->aspect_ratio);
|
||||
$this->assertEquals('pink', $requestobj->negative_prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the get_stability_aspect_ratio() method.
|
||||
* Verifies that the correct aspect ratio value is returned for a given combination of aspect ratio and quality settings.
|
||||
*
|
||||
* @covers ::get_stability_aspect_ratio
|
||||
* @dataProvider aspect_ratio_provider
|
||||
*
|
||||
* @param string $aspectratio The requested aspect ratio (e.g. square, landscape, portrait).
|
||||
* @param string $quality The requested quality setting (e.g. standard, hd).
|
||||
* @param string $expected The expected Stability aspect ratio value.
|
||||
*/
|
||||
public function test_get_stability_aspect_ratio(string $aspectratio, string $quality, string $expected): void {
|
||||
$processor = new process_generate_image($this->provider, $this->action);
|
||||
|
||||
$method = new \ReflectionMethod($processor, 'get_stability_aspect_ratio');
|
||||
|
||||
$result = $method->invoke($processor, $aspectratio, $quality);
|
||||
$this->assertEquals($expected, $result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for test_get_stability_aspect_ratio().
|
||||
* Provides combinations of aspect ratio and quality values and their expected Stability aspect ratio outputs.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function aspect_ratio_provider(): array {
|
||||
return [
|
||||
'square standard' => ['square', 'standard', '1:1'],
|
||||
'square hd' => ['square', 'hd', '1:1'],
|
||||
'landscape standard' => ['landscape', 'standard', '16:9'],
|
||||
'landscape hd' => ['landscape', 'hd', '3:2'],
|
||||
'portrait standard' => ['portrait', 'standard', '9:16'],
|
||||
'portrait hd' => ['portrait', 'hd', '4:5'],
|
||||
'invalid aspect falls back to square' => ['invalid', 'standard', '1:1'],
|
||||
'invalid quality falls back to standard' => ['landscape', 'invalid', '1:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\BedrockRuntime\BedrockRuntimeClient;
|
||||
use Aws\Exception\AwsException;
|
||||
use Aws\Result;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use core_ai\aiactions\base;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once(__DIR__ . '/testcase_helper_trait.php');
|
||||
|
||||
/**
|
||||
* Test Generate text provider class for AWS Bedrock provider methods.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \aiprovider_awsbedrock\provider
|
||||
* @covers \aiprovider_awsbedrock\process_generate_text
|
||||
* @covers \aiprovider_awsbedrock\abstract_processor
|
||||
*/
|
||||
final class process_generate_text_test extends \advanced_testcase {
|
||||
use testcase_helper_trait;
|
||||
|
||||
/** @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();
|
||||
$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(): 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');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertStringContainsString('This is a test prompt', $body->messages[0]->content[0]->text);
|
||||
$this->assertStringContainsString('You will receive a text input from the user.', $body->system[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request with extra model settings.
|
||||
*/
|
||||
public function test_create_request_object_with_model_settings(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
],
|
||||
);
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a protected method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->inferenceConfig->temperature);
|
||||
$this->assertEquals('100', $body->inferenceConfig->max_tokens);
|
||||
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_text::class,
|
||||
actionconfig: [
|
||||
'model' => 'amazon.nova-pro-v2',
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}',
|
||||
],
|
||||
);
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v2', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->inferenceConfig->temperature);
|
||||
$this->assertEquals('100', $body->inferenceConfig->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request with cross region inference.
|
||||
*/
|
||||
public function test_create_request_object_with_region_inference(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'model' => 'meta.llama3-2-90b-instruct-v1:0',
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
'cross_region_inference' => 'us.meta.llama3-2-90b-instruct-v1:0',
|
||||
],
|
||||
);
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a protected method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('us.meta.llama3-2-90b-instruct-v1:0', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for AWS API error responses.
|
||||
*
|
||||
* @return array The test cases from aws_api_error_provider_helper().
|
||||
*/
|
||||
public static function aws_api_error_provider(): array {
|
||||
return self::aws_api_error_provider_helper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handling of various API errors from AWS Bedrock.
|
||||
*
|
||||
* @dataProvider aws_api_error_provider
|
||||
* @param AwsException $exception The AWS exception to simulate.
|
||||
* @param int $expectedstatus The expected HTTP status code in the error response.
|
||||
*/
|
||||
public function test_handle_api_error(AwsException $exception, int $expectedstatus): void {
|
||||
// Create an instance of the class that processes API errors.
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_error');
|
||||
|
||||
$result = $method->invoke($processor, $exception);
|
||||
// Assert that the returned error code matches the expected HTTP status.
|
||||
$this->assertEquals($expectedstatus, $result['errorcode'], "Failed asserting for status $expectedstatus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the API success response handler method.
|
||||
*/
|
||||
public function test_handle_api_success(): void {
|
||||
// We're testing a protected 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, $this->get_mocked_aws_result());
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('end_turn', $result['finishreason']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API success response handling when Bedrock metadata headers are missing.
|
||||
* Ensures defaults are applied when usage headers are absent.
|
||||
*/
|
||||
public function test_handle_api_success_with_missing_headers(): void {
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
$stream = Utils::streamFor('{
|
||||
"output":{
|
||||
"message":{
|
||||
"content":[{"text":"The capital of Australia is Canberra."}],
|
||||
"role":"assistant"
|
||||
}
|
||||
},
|
||||
"stopReason":"end_turn"
|
||||
}');
|
||||
$resultobj = new Result([
|
||||
'body' => $stream,
|
||||
'contentType' => 'application/json',
|
||||
'@metadata' => [
|
||||
'statusCode' => 200,
|
||||
'headers' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($processor, $resultobj);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('', $result['fingerprint']);
|
||||
$this->assertEquals('0', $result['prompttokens']);
|
||||
$this->assertEquals('0', $result['completiontokens']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query_ai_api for a successful call.
|
||||
*/
|
||||
public function test_query_ai_api_success(): void {
|
||||
// Create a mock of the Bedrock client.
|
||||
$mockclient = $this->createMock(BedrockRuntimeClient::class);
|
||||
|
||||
// Properly mock the invokeModel call using __call.
|
||||
$mockclient->expects($this->any())
|
||||
->method('__call')
|
||||
->with('invokeModel', $this->anything()) // AWS SDK calls are dynamic via __call.
|
||||
->willReturn($this->get_mocked_aws_result());
|
||||
|
||||
// Now properly mock the provider while calling its constructor.
|
||||
$mockprovider = $this->getMockBuilder(get_class($this->provider))
|
||||
->setConstructorArgs([
|
||||
true, // Enable the provider.
|
||||
'mockprovider', // Provider name.
|
||||
'{}', // Empty config is ok here.
|
||||
])
|
||||
->onlyMethods(['create_bedrock_client']) // Only mock this method.
|
||||
->getMock();
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
// Create an instance of the processor with the mocked provider.
|
||||
$processor = new process_generate_text($mockprovider, $this->action);
|
||||
|
||||
// We're testing a protected method, so we need to setup reflector magic.
|
||||
$method = new \ReflectionMethod($processor, 'query_ai_api');
|
||||
|
||||
// Invoke the query_ai_api method.
|
||||
$result = $method->invoke($processor);
|
||||
|
||||
// Assertions.
|
||||
$this->assertIsArray($result);
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('end_turn', $result['finishreason']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response success.
|
||||
*/
|
||||
public function test_prepare_response_success(): void {
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'fingerprint' => 'fp_c4e5b6fa31',
|
||||
'generatedcontent' => 'Sure, here is some sample text',
|
||||
'finishreason' => 'stop',
|
||||
'prompttokens' => '11',
|
||||
'completiontokens' => '568',
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
];
|
||||
|
||||
$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());
|
||||
|
||||
$processor = $this->get_mocked_query_ai_api_result($this->provider);
|
||||
$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());
|
||||
|
||||
$processor = $this->get_mocked_query_ai_api_result($this->provider, false);
|
||||
$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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\generate_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'endpoint' => "https://api.awsbedrock.com/v1/chat/completions",
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: User rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: User rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
|
||||
$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);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->provider = $this->create_provider(\core_ai\aiactions\generate_text::class);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\generate_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'endpoint' => "https://api.awsbedrock.com/v1/chat/completions",
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: Global rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: Global rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_a21_jamba_request method.
|
||||
*/
|
||||
public function test_create_a21_jamba_request(): 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_ai21_jamba_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'frequency_penalty' => 0.5,
|
||||
'presence_penalty' => 0.5,
|
||||
'max_tokens' => 100,
|
||||
'stop' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals(0.5, $result->frequency_penalty);
|
||||
$this->assertEquals(0.5, $result->presence_penalty);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop);
|
||||
$this->assertEquals('user', $result->messages[1]->role);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[1]->content);
|
||||
$this->assertEquals('system', $result->messages[0]->role);
|
||||
$this->assertEquals('This is a test system instruction', $result->messages[0]->content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_amazon_request method.
|
||||
*/
|
||||
public function test_create_amazon_request(): 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_amazon_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'maxTokens' => 100,
|
||||
'topP' => 0.9,
|
||||
'topK' => 100,
|
||||
'schemaVersion' => 'messages-v1',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals('This is a test system instruction', $result->system[0]->text);
|
||||
$this->assertEquals(0.5, $result->inferenceConfig->temperature);
|
||||
$this->assertEquals(100, $result->inferenceConfig->maxTokens);
|
||||
$this->assertEquals(0.9, $result->inferenceConfig->topP);
|
||||
$this->assertEquals(100, $result->inferenceConfig->topK);
|
||||
$this->assertEquals('messages-v1', $result->schemaVersion);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[0]->content[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_anthropic_request method.
|
||||
*/
|
||||
public function test_create_anthropic_request(): 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_anthropic_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'top_p' => 0.9,
|
||||
'top_k' => 100,
|
||||
'max_tokens' => 100,
|
||||
'stop_sequences' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals('bedrock-2023-05-31', $result->anthropic_version);
|
||||
$this->assertEquals('This is a test system instruction', $result->system);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertEquals(100, $result->top_k);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop_sequences);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[0]->content[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_mistral_request method.
|
||||
*/
|
||||
public function test_create_mistral_request(): 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_mistral_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'top_p' => 0.9,
|
||||
'top_k' => 100,
|
||||
'max_tokens' => 100,
|
||||
'stop' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals(
|
||||
'<s>[INST] System: This is a test system instruction User: This is a test prompt [/INST]',
|
||||
$result->prompt
|
||||
);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertEquals(100, $result->top_k);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_meta_request method.
|
||||
*/
|
||||
public function test_create_meta_request(): 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_meta_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'max_gen_len' => 100,
|
||||
'top_p' => 0.9,
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertStringContainsString('This is a test system instruction', $result->prompt);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(100, $result->max_gen_len);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertStringContainsString('This is a test prompt', $result->prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request when modelextraparams is valid JSON, but not an object/array.
|
||||
*/
|
||||
public function test_create_request_ignores_non_array_modelextraparams(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '1',
|
||||
],
|
||||
);
|
||||
$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');
|
||||
$request = $method->invoke($processor);
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $request['modelId']);
|
||||
$this->assertObjectNotHasProperty('inferenceConfig', $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\BedrockRuntime\BedrockRuntimeClient;
|
||||
use Aws\Exception\AwsException;
|
||||
use Aws\Result;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use core_ai\aiactions\base;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once(__DIR__ . '/testcase_helper_trait.php');
|
||||
|
||||
/**
|
||||
* Test Summarise text provider class for AWS Bedrock provider methods.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \aiprovider_awsbedrock\provider
|
||||
* @covers \aiprovider_awsbedrock\process_summarise_text
|
||||
* @covers \aiprovider_awsbedrock\abstract_processor
|
||||
*/
|
||||
final class process_summarise_text_test extends \advanced_testcase {
|
||||
use testcase_helper_trait;
|
||||
|
||||
/** @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();
|
||||
$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(): 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');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertStringContainsString('This is a test prompt', $body->messages[0]->content[0]->text);
|
||||
$this->assertStringContainsString('You will receive a text input from the user.', $body->system[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request with extra model settings.
|
||||
*/
|
||||
public function test_create_request_with_model_settings(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\summarise_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
],
|
||||
);
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a protected method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->inferenceConfig->temperature);
|
||||
$this->assertEquals('100', $body->inferenceConfig->max_tokens);
|
||||
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\summarise_text::class,
|
||||
actionconfig: [
|
||||
'model' => 'amazon.nova-pro-v2',
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}',
|
||||
],
|
||||
);
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v2', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->inferenceConfig->temperature);
|
||||
$this->assertEquals('100', $body->inferenceConfig->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request with cross region inference.
|
||||
*/
|
||||
public function test_create_request_with_region_inference(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\summarise_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
'model' => 'meta.llama3-2-90b-instruct-v1:0',
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
'cross_region_inference' => 'us.meta.llama3-2-90b-instruct-v1:0',
|
||||
],
|
||||
);
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a protected method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('us.meta.llama3-2-90b-instruct-v1:0', $request['modelId']);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for AWS API error responses.
|
||||
*
|
||||
* @return array The test cases from aws_api_error_provider_helper().
|
||||
*/
|
||||
public static function aws_api_error_provider(): array {
|
||||
return self::aws_api_error_provider_helper();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test handling of various API errors from AWS Bedrock.
|
||||
*
|
||||
* @dataProvider aws_api_error_provider
|
||||
* @param AwsException $exception The AWS exception to simulate.
|
||||
* @param int $expectedstatus The expected HTTP status code in the error response.
|
||||
*/
|
||||
public function test_handle_api_error(AwsException $exception, int $expectedstatus): void {
|
||||
// Create an instance of the class that processes API errors.
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_error');
|
||||
|
||||
$result = $method->invoke($processor, $exception);
|
||||
// Assert that the returned error code matches the expected HTTP status.
|
||||
$this->assertEquals($expectedstatus, $result['errorcode'], "Failed asserting for status $expectedstatus");
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the API success response handler method.
|
||||
*/
|
||||
public function test_handle_api_success(): void {
|
||||
// We're testing a protected method, so we need to setup reflector magic.
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
|
||||
$result = $method->invoke($processor, $this->get_mocked_aws_result());
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('end_turn', $result['finishreason']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test API success response handling when Bedrock metadata headers are missing.
|
||||
* Ensures defaults are applied when usage headers are absent.
|
||||
*/
|
||||
public function test_handle_api_success_with_missing_headers(): void {
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
$stream = Utils::streamFor('{
|
||||
"output":{
|
||||
"message":{
|
||||
"content":[{"text":"The capital of Australia is Canberra."}],
|
||||
"role":"assistant"
|
||||
}
|
||||
},
|
||||
"stopReason":"end_turn"
|
||||
}');
|
||||
$resultobj = new Result([
|
||||
'body' => $stream,
|
||||
'contentType' => 'application/json',
|
||||
'@metadata' => [
|
||||
'statusCode' => 200,
|
||||
'headers' => [],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $method->invoke($processor, $resultobj);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('', $result['fingerprint']);
|
||||
$this->assertEquals('0', $result['prompttokens']);
|
||||
$this->assertEquals('0', $result['completiontokens']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query_ai_api for a successful call.
|
||||
*/
|
||||
public function test_query_ai_api_success(): void {
|
||||
// Create a mock of the Bedrock client.
|
||||
$mockclient = $this->createMock(BedrockRuntimeClient::class);
|
||||
|
||||
// Properly mock the invokeModel call using __call.
|
||||
$mockclient->expects($this->any())
|
||||
->method('__call')
|
||||
->with('invokeModel', $this->anything()) // AWS SDK calls are dynamic via __call.
|
||||
->willReturn($this->get_mocked_aws_result());
|
||||
|
||||
// Now properly mock the provider while calling its constructor.
|
||||
$mockprovider = $this->getMockBuilder(get_class($this->provider))
|
||||
->setConstructorArgs([
|
||||
true, // Enable the provider.
|
||||
'mockprovider', // Provider name.
|
||||
'{}', // Empty config is ok here.
|
||||
])
|
||||
->onlyMethods(['create_bedrock_client']) // Only mock this method.
|
||||
->getMock();
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
// Ensure the mock returns our fake client.
|
||||
$mockprovider->method('create_bedrock_client')
|
||||
->willReturn($mockclient);
|
||||
|
||||
// Create an instance of the processor with the mocked provider.
|
||||
$processor = new process_summarise_text($mockprovider, $this->action);
|
||||
|
||||
// We're testing a protected method, so we need to setup reflector magic.
|
||||
$method = new \ReflectionMethod($processor, 'query_ai_api');
|
||||
|
||||
// Invoke the query_ai_api method.
|
||||
$result = $method->invoke($processor);
|
||||
|
||||
// Assertions.
|
||||
$this->assertIsArray($result);
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('mock-request-id', $result['fingerprint']);
|
||||
$this->assertEquals('The capital of Australia is Canberra.', $result['generatedcontent']);
|
||||
$this->assertEquals('end_turn', $result['finishreason']);
|
||||
$this->assertEquals('11', $result['prompttokens']);
|
||||
$this->assertEquals('568', $result['completiontokens']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response success.
|
||||
*/
|
||||
public function test_prepare_response_success(): void {
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'fingerprint' => 'fp_c4e5b6fa31',
|
||||
'generatedcontent' => 'Sure, here is some sample text',
|
||||
'finishreason' => 'stop',
|
||||
'prompttokens' => '11',
|
||||
'completiontokens' => '568',
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
];
|
||||
|
||||
$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' => 'Internal server error.',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals('summarise_text', $result->get_actionname());
|
||||
$this->assertEquals($response['errorcode'], $result->get_errorcode());
|
||||
$this->assertEquals($response['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());
|
||||
|
||||
$processor = $this->get_mocked_query_ai_api_result($this->provider);
|
||||
$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());
|
||||
|
||||
$processor = $this->get_mocked_query_ai_api_result($this->provider, false);
|
||||
$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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\summarise_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'endpoint' => "https://api.awsbedrock.com/v1/chat/completions",
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: User rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: User rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
|
||||
$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);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->provider = $this->create_provider(\core_ai\aiactions\summarise_text::class);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\summarise_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'endpoint' => "https://api.awsbedrock.com/v1/chat/completions",
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Case 1: Global rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: Global rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$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);
|
||||
$this->create_action($user1->id);
|
||||
$processor = $this->get_mocked_query_ai_api_result($provider);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_a21_jamba_request method.
|
||||
*/
|
||||
public function test_create_a21_jamba_request(): 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_ai21_jamba_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'frequency_penalty' => 0.5,
|
||||
'presence_penalty' => 0.5,
|
||||
'max_tokens' => 100,
|
||||
'stop' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals(0.5, $result->frequency_penalty);
|
||||
$this->assertEquals(0.5, $result->presence_penalty);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop);
|
||||
$this->assertEquals('user', $result->messages[1]->role);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[1]->content);
|
||||
$this->assertEquals('system', $result->messages[0]->role);
|
||||
$this->assertEquals('This is a test system instruction', $result->messages[0]->content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_amazon_request method.
|
||||
*/
|
||||
public function test_create_amazon_request(): 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_amazon_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'maxTokens' => 100,
|
||||
'topP' => 0.9,
|
||||
'topK' => 100,
|
||||
'schemaVersion' => 'messages-v1',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals('This is a test system instruction', $result->system[0]->text);
|
||||
$this->assertEquals(0.5, $result->inferenceConfig->temperature);
|
||||
$this->assertEquals(100, $result->inferenceConfig->maxTokens);
|
||||
$this->assertEquals(0.9, $result->inferenceConfig->topP);
|
||||
$this->assertEquals(100, $result->inferenceConfig->topK);
|
||||
$this->assertEquals('messages-v1', $result->schemaVersion);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[0]->content[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_anthropic_request method.
|
||||
*/
|
||||
public function test_create_anthropic_request(): 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_anthropic_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'top_p' => 0.9,
|
||||
'top_k' => 100,
|
||||
'max_tokens' => 100,
|
||||
'stop_sequences' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals('bedrock-2023-05-31', $result->anthropic_version);
|
||||
$this->assertEquals('This is a test system instruction', $result->system);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertEquals(100, $result->top_k);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop_sequences);
|
||||
$this->assertEquals('This is a test prompt', $result->messages[0]->content[0]->text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_mistral_request method.
|
||||
*/
|
||||
public function test_create_mistral_request(): 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_mistral_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'top_p' => 0.9,
|
||||
'top_k' => 100,
|
||||
'max_tokens' => 100,
|
||||
'stop' => 'alpha beta gamma',
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertEquals(
|
||||
'<s>[INST] System: This is a test system instruction User: This is a test prompt [/INST]',
|
||||
$result->prompt
|
||||
);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertEquals(100, $result->top_k);
|
||||
$this->assertEquals(100, $result->max_tokens);
|
||||
$this->assertEquals(['alpha beta gamma'], $result->stop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_meta_request method.
|
||||
*/
|
||||
public function test_create_meta_request(): 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_meta_request');
|
||||
|
||||
$requestobj = new \stdClass();
|
||||
$systeminstruction = 'This is a test system instruction';
|
||||
$modelsettings = [
|
||||
'temperature' => 0.5,
|
||||
'max_gen_len' => 100,
|
||||
'top_p' => 0.9,
|
||||
'awsregion' => 'us-east-1',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $requestobj, $systeminstruction, $modelsettings);
|
||||
|
||||
$this->assertStringContainsString('This is a test system instruction', $result->prompt);
|
||||
$this->assertEquals(0.5, $result->temperature);
|
||||
$this->assertEquals(100, $result->max_gen_len);
|
||||
$this->assertEquals(0.9, $result->top_p);
|
||||
$this->assertStringContainsString('This is a test prompt', $result->prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request when modelextraparams is valid JSON, but not an object/array.
|
||||
*/
|
||||
public function test_create_request_ignores_non_array_modelextraparams(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\summarise_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '1',
|
||||
],
|
||||
);
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'create_request');
|
||||
$request = $method->invoke($processor);
|
||||
$body = (object) json_decode($request['body']);
|
||||
|
||||
$this->assertEquals('amazon.nova-pro-v1:0', $request['modelId']);
|
||||
$this->assertObjectNotHasProperty('inferenceConfig', $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
/**
|
||||
* Test AWS Bedrock provider methods.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @covers \aiprovider_awsbedrock\provider
|
||||
*/
|
||||
final class provider_test extends \advanced_testcase {
|
||||
/** @var \core_ai\manager */
|
||||
private $manager;
|
||||
|
||||
/** @var \core_ai\provider */
|
||||
private $provider;
|
||||
|
||||
/**
|
||||
* Overriding setUp() function to always reset after tests.
|
||||
*/
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->resetAfterTest();
|
||||
|
||||
// Create the provider instance.
|
||||
$this->manager = \core\di::get(\core_ai\manager::class);
|
||||
$config = ['data' => 'goeshere'];
|
||||
$this->provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_awsbedrock\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_action_list
|
||||
*/
|
||||
public function test_get_action_list(): void {
|
||||
$actionlist = $this->provider->get_action_list();
|
||||
$this->assertIsArray($actionlist);
|
||||
$this->assertCount(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 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_awsbedrock\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(
|
||||
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
|
||||
actual: $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', 'apisecret' => '456'],
|
||||
);
|
||||
$this->assertTrue($updatedprovider->is_provider_configured());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?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_awsbedrock;
|
||||
|
||||
use Aws\Result;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Aws\Command;
|
||||
use Aws\Exception\AwsException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
|
||||
/**
|
||||
* Trait for test cases.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
trait testcase_helper_trait {
|
||||
/**
|
||||
* Get the provider configuration.
|
||||
*
|
||||
* @return array The provider configuration.
|
||||
*/
|
||||
public function get_provider_config(): array {
|
||||
return [
|
||||
'apikey' => '123',
|
||||
'apisecret' => '456',
|
||||
'enableuserratelimit' => true,
|
||||
'userratelimit' => 1,
|
||||
'enableglobalratelimit' => true,
|
||||
'globalratelimit' => 1,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action configuration.
|
||||
*
|
||||
* @param string $actionclass The action class to use.
|
||||
* @param array $actionconfig The action configuration to use.
|
||||
* @return array The action configuration.
|
||||
*/
|
||||
public function get_action_config(string $actionclass, array $actionconfig = []): array {
|
||||
$defaultactionconfig = [
|
||||
$actionclass => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'awsregion' => 'ap-southeast-2',
|
||||
],
|
||||
],
|
||||
];
|
||||
foreach ($actionconfig as $key => $value) {
|
||||
$defaultactionconfig[$actionclass]['settings'][$key] = $value;
|
||||
}
|
||||
|
||||
return $defaultactionconfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the provider object.
|
||||
*
|
||||
* @param string $actionclass The action class to use.
|
||||
* @param array $actionconfig The action configuration to use.
|
||||
* @return provider The provider object.
|
||||
*/
|
||||
public function create_provider(
|
||||
string $actionclass,
|
||||
array $actionconfig = [],
|
||||
): provider {
|
||||
$manager = \core\di::get(\core_ai\manager::class);
|
||||
$config = $this->get_provider_config();
|
||||
$defaultactionconfig = [
|
||||
$actionclass => [
|
||||
'settings' => [
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'awsregion' => 'ap-southeast-2',
|
||||
],
|
||||
],
|
||||
];
|
||||
foreach ($actionconfig as $key => $value) {
|
||||
$defaultactionconfig[$actionclass]['settings'][$key] = $value;
|
||||
}
|
||||
$provider = $manager->create_provider_instance(
|
||||
classname: '\aiprovider_awsbedrock\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',
|
||||
];
|
||||
$this->testfile = $fs->create_file_from_string($fileinfo, 'This is test file content.');
|
||||
return $this->testfile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mocked Aws\Result.
|
||||
*/
|
||||
private function get_mocked_aws_result(): Result {
|
||||
$mockresponsebody = '{
|
||||
"output":{
|
||||
"message":{
|
||||
"content":[{"text":"The capital of Australia is Canberra."}],
|
||||
"role":"assistant"
|
||||
}
|
||||
},
|
||||
"stopReason":"end_turn",
|
||||
"usage":{
|
||||
"inputTokens":11,
|
||||
"outputTokens":568,
|
||||
"totalTokens":579,
|
||||
"cacheReadInputTokenCount":0,
|
||||
"cacheWriteInputTokenCount":0
|
||||
}
|
||||
}';
|
||||
|
||||
// Create a PSR-7 Stream for the response body.
|
||||
$stream = Utils::streamFor($mockresponsebody);
|
||||
|
||||
// Create a mocked Aws\Result.
|
||||
return new Result([
|
||||
'body' => $stream, // Simulate AWS SDK response body.
|
||||
'contentType' => 'application/json',
|
||||
'@metadata' => [
|
||||
'statusCode' => 200,
|
||||
'headers' => [
|
||||
'x-amzn-requestid' => 'mock-request-id',
|
||||
'x-amzn-bedrock-input-token-count' => '11',
|
||||
'x-amzn-bedrock-output-token-count' => '568',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the result from query_ai_api().
|
||||
* Returns a MockBuilder object for a processor.
|
||||
*
|
||||
* @param provider $provider The provider to use.
|
||||
* @param bool $success Whether the query was successful.
|
||||
* @return MockObject The mocked processor object.
|
||||
*/
|
||||
private function get_mocked_query_ai_api_result(
|
||||
provider $provider,
|
||||
bool $success = true
|
||||
): MockObject {
|
||||
// Define the mock successful response from query_ai_api().
|
||||
$responsesuccess = [
|
||||
'success' => true,
|
||||
'fingerprint' => 'mock-request-id',
|
||||
'prompttokens' => '11',
|
||||
'completiontokens' => '568',
|
||||
'model' => 'amazon.nova-pro-v1:0',
|
||||
'generatedcontent' => 'The capital of Australia is Canberra.',
|
||||
'finishreason' => 'FINISHED',
|
||||
];
|
||||
|
||||
$responseerror = [
|
||||
'success' => false,
|
||||
'errorcode' => 401,
|
||||
'error' => 'Internal server error',
|
||||
'errormessage' => 'Invalid Authentication',
|
||||
];
|
||||
|
||||
// Create a partial mock of `process_summarise_text`, only mocking `query_ai_api()`.
|
||||
$processor = $this->getMockBuilder(process_summarise_text::class)
|
||||
->setConstructorArgs([$provider, $this->action])
|
||||
->onlyMethods(['query_ai_api'])
|
||||
->getMock();
|
||||
|
||||
// Mock `query_ai_api()` to return the predefined response.
|
||||
if ($success) {
|
||||
$processor->method('query_ai_api')
|
||||
->willReturn($responsesuccess);
|
||||
} else {
|
||||
$processor->method('query_ai_api')
|
||||
->willReturn($responseerror);
|
||||
}
|
||||
|
||||
return $processor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for AWS API error responses.
|
||||
* Provides a set of AWS exceptions with corresponding HTTP status codes to test error handling in the AWS Bedrock provider.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function aws_api_error_provider_helper(): array {
|
||||
// Mock an AWS Command.
|
||||
$command = new Command('InvokeModel');
|
||||
|
||||
// Define various error responses.
|
||||
return [
|
||||
'400 Validation Exception' => [
|
||||
new AwsException(
|
||||
'ValidationException: Invalid modelId',
|
||||
$command,
|
||||
[
|
||||
'code' => 'ValidationException',
|
||||
'response' => new Response(400, [], json_encode([
|
||||
'message' => 'Invalid modelId: invalid-model-id',
|
||||
'code' => 'ValidationException',
|
||||
])),
|
||||
],
|
||||
),
|
||||
400,
|
||||
],
|
||||
'403 Access Denied' => [
|
||||
new AwsException(
|
||||
'AccessDeniedException: You do not have permission to access this resource',
|
||||
$command,
|
||||
[
|
||||
'code' => 'AccessDeniedException',
|
||||
'response' => new Response(403, [], json_encode([
|
||||
'message' => 'You do not have permission to invoke this model',
|
||||
'code' => 'AccessDeniedException',
|
||||
])),
|
||||
],
|
||||
),
|
||||
403,
|
||||
],
|
||||
'429 Throttling Exception' => [
|
||||
new AwsException(
|
||||
'ThrottlingException: Too many requests',
|
||||
$command,
|
||||
[
|
||||
'code' => 'ThrottlingException',
|
||||
'response' => new Response(429, [], json_encode([
|
||||
'message' => 'Rate limit exceeded, please try again later',
|
||||
'code' => 'ThrottlingException',
|
||||
])),
|
||||
],
|
||||
),
|
||||
429,
|
||||
],
|
||||
'500 Internal Server Error' => [
|
||||
new AwsException(
|
||||
'InternalServerException: AWS Bedrock encountered an error',
|
||||
$command,
|
||||
[
|
||||
'code' => 'InternalServerException',
|
||||
'response' => new Response(500, [], json_encode([
|
||||
'message' => 'An internal server error occurred',
|
||||
'code' => 'InternalServerException',
|
||||
])),
|
||||
],
|
||||
),
|
||||
500,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?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_awsbedrock.
|
||||
*
|
||||
* @package aiprovider_awsbedrock
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->component = 'aiprovider_awsbedrock';
|
||||
$plugin->version = 2026021600;
|
||||
$plugin->requires = 2026021000;
|
||||
$plugin->maturity = MATURITY_STABLE;
|
||||
Reference in New Issue
Block a user