From 75382a1e92a4e8db3dec2680f1e6be0bbd014e95 Mon Sep 17 00:00:00 2001 From: Matt Porritt Date: Sun, 28 Jul 2024 20:47:11 +1000 Subject: [PATCH] MDL-82411 AI: Provider Plugin - Azure AI Add an AI provider plugin for Microsoft Azure AI. The Azure AI provider supports generating images, as well as generating and summarising text. --- .../azureai/classes/abstract_processor.php | 139 ++++ .../azureai/classes/privacy/provider.php | 77 +++ .../classes/process_generate_image.php | 175 +++++ .../azureai/classes/process_generate_text.php | 111 +++ .../classes/process_summarise_text.php | 41 ++ ai/provider/azureai/classes/provider.php | 193 ++++++ .../azureai/lang/en/aiprovider_azureai.php | 51 ++ ai/provider/azureai/settings.php | 96 +++ .../tests/fixtures/image_request_success.json | 9 + ai/provider/azureai/tests/fixtures/test.jpg | Bin 0 -> 39476 bytes .../tests/fixtures/text_request_success.json | 68 ++ .../tests/process_generate_image_test.php | 646 ++++++++++++++++++ .../tests/process_generate_text_test.php | 452 ++++++++++++ .../tests/process_summarise_text_test.php | 444 ++++++++++++ ai/provider/azureai/tests/provider_test.php | 113 +++ ai/provider/azureai/version.php | 30 + ai/tests/manager_test.php | 7 +- lib/plugins.json | 3 +- 18 files changed, 2650 insertions(+), 5 deletions(-) create mode 100644 ai/provider/azureai/classes/abstract_processor.php create mode 100644 ai/provider/azureai/classes/privacy/provider.php create mode 100644 ai/provider/azureai/classes/process_generate_image.php create mode 100644 ai/provider/azureai/classes/process_generate_text.php create mode 100644 ai/provider/azureai/classes/process_summarise_text.php create mode 100644 ai/provider/azureai/classes/provider.php create mode 100644 ai/provider/azureai/lang/en/aiprovider_azureai.php create mode 100644 ai/provider/azureai/settings.php create mode 100644 ai/provider/azureai/tests/fixtures/image_request_success.json create mode 100644 ai/provider/azureai/tests/fixtures/test.jpg create mode 100644 ai/provider/azureai/tests/fixtures/text_request_success.json create mode 100644 ai/provider/azureai/tests/process_generate_image_test.php create mode 100644 ai/provider/azureai/tests/process_generate_text_test.php create mode 100644 ai/provider/azureai/tests/process_summarise_text_test.php create mode 100644 ai/provider/azureai/tests/provider_test.php create mode 100644 ai/provider/azureai/version.php diff --git a/ai/provider/azureai/classes/abstract_processor.php b/ai/provider/azureai/classes/abstract_processor.php new file mode 100644 index 00000000000..5081235a818 --- /dev/null +++ b/ai/provider/azureai/classes/abstract_processor.php @@ -0,0 +1,139 @@ +. + +namespace aiprovider_azureai; + +use core\http_client; +use core_ai\process_base; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\RequestOptions; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; + +/** + * Class process text generation. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class abstract_processor extends process_base { + /** + * Get the endpoint URI. + * + * @return UriInterface + */ + abstract protected function get_endpoint(): UriInterface; + + /** + * Get the system instructions. + * + * @return string + */ + protected function get_system_instruction(): string { + return $this->action::get_system_instruction(); + } + + /** + * Get the deployment name. + * + * @return string + */ + abstract protected function get_deployment_name(): string; + + /** + * Get the api version to use. + * + * @return string + */ + abstract protected function get_api_version(): string; + + /** + * Create the request object to send to the OpenAI API. + * + * This object contains all the required parameters for the request. + * + * @param string $userid The user id. + * @return RequestInterface The request object to send to the OpenAI API. + */ + abstract protected function create_request_object( + string $userid, + ): RequestInterface; + + /** + * Handle a successful response from the external AI api. + * + * @param ResponseInterface $response The response object. + * @return array The response. + */ + abstract protected function handle_api_success(ResponseInterface $response): array; + + #[\Override] + protected function query_ai_api(): array { + $request = $this->create_request_object( + userid: $this->provider->generate_userid($this->action->get_configuration('userid')), + ); + $request = $this->provider->add_authentication_headers($request); + + $client = \core\di::get(http_client::class); + try { + // Call the external AI service. + $response = $client->send($request, [ + 'base_uri' => $this->get_endpoint(), + RequestOptions::HTTP_ERRORS => false, + ]); + } catch (RequestException $e) { + // Handle any exceptions. + return [ + 'success' => false, + 'errorcode' => $e->getCode(), + 'errormessage' => $e->getMessage(), + ]; + } + + // Double-check the response codes, in case of a non 200 that didn't throw an error. + $status = $response->getStatusCode(); + if ($status === 200) { + return $this->handle_api_success($response); + } else { + return $this->handle_api_error($response); + } + } + + /** + * Handle an error from the external AI api. + * + * @param ResponseInterface $response The response object. + * @return array The error response. + */ + protected function handle_api_error(ResponseInterface $response): array { + $responsearr = [ + 'success' => false, + 'errorcode' => $response->getStatusCode(), + ]; + + $status = $response->getStatusCode(); + if ($status >= 500 && $status < 600) { + $responsearr['errormessage'] = $response->getReasonPhrase(); + } else { + $bodyobj = json_decode($response->getBody()->getContents()); + $responsearr['errormessage'] = $bodyobj->error->message; + } + + return $responsearr; + } +} diff --git a/ai/provider/azureai/classes/privacy/provider.php b/ai/provider/azureai/classes/privacy/provider.php new file mode 100644 index 00000000000..7e7e11168f3 --- /dev/null +++ b/ai/provider/azureai/classes/privacy/provider.php @@ -0,0 +1,77 @@ +. + +namespace aiprovider_azureai\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 Subsystem for Azure AI provider implementing null_provider. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @codeCoverageIgnore + */ +class provider implements + \core_privacy\local\metadata\provider, + \core_privacy\local\request\core_userlist_provider, + \core_privacy\local\request\plugin\provider { + + #[\Override] + public static function get_metadata(collection $collection): collection { + $collection->add_external_location_link('aiprovider_azureai', [ + 'prompttext' => 'privacy:metadata:aiprovider_azureai:prompttext', + 'model' => 'privacy:metadata:aiprovider_azureai:model', + 'numberimages' => 'privacy:metadata:aiprovider_azureai:numberimages', + 'responseformat' => 'privacy:metadata:aiprovider_azureai:responseformat', + ], 'privacy:metadata:aiprovider_azureai: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) { + } +} diff --git a/ai/provider/azureai/classes/process_generate_image.php b/ai/provider/azureai/classes/process_generate_image.php new file mode 100644 index 00000000000..25fb4fd191f --- /dev/null +++ b/ai/provider/azureai/classes/process_generate_image.php @@ -0,0 +1,175 @@ +. + +namespace aiprovider_azureai; + +use core\http_client; +use core_ai\ai_image; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Uri; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; + +/** + * Class process image generation. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_generate_image extends abstract_processor { + /** @var int The number of images to generate dall-e-3 only supports 1 */ + private int $numberimages = 1; + + #[\Override] + protected function get_endpoint(): UriInterface { + $url = rtrim(get_config('aiprovider_azureai', 'endpoint'), '/') + . '/openai/deployments/' + . $this->get_deployment_name() + . '/images/generations?api-version=' + . $this->get_api_version(); + + return new Uri($url); + } + + #[\Override] + protected function get_deployment_name(): string { + return get_config('aiprovider_azureai', 'action_generate_image_deployment'); + } + + #[\Override] + protected function get_api_version(): string { + return get_config('aiprovider_azureai', 'action_generate_image_apiversion'); + } + + #[\Override] + protected function query_ai_api(): array { + $response = parent::query_ai_api(); + + // If the request was successful, save the URL to a file. + if ($response['success']) { + $fileobj = $this->url_to_file( + $this->action->get_configuration('userid'), + $response['sourceurl'] + ); + // Add the file to the response, so the calling placement can do whatever they want with it. + $response['draftfile'] = $fileobj; + } + + return $response; + } + + /** + * Convert the given aspect ratio to an image size + * that is compatible with the azureai API. + * + * @param string $ratio The aspect ratio of the image. + * @return string The size of the image. + * @throws \coding_exception + */ + private function calculate_size(string $ratio): string { + if ($ratio === 'square') { + $size = '1024x1024'; + } else if ($ratio === 'landscape') { + $size = '1792x1024'; + } else if ($ratio === 'portrait') { + $size = '1024x1792'; + } else { + throw new \coding_exception('Invalid aspect ratio: ' . $ratio); + } + return $size; + } + + #[\Override] + protected function create_request_object(string $userid): RequestInterface { + return new Request( + method: 'POST', + uri: '', + body: json_encode((object) [ + 'prompt' => $this->action->get_configuration('prompttext'), + 'n' => $this->numberimages, + 'quality' => $this->action->get_configuration('quality'), + 'size' => $this->calculate_size($this->action->get_configuration('aspectratio')), + 'style' => $this->action->get_configuration('style'), + 'user' => $userid, + ]), + headers: [ + 'Content-Type' => 'application/json', + ], + ); + } + + #[\Override] + protected function handle_api_success(ResponseInterface $response): array { + $responsebody = $response->getBody(); + $bodyobj = json_decode($responsebody->getContents()); + + return [ + 'success' => true, + 'sourceurl' => $bodyobj->data[0]->url, + 'revisedprompt' => $bodyobj->data[0]->revised_prompt, + ]; + } + + /** + * Convert the url 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 int $userid The user id. + * @param string $url The URL to the image. + * @return \stored_file The file object. + */ + private function url_to_file(int $userid, string $url): \stored_file { + global $CFG; + + require_once("{$CFG->libdir}/filelib.php"); + + // Azure AI doesn't always return unique file names, but does return unique URLS. + // Therefore, some processing is needed to get a unique filename. + $parsedurl = parse_url($url, PHP_URL_PATH); // Parse the URL to get the path. + $fileext = pathinfo($parsedurl, PATHINFO_EXTENSION); // Get the file extension. + $filename = substr(hash('sha512', ($url . $userid)), 0, 16) . '.' . $fileext; + + $client = \core\di::get(http_client::class); + + // Download the image and add the watermark. + $downloadtmpdir = make_request_directory(); + $tempdst = $downloadtmpdir . $filename; + $client->get($url, [ + 'sink' => $tempdst, + 'timeout' => $CFG->repositorygetfiletimeout, + ]); + $image = new ai_image($tempdst); + $image->add_watermark()->save(); + + // We put the file in the user draft area initially. + // Placements (on behalf of the user) can then move it to the correct location. + $fileinfo = new \stdClass(); + $fileinfo->contextid = \context_user::instance($userid)->id; + $fileinfo->filearea = 'draft'; + $fileinfo->component = 'user'; + $fileinfo->itemid = file_get_unused_draft_itemid(); + $fileinfo->filepath = '/'; + $fileinfo->filename = $filename; + + $fs = get_file_storage(); + return $fs->create_file_from_string($fileinfo, file_get_contents($tempdst)); + } +} diff --git a/ai/provider/azureai/classes/process_generate_text.php b/ai/provider/azureai/classes/process_generate_text.php new file mode 100644 index 00000000000..4776753f955 --- /dev/null +++ b/ai/provider/azureai/classes/process_generate_text.php @@ -0,0 +1,111 @@ +. + +namespace aiprovider_azureai; + +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Uri; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; + +/** + * Class process text generation. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_generate_text extends abstract_processor { + #[\Override] + protected function get_endpoint(): UriInterface { + $url = rtrim(get_config('aiprovider_azureai', 'endpoint'), '/') + . '/openai/deployments/' + . $this->get_deployment_name() + . '/chat/completions?api-version=' + . $this->get_api_version(); + + return new Uri($url); + } + + #[\Override] + protected function get_deployment_name(): string { + return get_config('aiprovider_azureai', 'action_generate_text_deployment'); + } + + #[\Override] + protected function get_api_version(): string { + return get_config('aiprovider_azureai', 'action_generate_text_apiversion'); + } + + #[\Override] + protected function get_system_instruction(): string { + return get_config('aiprovider_azureai', 'action_generate_text_systeminstruction'); + } + + #[\Override] + protected function create_request_object(string $userid): RequestInterface { + // Create the user object. + $userobj = new \stdClass(); + $userobj->role = 'user'; + $userobj->content = $this->action->get_configuration('prompttext'); + + // Create the request object. + $requestobj = new \stdClass(); + $requestobj->user = $userid; + + // If there is a system string available, use it. + $systeminstruction = $this->get_system_instruction(); + if (!empty($systeminstruction)) { + $systemobj = new \stdClass(); + $systemobj->role = 'system'; + $systemobj->content = $systeminstruction; + $requestobj->messages = [$systemobj, $userobj]; + } else { + $requestobj->messages = [$userobj]; + } + + return new Request( + method: 'POST', + uri: '', + body: json_encode($requestobj), + headers: [ + 'Content-Type' => 'application/json', + ], + ); + } + + /** + * Handle a successful response from the external AI api. + * + * @param ResponseInterface $response The response object. + * @return array The response. + */ + protected function handle_api_success(ResponseInterface $response): array { + $responsebody = $response->getBody(); + $bodyobj = json_decode($responsebody->getContents()); + + return [ + 'success' => true, + 'id' => $bodyobj->id, + 'fingerprint' => $bodyobj->system_fingerprint, + 'generatedcontent' => $bodyobj->choices[0]->message->content, + 'finishreason' => $bodyobj->choices[0]->finish_reason, + 'prompttokens' => $bodyobj->usage->prompt_tokens, + 'completiontokens' => $bodyobj->usage->completion_tokens, + ]; + } +} diff --git a/ai/provider/azureai/classes/process_summarise_text.php b/ai/provider/azureai/classes/process_summarise_text.php new file mode 100644 index 00000000000..a8d3971fa9f --- /dev/null +++ b/ai/provider/azureai/classes/process_summarise_text.php @@ -0,0 +1,41 @@ +. + +namespace aiprovider_azureai; + +/** + * Class process text summarisation. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_summarise_text extends process_generate_text { + #[\Override] + protected function get_deployment_name(): string { + return get_config('aiprovider_azureai', 'action_summarise_text_deployment'); + } + + #[\Override] + protected function get_api_version(): string { + return get_config('aiprovider_azureai', 'action_summarise_text_apiversion'); + } + + #[\Override] + protected function get_system_instruction(): string { + return get_config('aiprovider_azureai', 'action_summarise_text_systeminstruction'); + } +} diff --git a/ai/provider/azureai/classes/provider.php b/ai/provider/azureai/classes/provider.php new file mode 100644 index 00000000000..aebd51e1424 --- /dev/null +++ b/ai/provider/azureai/classes/provider.php @@ -0,0 +1,193 @@ +. + +namespace aiprovider_azureai; + +use core_ai\aiactions; +use core_ai\rate_limiter; +use Psr\Http\Message\RequestInterface; + +/** + * Class provider. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider extends \core_ai\provider { + /** @var string The Azure AI API key. */ + private string $apikey; + + /** @var string The Azure AI API endpoint, is different for each organisation. */ + public string $apiendpoint; + + /** @var bool Is global rate limiting for the API enabled. */ + private bool $enableglobalratelimit; + + /** @var int The global rate limit. */ + private int $globalratelimit; + + /** @var bool Is user rate limiting for the API enabled */ + private bool $enableuserratelimit; + + /** @var int The user rate limit. */ + private int $userratelimit; + + /** + * Class constructor. + */ + public function __construct() { + // Get api key from config. + $this->apikey = get_config('aiprovider_azureai', 'apikey'); + // Get api endpoint url id from config. + $this->apiendpoint = get_config('aiprovider_azureai', 'endpoint'); + // Get global rate limit from config. + $this->enableglobalratelimit = get_config('aiprovider_azureai', 'enableglobalratelimit'); + $this->globalratelimit = get_config('aiprovider_azureai', 'globalratelimit'); + // Get user rate limit from config. + $this->enableuserratelimit = get_config('aiprovider_azureai', 'enableuserratelimit'); + $this->userratelimit = get_config('aiprovider_azureai', 'userratelimit'); + } + + /** + * Get the list of actions that this provider supports. + * + * @return array An array of action class names. + */ + public function get_action_list(): array { + return [ + \core_ai\aiactions\generate_text::class, + \core_ai\aiactions\generate_image::class, + \core_ai\aiactions\summarise_text::class, + ]; + } + + /** + * Generate a user id. + * This is a hash of the site id and user id, + * this means we can determine who made the request + * but don't pass any personal data to AzureAI. + * + * @param string $userid The user id. + * @return string The generated user id. + */ + public function generate_userid(string $userid): string { + global $CFG; + return hash('sha256', $CFG->siteidentifier . $userid); + } + + /** + * Update a request to add any headers required by the provider. + * + * @param \Psr\Http\Message\RequestInterface $request + * @return \Psr\Http\Message\RequestInterface + */ + public function add_authentication_headers(RequestInterface $request): RequestInterface { + return $request + ->withAddedHeader('api-key', $this->apikey); + } + + /** + * Check if the request is allowed by the rate limiter. + * + * @param aiactions\base $action The action to check. + * @return array|bool True on success, array of error details on failure. + */ + public function is_request_allowed(aiactions\base $action): array|bool { + $ratelimiter = \core\di::get(rate_limiter::class); + $component = \core\component::get_component_from_classname(get_class($this)); + + // Check the user rate limit. + if ($this->enableuserratelimit) { + if (!$ratelimiter->check_user_rate_limit( + component: $component, + ratelimit: $this->userratelimit, + userid: $action->get_configuration('userid') + )) { + return [ + 'success' => false, + 'errorcode' => 429, + 'errormessage' => 'User rate limit exceeded', + ]; + } + } + + // Check the global rate limit. + if ($this->enableglobalratelimit) { + if (!$ratelimiter->check_global_rate_limit( + component: $component, + ratelimit: $this->globalratelimit)) { + return [ + 'success' => false, + 'errorcode' => 429, + 'errormessage' => 'Global rate limit exceeded', + ]; + } + } + + return true; + } + + /** + * Get any action settings for this provider. + * + * @param string $action The action class name. + * @param \admin_root $ADMIN The admin root object. + * @param string $section The section name. + * @param bool $hassiteconfig Whether the current user has moodle/site:config capability. + * @return array An array of settings. + */ + public function get_action_settings( + string $action, + \admin_root $ADMIN, + string $section, + bool $hassiteconfig + ): array { + $actionname = substr($action, (strrpos($action, '\\') + 1)); + $settings = []; + + // Add API deployment name. + $settings[] = new \admin_setting_configtext( + "aiprovider_azureai/action_{$actionname}_deployment", + new \lang_string("action_deployment", 'aiprovider_azureai'), + new \lang_string("action_deployment_desc", 'aiprovider_azureai'), + '', + PARAM_ALPHANUMEXT, + ); + // Add API version. + $settings[] = new \admin_setting_configtext( + "aiprovider_azureai/action_{$actionname}_apiversion", + new \lang_string("action_apiversion", 'aiprovider_azureai'), + new \lang_string("action_apiversion_desc", 'aiprovider_azureai'), + '2024-06-01', + PARAM_ALPHANUMEXT, + ); + + if ($actionname === 'generate_text' || $actionname === 'summarise_text') { + // Add system instruction settings. + $settings[] = new \admin_setting_configtextarea( + "aiprovider_azureai/action_{$actionname}_systeminstruction", + new \lang_string("action_systeminstruction", 'aiprovider_azureai'), + new \lang_string("action_systeminstruction_desc", 'aiprovider_azureai'), + $action::get_system_instruction(), + PARAM_TEXT + ); + } + + return $settings; + } + +} diff --git a/ai/provider/azureai/lang/en/aiprovider_azureai.php b/ai/provider/azureai/lang/en/aiprovider_azureai.php new file mode 100644 index 00000000000..832193b6cab --- /dev/null +++ b/ai/provider/azureai/lang/en/aiprovider_azureai.php @@ -0,0 +1,51 @@ +. + +/** + * Strings for component aiprovider_azureai, language 'en'. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +$string['action_apiversion'] = 'Azure AI API version'; +$string['action_apiversion_desc'] = 'Enter the version number for your Azure AI API.'; +$string['action_deployment'] = 'Deployment ID'; +$string['action_deployment_desc'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; +$string['action_systeminstruction'] = 'System Instruction'; +$string['action_systeminstruction_desc'] = 'The instruction is used provided along with the user request for this action. It provides information to the AI model on how to generate the response.'; +$string['apikey'] = 'Azure AI API key'; +$string['apikey_desc'] = 'Enter your Azure AI API key.'; +$string['deployment'] = 'Azure AI API deployment name'; +$string['deployment_desc'] = 'Enter the deployment name for your Azure AI API.'; +$string['enableglobalratelimit'] = 'Enable global rate limiting'; +$string['enableglobalratelimit_desc'] = 'Enable global rate limiting for the Azure AI API provider.'; +$string['enableuserratelimit'] = 'Enable user rate limiting'; +$string['enableuserratelimit_desc'] = 'Enable user rate limiting for the Azure AI API provider.'; +$string['endpoint'] = 'Azure AI API endpoint'; +$string['endpoint_desc'] = 'Enter the endpoint URL for your Azure AI API. In the form of: https://YOUR_RESOURCE_NAME.azureai.azure.com/azureai/deployments'; +$string['globalratelimit'] = 'Global rate limit'; +$string['globalratelimit_desc'] = 'Set the number of requests per hour allowed for the global rate limit.'; +$string['pluginname'] = 'Azure AI API Provider'; +$string['privacy:metadata'] = 'The Azure Ai API provider plugin does not store any personal data.'; +$string['privacy:metadata:aiprovider_azureai:externalpurpose'] = 'This information is sent to the Azure API in order for a response to be generated. Your Azure AI account settings may change how Microsoft stores and retains this data. No user data is explicitly sent to Microsoft or stored in Moodle LMS by this plugin.'; +$string['privacy:metadata:aiprovider_azureai:model'] = 'The model used to generate the response.'; +$string['privacy:metadata:aiprovider_azureai:numberimages'] = 'The number of images used in the response. When generating images.'; +$string['privacy:metadata:aiprovider_azureai:prompttext'] = 'The user entered text prompt used to generate the response.'; +$string['privacy:metadata:aiprovider_azureai:responseformat'] = 'The format of the response. When generating images.'; +$string['userratelimit'] = 'User rate limit'; +$string['userratelimit_desc'] = 'Set the number of requests per hour allowed for the user rate limit.'; diff --git a/ai/provider/azureai/settings.php b/ai/provider/azureai/settings.php new file mode 100644 index 00000000000..8716d7f3140 --- /dev/null +++ b/ai/provider/azureai/settings.php @@ -0,0 +1,96 @@ +. + +/** + * Plugin administration pages are defined here. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use core_ai\admin\admin_settingspage_provider; + +defined('MOODLE_INTERNAL') || die(); + +if ($hassiteconfig) { + // Provider specific settings. + $settings = new admin_settingspage_provider( + 'aiprovider_azureai', + new lang_string('pluginname', 'aiprovider_azureai'), + 'moodle/site:config', + true + ); + + $settings->add(new admin_setting_heading( + 'aiprovider_azureai/general', + new lang_string('providersettings', 'core_ai'), + new lang_string('providersettings_desc', 'core_ai') + )); + + // Setting to store AzureAI API key. + $settings->add(new admin_setting_configpasswordunmask( + 'aiprovider_azureai/apikey', + new lang_string('apikey', 'aiprovider_azureai'), + new lang_string('apikey_desc', 'aiprovider_azureai'), + '', + )); + + // Setting to store AzureAI endpoint URL. + $settings->add(new admin_setting_configtext( + 'aiprovider_azureai/endpoint', + new lang_string('endpoint', 'aiprovider_azureai'), + new lang_string('endpoint_desc', 'aiprovider_azureai'), + '', + PARAM_URL + )); + + // Setting to enable/disable global rate limiting. + $settings->add(new admin_setting_configcheckbox('aiprovider_azureai/enableglobalratelimit', + new lang_string('enableglobalratelimit', 'aiprovider_azureai'), + new lang_string('enableglobalratelimit_desc', 'aiprovider_azureai'), + 0 + )); + + // Setting to set how many requests per hour are allowed for the global rate limit. + // Should only be enabled when global rate limiting is enabled. + $settings->add(new admin_setting_configtext( + 'aiprovider_azureai/globalratelimit', + new lang_string('globalratelimit', 'aiprovider_azureai'), + new lang_string('globalratelimit_desc', 'aiprovider_azureai'), + 100, + PARAM_INT + )); + $settings->hide_if('aiprovider_azureai/globalratelimit', 'aiprovider_azureai/enableglobalratelimit', 'eq', 0); + + // Setting to enable/disable user rate limiting. + $settings->add(new admin_setting_configcheckbox( + 'aiprovider_azureai/enableuserratelimit', + new lang_string('enableuserratelimit', 'aiprovider_azureai'), + new lang_string('enableuserratelimit_desc', 'aiprovider_azureai'), + 0 + )); + + // Setting to set how many requests per hour are allowed for the user rate limit. + // Should only be enabled when user rate limiting is enabled. + $settings->add(new admin_setting_configtext( + 'aiprovider_azureai/userratelimit', + new lang_string('userratelimit', 'aiprovider_azureai'), + new lang_string('userratelimit_desc', 'aiprovider_azureai'), + 10, + PARAM_INT)); + $settings->hide_if('aiprovider_azureai/userratelimit', 'aiprovider_azureai/enableuserratelimit', 'eq', 0); +} diff --git a/ai/provider/azureai/tests/fixtures/image_request_success.json b/ai/provider/azureai/tests/fixtures/image_request_success.json new file mode 100644 index 00000000000..4ba02247a9c --- /dev/null +++ b/ai/provider/azureai/tests/fixtures/image_request_success.json @@ -0,0 +1,9 @@ +{ + "created": 1719140500, + "data": [ + { + "revised_prompt": "An image that represents the concept of a 'test'. It could be a variety of things, such as a piece of paper with multiple-choice questions, a scientist in a lab conducting experiments, or a student at a desk studying for an upcoming exam. This image should use vivid colors and clear, detailed imagery to clearly represent the concept of 'test'.", + "url": "https:\/\/oaidalleapiprodscus.blob.core.windows.net\/private\/org-EdXlYn9JmBUAo1tZ1QeRcOvg\/user-8GYNL27bMyzS3WcLtkUwREgd\/img-uPMRNi0R9lxPPJYOf4NZUmMY.png?st=2024-06-23T10%3A01%3A40Z&se=2024-06-23T12%3A01%3A40Z&sp=r&sv=2023-11-03&sr=b&rscd=inline&rsct=image\/png&skoid=6aaadede-4fb3-4698-a8f6-684d7786b067&sktid=a48cca56-e6da-484e-a814-9c849652bcb3&skt=2024-06-23T00%3A13%3A04Z&ske=2024-06-24T00%3A13%3A04Z&sks=b&skv=2023-11-03&sig=bmZqUqQ2QLrmQFSGxTRQoKRVf4C6VyYCZ0aA4Y5%2BGCs%3D" + } + ] +} diff --git a/ai/provider/azureai/tests/fixtures/test.jpg b/ai/provider/azureai/tests/fixtures/test.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b9f96a4f4e0abe1200928fb8cc1a989a07e2de8d GIT binary patch literal 39476 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 literal 0 HcmV?d00001 diff --git a/ai/provider/azureai/tests/fixtures/text_request_success.json b/ai/provider/azureai/tests/fixtures/text_request_success.json new file mode 100644 index 00000000000..d36c4480193 --- /dev/null +++ b/ai/provider/azureai/tests/fixtures/text_request_success.json @@ -0,0 +1,68 @@ +{ + "choices":[ + { + "content_filter_results":{ + "hate":{ + "filtered":false, + "severity":"safe" + }, + "self_harm":{ + "filtered":false, + "severity":"safe" + }, + "sexual":{ + "filtered":false, + "severity":"safe" + }, + "violence":{ + "filtered":false, + "severity":"safe" + } + }, + "finish_reason":"stop", + "index":0, + "logprobs":null, + "message":{ + "content":"Sure, I'm here to help! How can I assist you today?", + "role":"assistant" + } + } + ], + "created":1721897889, + "id":"chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX", + "model":"gpt-4o-2024-05-13", + "object":"chat.completion", + "prompt_filter_results":[ + { + "prompt_index":0, + "content_filter_results":{ + "hate":{ + "filtered":false, + "severity":"safe" + }, + "jailbreak":{ + "filtered":false, + "detected":false + }, + "self_harm":{ + "filtered":false, + "severity":"safe" + }, + "sexual":{ + "filtered":false, + "severity":"safe" + }, + "violence":{ + "filtered":false, + "severity":"safe" + } + } + } + ], + "system_fingerprint":"fp_abc28019ad", + "usage":{ + "completion_tokens":14, + "prompt_tokens":12, + "total_tokens":26 + } +} diff --git a/ai/provider/azureai/tests/process_generate_image_test.php b/ai/provider/azureai/tests/process_generate_image_test.php new file mode 100644 index 00000000000..73457c1ea54 --- /dev/null +++ b/ai/provider/azureai/tests/process_generate_image_test.php @@ -0,0 +1,646 @@ +. + +namespace aiprovider_azureai; + +use core_ai\aiactions\base; +use core_ai\provider; +use GuzzleHttp\Psr7\Response; + +/** + * Test response_base Azure AI provider methods. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_ai\provider\azureai + */ +final class process_generate_image_test extends \advanced_testcase { + /** @var string A successful response in JSON format. */ + protected string $responsebodyjson; + + /** @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(); + // Load a response body from a file. + $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_azureai', 'image_request_success.json')); + $this->create_provider(); + $this->create_action(); + } + + /** + * Create the provider object. + */ + private function create_provider(): void { + $this->provider = new \aiprovider_azureai\provider(); + } + + /** + * 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', + ); + } + + /** + * Test calculate_size. + */ + public function test_calculate_size(): void { + $processor = new process_generate_image($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'calculate_size'); + + $ratio = 'square'; + $size = $method->invoke($processor, $ratio); + $this->assertEquals('1024x1024', $size); + + $ratio = 'portrait'; + $size = $method->invoke($processor, $ratio); + $this->assertEquals('1024x1792', $size); + + $ratio = 'landscape'; + $size = $method->invoke($processor, $ratio); + $this->assertEquals('1792x1024', $size); + } + + /** + * Test create_request_object + */ + public function test_create_request_object(): void { + $processor = new process_generate_image($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'create_request_object'); + $request = $method->invoke($processor, 1); + + $requestdata = (object) json_decode($request->getBody()->getContents()); + + $this->assertEquals('This is a test prompt', $requestdata->prompt); + $this->assertEquals('1', $requestdata->n); + $this->assertEquals('hd', $requestdata->quality); + $this->assertEquals('1024x1024', $requestdata->size); + } + + /** + * Test the API error response handler method. + */ + public function test_handle_api_error(): void { + $responses = [ + 500 => new Response(500, ['Content-Type' => 'application/json']), + 503 => new Response(503, ['Content-Type' => 'application/json']), + 401 => new Response(401, ['Content-Type' => 'application/json'], + '{"error": {"message": "Invalid Authentication"}}'), + 404 => new Response(404, ['Content-Type' => 'application/json'], + '{"error": {"message": "You must be a member of an organization to use the API"}}'), + 429 => new Response(429, ['Content-Type' => 'application/json'], + '{"error": {"message": "Rate limit reached for requests"}}'), + ]; + + $processor = new process_generate_image($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'handle_api_error'); + + foreach ($responses as $status => $response) { + $result = $method->invoke($processor, $response); + $this->assertEquals($status, $result['errorcode']); + if ($status == 500) { + $this->assertEquals('Internal Server Error', $result['errormessage']); + } else if ($status == 503) { + $this->assertEquals('Service Unavailable', $result['errormessage']); + } else { + $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); + } + } + } + + /** + * Test the API success response handler method. + */ + public function test_handle_api_success(): void { + $response = new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson + ); + + // We're testing a private method, so we need to setup reflector magic. + $processor = new process_generate_image($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'handle_api_success'); + + $result = $method->invoke($processor, $response); + + $this->stringContains('An image that represents the concept of a \'test\'.', $result['revisedprompt']); + $this->stringContains('oaidalleapiprodscus.blob.core.windows.net', $result['sourceurl']); + } + + /** + * Test query_ai_api for a successful call. + */ + public function test_query_ai_api_success(): void { + $this->resetAfterTest(); + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen( + self::get_fixture_path('aiprovider_azureai', 'test.jpg'), + 'r', + )), + )); + + $this->setAdminUser(); + + // Create a request object. + $requestobj = new \stdClass(); + $requestobj->prompt = 'generate a test image'; + $requestobj->model = 'awesome-ai-3'; + $requestobj->n = '3'; + $requestobj->quality = 'hd'; + $requestobj->response_format = 'url;'; + $requestobj->size = '1024x1024'; + $requestobj->style = 'vivid'; + $requestobj->user = 't3464h89dftjltestudfaser'; + + $processor = new process_generate_image($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'query_ai_api'); + $result = $method->invoke($processor); + + $this->stringContains('An image that represents the concept of a \'test\'.', $result['revisedprompt']); + $this->stringContains('oaidalleapiprodscus.blob.core.windows.net', $result['sourceurl']); + } + + /** + * 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', + ]; + + $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']); + } + + /** + * 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, + '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 url_to_file. + */ + public function test_url_to_file(): void { + $this->resetAfterTest(); + // Log in user. + $this->setUser($this->getDataGenerator()->create_user()); + + $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, 'url_to_file'); + + $contextid = 1; + $url = $this->getExternalTestFileUrl('/test.jpg', false); + $fileobj = $method->invoke($processor, $contextid, $url); + + $this->assertEquals('user', $fileobj->get_component()); + $this->assertEquals('draft', $fileobj->get_filearea()); + } + + /** + * Test process. + */ + public function test_process(): void { + $this->resetAfterTest(); + // Log in user. + $this->setUser($this->getDataGenerator()->create_user()); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + $url = 'https://example.com/test.jpg'; + + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + + // Create a request object. + $contextid = 1; + $userid = 1; + $prompttext = 'This is a test prompt'; + $aspectratio = 'square'; + $quality = 'hd'; + $numimages = 1; + $style = 'vivid'; + $this->action = new \core_ai\aiactions\generate_image( + contextid: $contextid, + userid: $userid, + prompttext: $prompttext, + quality: $quality, + aspectratio: $aspectratio, + numimages: $numimages, + style: $style, + ); + + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertTrue($result->get_success()); + $this->assertEquals('generate_image', $result->get_actionname()); + $this->assertEquals('An image that represents the concept of a \'test\'.', $result->get_response_data()['revisedprompt']); + $this->assertEquals($url, $result->get_response_data()['sourceurl']); + } + + /** + * Test process method with error. + */ + public function test_process_error(): void { + $this->resetAfterTest(); + // Log in user. + $this->setUser($this->getDataGenerator()->create_user()); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from Azure AI. + $mock->append(new Response( + 401, + ['Content-Type' => 'application/json'], + json_encode(['error' => ['message' => 'Invalid Authentication']]), + )); + + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertFalse($result->get_success()); + $this->assertEquals('generate_image', $result->get_actionname()); + $this->assertEquals(401, $result->get_errorcode()); + $this->assertEquals('Invalid Authentication', $result->get_errormessage()); + } + + /** + * Test process method with user rate limiter. + */ + public function test_process_with_user_rate_limiter(): void { + $this->resetAfterTest(); + // Create users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + // Log in user1. + $this->setUser($user1); + // Mock clock. + $clock = $this->mock_clock_with_frozen(); + + // Set the user rate limiter. + set_config('enableuserratelimit', 1, 'aiprovider_azureai'); + set_config('userratelimit', 1, 'aiprovider_azureai'); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + $url = 'https://example.com/test.jpg'; + + // Case 1: User rate limit has not been reached. + $this->create_provider(); + $this->create_action($user1->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 2: User rate limit has been reached. + $clock->bump(HOURSECS - 10); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertEquals(429, $result->get_errorcode()); + $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); + $this->assertFalse($result->get_success()); + + // Case 3: User rate limit has not been reached for a different user. + // Log in user2. + $this->setUser($user2); + $this->create_provider(); + $this->create_action($user2->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 4: Time window has passed, user rate limit should be reset. + $clock->bump(11); + // Log in user1. + $this->setUser($user1); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + } + + /** + * Test process method with global rate limiter. + */ + public function test_process_with_global_rate_limiter(): void { + $this->resetAfterTest(); + // Create users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + // Log in user1. + $this->setUser($user1); + // Mock clock. + $clock = $this->mock_clock_with_frozen(); + + // Set the global rate limiter. + set_config('enableglobalratelimit', 1, 'aiprovider_azureai'); + set_config('globalratelimit', 1, 'aiprovider_azureai'); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + $url = 'https://example.com/test.jpg'; + + // Case 1: Global rate limit has not been reached. + $this->create_provider(); + $this->create_action($user1->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 2: Global rate limit has been reached. + $clock->bump(HOURSECS - 10); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertEquals(429, $result->get_errorcode()); + $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); + $this->assertFalse($result->get_success()); + + // Case 3: Global rate limit has been reached for a different user too. + // Log in user2. + $this->setUser($user2); + $this->create_provider(); + $this->create_action($user2->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertFalse($result->get_success()); + + // Case 4: Time window has passed, global rate limit should be reset. + $clock->bump(11); + // Log in user1. + $this->setUser($user1); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'created' => 1719140500, + 'data' => [ + (object) [ + 'revised_prompt' => 'An image that represents the concept of a \'test\'.', + 'url' => $url, + ], + ], + ]), + )); + // The image downloaded from the server successfully. + $mock->append(new Response( + 200, + ['Content-Type' => 'image/jpeg'], + \GuzzleHttp\Psr7\Utils::streamFor(fopen(self::get_fixture_path('aiprovider_azureai', 'test.jpg'), 'r')), + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_image($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + } +} diff --git a/ai/provider/azureai/tests/process_generate_text_test.php b/ai/provider/azureai/tests/process_generate_text_test.php new file mode 100644 index 00000000000..0189facbe3e --- /dev/null +++ b/ai/provider/azureai/tests/process_generate_text_test.php @@ -0,0 +1,452 @@ +. + +namespace aiprovider_azureai; + +use aiprovider_azureai\process_generate_text; +use core_ai\aiactions\base; +use core_ai\aiactions\generate_text; +use core_ai\provider; +use GuzzleHttp\Psr7\Response; + +/** + * Test Generate text provider class for azureai provider methods. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \aiprovider_azureai\provider + * @covers \aiprovider_azureai\process_generate_text + * @covers \aiprovider_azureai\abstract_processor + */ +final class process_generate_text_test extends \advanced_testcase { + /** @var string A successful response in JSON format. */ + protected string $responsebodyjson; + + /** @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(); + // Load a response body from a file. + $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_azureai', 'text_request_success.json')); + $this->create_provider(); + $this->create_action(); + } + + /** + * Create the provider object. + */ + private function create_provider(): void { + $this->provider = new \aiprovider_azureai\provider(); + } + + /** + * Create the action object. + * + * @param int $userid The user id to use in the action. + */ + private function create_action(int $userid = 1): void { + $this->action = new \core_ai\aiactions\generate_text( + contextid: 1, + userid: $userid, + prompttext: 'This is a test prompt', + ); + } + + /** + * Test create_request_object + */ + public function test_create_request_object(): void { + $processor = new process_generate_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'create_request_object'); + $request = $method->invoke($processor, 1); + + $body = (object) json_decode($request->getBody()->getContents()); + + $this->assertEquals('This is a test prompt', $body->messages[1]->content); + $this->assertEquals('user', $body->messages[1]->role); + } + + /** + * Test the API error response handler method. + */ + public function test_handle_api_error(): void { + $responses = [ + 500 => new Response(500, ['Content-Type' => 'application/json']), + 503 => new Response(503, ['Content-Type' => 'application/json']), + 401 => new Response( + 401, + ['Content-Type' => 'application/json'], + json_encode(['error' => ['message' => 'Invalid Authentication']]), + ), + 404 => new Response( + 404, + ['Content-Type' => 'application/json'], + json_encode(['error' => ['message' => 'You must be a member of an organization to use the API']]), + ), + 429 => new Response( + 429, + ['Content-Type' => 'application/json'], + json_encode(['error' => ['message' => 'Rate limit reached for requests']]), + ), + ]; + + $processor = new process_generate_text($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'handle_api_error'); + + foreach ($responses as $status => $response) { + $result = $method->invoke($processor, $response); + $this->assertEquals($status, $result['errorcode']); + if ($status == 500) { + $this->assertEquals('Internal Server Error', $result['errormessage']); + } else if ($status == 503) { + $this->assertEquals('Service Unavailable', $result['errormessage']); + } else { + $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); + } + } + } + + /** + * Test the API success response handler method. + */ + public function test_handle_api_success(): void { + $response = new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson + ); + + // We're testing a private method, so we need to setup reflector magic. + $processor = new process_generate_text($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'handle_api_success'); + + $result = $method->invoke($processor, $response); + + $this->assertTrue($result['success']); + $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); + $this->assertEquals('fp_abc28019ad', $result['fingerprint']); + $this->assertStringContainsString('Sure, I\'m here to help', $result['generatedcontent']); + $this->assertEquals('stop', $result['finishreason']); + $this->assertEquals('12', $result['prompttokens']); + $this->assertEquals('14', $result['completiontokens']); + + } + + /** + * Test query_ai_api for a successful call. + */ + public function test_query_ai_api_success(): void { + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + + $processor = new process_generate_text($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'query_ai_api'); + $result = $method->invoke($processor); + + $this->assertTrue($result['success']); + $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); + $this->assertEquals('fp_abc28019ad', $result['fingerprint']); + $this->assertStringContainsString('Sure, I\'m here to help', $result['generatedcontent']); + $this->assertEquals('stop', $result['finishreason']); + $this->assertEquals('12', $result['prompttokens']); + $this->assertEquals('14', $result['completiontokens']); + } + + /** + * Test prepare_response success. + */ + public function test_prepare_response_success(): void { + $processor = new process_generate_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'prepare_response'); + + $response = [ + 'success' => true, + 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', + 'fingerprint' => 'fp_c4e5b6fa31', + 'generatedcontent' => 'Sure, here is some sample text', + 'finishreason' => 'stop', + 'prompttokens' => '11', + 'completiontokens' => '14', + ]; + + $result = $method->invoke($processor, $response); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertTrue($result->get_success()); + $this->assertEquals('generate_text', $result->get_actionname()); + $this->assertEquals($response['success'], $result->get_success()); + $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); + } + + /** + * Test prepare_response error. + */ + public function test_prepare_response_error(): void { + $processor = new process_generate_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'prepare_response'); + + $response = [ + 'success' => false, + 'errorcode' => 500, + 'errormessage' => 'Internal server error.', + ]; + + $result = $method->invoke($processor, $response); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertFalse($result->get_success()); + $this->assertEquals('generate_text', $result->get_actionname()); + $this->assertEquals($response['errorcode'], $result->get_errorcode()); + $this->assertEquals($response['errormessage'], $result->get_errormessage()); + } + + /** + * Test process method. + */ + public function test_process(): void { + $this->resetAfterTest(); + // Log in user. + $this->setUser($this->getDataGenerator()->create_user()); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertTrue($result->get_success()); + $this->assertEquals('generate_text', $result->get_actionname()); + } + + /** + * Test process method with error. + */ + public function test_process_error(): void { + $this->resetAfterTest(); + // Log in user. + $this->setUser($this->getDataGenerator()->create_user()); + + // Mock the http client to return an usuccessful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from Azure AI. + $mock->append(new Response( + 401, + ['Content-Type' => 'application/json'], + json_encode(['error' => ['message' => 'Invalid Authentication']]), + )); + + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertFalse($result->get_success()); + $this->assertEquals('generate_text', $result->get_actionname()); + $this->assertEquals(401, $result->get_errorcode()); + $this->assertEquals('Invalid Authentication', $result->get_errormessage()); + } + + /** + * Test process method with user rate limiter. + */ + public function test_process_with_user_rate_limiter(): void { + $this->resetAfterTest(); + // Create users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + // Log in user1. + $this->setUser($user1); + // Mock clock. + $clock = $this->mock_clock_with_frozen(); + + // Set the user rate limiter. + set_config('enableuserratelimit', 1, 'aiprovider_azureai'); + set_config('userratelimit', 1, 'aiprovider_azureai'); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // Case 1: User rate limit has not been reached. + $this->create_provider(); + $this->create_action($user1->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 2: User rate limit has been reached. + $clock->bump(HOURSECS - 10); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertEquals(429, $result->get_errorcode()); + $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); + $this->assertFalse($result->get_success()); + + // Case 3: User rate limit has not been reached for a different user. + // Log in user2. + $this->setUser($user2); + $this->create_provider(); + $this->create_action($user2->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 4: Time window has passed, user rate limit should be reset. + $clock->bump(11); + // Log in user1. + $this->setUser($user1); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + } + + /** + * Test process method with global rate limiter. + */ + public function test_process_with_global_rate_limiter(): void { + $this->resetAfterTest(); + // Create users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + // Log in user1. + $this->setUser($user1); + // Mock clock. + $clock = $this->mock_clock_with_frozen(); + + // Set the global rate limiter. + set_config('enableglobalratelimit', 1, 'aiprovider_azureai'); + set_config('globalratelimit', 1, 'aiprovider_azureai'); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // Case 1: Global rate limit has not been reached. + $this->create_provider(); + $this->create_action($user1->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 2: Global rate limit has been reached. + $clock->bump(HOURSECS - 10); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertEquals(429, $result->get_errorcode()); + $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); + $this->assertFalse($result->get_success()); + + // Case 3: Global rate limit has been reached for a different user too. + // Log in user2. + $this->setUser($user2); + $this->create_provider(); + $this->create_action($user2->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertFalse($result->get_success()); + + // Case 4: Time window has passed, global rate limit should be reset. + $clock->bump(11); + // Log in user1. + $this->setUser($user1); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_generate_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + } +} diff --git a/ai/provider/azureai/tests/process_summarise_text_test.php b/ai/provider/azureai/tests/process_summarise_text_test.php new file mode 100644 index 00000000000..6cda50fe30f --- /dev/null +++ b/ai/provider/azureai/tests/process_summarise_text_test.php @@ -0,0 +1,444 @@ +. + +namespace aiprovider_azureai; + +use aiprovider_azureai\process_summarise_text; +use core_ai\aiactions\base; +use core_ai\provider; +use GuzzleHttp\Psr7\Response; + +/** + * Test Generate text provider class for Azure AI provider methods. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \aiprovider_azureai\provider + * @covers \aiprovider_azureai\process_summarise_text + * @covers \aiprovider_azureai\abstract_processor + */ +final class process_summarise_text_test extends \advanced_testcase { + /** @var string A successful response in JSON format. */ + protected string $responsebodyjson; + + /** @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(); + // Load a response body from a file. + $this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_azureai', 'text_request_success.json')); + $this->create_provider(); + $this->create_action(); + } + + /** + * Create the provider object. + */ + private function create_provider(): void { + $this->provider = new \aiprovider_azureai\provider(); + } + + /** + * Create the action object. + * + * @param int $userid The user id to use in the action. + */ + private function create_action(int $userid = 1): void { + $this->action = new \core_ai\aiactions\summarise_text( + contextid: 1, + userid: $userid, + prompttext: 'This is a test prompt', + ); + } + + /** + * Test create_request_object + */ + public function test_create_request_object(): void { + $processor = new process_summarise_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'create_request_object'); + $request = $method->invoke($processor, 1); + + $body = (object) json_decode($request->getBody()->getContents()); + + $this->assertEquals('system', $body->messages[0]->role); + $this->assertEquals(get_string('action_summarise_text_instruction', 'core_ai'), $body->messages[0]->content); + $this->assertEquals('This is a test prompt', $body->messages[1]->content); + $this->assertEquals('user', $body->messages[1]->role); + } + + /** + * Test the API error response handler method. + */ + public function test_handle_api_error(): void { + $responses = [ + 500 => new Response(500, ['Content-Type' => 'application/json']), + 503 => new Response(503, ['Content-Type' => 'application/json']), + 401 => new Response(401, ['Content-Type' => 'application/json'], + '{"error": {"message": "Invalid Authentication"}}'), + 404 => new Response(404, ['Content-Type' => 'application/json'], + '{"error": {"message": "You must be a member of an organization to use the API"}}'), + 429 => new Response(429, ['Content-Type' => 'application/json'], + '{"error": {"message": "Rate limit reached for requests"}}'), + ]; + + $processor = new process_summarise_text($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'handle_api_error'); + + foreach ($responses as $status => $response) { + $result = $method->invoke($processor, $response); + $this->assertEquals($status, $result['errorcode']); + if ($status == 500) { + $this->assertEquals('Internal Server Error', $result['errormessage']); + } else if ($status == 503) { + $this->assertEquals('Service Unavailable', $result['errormessage']); + } else { + $this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']); + } + } + } + + /** + * Test the API success response handler method. + */ + public function test_handle_api_success(): void { + $response = new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson + ); + + // We're testing a private method, so we need to set up reflector magic. + $processor = new process_summarise_text($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'handle_api_success'); + + $result = $method->invoke($processor, $response); + + $this->assertTrue($result['success']); + $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); + $this->assertEquals('fp_abc28019ad', $result['fingerprint']); + $this->assertStringContainsString('Sure, I\'m here to help!', $result['generatedcontent']); + $this->assertEquals('stop', $result['finishreason']); + $this->assertEquals('12', $result['prompttokens']); + $this->assertEquals('14', $result['completiontokens']); + + } + + /** + * Test query_ai_api for a successful call. + */ + public function test_query_ai_api_success(): void { + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + + $processor = new process_summarise_text($this->provider, $this->action); + $method = new \ReflectionMethod($processor, 'query_ai_api'); + $result = $method->invoke($processor); + + $this->assertTrue($result['success']); + $this->assertEquals('chatcmpl-9ooaXlMSUIhOkd2pfxKBgpipMynkX', $result['id']); + $this->assertEquals('fp_abc28019ad', $result['fingerprint']); + $this->assertStringContainsString('Sure, I\'m here to help!', $result['generatedcontent']); + $this->assertEquals('stop', $result['finishreason']); + $this->assertEquals('12', $result['prompttokens']); + $this->assertEquals('14', $result['completiontokens']); + } + + /** + * Test prepare_response success. + */ + public function test_prepare_response_success(): void { + $processor = new process_summarise_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'prepare_response'); + + $response = [ + 'success' => true, + 'id' => 'chatcmpl-9lkwPWOIiQEvI3nfcGofJcmS5lPYo', + 'fingerprint' => 'fp_c4e5b6fa31', + 'generatedcontent' => 'Sure, here is some sample text', + 'finishreason' => 'stop', + 'prompttokens' => '11', + 'completiontokens' => '568', + ]; + + $result = $method->invoke($processor, $response); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertTrue($result->get_success()); + $this->assertEquals('summarise_text', $result->get_actionname()); + $this->assertEquals($response['success'], $result->get_success()); + $this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']); + } + + /** + * Test prepare_response error. + */ + public function test_prepare_response_error(): void { + $processor = new process_summarise_text($this->provider, $this->action); + + // We're working with a private method here, so we need to use reflection. + $method = new \ReflectionMethod($processor, 'prepare_response'); + + $response = [ + 'success' => false, + 'errorcode' => 500, + 'errormessage' => 'Internal server error.', + ]; + + $result = $method->invoke($processor, $response); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertFalse($result->get_success()); + $this->assertEquals('summarise_text', $result->get_actionname()); + $this->assertEquals($response['errorcode'], $result->get_errorcode()); + $this->assertEquals($response['errormessage'], $result->get_errormessage()); + } + + /** + * Test process method. + */ + public function test_process(): void { + $this->resetAfterTest(); + // Log in user. + $this->setUser($this->getDataGenerator()->create_user()); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertTrue($result->get_success()); + $this->assertEquals('summarise_text', $result->get_actionname()); + } + + /** + * Test process method with error. + */ + public function test_process_error(): void { + $this->resetAfterTest(); + // Log in user. + $this->setUser($this->getDataGenerator()->create_user()); + + // Mock the http client to return an unsuccessful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // The response from AzureAI. + $mock->append(new Response( + 401, + ['Content-Type' => 'application/json'], + json_encode(['error' => ['message' => 'Invalid Authentication']]), + )); + + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + + $this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result); + $this->assertFalse($result->get_success()); + $this->assertEquals('summarise_text', $result->get_actionname()); + $this->assertEquals(401, $result->get_errorcode()); + $this->assertEquals('Invalid Authentication', $result->get_errormessage()); + } + + /** + * Test process method with user rate limiter. + */ + public function test_process_with_user_rate_limiter(): void { + $this->resetAfterTest(); + // Create users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + // Log in user1. + $this->setUser($user1); + // Mock clock. + $clock = $this->mock_clock_with_frozen(); + + // Set the user rate limiter. + set_config('enableuserratelimit', 1, 'aiprovider_azureai'); + set_config('userratelimit', 1, 'aiprovider_azureai'); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // Case 1: User rate limit has not been reached. + $this->create_provider(); + $this->create_action($user1->id); + // The response from Azure I. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 2: User rate limit has been reached. + $clock->bump(HOURSECS - 10); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertEquals(429, $result->get_errorcode()); + $this->assertEquals('User rate limit exceeded', $result->get_errormessage()); + $this->assertFalse($result->get_success()); + + // Case 3: User rate limit has not been reached for a different user. + // Log in user2. + $this->setUser($user2); + $this->create_provider(); + $this->create_action($user2->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 4: Time window has passed, user rate limit should be reset. + $clock->bump(11); + // Log in user1. + $this->setUser($user1); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + } + + /** + * Test process method with global rate limiter. + */ + public function test_process_with_global_rate_limiter(): void { + $this->resetAfterTest(); + // Create users. + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + // Log in user1. + $this->setUser($user1); + // Mock clock. + $clock = $this->mock_clock_with_frozen(); + + // Set the global rate limiter. + set_config('enableglobalratelimit', 1, 'aiprovider_azureai'); + set_config('globalratelimit', 1, 'aiprovider_azureai'); + + // Mock the http client to return a successful response. + ['mock' => $mock] = $this->get_mocked_http_client(); + + // Case 1: Global rate limit has not been reached. + $this->create_provider(); + $this->create_action($user1->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + + // Case 2: Global rate limit has been reached. + $clock->bump(HOURSECS - 10); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertEquals(429, $result->get_errorcode()); + $this->assertEquals('Global rate limit exceeded', $result->get_errormessage()); + $this->assertFalse($result->get_success()); + + // Case 3: Global rate limit has been reached for a different user too. + // Log in user2. + $this->setUser($user2); + $this->create_provider(); + $this->create_action($user2->id); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertFalse($result->get_success()); + + // Case 4: Time window has passed, global rate limit should be reset. + $clock->bump(11); + // Log in user1. + $this->setUser($user1); + // The response from Azure AI. + $mock->append(new Response( + 200, + ['Content-Type' => 'application/json'], + $this->responsebodyjson, + )); + $this->create_provider(); + $this->create_action($user1->id); + $processor = new process_summarise_text($this->provider, $this->action); + $result = $processor->process(); + $this->assertTrue($result->get_success()); + } +} diff --git a/ai/provider/azureai/tests/provider_test.php b/ai/provider/azureai/tests/provider_test.php new file mode 100644 index 00000000000..d6140da2c10 --- /dev/null +++ b/ai/provider/azureai/tests/provider_test.php @@ -0,0 +1,113 @@ +. + +namespace aiprovider_azureai; + +/** + * Test Azure AI provider methods. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_ai\provider\azureai + */ +final class provider_test extends \advanced_testcase { + + /** + * Test get_action_list + */ + public function test_get_action_list(): void { + $provider = new \aiprovider_azureai\provider(); + $actionlist = $provider->get_action_list(); + $this->assertIsArray($actionlist); + $this->assertEquals(3, count($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); + } + + /** + * Test generate_userid. + */ + public function test_generate_userid(): void { + $provider = new \aiprovider_azureai\provider(); + $userid = $provider->generate_userid(1); + + // Assert that the generated userid is a string of proper length. + $this->assertIsString($userid); + $this->assertEquals(64, strlen($userid)); + } + + /** + * Test is_request_allowed. + */ + public function test_is_request_allowed(): void { + $this->resetAfterTest(true); + + // Set plugin config rate limiter settings. + set_config('enableglobalratelimit', 1, 'aiprovider_azureai'); + set_config('globalratelimit', 5, 'aiprovider_azureai'); + set_config('enableuserratelimit', 1, 'aiprovider_azureai'); + set_config('userratelimit', 3, 'aiprovider_azureai'); + + $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 + ); + $provider = new provider(); + + // Make 3 requests, all should be allowed. + for ($i = 0; $i < 3; $i++) { + $this->assertTrue($provider->is_request_allowed($action)); + } + + // The 4th request for the same user should be denied. + $result = $provider->is_request_allowed($action); + $this->assertFalse($result['success']); + $this->assertEquals('User rate limit exceeded', $result['errormessage']); + + // Change user id to make a request for a different user, should pass (4 requests for global rate). + $action = new \core_ai\aiactions\generate_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('Global rate limit exceeded', $result['errormessage']); + } +} diff --git a/ai/provider/azureai/version.php b/ai/provider/azureai/version.php new file mode 100644 index 00000000000..b04dc46f4d2 --- /dev/null +++ b/ai/provider/azureai/version.php @@ -0,0 +1,30 @@ +. + +/** + * Version information for aiprovider_azureai. + * + * @package aiprovider_azureai + * @copyright 2024 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'aiprovider_azureai'; +$plugin->version = 2024061400; +$plugin->requires = 2024041600; +$plugin->maturity = MATURITY_STABLE; diff --git a/ai/tests/manager_test.php b/ai/tests/manager_test.php index 94720778093..b3519aefee5 100644 --- a/ai/tests/manager_test.php +++ b/ai/tests/manager_test.php @@ -88,17 +88,16 @@ final class manager_test extends \advanced_testcase { $this->assertEquals($actions, array_keys($providers)); // Assert that there is only one provider for each action. - $this->assertCount(1, $providers[generate_text::class]); - $this->assertCount(1, $providers[summarise_text::class]); + $this->assertCount(2, $providers[generate_text::class]); + $this->assertCount(2, $providers[summarise_text::class]); - // Disable the generate text action for the open ai provider. + // Disable the generate text action for the Open AI provider. set_config(generate_text::class, 0, 'aiprovider_openai'); $providers = $manager->get_providers_for_actions($actions, true); // Assert that there is no provider for the generate text action. $this->assertCount(0, $providers[generate_text::class]); $this->assertCount(1, $providers[summarise_text::class]); - } /** diff --git a/lib/plugins.json b/lib/plugins.json index 3d804113eac..528285ce65f 100644 --- a/lib/plugins.json +++ b/lib/plugins.json @@ -4,7 +4,8 @@ "editor" ], "aiprovider": [ - "openai" + "openai", + "azureai" ], "antivirus": [ "clamav"