AI Hub is a small BYOK (bring your own key) broker for Moodle. It lets the institution’s own plugins generate text through shared AI API keys, without each plugin reimplementing the HTTP transport, the SSRF guard, the provider ladder or a key store.
👈 Use the sidebar to jump to any section on this page.
✨ Features
- 🔑 BYOK key store: site keys (admin) and optional personal keys (per user, opt-in) for Gemini, Groq, DeepSeek and any OpenAI-compatible endpoint.
- 🪜 Personal → site resolution: the hub tries the user’s own key first, then the site key, exposing a single result to the caller.
- 🧩 One-call facade:
\local_aihub\ai::generate_text()andis_available()— consumed by sibling plugins through a soft dependency (class_exists), with no hard dependency entry. - 🚫 Does not wrap
core_ai: each consumer keeps its owncore_aifallback, so a site that already hascore_aiconfigured needs no extra setup — the hub stays optional. - 👁️ Write-only personal keys: once saved, a personal key is never returned to the browser — the page only reports a configured / not configured status, closing the read vector via Log in as.
- 🧑💻 Self-service page: My AI keys (in the user’s preferences) to add/replace/remove personal keys and review one’s own recent usage.
- 📊 Administrator usage report: every provider call served by the site keys, across all users, with failures-only and successes-only filters and CSV / Excel download — gated by
local/aihub:viewusage. - 🛡️ SSRF guard: the configurable OpenAI-compatible endpoint is forced to HTTPS, with loopback / link-local / private ranges blocked and DNS A/AAAA anti-rebinding.
- 🧾 Usage log + retention task: one row per provider call, not per request (user, requesting component, what was generated, provider, model, key tier, outcome and — when it failed — the provider’s own reason), and a scheduled task that purges logs past a configurable retention. A request that falls through to another provider therefore leaves a row for each, so a key that never works cannot hide behind whichever one answers.
- 🔒 Privacy-complete: full Privacy provider for the usage log and personal preferences, with the three external destinations declared.
🧩 How consumers use it
A sibling plugin consumes the hub through a runtime guard and keeps its own core_ai fallback:
// Availability: hub keys first, then the consumer's own core_ai fallback.
public static function has_ai(): bool {
if (class_exists(\local_aihub\ai::class) && \local_aihub\ai::is_available()) {
return true; // BYOK via the hub
}
return self::has_core_ai(); // works without the hub
}
// Generation.
$result = \local_aihub\ai::generate_text(
'', // system prompt (optional)
$prompt, // user prompt
false, // JSON mode
'your_frankenstyle', // 4th arg: caller component, for the usage log
'Short label' // 5th arg: what is being generated, shown in the admin report
);
if ($result['success']) {
// $result['data'] is RAW, untrusted text — validate and format_text() it.
}
The returned text is raw and untrusted: validate its structure and pass it through format_text() before displaying or persisting it.
Two notes on the return value. The generated text is under data, and the reason for a failure
under message — reading the array as an object returns null without erroring, which turns
every success into a silent fallback. And one call can log several rows: the hub records each
provider it tried, under the component and label you passed, so a request that fell through to a
second provider appears twice in the report, once failed and once succeeded.
🔗 Key resolution (BYOK ladder)
The hub resolves a key tier by tier and stops at the first tier that holds a key:
| Tier | Source |
|---|---|
| 1 | Personal key — the user’s own key, when personal keys are enabled and the user has local/aihub:usepersonalkey |
| 2 | Site key — the admin key set in the hub settings |
Within the chosen tier, providers are tried in the order Gemini → Groq → DeepSeek → OpenAI-compatible (first key found is used; if its call fails, the next provider in the same tier is tried). When no tier holds a key, generate_text() returns success = false — the hub never falls back to core_ai; that decision belongs to the consumer.
📦 Requirements
| Component | Version |
|---|---|
| Moodle | 4.5+ |
| PHP | 8.1+ |
No bundled third-party libraries. The AI providers are external services, declared in the Privacy provider — not in thirdpartylibs.xml.
🛠️ Installation & Configuration
- Download the
.zipfile or clone this repository. - Extract the folder into your Moodle
local/directory. - Rename the folder to
aihub(if necessary). Final path:your-moodle/local/aihub/ - Visit Site administration → Notifications to complete installation.
Configuring a provider key is optional — the plugin installs and works without one. See Usage below for setting site keys and enabling personal keys.
📖 Usage
- Configure site keys (admin): Site administration → Plugins → Local plugins → AI Hub → Settings. Set any of the Gemini, Groq, DeepSeek or OpenAI-compatible keys, and (optionally) enable personal API keys.
- Add a personal key (user): users who have the
local/aihub:usepersonalkeycapability get a My AI keys entry in their preferences, where they store their own key (write-only) and see their recent usage. - Review site-key usage (admin): the AI usage report (under the AI Hub category, capability
local/aihub:viewusage) lists every provider call served by the site keys across all users, with CSV and Excel download. Each attempt gets its own row, carrying the outcome and the reason when it failed, and filters for failures only or successes only answer whether a provider has stopped responding, or narrow the list back to what was actually generated. - Consume from a plugin (developer): call
\local_aihub\ai::generate_text()behind aclass_exists()guard, keeping your owncore_aifallback (see How consumers use it).
🧪 Automated Tests
The hub ships with a PHPUnit and Behat suite; every CI push runs against the matrix (Moodle 4.5 → 5.x, PostgreSQL & MariaDB).
PHPUnit — Unit & Integration Tests
| Test file | Cases | What is covered |
|---|---|---|
tests/local/keys_test.php |
8 | OpenAI base-URL/model defaults, including the fallback reached only when an admin blanks the setting; personal-key get/save/clear roundtrip; personal OpenAI-compatible URL/model roundtrip; personal_keys_allowed honouring the toggle and the capability; key resolution personal → site; has_any_key across personal and site keys |
tests/local/client_test.php |
21 | is_safe_url SSRF cases (http, loopback, private range, missing host, public IP); DNS-rebinding branch stubbed via dns_stub_client; resolve_openai_url appends /chat/completions; personal tier wins over site; provider fall-through within a tier (Gemini → Groq → DeepSeek); attempts are reported back, including a failure covered by a later success and attempts spanning both tiers; the request each provider assembles — Gemini’s own system-instruction and JSON-mode fields versus the chat-completions shape, per-provider endpoint and model; the OpenAI leg resolving personal-then-site URL and model field by field; response parsing per provider, error unwrapping, transport failure, and an unexpected body |
tests/local/usage_log_test.php |
8 | Record insert (with keysource, empty model nulled); a failed attempt keeping its reason; the outcome filter narrowing both readers in either direction; site-scoped readers excluding personal/untagged rows; the export reader returning every row past the screen’s cap; display_name naming a person, the system or an unresolved id |
tests/local/export_test.php |
3 | Personal usage export (all rows, every column); a failed attempt exporting its outcome and reason; site-keys report export including the user column and excluding personal usage |
tests/ai_test.php |
6 | is_available states; a successful generation logs the calling component, the description and the key tier; no provider called logs nothing; every attempt is logged, so a failure covered by a later success leaves two rows; report_usage() writing a row for a consumer that resolved its own key, success or failure |
tests/db_upgrade_test.php |
3 | The upgrade step adds the column with the type, length and nullability install.xml declares; existing rows keep their data and read as successes; a second run is a no-op |
tests/lib_test.php |
4 | The navigation entry is added on the user’s own preferences page, and not on someone else’s, not without the capability, and not when personal keys are disabled site-wide |
tests/privacy_provider_test.php |
9 | Metadata declaration; context/user discovery; export_user_data (log rows + redacted key value); the readers refusing a non-user context and a user with no rows; per-user deletion, whole-context deletion and approved-user-list deletion, each with isolation |
tests/output/mykeys_test.php |
4 | Per-provider personal-key status (set/unset) with no key value ever placed in the template context; usage rows carrying the right provider icon, including the fallback for an unrecognised provider; a failed attempt showing its reason; the page rendering end to end with no stored key in the markup |
tests/output/report_test.php |
9 | Site-keys report rows carry the requesting user’s name and correct provider icon, excluding personal-key rows; empty state; failed rows carrying their reason; each of the three filters narrowing to its own outcome, with exactly one reading as active and an unrecognised one falling back to the full list; the per-filter empty wording; a request with no user attributed to the system; a row written before the failure columns existed still reading as a success; the page rendering end to end |
tests/task/purge_old_logs_test.php |
3 | Rows older than the configured retention are deleted, newer rows are kept; a retention of 0 keeps every row indefinitely; the task names itself from a language string |
| Total | 78 |
vendor/bin/phpunit --testsuite local_aihub
Line coverage by class (PHPUnit + Xdebug):
| Class | Line coverage |
|---|---|
ai |
95% |
local\client |
91% |
local\export |
86% |
local\keys |
100% |
local\usage_log |
100% |
output\mykeys |
100% |
output\renderer |
100% |
output\report |
100% |
privacy\provider |
100% |
task\purge_old_logs |
100% |
lib.php |
100% |
| Overall | 96% |
The transport layer used to sit outside the suite, on the grounds that it makes live HTTP calls.
That reasoning covered more ground than it should have: building a request and parsing a response
are not network operations, and leaving them to the mock meant the part of the plugin that talks
to providers — including the code that turns an API refusal into the sentence an administrator
reads — was the one part nothing checked. Two fixtures reach it now: recording_client runs the
real call_* methods and captures what they assembled, and stub_transport_client answers from a
canned transport so the parsing runs for real, through a single seam (make_curl()).
What is left uncovered is genuinely out of reach of a unit test:
local\export—download()anddownload_site()hand off to the dataformat API and thendie(). The rows they carry are covered; the response itself is covered by Behat below.local\client—resolve_dns()performs a real DNS lookup, andmake_curl()exists to be replaced. The decision that depends on the lookup, including the DNS-rebinding branch, is fully covered throughdns_stub_client.ai— one line: the guard that refusesset_client_for_testing()outside a test, which by definition cannot run inside one.
Behat — Acceptance Tests
| Feature file | Scenarios | What is covered |
|---|---|---|
tests/behat/mykeys.feature |
5 | A provider starts unconfigured; saving a personal key marks it configured without revealing the stored value; a failed attempt of the user’s own showing its reason; the personal history downloading as CSV and as Excel |
tests/behat/report.feature |
7 | Both attempts of one request listed with the reason the first failed; the failures filter hiding what worked, the successes filter hiding what failed, and returning to the full list; a request made outside a session attributed rather than shown as an id; the report downloading as CSV and as Excel |
| Total | 12 |
php admin/tool/behat/cli/init.php
vendor/bin/behat --tags=@local_aihub --profile=chrome
🔐 Security & Compliance
- Capability- and opt-in-gated personal keys (
local/aihub:usepersonalkeyand the site toggle). - Write-only personal keys — never returned to the page after saving, even under Log in as.
- Site keys stored with
admin_setting_configpasswordunmask; the SSRF guard protects the configurable endpoint. require_sesskey()on the key form; capabilitylocal/aihub:viewusageon the usage report.- Full Privacy API coverage (export/delete of the usage log and personal preferences) with the external destinations declared.
🔎 Third-party Service Disclosure
The hub transmits prompt text to a third-party AI provider only when a key is configured and a generation is requested.
Is an API key required?
No. The plugin installs and runs without any key — it simply reports that no source is available, and consumers fall back to their own core_ai integration. No external request is made until a site or personal key is set.
Supported providers
- Google Gemini — https://ai.google.dev/ — model:
gemini-flash-latest(rolling alias, always Google’s current Flash release; fixed, not configurable) - Groq — https://console.groq.com/ — model:
openai/gpt-oss-120b(fixed, not configurable; see Groq model choice below) - DeepSeek — https://deepseek.com/ — model:
deepseek-flash(fixed, not configurable) - OpenAI-compatible APIs — any provider that follows the OpenAI API format (OpenRouter, self-hosted models via LM Studio, an Ollama proxy, etc.) — model: configurable per key (site setting / personal key), defaults to
gpt-4o-miniwhen left empty
These services operate under their own terms of service and privacy policies.
Groq model choice
The Groq slot calls a single fixed model, openai/gpt-oss-120b. Groq periodically deprecates
specific model IDs (e.g. llama-3.3-70b-versatile, decommissioned 2026-08-16) and expects
migration to a named replacement. Among the free-tier options Groq recommended for this migration,
openai/gpt-oss-120b was chosen over qwen/qwen3.6-27b for stronger instruction-following and
more reliable JSON-mode output across the varied prompts sent by different consumer plugins.
How to obtain an API key
API keys are created directly on the provider’s official website. Gemini, Groq and DeepSeek currently offer free usage tiers or trial credits (pricing policies may change). The hub does not provide API keys.
Where keys are configured
- Personal key — set by each user in My AI keys (preferences), when personal keys are enabled and the user has the capability.
- Site key — set by the admin in Site administration → Plugins → Local plugins → AI Hub.
Data Transmission
When a key resolves to a provider, the prompt text is transmitted to that provider’s API to generate the response:
- Google Gemini —
generativelanguage.googleapis.com - Groq —
api.groq.com - DeepSeek —
api.deepseek.com - OpenAI-compatible — the endpoint configured by the admin or user (default
api.openai.com)
The hub stores a usage log (who requested, which component, a short label of what was generated, provider, model, key tier and time) but does not store prompts or AI responses. All external destinations are declared in the plugin’s Privacy provider.
Demo credentials
Not applicable — no credentials are required to install or use the hub. Every AI feature stays inert until a site or personal key is configured.
📄 License
This project is licensed under the GNU General Public License v3 (GPLv3).
Copyright: 2026 Jean Lúcio