MDL-84345 AI: Provider Plugin - Deepseek
Add a AI provider that supports the DeepSeek API. Allows AI Subsystem to use DeepSeek models via their API. Does not support DeepSeek models running locally. Co-authored by: Yusuf Wibisono <[email protected]>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
define("aiprovider_deepseek/modelchooser",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0;
|
||||
/**
|
||||
* AI provider model selection handler.
|
||||
*
|
||||
* @module aiprovider_deepseek/modelchooser
|
||||
* @copyright 2025 Matt Porritt <matt.porritt@moodle.com>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
const Selectors_fields={selector:'[data-modelchooser-field="selector"]',updateButton:'[data-modelchooser-field="updateButton"]'};_exports.init=()=>{const modelSelector=document.querySelector(Selectors_fields.selector);modelSelector&&modelSelector.addEventListener("change",(e=>{modelSelector.options[e.target.selectedIndex].selected=!0;e.target.closest("form").querySelector(Selectors_fields.updateButton).click()}))}}));
|
||||
|
||||
//# sourceMappingURL=modelchooser.min.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"modelchooser.min.js","sources":["../src/modelchooser.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/ //\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see <http://www.gnu.org/licenses/>.\n\n/**\n * AI provider model selection handler.\n *\n * @module aiprovider_deepseek/modelchooser\n * @copyright 2025 Matt Porritt <[email protected]>\n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst Selectors = {\n fields: {\n selector: '[data-modelchooser-field=\"selector\"]',\n updateButton: '[data-modelchooser-field=\"updateButton\"]',\n },\n};\n\n/**\n * Initialise the AI provider chooser.\n */\nexport const init = () => {\n const modelSelector = document.querySelector(Selectors.fields.selector);\n if (modelSelector) {\n modelSelector.addEventListener('change', e => {\n modelSelector.options[e.target.selectedIndex].selected = true;\n const form = e.target.closest('form');\n const updateButton = form.querySelector(Selectors.fields.updateButton);\n updateButton.click();\n });\n }\n};\n"],"names":["Selectors","selector","updateButton","modelSelector","document","querySelector","addEventListener","e","options","target","selectedIndex","selected","closest","click"],"mappings":";;;;;;;;MAsBMA,iBACM,CACJC,SAAU,uCACVC,aAAc,0DAOF,WACVC,cAAgBC,SAASC,cAAcL,iBAAiBC,UAC1DE,eACAA,cAAcG,iBAAiB,UAAUC,IACrCJ,cAAcK,QAAQD,EAAEE,OAAOC,eAAeC,UAAW,EAC5CJ,EAAEE,OAAOG,QAAQ,QACJP,cAAcL,iBAAiBE,cAC5CW"}
|
||||
@@ -0,0 +1,43 @@
|
||||
// This file is part of Moodle - http://moodle.org/ //
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* AI provider model selection handler.
|
||||
*
|
||||
* @module aiprovider_deepseek/modelchooser
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
const Selectors = {
|
||||
fields: {
|
||||
selector: '[data-modelchooser-field="selector"]',
|
||||
updateButton: '[data-modelchooser-field="updateButton"]',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialise the AI provider chooser.
|
||||
*/
|
||||
export const init = () => {
|
||||
const modelSelector = document.querySelector(Selectors.fields.selector);
|
||||
if (modelSelector) {
|
||||
modelSelector.addEventListener('change', e => {
|
||||
modelSelector.options[e.target.selectedIndex].selected = true;
|
||||
const form = e.target.closest('form');
|
||||
const updateButton = form.querySelector(Selectors.fields.updateButton);
|
||||
updateButton.click();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use core\http_client;
|
||||
use core_ai\process_base;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use GuzzleHttp\Psr7\Uri;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
/**
|
||||
* Class process text generation.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
abstract class abstract_processor extends process_base {
|
||||
/**
|
||||
* Get the endpoint URI.
|
||||
*
|
||||
* @return UriInterface
|
||||
*/
|
||||
protected function get_endpoint(): UriInterface {
|
||||
return new Uri($this->provider->actionconfig[$this->action::class]['settings']['endpoint']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the model to use.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_model(): string {
|
||||
return $this->provider->actionconfig[$this->action::class]['settings']['model'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the model settings.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_model_settings(): array {
|
||||
$settings = $this->provider->actionconfig[$this->action::class]['settings'];
|
||||
if (!empty($settings['modelextraparams'])) {
|
||||
// Custom model settings.
|
||||
$params = json_decode($settings['modelextraparams'], true);
|
||||
foreach ($params as $key => $param) {
|
||||
$settings[$key] = $param;
|
||||
}
|
||||
}
|
||||
|
||||
// Unset unnecessary settings.
|
||||
unset(
|
||||
$settings['model'],
|
||||
$settings['endpoint'],
|
||||
$settings['systeminstruction'],
|
||||
$settings['providerid'],
|
||||
$settings['modelextraparams'],
|
||||
);
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the system instructions.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_system_instruction(): string {
|
||||
return $this->action::get_system_instruction();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the request object to send to the Deepseek 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 Deepseek 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 \core_ai\error\factory::create($e->getCode(), $e->getMessage())->get_error_details();
|
||||
}
|
||||
|
||||
// Double-check the response codes, in case of a non 200 that didn't throw an error.
|
||||
$status = $response->getStatusCode();
|
||||
if ($status === 200) {
|
||||
return $this->handle_api_success($response);
|
||||
} else {
|
||||
return $this->handle_api_error($response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error from the external AI api.
|
||||
*
|
||||
* @param ResponseInterface $response The response object.
|
||||
* @return array The error response.
|
||||
*/
|
||||
protected function handle_api_error(ResponseInterface $response): array {
|
||||
$status = $response->getStatusCode();
|
||||
if ($status >= 500 && $status < 600) {
|
||||
$errormessage = $response->getReasonPhrase();
|
||||
} else {
|
||||
$bodyobj = json_decode($response->getBody()->getContents());
|
||||
$errormessage = $bodyobj->error->message;
|
||||
}
|
||||
|
||||
return \core_ai\error\factory::create($status, $errormessage)->get_error_details();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek\aimodel;
|
||||
|
||||
/**
|
||||
* DeepSeek base AI model interface.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
interface deepseek_base {
|
||||
/** @var int MODEL_TYPE_TEXT Text model type. */
|
||||
public const MODEL_TYPE_TEXT = 1;
|
||||
|
||||
/**
|
||||
* Get model type.
|
||||
*
|
||||
* @return int Model type.
|
||||
*/
|
||||
public function model_type(): int;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek\aimodel;
|
||||
|
||||
use core_ai\aimodel\base;
|
||||
use MoodleQuickForm;
|
||||
|
||||
/**
|
||||
* DeepSeek chat AI model.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class deepseek_chat extends base implements deepseek_base {
|
||||
#[\Override]
|
||||
public function get_model_name(): string {
|
||||
return 'deepseek-chat';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_model_display_name(): string {
|
||||
return 'deepseek-chat';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_model_settings(): array {
|
||||
return [
|
||||
'temperature' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_temperature',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_FLOAT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_temperature',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'logprobs' => [
|
||||
'elementtype' => 'checkbox',
|
||||
'label' => [
|
||||
'identifier' => 'settings_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_BOOL,
|
||||
'help' => [
|
||||
'identifier' => 'settings_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'top_logprobs' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_top_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_FLOAT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_top_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'top_p' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_top_p',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_FLOAT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_top_p',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'max_tokens' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_max_tokens',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_INT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_max_tokens',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'frequency_penalty' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_frequency_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_RAW, // This is a raw value because it can be a float from -2.0 to 2.0.
|
||||
'help' => [
|
||||
'identifier' => 'settings_frequency_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'presence_penalty' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_presence_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_RAW, // This is a raw value because it can be a float from -2.0 to 2.0.
|
||||
'help' => [
|
||||
'identifier' => 'settings_presence_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function add_model_settings(MoodleQuickForm $mform): void {
|
||||
$settings = $this->get_model_settings();
|
||||
foreach ($settings as $key => $setting) {
|
||||
if ($setting['elementtype'] === 'checkbox') {
|
||||
$groupname = $key . '_group';
|
||||
$mform->addGroup([
|
||||
$mform->createElement(
|
||||
'checkbox',
|
||||
$key,
|
||||
get_string($setting['label']['identifier'] . '_label', $setting['label']['component']),
|
||||
'',
|
||||
['class' => 'pt-1'],
|
||||
),
|
||||
], $groupname, get_string($setting['label']['identifier'], $setting['label']['component']));
|
||||
$mform->setType($key, $setting['type']);
|
||||
if (isset($setting['help'])) {
|
||||
$mform->addHelpButton($groupname, $setting['help']['identifier'], $setting['help']['component']);
|
||||
}
|
||||
} else {
|
||||
$mform->addElement(
|
||||
$setting['elementtype'],
|
||||
$key,
|
||||
get_string($setting['label']['identifier'], $setting['label']['component']),
|
||||
);
|
||||
$mform->setType($key, $setting['type']);
|
||||
if (isset($setting['help'])) {
|
||||
$mform->addHelpButton($key, $setting['help']['identifier'], $setting['help']['component']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function model_type(): int {
|
||||
return self::MODEL_TYPE_TEXT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek\aimodel;
|
||||
|
||||
use core_ai\aimodel\base;
|
||||
use MoodleQuickForm;
|
||||
|
||||
/**
|
||||
* DeepSeek reasoner AI model.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Yusuf Wibisono <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class deepseek_reasoner extends base implements deepseek_base {
|
||||
#[\Override]
|
||||
public function get_model_name(): string {
|
||||
return 'deepseek-reasoner';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_model_display_name(): string {
|
||||
return 'deepseek-reasoner';
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_model_settings(): array {
|
||||
return [
|
||||
'temperature' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_temperature',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_FLOAT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_temperature',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'logprobs' => [
|
||||
'elementtype' => 'checkbox',
|
||||
'label' => [
|
||||
'identifier' => 'settings_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_BOOL,
|
||||
'help' => [
|
||||
'identifier' => 'settings_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'top_logprobs' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_top_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_FLOAT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_top_logprobs',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'top_p' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_top_p',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_FLOAT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_top_p',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'max_tokens' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_max_tokens',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_INT,
|
||||
'help' => [
|
||||
'identifier' => 'settings_max_tokens',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'frequency_penalty' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_frequency_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_RAW, // This is a raw value because it can be a float from -2.0 to 2.0.
|
||||
'help' => [
|
||||
'identifier' => 'settings_frequency_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
'presence_penalty' => [
|
||||
'elementtype' => 'text',
|
||||
'label' => [
|
||||
'identifier' => 'settings_presence_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
'type' => PARAM_RAW, // This is a raw value because it can be a float from -2.0 to 2.0.
|
||||
'help' => [
|
||||
'identifier' => 'settings_presence_penalty',
|
||||
'component' => 'aiprovider_deepseek',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function add_model_settings(MoodleQuickForm $mform): void {
|
||||
$settings = $this->get_model_settings();
|
||||
foreach ($settings as $key => $setting) {
|
||||
if ($setting['elementtype'] === 'checkbox') {
|
||||
$groupname = $key . '_group';
|
||||
$mform->addGroup([
|
||||
$mform->createElement(
|
||||
'checkbox',
|
||||
$key,
|
||||
get_string($setting['label']['identifier'] . '_label', $setting['label']['component']),
|
||||
'',
|
||||
['class' => 'pt-1'],
|
||||
),
|
||||
], $groupname, get_string($setting['label']['identifier'], $setting['label']['component']));
|
||||
$mform->setType($key, $setting['type']);
|
||||
if (isset($setting['help'])) {
|
||||
$mform->addHelpButton($groupname, $setting['help']['identifier'], $setting['help']['component']);
|
||||
}
|
||||
} else {
|
||||
$mform->addElement(
|
||||
$setting['elementtype'],
|
||||
$key,
|
||||
get_string($setting['label']['identifier'], $setting['label']['component']),
|
||||
);
|
||||
$mform->setType($key, $setting['type']);
|
||||
if (isset($setting['help'])) {
|
||||
$mform->addHelpButton($key, $setting['help']['identifier'], $setting['help']['component']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function model_type(): int {
|
||||
return self::MODEL_TYPE_TEXT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek\form;
|
||||
|
||||
use aiprovider_deepseek\helper;
|
||||
use core_ai\form\action_settings_form;
|
||||
|
||||
/**
|
||||
* Base action settings form for Deepseek provider.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class action_form extends action_settings_form {
|
||||
/**
|
||||
* @var array Action configuration.
|
||||
*/
|
||||
protected array $actionconfig;
|
||||
/**
|
||||
* @var string|null Return URL.
|
||||
*/
|
||||
protected ?string $returnurl;
|
||||
/**
|
||||
* @var string Action name.
|
||||
*/
|
||||
protected string $actionname;
|
||||
/**
|
||||
* @var string Action class.
|
||||
*/
|
||||
protected string $action;
|
||||
/**
|
||||
* @var int Provider ID.
|
||||
*/
|
||||
protected int $providerid;
|
||||
/**
|
||||
* @var string Provider name.
|
||||
*/
|
||||
protected string $providername;
|
||||
/**
|
||||
* @var array Stored model settings.
|
||||
*/
|
||||
protected array $storedmodelsettings;
|
||||
|
||||
#[\Override]
|
||||
protected function definition(): void {
|
||||
$mform = $this->_form;
|
||||
$this->actionconfig = $this->_customdata['actionconfig']['settings'] ?? [];
|
||||
$this->returnurl = $this->_customdata['returnurl'] ?? null;
|
||||
$this->actionname = $this->_customdata['actionname'];
|
||||
$this->action = $this->_customdata['action'];
|
||||
$this->providerid = $this->_customdata['providerid'] ?? 0;
|
||||
$this->providername = $this->_customdata['providername'] ?? 'aiprovider_deepseek';
|
||||
$this->storedmodelsettings = $this->_customdata['actionconfig']['modelsettings'] ?? [];
|
||||
|
||||
$mform->addElement('header', 'generalsettingsheader', get_string('general', 'core'));
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function set_data($data): void {
|
||||
if (!empty($data['modelextraparams'])) {
|
||||
$data['modelextraparams'] = json_encode(json_decode($data['modelextraparams']), JSON_PRETTY_PRINT);
|
||||
}
|
||||
parent::set_data($data);
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_data(): ?\stdClass {
|
||||
$data = parent::get_data();
|
||||
|
||||
if (isset($data->model)) {
|
||||
if ($data->modeltemplate === 'custom') {
|
||||
$data->modelsettings['custom']['modelextraparams'] = $data->modelextraparams;
|
||||
} else {
|
||||
$modelclass = helper::get_model_class($data->model);
|
||||
if ($modelclass) {
|
||||
if ($modelclass->has_model_settings()) {
|
||||
$modelsettings = array_keys($modelclass->get_model_settings());
|
||||
// Process the model settings.
|
||||
$modeldata = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (in_array($key, $modelsettings)) {
|
||||
$modeldata[$key] = $value;
|
||||
}
|
||||
}
|
||||
if (!empty($modeldata)) {
|
||||
$data->modelsettings[$data->model] = $modeldata;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($data)) {
|
||||
unset($data->custommodel);
|
||||
unset($data->modeltemplate);
|
||||
// Unset any false-y values.
|
||||
$data = (object) array_filter((array) $data);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function validation($data, $files): array {
|
||||
$errors = parent::validation($data, $files);
|
||||
|
||||
// Validate the extra parameters.
|
||||
if (!empty($data['modelextraparams'])) {
|
||||
json_decode($data['modelextraparams']);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$errors['modelextraparams'] = get_string('invalidjson', 'aiprovider_deepseek');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate the model.
|
||||
if ($data['modeltemplate'] === 'custom' && empty($data['custommodel'])) {
|
||||
$errors['custommodel'] = get_string('required');
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function get_defaults(): array {
|
||||
$data = parent::get_defaults();
|
||||
|
||||
unset(
|
||||
$data['modeltemplate'],
|
||||
$data['custommodel'],
|
||||
$data['modelextraparams'],
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add model fields to the form.
|
||||
*
|
||||
* @param int $modeltype Model type.
|
||||
*/
|
||||
protected function add_model_fields(int $modeltype): void {
|
||||
global $PAGE;
|
||||
$PAGE->requires->js_call_amd('aiprovider_deepseek/modelchooser', 'init');
|
||||
$mform = $this->_form;
|
||||
|
||||
// Determine which model to use as the default.
|
||||
if (
|
||||
!empty($this->actionconfig['model']) &&
|
||||
(!array_key_exists($this->actionconfig['model'], $this->get_model_list($modeltype)) ||
|
||||
!empty($this->actionconfig['modelextraparams']))
|
||||
) {
|
||||
$defaultmodel = 'custom';
|
||||
} else {
|
||||
$defaultmodel = $this->actionconfig['model'] ?? 'deepseek-chat';
|
||||
}
|
||||
|
||||
// Get this model's stored values to assist model switching and value population in JS.
|
||||
$modeltemplate = optional_param('modeltemplate', $defaultmodel, PARAM_TEXT);
|
||||
if (isset($this->storedmodelsettings[$modeltemplate])) {
|
||||
$this->storedmodelsettings = [$modeltemplate => $this->storedmodelsettings[$modeltemplate]];
|
||||
}
|
||||
|
||||
// Model chooser.
|
||||
$mform->addElement(
|
||||
'select',
|
||||
'modeltemplate',
|
||||
get_string("action:{$this->actionname}:model", 'aiprovider_deepseek'),
|
||||
$this->get_model_list($modeltype),
|
||||
['data-modelchooser-field' => 'selector', 'data-storedmodelsettings' => json_encode($this->storedmodelsettings)],
|
||||
);
|
||||
$mform->setType('modeltemplate', PARAM_TEXT);
|
||||
$mform->addRule('modeltemplate', null, 'required', null, 'client');
|
||||
$mform->setDefault('modeltemplate', $defaultmodel);
|
||||
$mform->addHelpButton('modeltemplate', "action:{$this->actionname}:model", 'aiprovider_deepseek');
|
||||
|
||||
$mform->addElement('hidden', 'model', $this->actionconfig['model'] ?? 'deepseek-chat');
|
||||
$mform->setType('model', PARAM_TEXT);
|
||||
|
||||
$mform->addElement('text', 'custommodel', get_string('custom_model_name', 'aiprovider_deepseek'));
|
||||
$mform->setType('custommodel', PARAM_TEXT);
|
||||
$mform->setDefault('custommodel', $this->actionconfig['model'] ?? '');
|
||||
$mform->hideIf('custommodel', 'modeltemplate', 'neq', 'custom');
|
||||
|
||||
$mform->registerNoSubmitButton('updateactionsettings');
|
||||
$mform->addElement(
|
||||
'submit',
|
||||
'updateactionsettings',
|
||||
'updateactionsettings',
|
||||
['data-modelchooser-field' => 'updateButton', 'class' => 'd-none']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of models.
|
||||
*
|
||||
* @param int $modeltype Model type.
|
||||
* @return array List of models.
|
||||
*/
|
||||
protected function get_model_list(int $modeltype): array {
|
||||
$models = [];
|
||||
$models['custom'] = get_string('custom', 'core_form');
|
||||
foreach (helper::get_model_classes() as $class) {
|
||||
$model = new $class();
|
||||
if ($model->model_type() == $modeltype) {
|
||||
$models[$model->get_model_name()] = $model->get_model_display_name();
|
||||
}
|
||||
}
|
||||
return $models;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek\form;
|
||||
|
||||
use aiprovider_deepseek\aimodel\deepseek_base;
|
||||
|
||||
|
||||
/**
|
||||
* Generate text action provider settings form.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class action_generate_text_form extends action_form {
|
||||
#[\Override]
|
||||
protected function definition(): void {
|
||||
parent::definition();
|
||||
$mform = $this->_form;
|
||||
|
||||
$this->add_model_fields(deepseek_base::MODEL_TYPE_TEXT);
|
||||
|
||||
// API endpoint.
|
||||
$mform->addElement(
|
||||
'text',
|
||||
'endpoint',
|
||||
get_string("action:{$this->actionname}:endpoint", 'aiprovider_deepseek'),
|
||||
'maxlength="255" size="30"',
|
||||
);
|
||||
$mform->setType('endpoint', PARAM_URL);
|
||||
$mform->addRule('endpoint', null, 'required', null, 'client');
|
||||
$mform->setDefault('endpoint', $actionconfig['endpoint'] ?? 'https://api.deepseek.com/chat/completions');
|
||||
|
||||
// System Instructions.
|
||||
$mform->addElement(
|
||||
'textarea',
|
||||
'systeminstruction',
|
||||
get_string("action:{$this->actionname}:systeminstruction", 'aiprovider_deepseek'),
|
||||
'wrap="virtual" rows="5" cols="20"',
|
||||
);
|
||||
$mform->setType('systeminstruction', PARAM_TEXT);
|
||||
$mform->setDefault('systeminstruction', $actionconfig['systeminstruction'] ?? $this->action::get_system_instruction());
|
||||
$mform->addHelpButton('systeminstruction', "action:{$this->actionname}:systeminstruction", 'aiprovider_deepseek');
|
||||
|
||||
if ($this->returnurl) {
|
||||
$mform->addElement('hidden', 'returnurl', $this->returnurl);
|
||||
$mform->setType('returnurl', PARAM_LOCALURL);
|
||||
}
|
||||
|
||||
// Add the action class as a hidden field.
|
||||
$mform->addElement('hidden', 'action', $this->action);
|
||||
$mform->setType('action', PARAM_TEXT);
|
||||
|
||||
// Add the provider class as a hidden field.
|
||||
$mform->addElement('hidden', 'provider', $this->providername);
|
||||
$mform->setType('provider', PARAM_TEXT);
|
||||
|
||||
// Add the provider id as a hidden field.
|
||||
$mform->addElement('hidden', 'providerid', $this->providerid);
|
||||
$mform->setType('providerid', PARAM_INT);
|
||||
|
||||
$this->set_data($this->actionconfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use core_ai\aimodel\base;
|
||||
|
||||
/**
|
||||
* Helper class for the Deepseek provider.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class helper {
|
||||
/**
|
||||
* Get all model classes.
|
||||
*
|
||||
* @return array Array of model classes.
|
||||
*/
|
||||
public static function get_model_classes(): array {
|
||||
$models = [];
|
||||
$modelclasses = \core_component::get_component_classes_in_namespace('aiprovider_deepseek', 'aimodel');
|
||||
foreach ($modelclasses as $class => $path) {
|
||||
if (!class_exists($class) || !is_a($class, base::class, true)) {
|
||||
throw new \coding_exception("Model class not valid: {$class}");
|
||||
}
|
||||
$models[] = $class;
|
||||
}
|
||||
return $models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model class by name.
|
||||
*
|
||||
* @param string $modelname Model name.
|
||||
* @return base|null
|
||||
*/
|
||||
public static function get_model_class(string $modelname): ?base {
|
||||
foreach (static::get_model_classes() as $classname) {
|
||||
$model = new $classname();
|
||||
if ($model->get_model_name() === $modelname) {
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use core_ai\hook\after_ai_action_settings_form_hook;
|
||||
use core_ai\hook\after_ai_provider_form_hook;
|
||||
|
||||
/**
|
||||
* Hook listener for DeepSeek Provider.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class hook_listener {
|
||||
/**
|
||||
* Hook listener for the DeepSeek instance setup form.
|
||||
*
|
||||
* @param after_ai_provider_form_hook $hook The hook to add to the AI instance setup.
|
||||
*/
|
||||
public static function set_form_definition_for_aiprovider_deepseek(after_ai_provider_form_hook $hook): void {
|
||||
if ($hook->plugin !== 'aiprovider_deepseek') {
|
||||
return;
|
||||
}
|
||||
|
||||
$mform = $hook->mform;
|
||||
|
||||
// Required setting to store DeepSeek API key.
|
||||
$mform->addElement(
|
||||
'passwordunmask',
|
||||
'apikey',
|
||||
get_string('apikey', 'aiprovider_deepseek'),
|
||||
['size' => 75],
|
||||
);
|
||||
$mform->addHelpButton('apikey', 'apikey', 'aiprovider_deepseek');
|
||||
$mform->addRule('apikey', get_string('required'), 'required', null, 'client');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook listener for the DeepSeek action settings form.
|
||||
*
|
||||
* @param after_ai_action_settings_form_hook $hook The hook to add to config action settings.
|
||||
*/
|
||||
public static function set_model_form_definition_for_aiprovider_deepseek(after_ai_action_settings_form_hook $hook): void {
|
||||
if ($hook->plugin !== 'aiprovider_deepseek') {
|
||||
return;
|
||||
}
|
||||
|
||||
$mform = $hook->mform;
|
||||
if (isset($mform->_elementIndex['modeltemplate'])) {
|
||||
$model = $mform->getElementValue('modeltemplate');
|
||||
if (is_array($model)) {
|
||||
$model = $model[0];
|
||||
}
|
||||
|
||||
if ($model == 'custom') {
|
||||
$mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_deepseek'));
|
||||
$settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_deepseek'));
|
||||
$mform->addElement('html', $settingshelp);
|
||||
$mform->addElement(
|
||||
'textarea',
|
||||
'modelextraparams',
|
||||
get_string('extraparams', 'aiprovider_deepseek'),
|
||||
['rows' => 5, 'cols' => 20],
|
||||
);
|
||||
$mform->setType('modelextraparams', PARAM_TEXT);
|
||||
$mform->addElement('static', 'modelextraparams_help', null, get_string('extraparams_help', 'aiprovider_deepseek'));
|
||||
} else {
|
||||
$targetmodel = helper::get_model_class($model);
|
||||
if ($targetmodel) {
|
||||
if ($targetmodel->has_model_settings()) {
|
||||
$mform->addElement('header', 'modelsettingsheader', get_string('settings', 'aiprovider_deepseek'));
|
||||
$settingshelp = \html_writer::tag('p', get_string('settings_help', 'aiprovider_deepseek'));
|
||||
$mform->addElement('html', $settingshelp);
|
||||
$targetmodel->add_model_settings($mform);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek\privacy;
|
||||
|
||||
use core_privacy\local\metadata\collection;
|
||||
use core_privacy\local\request\approved_contextlist;
|
||||
use core_privacy\local\request\approved_userlist;
|
||||
use core_privacy\local\request\contextlist;
|
||||
use core_privacy\local\request\userlist;
|
||||
|
||||
/**
|
||||
* Privacy provider implementation for Deepseek provider
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
class provider implements
|
||||
\core_privacy\local\request\core_userlist_provider,
|
||||
\core_privacy\local\metadata\provider,
|
||||
\core_privacy\local\request\plugin\provider {
|
||||
#[\Override]
|
||||
public static function get_metadata(collection $collection): collection {
|
||||
$collection->add_external_location_link('aiprovider_deepseek', [
|
||||
'prompttext' => 'privacy:metadata:aiprovider_deepseek:prompttext',
|
||||
'model' => 'privacy:metadata:aiprovider_deepseek:model',
|
||||
], 'privacy:metadata:aiprovider_deepseek: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) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function delete_data_for_users(approved_userlist $userlist) {
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function delete_data_for_user(approved_contextlist $contextlist) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
/**
|
||||
* Class process text explanation.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Yusuf Wibisono <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class process_explain_text extends process_generate_text {
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Class process text generation.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class process_generate_text extends abstract_processor {
|
||||
#[\Override]
|
||||
protected function get_system_instruction(): string {
|
||||
return $this->provider->actionconfig[$this->action::class]['settings']['systeminstruction'];
|
||||
}
|
||||
|
||||
#[\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->model = $this->get_model();
|
||||
$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];
|
||||
}
|
||||
|
||||
// Append the extra model settings.
|
||||
$modelsettings = $this->get_model_settings();
|
||||
foreach ($modelsettings as $setting => $value) {
|
||||
$requestobj->$setting = $value;
|
||||
}
|
||||
|
||||
return new Request(
|
||||
method: 'POST',
|
||||
uri: '',
|
||||
headers: [
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
body: json_encode($requestobj),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
'model' => $bodyobj->model ?? $this->get_model(), // Fallback to config model.
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
/**
|
||||
* Class process text summarisation.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class process_summarise_text extends process_generate_text {
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use core_ai\form\action_settings_form;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
|
||||
/**
|
||||
* Class provider.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class provider extends \core_ai\provider {
|
||||
#[\Override]
|
||||
public static function get_action_list(): array {
|
||||
return [
|
||||
\core_ai\aiactions\generate_text::class,
|
||||
\core_ai\aiactions\summarise_text::class,
|
||||
\core_ai\aiactions\explain_text::class,
|
||||
];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function get_action_settings(
|
||||
string $action,
|
||||
array $customdata = [],
|
||||
): action_settings_form|bool {
|
||||
$actionname = substr($action, (strrpos($action, '\\') + 1));
|
||||
$customdata['actionname'] = $actionname;
|
||||
$customdata['action'] = $action;
|
||||
$customdata['providername'] = 'aiprovider_deepseek';
|
||||
if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') {
|
||||
return new form\action_generate_text_form(customdata: $customdata);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function add_authentication_headers(RequestInterface $request): RequestInterface {
|
||||
return $request->withAddedHeader('Authorization', "Bearer {$this->config['apikey']}");
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public static function get_action_setting_defaults(string $action): array {
|
||||
$actionname = substr($action, (strrpos($action, '\\') + 1));
|
||||
$customdata = [
|
||||
'actionname' => $actionname,
|
||||
'action' => $action,
|
||||
'providername' => 'aiprovider_deepseek',
|
||||
];
|
||||
if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') {
|
||||
$mform = new form\action_generate_text_form(customdata: $customdata);
|
||||
return $mform->get_defaults();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
#[\Override]
|
||||
public function is_provider_configured(): bool {
|
||||
return !empty($this->config['apikey']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Hook listener callbacks for the Deepseek Provider.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$callbacks = [
|
||||
[
|
||||
'hook' => \core_ai\hook\after_ai_provider_form_hook::class,
|
||||
'callback' => \aiprovider_deepseek\hook_listener::class . '::set_form_definition_for_aiprovider_deepseek',
|
||||
],
|
||||
|
||||
[
|
||||
'hook' => \core_ai\hook\after_ai_action_settings_form_hook::class,
|
||||
'callback' => \aiprovider_deepseek\hook_listener::class . '::set_model_form_definition_for_aiprovider_deepseek',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Strings for component aiprovider_deepseek, language 'en'.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
$string['action:explain_text:endpoint'] = 'API endpoint';
|
||||
$string['action:explain_text:model'] = 'Text explanation model';
|
||||
$string['action:explain_text:model_help'] = 'The model used to explain the provided text.';
|
||||
$string['action:explain_text:systeminstruction'] = 'System instruction';
|
||||
$string['action:explain_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.';
|
||||
$string['action:generate_text:endpoint'] = 'API endpoint';
|
||||
$string['action:generate_text:model'] = 'AI model';
|
||||
$string['action:generate_text:model_help'] = 'The model used to generate the text response.';
|
||||
$string['action:generate_text:systeminstruction'] = 'System instruction';
|
||||
$string['action:generate_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.';
|
||||
$string['action:summarise_text:endpoint'] = 'API endpoint';
|
||||
$string['action:summarise_text:model'] = 'AI model';
|
||||
$string['action:summarise_text:model_help'] = 'The model used to summarise the provided text.';
|
||||
$string['action:summarise_text:systeminstruction'] = 'System instruction';
|
||||
$string['action:summarise_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.';
|
||||
$string['apikey'] = 'DeepSeek API key';
|
||||
$string['apikey_help'] = 'Get a key from your <a href="https://platform.deepseek.com/api_keys" target="_blank">DeepSeek API keys</a>.';
|
||||
$string['custom_model_name'] = 'Custom model name';
|
||||
$string['extraparams'] = 'Extra parameters';
|
||||
$string['extraparams_help'] = 'Extra parameters can be configured here. We support JSON format. For example:
|
||||
<pre>
|
||||
{
|
||||
"temperature": 0.5,
|
||||
"max_tokens": 100
|
||||
}
|
||||
</pre>';
|
||||
$string['invalidjson'] = 'Invalid JSON string';
|
||||
$string['pluginname'] = 'Deepseek API provider';
|
||||
$string['privacy:metadata'] = 'The DeepSeek API provider plugin does not store any personal data.';
|
||||
$string['privacy:metadata:aiprovider_deepseek:externalpurpose'] = 'This information is sent to the Deepseek API in order for a response to be generated. Your Deepseek account settings may change how Deepseek stores and retains this data. No user data is explicitly sent to Deepseek or stored in Moodle LMS by this plugin.';
|
||||
$string['privacy:metadata:aiprovider_deepseek:model'] = 'The model used to generate the response.';
|
||||
$string['privacy:metadata:aiprovider_deepseek:prompttext'] = 'The user entered text prompt used to generate the response.';
|
||||
$string['settings'] = 'Settings';
|
||||
$string['settings_frequency_penalty'] = 'frequency_penalty';
|
||||
$string['settings_frequency_penalty_help'] = 'Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model\'s likelihood to repeat the same line verbatim.';
|
||||
$string['settings_help'] = 'Adjust the settings below to customise how requests are sent to Deepseek.';
|
||||
$string['settings_logprobs'] = 'logprobs';
|
||||
$string['settings_logprobs_help'] = 'Whether to return log probabilities of the output tokens or not. If true, returns the log probabilities of each output token returned in the content of message.';
|
||||
$string['settings_logprobs_label'] = 'Enable';
|
||||
$string['settings_max_tokens'] = 'max_tokens';
|
||||
$string['settings_max_tokens_help'] = 'Integer between 1 and 8192. The maximum number of tokens that can be generated in the chat completion. The total length of input tokens and generated tokens is limited by the model\'s context length. If max_tokens is not specified, the default value 4096 is used.';
|
||||
$string['settings_presence_penalty'] = 'presence_penalty';
|
||||
$string['settings_presence_penalty_help'] = 'Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the model\'s likelihood to talk about new topics.';
|
||||
$string['settings_temperature'] = 'temperature';
|
||||
$string['settings_temperature_help'] = 'What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic. We generally recommend altering this or top_p but not both.';
|
||||
$string['settings_top_logprobs'] = 'top_logprobs';
|
||||
$string['settings_top_logprobs_help'] = 'An integer between 0 and 20 specifying the number of most likely tokens to return at each token position, each with an associated log probability. logprobs must be set to true if this parameter is used.';
|
||||
$string['settings_top_p'] = 'top_p';
|
||||
$string['settings_top_p_help'] = 'An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered. We generally recommend altering this or temperature but not both.';
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "3a621831-3173-4437-b58a-f16287ecce85",
|
||||
"object": "chat.completion",
|
||||
"created": 1755253949,
|
||||
"model": "deepseek-chat",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "Mono Potassium Phosphate (MKP), with the chemical formula KH₂PO₄, is a highly soluble fertilizer providing **phosphorus (P)** and **potassium (K)**. \n\n### Key Features: \n- **Nutrient Ratio:** 52% P₂O₅ (phosphorus) & 34% K₂O (potassium). \n- **Uses:** Boosts root growth, flowering, and fruiting; used in hydroponics and foliar sprays. \n- **Benefits:** Fast absorption, low salinity, pH-stabilizing. \n- **Compatibility:** Mixes well with most fertilizers except calcium-based ones. \n\nIdeal for crops needing **P & K** during flowering/fruiting stages."
|
||||
},
|
||||
"logprobs": null,
|
||||
"finish_reason": "stop"
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 23,
|
||||
"completion_tokens": 150,
|
||||
"total_tokens": 173,
|
||||
"prompt_tokens_details": {
|
||||
"cached_tokens": 0
|
||||
},
|
||||
"prompt_cache_hit_tokens": 0,
|
||||
"prompt_cache_miss_tokens": 23
|
||||
},
|
||||
"system_fingerprint": "fp_8802369eaa_prod0623_fp8_kvcache"
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use core_ai\aiactions\base;
|
||||
use core_ai\provider;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once(__DIR__ . '/testcase_helper_trait.php');
|
||||
|
||||
/**
|
||||
* Test Explain text provider class for Deepseek provider methods.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Yusuf Wibisono <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*
|
||||
* @covers \aiprovider_deepseek\provider
|
||||
* @covers \aiprovider_deepseek\process_explain_text
|
||||
* @covers \aiprovider_deepseek\abstract_processor
|
||||
*/
|
||||
final class process_explain_text_test extends \advanced_testcase {
|
||||
use testcase_helper_trait;
|
||||
|
||||
/** @var string A successful response in JSON format. */
|
||||
protected string $responsebodyjson;
|
||||
|
||||
/** @var \core_ai\manager AI Manager. */
|
||||
private $manager;
|
||||
|
||||
/** @var provider The provider that will process the action. */
|
||||
protected provider $provider;
|
||||
|
||||
/** @var base The action to process. */
|
||||
protected base $action;
|
||||
|
||||
/**
|
||||
* Set up the test.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->resetAfterTest();
|
||||
// Load a response body from a file.
|
||||
$this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_deepseek', 'text_request_success.json'));
|
||||
$this->manager = \core\di::get(\core_ai\manager::class);
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\explain_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'),
|
||||
],
|
||||
);
|
||||
$this->create_action();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the action object.
|
||||
* @param int $userid The user id to use in the action.
|
||||
*/
|
||||
private function create_action(int $userid = 1): void {
|
||||
$this->action = new \core_ai\aiactions\explain_text(
|
||||
contextid: 1,
|
||||
userid: $userid,
|
||||
prompttext: 'This is a test prompt',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request_object
|
||||
*/
|
||||
public function test_create_request_object(): void {
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('system', $body->messages[0]->role);
|
||||
$this->assertEquals(get_string('action_explain_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 create_request_object with extra model settings.
|
||||
*/
|
||||
public function test_create_request_object_with_model_settings(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\explain_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'),
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
],
|
||||
);
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('deepseek-chat', $body->model);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\explain_text::class,
|
||||
actionconfig: [
|
||||
'model' => 'my-custom-deepseek',
|
||||
'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}',
|
||||
],
|
||||
);
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('my-custom-deepseek', $body->model);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_error');
|
||||
|
||||
foreach ($responses as $status => $response) {
|
||||
$result = $method->invoke($processor, $response);
|
||||
$this->assertEquals($status, $result['errorcode']);
|
||||
if ($status == 500) {
|
||||
$this->assertEquals('Internal Server Error', $result['errormessage']);
|
||||
} else if ($status == 503) {
|
||||
$this->assertEquals('Service Unavailable', $result['errormessage']);
|
||||
} else {
|
||||
$this->assertStringContainsString($response->getBody()->getContents(), $result['errormessage']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the API success response handler method.
|
||||
*/
|
||||
public function test_handle_api_success(): void {
|
||||
$response = new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
);
|
||||
|
||||
// We're testing a private method, so we need to set up reflector magic.
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('3a621831-3173-4437-b58a-f16287ecce85', $result['id']);
|
||||
$this->assertEquals('fp_8802369eaa_prod0623_fp8_kvcache', $result['fingerprint']);
|
||||
$this->assertStringContainsString('Mono Potassium Phosphate (MKP)', $result['generatedcontent']);
|
||||
$this->assertEquals('stop', $result['finishreason']);
|
||||
$this->assertEquals('23', $result['prompttokens']);
|
||||
$this->assertEquals('150', $result['completiontokens']);
|
||||
$this->assertEquals('deepseek-chat', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query_ai_api for a successful call.
|
||||
*/
|
||||
public function test_query_ai_api_success(): void {
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'query_ai_api');
|
||||
$result = $method->invoke($processor);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('3a621831-3173-4437-b58a-f16287ecce85', $result['id']);
|
||||
$this->assertEquals('fp_8802369eaa_prod0623_fp8_kvcache', $result['fingerprint']);
|
||||
$this->assertStringContainsString('Mono Potassium Phosphate (MKP)', $result['generatedcontent']);
|
||||
$this->assertEquals('stop', $result['finishreason']);
|
||||
$this->assertEquals('23', $result['prompttokens']);
|
||||
$this->assertEquals('150', $result['completiontokens']);
|
||||
$this->assertEquals('deepseek-chat', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response success.
|
||||
*/
|
||||
public function test_prepare_response_success(): void {
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'id' => '3a621831-3173-4437-b58a-f16287ecce85',
|
||||
'fingerprint' => 'fp_8802369eaa_prod0623_fp8_kvcache',
|
||||
'generatedcontent' => 'Mono Potassium Phosphate (MKP)',
|
||||
'finishreason' => 'stop',
|
||||
'prompttokens' => '23',
|
||||
'completiontokens' => '150',
|
||||
'model' => 'deepseek-chat',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals('explain_text', $result->get_actionname());
|
||||
$this->assertEquals($response['success'], $result->get_success());
|
||||
$this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']);
|
||||
$this->assertEquals($response['model'], $result->get_response_data()['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response error.
|
||||
*/
|
||||
public function test_prepare_response_error(): void {
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'errorcode' => 500,
|
||||
'error' => 'Internal server error',
|
||||
'errormessage' => 'Try again later',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals('explain_text', $result->get_actionname());
|
||||
$this->assertEquals($response['error'], $result->get_error());
|
||||
$this->assertEquals($response['errorcode'], $result->get_errorcode());
|
||||
$this->assertEquals($response['errormessage'], $result->get_errormessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method.
|
||||
*/
|
||||
public function test_process(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$result = $processor->process();
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals('explain_text', $result->get_actionname());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with error.
|
||||
*/
|
||||
public function test_process_error(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
401,
|
||||
['Content-Type' => 'application/json'],
|
||||
json_encode(['error' => ['message' => 'Invalid Authentication']]),
|
||||
));
|
||||
|
||||
$processor = new process_explain_text($this->provider, $this->action);
|
||||
$result = $processor->process();
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals('explain_text', $result->get_actionname());
|
||||
$this->assertEquals(401, $result->get_errorcode());
|
||||
$this->assertEquals('Invalid Authentication', $result->get_errormessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with user rate limiter.
|
||||
*/
|
||||
public function test_process_with_user_rate_limiter(): void {
|
||||
// Create users.
|
||||
$user1 = $this->getDataGenerator()->create_user();
|
||||
$user2 = $this->getDataGenerator()->create_user();
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// Mock clock.
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Set the user rate limiter.
|
||||
$config = [
|
||||
'apikey' => '123',
|
||||
'enableuserratelimit' => true,
|
||||
'userratelimit' => 1,
|
||||
];
|
||||
$provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\explain_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'deepseek-chat',
|
||||
'endpoint' => 'https://api.deepseek.com/chat/completions',
|
||||
'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// Case 1: User rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: User rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertEquals(429, $result->get_errorcode());
|
||||
$this->assertEquals(
|
||||
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
|
||||
actual: $result->get_errormessage(),
|
||||
);
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 3: User rate limit has not been reached for a different user.
|
||||
// Log in user2.
|
||||
$this->setUser($user2);
|
||||
$this->create_action($user2->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 4: Time window has passed, user rate limit should be reset.
|
||||
$clock->bump(11);
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with global rate limiter.
|
||||
*/
|
||||
public function test_process_with_global_rate_limiter(): void {
|
||||
// Create users.
|
||||
$user1 = $this->getDataGenerator()->create_user();
|
||||
$user2 = $this->getDataGenerator()->create_user();
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// Mock clock.
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Set the global rate limiter.
|
||||
$config = [
|
||||
'apikey' => '123',
|
||||
'enableglobalratelimit' => true,
|
||||
'globalratelimit' => 1,
|
||||
];
|
||||
$provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\explain_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'deepseek-chat',
|
||||
'endpoint' => 'https://api.deepseek.com/chat/completions',
|
||||
'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// Case 1: Global rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: Global rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertEquals(429, $result->get_errorcode());
|
||||
$this->assertEquals(
|
||||
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
|
||||
actual: $result->get_errormessage(),
|
||||
);
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 3: Global rate limit has been reached for a different user too.
|
||||
// Log in user2.
|
||||
$this->setUser($user2);
|
||||
$this->create_action($user2->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 4: Time window has passed, global rate limit should be reset.
|
||||
$clock->bump(11);
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_explain_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use core_ai\aiactions\base;
|
||||
use core_ai\provider;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once(__DIR__ . '/testcase_helper_trait.php');
|
||||
|
||||
/**
|
||||
* Test Generate text provider class for Deepseek provider methods.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Yusuf Wibisono <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*
|
||||
* @covers \aiprovider_deepseek\provider
|
||||
* @covers \aiprovider_deepseek\process_generate_text
|
||||
* @covers \aiprovider_deepseek\abstract_processor
|
||||
*/
|
||||
final class process_generate_text_test extends \advanced_testcase {
|
||||
use testcase_helper_trait;
|
||||
|
||||
/** @var string A successful response in JSON format. */
|
||||
protected string $responsebodyjson;
|
||||
|
||||
/** @var \core_ai\manager AI Manager. */
|
||||
private $manager;
|
||||
|
||||
/** @var provider The provider that will process the action. */
|
||||
protected provider $provider;
|
||||
|
||||
/** @var base The action to process. */
|
||||
protected base $action;
|
||||
|
||||
/**
|
||||
* Set up the test.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->resetAfterTest();
|
||||
// Load a response body from a file.
|
||||
$this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_deepseek', 'text_request_success.json'));
|
||||
$this->manager = \core\di::get(\core_ai\manager::class);
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
],
|
||||
);
|
||||
$this->create_action();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the action object.
|
||||
* @param int $userid The user id to use in the action.
|
||||
*/
|
||||
private function create_action(int $userid = 1): void {
|
||||
$this->action = new \core_ai\aiactions\generate_text(
|
||||
contextid: 1,
|
||||
userid: $userid,
|
||||
prompttext: 'This is a test prompt',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request_object
|
||||
*/
|
||||
public function test_create_request_object(): void {
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('system', $body->messages[0]->role);
|
||||
$this->assertEquals(get_string('action_generate_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 create_request_object with extra model settings.
|
||||
*/
|
||||
public function test_create_request_object_with_model_settings(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
],
|
||||
);
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('deepseek-chat', $body->model);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\generate_text::class,
|
||||
actionconfig: [
|
||||
'model' => 'my-custom-deepseek',
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}',
|
||||
],
|
||||
);
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('my-custom-deepseek', $body->model);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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_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_generate_text($this->provider, $this->action);
|
||||
$method = new \ReflectionMethod($processor, 'handle_api_success');
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertTrue($result['success']);
|
||||
$this->assertEquals('3a621831-3173-4437-b58a-f16287ecce85', $result['id']);
|
||||
$this->assertEquals('fp_8802369eaa_prod0623_fp8_kvcache', $result['fingerprint']);
|
||||
$this->assertStringContainsString('Mono Potassium Phosphate (MKP)', $result['generatedcontent']);
|
||||
$this->assertEquals('stop', $result['finishreason']);
|
||||
$this->assertEquals('23', $result['prompttokens']);
|
||||
$this->assertEquals('150', $result['completiontokens']);
|
||||
$this->assertEquals('deepseek-chat', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query_ai_api for a successful call.
|
||||
*/
|
||||
public function test_query_ai_api_success(): void {
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$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('3a621831-3173-4437-b58a-f16287ecce85', $result['id']);
|
||||
$this->assertEquals('fp_8802369eaa_prod0623_fp8_kvcache', $result['fingerprint']);
|
||||
$this->assertStringContainsString('Mono Potassium Phosphate (MKP)', $result['generatedcontent']);
|
||||
$this->assertEquals('stop', $result['finishreason']);
|
||||
$this->assertEquals('23', $result['prompttokens']);
|
||||
$this->assertEquals('150', $result['completiontokens']);
|
||||
$this->assertEquals('deepseek-chat', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response success.
|
||||
*/
|
||||
public function test_prepare_response_success(): void {
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'id' => '3a621831-3173-4437-b58a-f16287ecce85',
|
||||
'fingerprint' => 'fp_8802369eaa_prod0623_fp8_kvcache',
|
||||
'generatedcontent' => 'Mono Potassium Phosphate (MKP)',
|
||||
'finishreason' => 'stop',
|
||||
'prompttokens' => '23',
|
||||
'completiontokens' => '150',
|
||||
'model' => 'deepseek-chat',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals('generate_text', $result->get_actionname());
|
||||
$this->assertEquals($response['success'], $result->get_success());
|
||||
$this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']);
|
||||
$this->assertEquals($response['model'], $result->get_response_data()['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response error.
|
||||
*/
|
||||
public function test_prepare_response_error(): void {
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'errorcode' => 500,
|
||||
'error' => 'Internal server error',
|
||||
'errormessage' => 'Try again later',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals('generate_text', $result->get_actionname());
|
||||
$this->assertEquals($response['error'], $result->get_error());
|
||||
$this->assertEquals($response['errorcode'], $result->get_errorcode());
|
||||
$this->assertEquals($response['errormessage'], $result->get_errormessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method.
|
||||
*/
|
||||
public function test_process(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
|
||||
$processor = new process_generate_text($this->provider, $this->action);
|
||||
$result = $processor->process();
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals('generate_text', $result->get_actionname());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with error.
|
||||
*/
|
||||
public function test_process_error(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$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 {
|
||||
// Create users.
|
||||
$user1 = $this->getDataGenerator()->create_user();
|
||||
$user2 = $this->getDataGenerator()->create_user();
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// Mock clock.
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Set the user rate limiter.
|
||||
$config = [
|
||||
'apikey' => '123',
|
||||
'enableuserratelimit' => true,
|
||||
'userratelimit' => 1,
|
||||
];
|
||||
$provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\generate_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'deepseek-chat',
|
||||
'endpoint' => 'https://api.deepseek.com/chat/completions',
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// Case 1: User rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: User rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertEquals(429, $result->get_errorcode());
|
||||
$this->assertEquals(
|
||||
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
|
||||
actual: $result->get_errormessage(),
|
||||
);
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 3: User rate limit has not been reached for a different user.
|
||||
// Log in user2.
|
||||
$this->setUser($user2);
|
||||
$this->create_action($user2->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 4: Time window has passed, user rate limit should be reset.
|
||||
$clock->bump(11);
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with global rate limiter.
|
||||
*/
|
||||
public function test_process_with_global_rate_limiter(): void {
|
||||
// Create users.
|
||||
$user1 = $this->getDataGenerator()->create_user();
|
||||
$user2 = $this->getDataGenerator()->create_user();
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// Mock clock.
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Set the global rate limiter.
|
||||
$config = [
|
||||
'apikey' => '123',
|
||||
'enableglobalratelimit' => true,
|
||||
'globalratelimit' => 1,
|
||||
];
|
||||
$provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\generate_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'deepseek-chat',
|
||||
'endpoint' => 'https://api.deepseek.com/chat/completions',
|
||||
'systeminstruction' => get_string('action_generate_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// Case 1: Global rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: Global rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertEquals(429, $result->get_errorcode());
|
||||
$this->assertEquals(
|
||||
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
|
||||
actual: $result->get_errormessage(),
|
||||
);
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 3: Global rate limit has been reached for a different user too.
|
||||
// Log in user2.
|
||||
$this->setUser($user2);
|
||||
$this->create_action($user2->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 4: Time window has passed, global rate limit should be reset.
|
||||
$clock->bump(11);
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_generate_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
use core_ai\aiactions\base;
|
||||
use core_ai\provider;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
require_once(__DIR__ . '/testcase_helper_trait.php');
|
||||
|
||||
/**
|
||||
* Test Summarise text provider class for Deepseek provider methods.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Yusuf Wibisono <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*
|
||||
* @covers \aiprovider_deepseek\provider
|
||||
* @covers \aiprovider_deepseek\process_summarise_text
|
||||
* @covers \aiprovider_deepseek\abstract_processor
|
||||
*/
|
||||
final class process_summarise_text_test extends \advanced_testcase {
|
||||
use testcase_helper_trait;
|
||||
|
||||
/** @var string A successful response in JSON format. */
|
||||
protected string $responsebodyjson;
|
||||
|
||||
/** @var \core_ai\manager AI Manager. */
|
||||
private $manager;
|
||||
|
||||
/** @var provider The provider that will process the action. */
|
||||
protected provider $provider;
|
||||
|
||||
/** @var base The action to process. */
|
||||
protected base $action;
|
||||
|
||||
/**
|
||||
* Set up the test.
|
||||
*/
|
||||
protected function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->resetAfterTest();
|
||||
// Load a response body from a file.
|
||||
$this->responsebodyjson = file_get_contents(self::get_fixture_path('aiprovider_deepseek', 'text_request_success.json'));
|
||||
$this->manager = \core\di::get(\core_ai\manager::class);
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\summarise_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
],
|
||||
);
|
||||
$this->create_action();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the action object.
|
||||
* @param int $userid The user id to use in the action.
|
||||
*/
|
||||
private function create_action(int $userid = 1): void {
|
||||
$this->action = new \core_ai\aiactions\summarise_text(
|
||||
contextid: 1,
|
||||
userid: $userid,
|
||||
prompttext: 'This is a test prompt',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test create_request_object
|
||||
*/
|
||||
public function test_create_request_object(): void {
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('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 create_request_object with extra model settings.
|
||||
*/
|
||||
public function test_create_request_object_with_model_settings(): void {
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\summarise_text::class,
|
||||
actionconfig: [
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
'temperature' => '0.5',
|
||||
'max_tokens' => '100',
|
||||
],
|
||||
);
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('deepseek-chat', $body->model);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
|
||||
$this->provider = $this->create_provider(
|
||||
actionclass: \core_ai\aiactions\summarise_text::class,
|
||||
actionconfig: [
|
||||
'model' => 'my-custom-deepseek',
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
'modelextraparams' => '{"temperature": 0.5,"max_tokens": 100}',
|
||||
],
|
||||
);
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'create_request_object');
|
||||
$request = $method->invoke($processor, 1);
|
||||
|
||||
$body = (object) json_decode($request->getBody()->getContents());
|
||||
|
||||
$this->assertEquals('my-custom-deepseek', $body->model);
|
||||
$this->assertEquals('0.5', $body->temperature);
|
||||
$this->assertEquals('100', $body->max_tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('3a621831-3173-4437-b58a-f16287ecce85', $result['id']);
|
||||
$this->assertEquals('fp_8802369eaa_prod0623_fp8_kvcache', $result['fingerprint']);
|
||||
$this->assertStringContainsString('Mono Potassium Phosphate (MKP)', $result['generatedcontent']);
|
||||
$this->assertEquals('stop', $result['finishreason']);
|
||||
$this->assertEquals('23', $result['prompttokens']);
|
||||
$this->assertEquals('150', $result['completiontokens']);
|
||||
$this->assertEquals('deepseek-chat', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test query_ai_api for a successful call.
|
||||
*/
|
||||
public function test_query_ai_api_success(): void {
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$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('3a621831-3173-4437-b58a-f16287ecce85', $result['id']);
|
||||
$this->assertEquals('fp_8802369eaa_prod0623_fp8_kvcache', $result['fingerprint']);
|
||||
$this->assertStringContainsString('Mono Potassium Phosphate (MKP)', $result['generatedcontent']);
|
||||
$this->assertEquals('stop', $result['finishreason']);
|
||||
$this->assertEquals('23', $result['prompttokens']);
|
||||
$this->assertEquals('150', $result['completiontokens']);
|
||||
$this->assertEquals('deepseek-chat', $result['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response success.
|
||||
*/
|
||||
public function test_prepare_response_success(): void {
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => true,
|
||||
'id' => '3a621831-3173-4437-b58a-f16287ecce85',
|
||||
'fingerprint' => 'fp_8802369eaa_prod0623_fp8_kvcache',
|
||||
'generatedcontent' => 'Mono Potassium Phosphate (MKP)',
|
||||
'finishreason' => 'stop',
|
||||
'prompttokens' => '23',
|
||||
'completiontokens' => '150',
|
||||
'model' => 'deepseek-chat',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals('summarise_text', $result->get_actionname());
|
||||
$this->assertEquals($response['success'], $result->get_success());
|
||||
$this->assertEquals($response['generatedcontent'], $result->get_response_data()['generatedcontent']);
|
||||
$this->assertEquals($response['model'], $result->get_response_data()['model']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test prepare_response error.
|
||||
*/
|
||||
public function test_prepare_response_error(): void {
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
|
||||
// We're working with a private method here, so we need to use reflection.
|
||||
$method = new \ReflectionMethod($processor, 'prepare_response');
|
||||
|
||||
$response = [
|
||||
'success' => false,
|
||||
'errorcode' => 500,
|
||||
'error' => 'Internal server error',
|
||||
'errormessage' => 'Try again later',
|
||||
];
|
||||
|
||||
$result = $method->invoke($processor, $response);
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertFalse($result->get_success());
|
||||
$this->assertEquals('summarise_text', $result->get_actionname());
|
||||
$this->assertEquals($response['error'], $result->get_error());
|
||||
$this->assertEquals($response['errorcode'], $result->get_errorcode());
|
||||
$this->assertEquals($response['errormessage'], $result->get_errormessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method.
|
||||
*/
|
||||
public function test_process(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
|
||||
$processor = new process_summarise_text($this->provider, $this->action);
|
||||
$result = $processor->process();
|
||||
|
||||
$this->assertInstanceOf(\core_ai\aiactions\responses\response_base::class, $result);
|
||||
$this->assertTrue($result->get_success());
|
||||
$this->assertEquals('summarise_text', $result->get_actionname());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with error.
|
||||
*/
|
||||
public function test_process_error(): void {
|
||||
// Log in user.
|
||||
$this->setUser($this->getDataGenerator()->create_user());
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// The response from Deepseek.
|
||||
$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 {
|
||||
// Create users.
|
||||
$user1 = $this->getDataGenerator()->create_user();
|
||||
$user2 = $this->getDataGenerator()->create_user();
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// Mock clock.
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Set the user rate limiter.
|
||||
$config = [
|
||||
'apikey' => '123',
|
||||
'enableuserratelimit' => true,
|
||||
'userratelimit' => 1,
|
||||
];
|
||||
$provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\summarise_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'deepseek-chat',
|
||||
'endpoint' => 'https://api.deepseek.com/chat/completions',
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// Case 1: User rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: User rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertEquals(429, $result->get_errorcode());
|
||||
$this->assertEquals(
|
||||
expected: 'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
|
||||
actual: $result->get_errormessage(),
|
||||
);
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 3: User rate limit has not been reached for a different user.
|
||||
// Log in user2.
|
||||
$this->setUser($user2);
|
||||
$this->create_action($user2->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 4: Time window has passed, user rate limit should be reset.
|
||||
$clock->bump(11);
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
|
||||
/**
|
||||
* Test process method with global rate limiter.
|
||||
*/
|
||||
public function test_process_with_global_rate_limiter(): void {
|
||||
// Create users.
|
||||
$user1 = $this->getDataGenerator()->create_user();
|
||||
$user2 = $this->getDataGenerator()->create_user();
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// Mock clock.
|
||||
$clock = $this->mock_clock_with_frozen();
|
||||
|
||||
// Set the global rate limiter.
|
||||
$config = [
|
||||
'apikey' => '123',
|
||||
'enableglobalratelimit' => true,
|
||||
'globalratelimit' => 1,
|
||||
];
|
||||
$provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: [
|
||||
\core_ai\aiactions\summarise_text::class => [
|
||||
'settings' => [
|
||||
'model' => 'deepseek-chat',
|
||||
'endpoint' => 'https://api.deepseek.com/chat/completions',
|
||||
'systeminstruction' => get_string('action_summarise_text_instruction', 'core_ai'),
|
||||
],
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
// Mock the http client to return a successful response.
|
||||
['mock' => $mock] = $this->get_mocked_http_client();
|
||||
|
||||
// Case 1: Global rate limit has not been reached.
|
||||
$this->create_action($user1->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
|
||||
// Case 2: Global rate limit has been reached.
|
||||
$clock->bump(HOURSECS - 10);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertEquals(429, $result->get_errorcode());
|
||||
$this->assertEquals(
|
||||
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
|
||||
actual: $result->get_errormessage(),
|
||||
);
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 3: Global rate limit has been reached for a different user too.
|
||||
// Log in user2.
|
||||
$this->setUser($user2);
|
||||
$this->create_action($user2->id);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertFalse($result->get_success());
|
||||
|
||||
// Case 4: Time window has passed, global rate limit should be reset.
|
||||
$clock->bump(11);
|
||||
// Log in user1.
|
||||
$this->setUser($user1);
|
||||
// The response from Deepseek.
|
||||
$mock->append(new Response(
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
$this->responsebodyjson,
|
||||
));
|
||||
$this->create_action($user1->id);
|
||||
$processor = new process_summarise_text($provider, $this->action);
|
||||
$result = $processor->process();
|
||||
$this->assertTrue($result->get_success());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
/**
|
||||
* Test Deepseek provider methods.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Yusuf Wibisono <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*
|
||||
* @covers \aiprovider_deepseek\provider
|
||||
*/
|
||||
final class provider_test extends \advanced_testcase {
|
||||
/** @var \core_ai\manager */
|
||||
private $manager;
|
||||
|
||||
/** @var \core_ai\provider */
|
||||
private $provider;
|
||||
|
||||
/**
|
||||
* Overriding setUp() function to always reset after tests.
|
||||
*/
|
||||
public function setUp(): void {
|
||||
parent::setUp();
|
||||
$this->resetAfterTest();
|
||||
|
||||
// Create the provider instance.
|
||||
$this->manager = \core\di::get(\core_ai\manager::class);
|
||||
$config = ['data' => 'goeshere'];
|
||||
$this->provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test get_action_list
|
||||
*/
|
||||
public function test_get_action_list(): void {
|
||||
$actionlist = $this->provider->get_action_list();
|
||||
$this->assertIsArray($actionlist);
|
||||
$this->assertCount(3, $actionlist);
|
||||
$this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist);
|
||||
$this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist);
|
||||
$this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test is_request_allowed.
|
||||
*/
|
||||
public function test_is_request_allowed(): void {
|
||||
// Create the provider instance.
|
||||
$config = [
|
||||
'enableuserratelimit' => true,
|
||||
'userratelimit' => 3,
|
||||
'enableglobalratelimit' => true,
|
||||
'globalratelimit' => 5,
|
||||
];
|
||||
|
||||
$provider = $this->manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
);
|
||||
|
||||
$contextid = 1;
|
||||
$userid = 1;
|
||||
$prompttext = 'This is a test prompt';
|
||||
$action = new \core_ai\aiactions\generate_text(
|
||||
contextid: $contextid,
|
||||
userid: $userid,
|
||||
prompttext: $prompttext,
|
||||
);
|
||||
|
||||
// Make 3 requests, all should be allowed.
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$this->assertTrue($provider->is_request_allowed($action));
|
||||
}
|
||||
|
||||
// The 4th request for the same user should be denied.
|
||||
$result = $provider->is_request_allowed($action);
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals(
|
||||
'You have reached the maximum number of AI requests you can make in an hour. Try again later.',
|
||||
$result['errormessage'],
|
||||
);
|
||||
|
||||
// Change user id to make a request for a different user, should pass (4 requests for global rate).
|
||||
$action = new \core_ai\aiactions\generate_text(
|
||||
contextid: $contextid,
|
||||
userid: 2,
|
||||
prompttext: $prompttext,
|
||||
);
|
||||
$this->assertTrue($provider->is_request_allowed($action));
|
||||
|
||||
// Make a 5th request for the global rate limit, it should be allowed.
|
||||
$this->assertTrue($provider->is_request_allowed($action));
|
||||
|
||||
// The 6th request should be denied.
|
||||
$result = $provider->is_request_allowed($action);
|
||||
$this->assertFalse($result['success']);
|
||||
$this->assertEquals(
|
||||
expected: 'The AI service has reached the maximum number of site-wide requests per hour. Try again later.',
|
||||
actual: $result['errormessage'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
namespace aiprovider_deepseek;
|
||||
|
||||
/**
|
||||
* Trait for test cases.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Yusuf Wibisono <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
trait testcase_helper_trait {
|
||||
/**
|
||||
* Create the provider object.
|
||||
*
|
||||
* @param string $actionclass The action class to use.
|
||||
* @param array $actionconfig The action configuration to use.
|
||||
*/
|
||||
public function create_provider(
|
||||
string $actionclass,
|
||||
array $actionconfig = [],
|
||||
): \core_ai\provider {
|
||||
$manager = \core\di::get(\core_ai\manager::class);
|
||||
$config = [
|
||||
'enableuserratelimit' => true,
|
||||
'userratelimit' => 1,
|
||||
'enableglobalratelimit' => true,
|
||||
'globalratelimit' => 1,
|
||||
'apikey' => '123',
|
||||
];
|
||||
$defaultactionconfig = [
|
||||
$actionclass => [
|
||||
'settings' => [
|
||||
'model' => 'deepseek-chat',
|
||||
'endpoint' => "https://api.deepseek.com/chat/completions",
|
||||
],
|
||||
],
|
||||
];
|
||||
foreach ($actionconfig as $key => $value) {
|
||||
$defaultactionconfig[$actionclass]['settings'][$key] = $value;
|
||||
}
|
||||
$provider = $manager->create_provider_instance(
|
||||
classname: '\aiprovider_deepseek\provider',
|
||||
name: 'dummy',
|
||||
config: $config,
|
||||
actionconfig: $defaultactionconfig,
|
||||
);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Version information for aiprovider_deepseek.
|
||||
*
|
||||
* @package aiprovider_deepseek
|
||||
* @copyright 2025 Matt Porritt <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->component = 'aiprovider_deepseek';
|
||||
$plugin->version = 2025041400;
|
||||
$plugin->requires = 2025040800;
|
||||
$plugin->maturity = MATURITY_STABLE;
|
||||
Reference in New Issue
Block a user