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.
Source code: github.com/jeanlucio/moodle-local_aihub
β¨ 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 request served by the site keys, across all users, with 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 request (user, requesting component, what was generated, provider, model, key tier) and a scheduled task that purges logs past a configurable retention.
- π 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.
π 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 request served by the site keys across all users, with CSV and Excel download. - 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 |
6 | OpenAI base-URL/model defaults; 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/site keys |
tests/local/client_test.php |
9 | is_safe_url SSRF cases (http, loopback, private range, public IP); DNS-rebinding branch stubbed via dns_stub_client β a resolved private IP is blocked, a resolved public IP passes, no records resolves through; resolve_openai_url appends /chat/completions; personal tier wins over site; provider fall-through within a tier (Gemini β Groq, and Gemini/Groq β DeepSeek); no key β success=false (no real HTTP) |
tests/local/usage_log_test.php |
3 | Record insert (with keysource, empty model nulled); site-scoped readers excluding personal/untagged rows; recent-per-user filtering |
tests/local/export_test.php |
2 | Personal usage export (all rows, every column); site-keys report export including the user column and excluding personal usage |
tests/ai_test.php |
4 | is_available states; a successful generation logs the calling component, the description and the key tier; a failed generation logs nothing; report_usage() writes a log row for a consumer that resolved its own key |
tests/privacy_provider_test.php |
4 | Metadata declaration; context/user discovery; export_user_data (log rows + redacted key value); per-user deletion with isolation |
tests/output/mykeys_test.php |
2 | Per-provider personal-key status (set/unset) with no key value ever placed in the template context; recent usage rows carry the correct provider icon, including the fallback for an unrecognised provider |
tests/output/report_test.php |
2 | Site-keys report rows carry the requesting userβs name and correct provider icon, excluding personal-key rows; empty state when there is no site-keys usage |
tests/task/purge_old_logs_test.php |
2 | Rows older than the configured retention are deleted, newer rows are kept; a retention of 0 keeps every row indefinitely |
| Total | 34 | Β |
vendor/bin/phpunit --testsuite local_aihub
Line coverage by class (PHPUnit + Xdebug):
| Class | Line coverage |
|---|---|
ai |
85% |
local\client |
36% |
local\export |
83% |
local\keys |
92% |
local\usage_log |
85% |
output\mykeys |
100% |
output\renderer |
0% |
output\report |
100% |
privacy\provider |
86% |
task\purge_old_logs |
86% |
| Overall | 73% |
Two classes are intentionally not fully unit-tested, both for the same reason β the untested lines are live network calls, not business logic:
local\client:call_gemini(),call_groq(),call_deepseek(),call_openai_compatible()andhttp_post()make real HTTP calls to external providers, so they are never unit-tested against the live network.tests/fixtures/mock_client.phpoverrides them to test the key-resolution ladder in isolation; the transport methods themselves are verified by hand against the real APIs before each release instead. The DNS-rebinding branch ofis_safe_url()used to fall in the same bucket (a realdns_get_record()call), but its resolution step was extracted intoresolve_dns(), whichtests/fixtures/dns_stub_client.phpoverrides β so that branch is now fully covered without any real DNS lookup.output\renderer: its only method delegates a single line torender_from_template(). Asserting the resulting HTML is a job for the Behat suite below (mykeys.feature), not PHPUnit.
Behat β Acceptance Tests
| Feature file | Scenarios | What is covered |
|---|---|---|
tests/behat/mykeys.feature |
2 | A provider starts unconfigured; saving a personal key marks it configured without revealing the stored value |
| Total | 2 | Β |
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-v4-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