diff --git a/classes/local/aitool_option_vertexai.php b/classes/local/aitool_option_vertexai.php new file mode 100644 index 0000000..4c810d4 --- /dev/null +++ b/classes/local/aitool_option_vertexai.php @@ -0,0 +1,96 @@ +. + +namespace local_ai_manager\local; + +use stdClass; + +/** + * Helper class for providing the necessary extension functions to implement the Google OAuth authentication for access to + * Google's Vertex AI. + * + * @package local_ai_manager + * @copyright 2024 ISB Bayern + * @author Philipp Memmel + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class aitool_option_vertexai { + + /** + * Extends the form definition of the edit instance form by adding the Vertex AI options. + * + * @param \MoodleQuickForm $mform the mform object + */ + public static function extend_form_definition(\MoodleQuickForm $mform): void { + $mform->freeze('apikey'); + $mform->freeze('endpoint'); + $mform->addElement('textarea', 'serviceaccountjson', + get_string('serviceaccountjson', 'local_ai_manager'), ['rows' => '20']); + } + + /** + * Adds the Vertex AI data to the form data to be passed to the form when loading. + * + * @param string $serviceaccountjson the service account JSON string + * @return stdClass the object to pass to the form when loading + */ + public static function add_vertexai_to_form_data(string $serviceaccountjson): stdClass { + $data = new stdClass(); + $data->serviceaccountjson = $serviceaccountjson; + return $data; + } + + /** + * Extract the service account JSON and calculate the new endpoint from the form data submitted by the form. + * + * @param stdClass $data the form data after submission + * @return array array of the service account JSON and the calculated endpoint + */ + public static function extract_vertexai_to_store(stdClass $data): array { + $serviceaccountjson = trim($data->serviceaccountjson); + $serviceaccountinfo = json_decode($serviceaccountjson); + $projectid = $serviceaccountinfo->project_id; + + $endpoint = 'https://europe-west3-aiplatform.googleapis.com/v1/projects/' . $projectid + . '/locations/europe-west3/publishers/google/models/' + . $data->model . ':predict'; + return [$serviceaccountjson, $endpoint]; + } + + /** + * Validation function for the Vertex AI option when form is being submitted. + * + * @param array $data the data being submitted by the form + * @return array associative array ['mformelementname' => 'error string'] if there are validation errors, otherwise empty array + */ + public static function validate_vertexai(array $data): array { + $errors = []; + if (empty($data['serviceaccountjson'])) { + $errors['serviceaccountjson'] = get_string('error_vertexai_serviceaccountjsonempty', 'local_ai_manager'); + return $errors; + } + + $serviceaccountinfo = json_decode(trim($data['serviceaccountjson'])); + foreach (['private_key_id', 'private_key', 'client_email'] as $field) { + if (!property_exists($serviceaccountinfo, $field)) { + $errors['serviceaccountjson'] = get_string('error_vertexai_serviceaccountjsoninvalid', 'local_ai_manager', $field); + break; + } + } + + return $errors; + } +} diff --git a/classes/local/aitool_option_vertexai_authhandler.php b/classes/local/aitool_option_vertexai_authhandler.php new file mode 100644 index 0000000..8abfc1d --- /dev/null +++ b/classes/local/aitool_option_vertexai_authhandler.php @@ -0,0 +1,143 @@ +. + +namespace local_ai_manager\local; + +use core\http_client; +use Firebase\JWT\JWT; +use Psr\Http\Client\ClientExceptionInterface; + +/** + * Class responsible for handling authentication with Vertex AI using Google's OAuth mechanism. + */ +class aitool_option_vertexai_authhandler { + + /** + * Constructor for the auth handler. + */ + public function __construct( + /** @var int The ID of the instance being used. Will be used as key for the cache handling. */ + private readonly int $instanceid, + /** @var string The serviceaccountinfo stringified JSON */ + private readonly string $serviceaccountinfo + ) { + } + + /** + * Retrieves a fresh access token from the Google oauth endpoint. + * + * @return array of the form ['access_token' => 'xxx', 'expires' => 1730805678] containing the access token and the time at + * which the token expires. If there has been an error, the array is of the form + * ['error' => 'more detailed info about the error'] + */ + public function retrieve_access_token(): array { + $clock = \core\di::get(\core\clock::class); + $serviceaccountinfo = json_decode($this->serviceaccountinfo); + $kid = $serviceaccountinfo->private_key_id; + $privatekey = $serviceaccountinfo->private_key; + $clientemail = $serviceaccountinfo->client_email; + $jwtpayload = [ + 'iss' => $clientemail, + 'sub' => $clientemail, + 'scope' => 'https://www.googleapis.com/auth/cloud-platform', + 'aud' => 'https://oauth2.googleapis.com/token', + 'iat' => $clock->time(), + 'exp' => $clock->time() + HOURSECS, + ]; + $jwt = JWT::encode($jwtpayload, $privatekey, 'RS256', null, ['kid' => $kid]); + + $client = new http_client([ + 'timeout' => get_config('local_ai_manager', 'requesttimeout'), + ]); + $options['query'] = [ + 'assertion' => $jwt, + 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', + ]; + + try { + $response = $client->post('https://oauth2.googleapis.com/token', $options); + } catch (ClientExceptionInterface $exception) { + return ['error' => $exception->getMessage()]; + } + if ($response->getStatusCode() === 200) { + $content = $response->getBody()->getContents(); + if (empty($content)) { + return ['error' => 'Empty response']; + } + $content = json_decode($content, true); + if (empty($content['access_token'])) { + return ['error' => 'Response does not contain "access_token" key']; + } + return [ + 'access_token' => $content['access_token'], + // We set the expiry time of the access token and reduce it by 10 seconds to avoid some errors caused + // by different clocks on different servers, latency etc. + 'expires' => $clock->time() + intval($content['expires_in']) - 10, + ]; + } else { + return ['error' => 'Response status code is not OK 200, but ' . $response->getStatusCode() . ': ' . + $response->getBody()->getContents()]; + } + } + + /** + * Gets an access token for accessing Vertex AI endpoints. + * + * This will check if the cached access token still has not expired. If cache is empty or the token has expired + * a new access token will be fetched by calling {@see self::retrieve_access_token} and the new token will be stored + * in the cache. + * + * @return string the access token as string, empty if no + */ + public function get_access_token(): string { + $clock = \core\di::get(\core\clock::class); + $authcache = \cache::make('local_ai_manager', 'googleauth'); + $cachedauthinfo = $authcache->get($this->instanceid); + if (empty($cachedauthinfo) || json_decode($cachedauthinfo)->expires < $clock->time()) { + $authinfo = $this->retrieve_access_token(); + if (!empty($authinfo['error'])) { + throw new \moodle_exception('Error retrieving access token', '', '', '', $authinfo['error']); + } + $cachedauthinfo = json_encode($authinfo); + $authcache->set($this->instanceid, $cachedauthinfo); + $accesstoken = $authinfo['access_token']; + } else { + $accesstoken = json_decode($cachedauthinfo, true)['access_token']; + } + return $accesstoken; + } + + /** + * Refreshes the current access token. + * + * Clears the existing access token and retrieves a new one by invoking {@see self::get_access_token}. + * + * @return string the newly fetched access token as a string + */ + public function refresh_access_token(): string { + $this->clear_access_token(); + return $this->get_access_token(); + } + + /** + * Clears the access token from the cache for the current instance. + */ + public function clear_access_token(): void { + $authcache = \cache::make('local_ai_manager', 'googleauth'); + $authcache->delete($this->instanceid); + } + +} diff --git a/tools/imagen/db/caches.php b/db/caches.php similarity index 95% rename from tools/imagen/db/caches.php rename to db/caches.php index cd8cc02..d4349f6 100644 --- a/tools/imagen/db/caches.php +++ b/db/caches.php @@ -17,7 +17,7 @@ /** * Cache definitions. * - * @package aitool_imagen + * @package local_ai_manager * @copyright 2024 ISB Bayern * @author Philipp Memmel * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -26,7 +26,7 @@ defined('MOODLE_INTERNAL') || die(); $definitions = [ - 'auth' => [ + 'googleauth' => [ 'mode' => cache_store::MODE_APPLICATION, 'simplekeys' => true, 'simpledata' => true, diff --git a/lang/de/local_ai_manager.php b/lang/de/local_ai_manager.php index a36893e..3b080cf 100644 --- a/lang/de/local_ai_manager.php +++ b/lang/de/local_ai_manager.php @@ -52,6 +52,7 @@ $string['assignrole'] = 'Rolle zuweisen'; $string['basicsettings'] = 'Grundeinstellungen'; $string['basicsettingsdesc'] = 'Grundeinstellungen des AI-Managers konfigurieren'; +$string['cachedef_googleauth'] = 'Cache für Google-OAuth2-Access-Token'; $string['configure_instance'] = 'KI-Tool-Instanzen konfigurieren'; $string['configureaitool'] = 'KI-Tool konfigurieren'; $string['configurepurposes'] = 'Einsatzzwecke konfigurieren'; @@ -81,6 +82,8 @@ $string['error_unavailable_selection'] = 'Dieses Tool ist nur verfügbar, wenn Text markiert wurde.'; $string['error_userlocked'] = 'Ihr Nutzer wurde von Ihrem Tenant-Manager gesperrt.'; $string['error_usernotconfirmed'] = 'Für die Nutzung müssen die Nutzungsbedingungen akzeptiert werden.'; +$string['error_vertexai_serviceaccountjsonempty'] = 'Sie müssen den Inhalt der JSON-Datei, die Sie beim Anlegen des Service-Accounts in Ihrer Google-Cloud-Console heruntergeladen haben, einfügen.'; +$string['error_vertexai_serviceaccountjsoninvalid'] = 'Ungültiges Format. Es fehlt der Eintrag "{$a}".'; $string['exception_curl'] = 'Es ist ein Fehler bei der Verbindung zum externen KI-Tool aufgetreten.'; $string['exception_curl28'] = 'Die API hat zu lange gebraucht, um Ihre Anfrage zu verarbeiten, oder konnte nicht in angemessener Zeit erreicht werden.'; $string['exception_default'] = 'Ein allgemeiner Fehler ist aufgetreten, während versucht wurde, die Anfrage an das KI-Tool zu senden.'; @@ -152,6 +155,7 @@ $string['role_unlimited'] = 'Unbeschränkte Rolle'; $string['select_tool_for_purpose'] = 'Einsatzzweck "{$a}"'; $string['selecteduserscount'] = '{$a} ausgewählt'; +$string['serviceaccountjson'] = 'Inhalt der JSON-Datei des Google-Serviceaccounts'; $string['settingsgeneral'] = 'Allgemein'; $string['small'] = 'klein'; $string['squared'] = 'quadratisch'; diff --git a/lang/en/local_ai_manager.php b/lang/en/local_ai_manager.php index dcd738f..8f74018 100644 --- a/lang/en/local_ai_manager.php +++ b/lang/en/local_ai_manager.php @@ -52,6 +52,7 @@ $string['assignrole'] = 'Assign role'; $string['basicsettings'] = 'Basic settings'; $string['basicsettingsdesc'] = 'Configure basic settings for the AI manager plugin'; +$string['cachedef_googleauth'] = 'Cache for Google OAuth2 access token'; $string['configure_instance'] = 'Configure AI Tool Instances'; $string['configureaitool'] = 'Configure AI tool'; $string['configurepurposes'] = 'Configure the purposes'; @@ -81,6 +82,8 @@ $string['error_unavailable_selection'] = 'This tool is only available if no text has been selected.'; $string['error_userlocked'] = 'Your user has been locked by your tenant manager.'; $string['error_usernotconfirmed'] = 'You have not accepted the terms of use yet.'; +$string['error_vertexai_serviceaccountjsonempty'] = 'You need to paste the content of the JSON file that you downloaded when creating the service account in your Google Cloud Console.'; +$string['error_vertexai_serviceaccountjsoninvalid'] = 'Invalid format. The entry "{$a}" is missing.'; $string['exception_curl'] = 'A connection error to the external API endpoint has occurred'; $string['exception_curl28'] = 'The API took too long to process your request or could not be reached in a reasonable time'; $string['exception_default'] = 'A general error occurred while trying to send the request to the AI tool'; @@ -152,6 +155,7 @@ $string['role_unlimited'] = 'unlimited role'; $string['select_tool_for_purpose'] = 'Purpose {$a}'; $string['selecteduserscount'] = '{$a} selected'; +$string['serviceaccountjson'] = 'Content of the JSON file of the Google service account'; $string['small'] = 'small'; $string['squared'] = 'squared'; $string['statistics_for'] = 'Statistic for {$a}'; diff --git a/tools/imagen/classes/connector.php b/tools/imagen/classes/connector.php index 338c321..ba356d9 100644 --- a/tools/imagen/classes/connector.php +++ b/tools/imagen/classes/connector.php @@ -19,6 +19,8 @@ use core\http_client; use Firebase\JWT\JWT; use local_ai_manager\base_connector; +use local_ai_manager\base_instance; +use local_ai_manager\local\aitool_option_vertexai_authhandler; use local_ai_manager\local\prompt_response; use local_ai_manager\local\request_response; use local_ai_manager\local\unit; @@ -37,9 +39,19 @@ */ class connector extends base_connector { + /** @var aitool_option_vertexai_authhandler Auth handler for Vertex AI. */ + private aitool_option_vertexai_authhandler $vertexaiauthhandler; + /** @var string The access token to use for authentication against the Google imagen API endpoint. */ private string $accesstoken = ''; + public function __construct(base_instance $instance) { + parent::__construct($instance); + $serviceaccountinfo = empty($this->instance->get_customfield1()) ? '' : $this->instance->get_customfield1(); + $this->vertexaiauthhandler = + new aitool_option_vertexai_authhandler($this->instance->get_id(), $serviceaccountinfo); + } + #[\Override] public function get_models_by_purpose(): array { return [ @@ -96,10 +108,11 @@ public function make_request(array $data): request_response { $data['instances'][0]['prompt'] = $translatedprompt; try { + // Composing the "Authorization" header is not that easy as just looking up a Bearer token in the database. // So we here explicitly retrieve the access token from cache or the Google OAuth API and do some proper error handling. // After we stored it in $this->accesstoken it can be properly set into the header by the self::get_headers method. - $this->accesstoken = $this->get_access_token(); + $this->accesstoken = $this->vertexaiauthhandler->get_access_token(); } catch (\moodle_exception $exception) { return request_response::create_from_error(0, $exception->getMessage(), $exception->getTraceAsString()); } @@ -114,8 +127,12 @@ public function make_request(array $data): request_response { $requestresponse->get_response()->rewind(); $content = json_decode($requestresponse->get_response()->getContents(), true); if (!empty(array_filter($content['error']['details'], fn($details) => $details['reason'] === 'ACCESS_TOKEN_EXPIRED'))) { - $authcache = \cache::make('aitool_imagen', 'auth'); - $authcache->delete($this->instance->get_id()); + // We refresh the outdated access token and send the request again. + try { + $this->accesstoken = $this->vertexaiauthhandler->refresh_access_token(); + } catch (\moodle_exception $exception) { + return request_response::create_from_error(0, $exception->getMessage(), $exception->getTraceAsString()); + } $requestresponse = parent::make_request($data); } } @@ -162,91 +179,6 @@ public function get_available_options(): array { return $options; } - /** - * Retrieves a fresh access token from the Google oauth endpoint. - * - * @return array of the form ['access_token' => 'xxx', 'expires' => 1730805678] containing the access token and the time at - * which the token expires. If there has been an error, the array is of the form - * ['error' => 'more detailed info about the error'] - * @throws \dml_exception - */ - public function retrieve_access_token(): array { - $clock = \core\di::get(\core\clock::class); - $serviceaccountinfo = json_decode($this->instance->get_customfield1()); - $kid = $serviceaccountinfo->private_key_id; - $privatekey = $serviceaccountinfo->private_key; - $clientemail = $serviceaccountinfo->client_email; - $jwtpayload = [ - 'iss' => $clientemail, - 'sub' => $clientemail, - 'scope' => 'https://www.googleapis.com/auth/cloud-platform', - 'aud' => 'https://oauth2.googleapis.com/token', - 'iat' => $clock->time(), - 'exp' => $clock->time() + HOURSECS, - ]; - $jwt = JWT::encode($jwtpayload, $privatekey, 'RS256', null, ['kid' => $kid]); - - $client = new http_client([ - 'timeout' => get_config('local_ai_manager', 'requesttimeout'), - ]); - $options['query'] = [ - 'assertion' => $jwt, - 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', - ]; - - try { - $response = $client->post('https://oauth2.googleapis.com/token', $options); - } catch (ClientExceptionInterface $exception) { - return ['error' => $exception->getMessage()]; - } - if ($response->getStatusCode() === 200) { - $content = $response->getBody()->getContents(); - if (empty($content)) { - return ['error' => 'Empty response']; - } - $content = json_decode($content, true); - if (empty($content['access_token'])) { - return ['error' => 'Response does not contain "access_token" key']; - } - return [ - 'access_token' => $content['access_token'], - // We set the expiry time of the access token and reduce it by 10 seconds to avoid some errors caused - // by different clocks on different servers, latency etc. - 'expires' => $clock->time() + intval($content['expires_in']) - 10, - ]; - } else { - return ['error' => 'Response status code is not OK 200, but ' . $response->getStatusCode() . ': ' . - $response->getBody()->getContents()]; - } - } - - /** - * Gets an access token for accessing the imagen API. - * - * This will check if the cached access token still has not expired. If cache is empty or the token has expired - * a new access token will be fetched by calling {@see self::retrieve_access_token} and the new token will be stored - * in the cache. - * - * @return string the access token as string, empty if no - */ - public function get_access_token(): string { - $clock = \core\di::get(\core\clock::class); - $authcache = \cache::make('aitool_imagen', 'auth'); - $cachedauthinfo = $authcache->get($this->instance->get_id()); - if (empty($cachedauthinfo) || json_decode($cachedauthinfo)->expires < $clock->time()) { - $authinfo = $this->retrieve_access_token(); - if (!empty($authinfo['error'])) { - throw new \moodle_exception('Error retrieving access token', '', '', '', $authinfo['error']); - } - $cachedauthinfo = json_encode($authinfo); - $authcache->set($this->instance->get_id(), $cachedauthinfo); - $accesstoken = $authinfo['access_token']; - } else { - $accesstoken = json_decode($cachedauthinfo, true)['access_token']; - } - return $accesstoken; - } - #[\Override] protected function get_custom_error_message(int $code, ?ClientExceptionInterface $exception = null): string { $message = ''; diff --git a/tools/imagen/classes/instance.php b/tools/imagen/classes/instance.php index 269840d..337739b 100644 --- a/tools/imagen/classes/instance.php +++ b/tools/imagen/classes/instance.php @@ -17,6 +17,7 @@ namespace aitool_imagen; use local_ai_manager\base_instance; +use local_ai_manager\local\aitool_option_vertexai; use stdClass; /** @@ -31,48 +32,25 @@ class instance extends base_instance { #[\Override] protected function extend_form_definition(\MoodleQuickForm $mform): void { - $mform->freeze('apikey'); - $mform->freeze('endpoint'); - $mform->addElement('textarea', 'serviceaccountjson', - get_string('serviceaccountjson', 'aitool_imagen'), ['rows' => '20']); + aitool_option_vertexai::extend_form_definition($mform); } #[\Override] protected function get_extended_formdata(): stdClass { - $data = new stdClass(); - $data->serviceaccountjson = $this->get_customfield1(); - return $data; + return aitool_option_vertexai::add_vertexai_to_form_data($this->get_customfield1()); } #[\Override] protected function extend_store_formdata(stdClass $data): void { - $serviceaccountjson = trim($data->serviceaccountjson); - $this->set_customfield1($serviceaccountjson); - $serviceaccountinfo = json_decode($serviceaccountjson); - $projectid = $serviceaccountinfo->project_id; - $endpoint = 'https://europe-west3-aiplatform.googleapis.com/v1/projects/' . $projectid - . '/locations/europe-west3/publishers/google/models/' - . $this->get_model() . ':predict'; + [$serviceaccountjson, $endpoint] = aitool_option_vertexai::extract_vertexai_to_store($data); + + $this->set_customfield1($serviceaccountjson); $this->set_endpoint($endpoint); } #[\Override] protected function extend_validation(array $data, array $files): array { - $errors = []; - if (empty($data['serviceaccountjson'])) { - $errors['serviceaccountjson'] = get_string('err_serviceaccountjsonempty', 'aitool_imagen'); - return $errors; - } - - $serviceaccountinfo = json_decode(trim($data['serviceaccountjson'])); - foreach (['private_key_id', 'private_key', 'client_email'] as $field) { - if (!property_exists($serviceaccountinfo, $field)) { - $errors['serviceaccountjson'] = get_string('err_serviceaccountjsoninvalid', 'aitool_imagen', $field); - break; - } - } - - return $errors; + return aitool_option_vertexai::validate_vertexai($data); } } diff --git a/tools/imagen/lang/de/aitool_imagen.php b/tools/imagen/lang/de/aitool_imagen.php index 8950388..4dc3c18 100644 --- a/tools/imagen/lang/de/aitool_imagen.php +++ b/tools/imagen/lang/de/aitool_imagen.php @@ -24,11 +24,7 @@ */ $string['adddescription'] = 'Imagen ist ein KI-Modell von Google, das darauf spezialisiert ist, aus Textbeschreibungen Bilder zu generieren.'; -$string['cachedef_imagenauth'] = 'Cache zum Speichern von Authentifizierungstokens'; $string['err_contentpolicyviolation'] = 'Ihre Anfrage wurde vom Sicherheitssystem des KI-Tools zurückgewiesen. Ihr Prompt enthält vermutlich eine Anweisung, die nicht erlaubt ist.'; $string['err_predictionmissing'] = 'Auf Ihre Anfrage hin konnte kein Bild generiert werden. Ihr Prompt enthält vermutlich eine Anweisung, die nicht erlaubt ist.'; -$string['err_serviceaccountjsonempty'] = 'Sie müssen den Inhalt der JSON-Datei, die Sie beim Anlegen des Service-Accounts in Ihrer Google-Cloud-Console heruntergeladen haben, einfügen.'; -$string['err_serviceaccountjsoninvalid'] = 'Ungültiges Format. Es fehlt der Eintrag "{$a}".'; $string['pluginname'] = 'Imagen'; $string['privacy:metadata'] = 'Das Subplugin "Imagen" des AI-Manager-Plugins speichert keine personenbezogenen Daten.'; -$string['serviceaccountjson'] = 'Inhalt der JSON-Datei des Google-Serviceaccounts'; diff --git a/tools/imagen/lang/en/aitool_imagen.php b/tools/imagen/lang/en/aitool_imagen.php index c31fd42..396f944 100644 --- a/tools/imagen/lang/en/aitool_imagen.php +++ b/tools/imagen/lang/en/aitool_imagen.php @@ -24,11 +24,7 @@ */ $string['adddescription'] = 'Imagen is an AI model from Google that specializes in generating images from text descriptions.'; -$string['cachedef_imagenauth'] = 'Cache for storing authentication tokens'; $string['err_contentpolicyviolation'] = 'Your request was rejected as a result of our safety system. Your prompt probably requests something that is not allowed.'; $string['err_predictionmissing'] = 'No image could be generated based on your prompt. Your prompt probably requests something that is not allowed.'; -$string['err_serviceaccountjsonempty'] = 'You need to paste the content of the JSON file that you downloaded when creating the service account in your Google Cloud Console.'; -$string['err_serviceaccountjsoninvalid'] = 'Invalid format. The entry "{$a}" is missing.'; $string['pluginname'] = 'Imagen'; $string['privacy:metadata'] = 'The local ai_manager tool subplugin "Imagen" does not store any personal data.'; -$string['serviceaccountjson'] = 'Content of the JSON file of the Google service account'; diff --git a/tools/imagen/version.php b/tools/imagen/version.php index 1745d66..fa41bbc 100644 --- a/tools/imagen/version.php +++ b/tools/imagen/version.php @@ -24,7 +24,7 @@ */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024110503; +$plugin->version = 2024120200; $plugin->requires = 2023042403; $plugin->release = '0.0.1'; $plugin->component = 'aitool_imagen'; diff --git a/version.php b/version.php index c8c9bf0..00b7534 100644 --- a/version.php +++ b/version.php @@ -24,7 +24,7 @@ */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2024110501; +$plugin->version = 2024120200; $plugin->requires = 2024042200; $plugin->release = '0.0.3'; $plugin->component = 'local_ai_manager';