Hello all.
There have been a lot of very cool developments with modx and generative AI recently, so I thought I would share my latest thing.
Aixo is a service class that you can build AI plugins and snippets on top of. One benefit is that it the architecture of it allows you to connect to any provider you want (OpenAI, HuggingFace etc.) as well as local models (theoretically).
It works like this. First you have the main class in aixo/src/Aixo.php
<?php
namespace MODX\Aixo;
use MODX\Revolution\modX;
use MODX\Aixo\Providers\AixoProviderInterface;
class Aixo {
/** @var modX */
protected $modx;
/** @var array<string, AixoProviderInterface> Loaded provider instances keyed by provider key */
protected $providers = [];
/** @var bool Debug mode flag */
protected $debug = false;
/**
* Constructor: initialize Aixo service, load providers, and set debug mode.
*/
public function __construct(modX $modx) {
$this->modx = $modx;
// Load debug setting (defaults to false if not set)
$this->debug = (bool)$modx->getOption('aixo.debug', null, false);
// Dynamically load and instantiate all providers
$this->loadProviders();
}
/**
* Load all provider classes from the providers directory and instantiate them.
*/
protected function loadProviders(): void {
$providersDir = __DIR__ . '/Providers';
if (!is_dir($providersDir)) {
return;
}
// Include all PHP files in the providers directory (except interface or abstract classes)
foreach (glob($providersDir . '/*.php') as $providerFile) {
if (strpos($providerFile, 'Interface.php') !== false || strpos($providerFile, 'Abstract') !== false) {
continue; // skip interface and abstract base class files
}
require_once($providerFile);
}
// Instantiate each provider class that implements the interface
foreach (get_declared_classes() as $className) {
// Only consider classes in the MODX\Aixo\Providers namespace
if (strpos($className, 'MODX\\Aixo\\Providers\\') === 0) {
if (in_array(AixoProviderInterface::class, class_implements($className) ?: [])) {
/** @var AixoProviderInterface $provider */
$provider = new $className($this->modx);
// Use the provider's key (identifier) to store it
$key = strtolower($provider->getKey());
$this->providers[$key] = $provider;
}
}
}
}
/**
* Get a provider by name/key.
*/
public function getProvider(string $name): ?AixoProviderInterface {
$key = strtolower($name);
return $this->providers[$key] ?? null;
}
/**
* Return all loaded providers.
* @return AixoProviderInterface[]
*/
public function getProviders(): array {
return $this->providers;
}
/**
* Process an AI request using a specified or default provider.
*
* @param string $prompt The input prompt/question for the AI.
* @param string|null $providerName Optional provider key (e.g. "openai"); if null or empty, uses default provider.
* @param array $options Additional options (model, temperature, etc.) to override defaults.
* @return string The AI-generated response (or an empty string on failure).
*/
public function process(string $prompt, ?string $providerName = null, array $options = []): string {
if (trim($prompt) === '') {
// No prompt provided
return '';
}
// Determine which provider to use
$providerKey = strtolower($providerName ?? $this->modx->getOption('aixo.default_provider', null, 'openai'));
if (!isset($this->providers[$providerKey])) {
// Provider not found
$this->modx->log(modX::LOG_LEVEL_ERROR, "[Aixo] Provider '{$providerKey}' is not available (not installed).");
return '';
}
$provider = $this->providers[$providerKey];
// Check provider availability (e.g. API key configured)
if (!$provider->isAvailable()) {
$this->modx->log(modX::LOG_LEVEL_ERROR, "[Aixo] Provider '{$providerKey}' is not configured or available.");
return '';
}
// Merge default model/temperature if not provided in options
if (empty($options['model'])) {
$options['model'] = $this->modx->getOption('aixo.default_model', null, '');
}
if (empty($options['temperature'])) {
// Note: stored as string, ensure float
$options['temperature'] = $this->modx->getOption('aixo.default_temperature', null, '0.7');
}
// Log the request if in debug mode
if ($this->debug) {
$this->modx->log(modX::LOG_LEVEL_INFO, "[Aixo] Request to provider '{$providerKey}' with prompt: " . $prompt);
}
// Perform the AI request via the provider
$result = '';
try {
$result = (string) $provider->process($prompt, $options);
} catch (\Exception $e) {
// Catch any unexpected exception from provider
$providerError = $e->getMessage();
$provider->getLastError() || $providerError; // ensure lastError is populated if exception
$this->modx->log(modX::LOG_LEVEL_ERROR, "[Aixo] Exception in provider '{$providerKey}': " . $providerError);
}
// Check for errors reported by provider
$errorMsg = $provider->getLastError();
if (!empty($errorMsg)) {
// Log errors (always log errors, even if not in debug mode)
$this->modx->log(modX::LOG_LEVEL_ERROR, "[Aixo] Error from provider '{$providerKey}': " . $errorMsg);
// In debug mode, also note the prompt that caused it (already logged above)
} else {
// If no error and in debug mode, log the response
if ($this->debug) {
$this->modx->log(modX::LOG_LEVEL_INFO, "[Aixo] Response from '{$providerKey}': " . $result);
}
}
////WIP/////
// Assume $response is the API response and $responseContent is the text to return.
// Example: for OpenAI, $response may be an array or object containing 'usage'.
$providerName = $options['provider'] ?? 'Unknown';
$modelName = $options['model'] ?? 'Unknown';
$metadata = $options['metadata'] ?? null;
// Retrieve token count from API response
$tokensUsed = 0;
if (isset($response['usage']['total_tokens'])) {
$tokensUsed = (int) $response['usage']['total_tokens'];
} elseif (isset($response['token_count'])) {
// If a different provider returns token count in another format
$tokensUsed = (int) $response['token_count'];
}
// Log the token usage if we have data
if ($tokensUsed > 0) {
// Make sure the Aixo package is loaded for xPDO
$this->modx->addPackage('aixo', $this->corePath . 'model/');
// Create a new log object and set fields
$usageLog = $this->modx->newObject('modAixoTokenUsage');
if ($usageLog) {
$usageLog->fromArray([
'provider' => $providerName,
'model' => $modelName,
'tokens' => $tokensUsed,
'timestamp' => date('Y-m-d H:i:s'),
'metadata' => $metadata,
]);
$usageLog->save();
}
}
return $result;
}
}
This does most of the heavy lifting for you. It basically takes your selected Provider class and then creates a new service using the modx DI Container called
$aixo = $modx->services->get('aixo');
You can use this new service is any snippet or plugin (or whatever) by just sending a prompt and handling the response:
// Process the prompt through Aixo's AI service
$response = $aixo->process($prompt, $provider, $options);
// Return the AI-generated response (or an empty string on error)
return $response;
Which is pretty simple.
But how do the Provider classes work? Well, it relies on a base called aixo/src/providers/AixoProviderInterface.php which looks like this:
<?php
namespace MODX\Aixo\Providers;
interface AixoProviderInterface {
/**
* A unique provider key (identifier used in settings and code, e.g. "openai").
*/
public function getKey(): string;
/**
* Human-readable provider name (for display, e.g. "OpenAI API").
*/
public function getName(): string;
/**
* Whether this provider is available for use (e.g. proper configuration in place).
*/
public function isAvailable(): bool;
/**
* Process an AI prompt and return the response text.
* @param string $prompt The input text/prompt for AI.
* @param array $options Options such as model, temperature, etc.
* @return string The AI-generated response (empty string on failure).
*/
public function process(string $prompt, array $options = []): string;
/**
* Get a message for the last error (if any) that occurred in process().
* Returns an empty string if the last operation was successful.
*/
public function getLastError(): string;
}
You then create an individual setup for each provider. As an example we could use OpenAI at aixo/src/providers/OpenAIProvider.php, which would look like this:
<?php
namespace MODX\Aixo\Providers;
use MODX\Revolution\modX;
class OpenAIProvider implements AixoProviderInterface {
/** @var modX */
protected $modx;
/** @var string Last error message, or empty if none */
protected $lastError = '';
public function __construct(modX $modx) {
$this->modx = $modx;
}
public function getKey(): string {
return 'openai';
}
public function getName(): string {
return 'OpenAI API';
}
public function isAvailable(): bool {
// Check that an API key is set and cURL is available
$apiKey = trim((string)$this->modx->getOption('aixo.api_key_openai', null, ''));
if (empty($apiKey)) {
return false; // No API key configured
}
if (!function_exists('curl_init')) {
// cURL PHP extension not available
return false;
}
return true;
}
public function getLastError(): string {
return $this->lastError;
}
public function process(string $prompt, array $options = []): string {
$this->lastError = ''; // Reset error
// Ensure API key is available
$apiKey = trim((string)$this->modx->getOption('aixo.api_key_openai', null, ''));
if (empty($apiKey)) {
$this->lastError = 'Missing OpenAI API key';
return '';
}
// Determine model, temperature, and max tokens
$model = $options['model'] ?? $this->modx->getOption('aixo.default_model', null, 'gpt-3.5-turbo');
$temperature = $options['temperature'] ?? $this->modx->getOption('aixo.default_temperature', null, '0.7');
$maxTokens = $options['max_tokens'] ?? $this->modx->getOption('aixo.max_tokens', null, '256');
// Prepare the API request using the chat completions endpoint
$endpoint = "https://api.openai.com/v1/chat/completions";
$requestData = [
'model' => $model,
'messages' => [
['role' => 'system', 'content' => 'You are a helpful assistant.'],
['role' => 'user', 'content' => $prompt]
],
'max_tokens' => intval($maxTokens),
'temperature' => floatval($temperature)
];
// Initialize cURL
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Set headers with content type and authorization
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"Authorization: Bearer {$apiKey}"
]);
// Set timeouts (optional)
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
curl_setopt($ch, CURLOPT_TIMEOUT, 8);
// Send data as JSON via POST
$payload = json_encode($requestData);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
// Execute request
$responseBody = curl_exec($ch);
if ($responseBody === false) {
$this->lastError = 'cURL Error: ' . curl_error($ch);
curl_close($ch);
return '';
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Check for HTTP errors
if ($httpCode !== 200) {
$this->lastError = "OpenAI API error (HTTP {$httpCode}): $responseBody";
return '';
}
// Decode JSON response
$resultData = json_decode($responseBody, true);
if (!$resultData) {
$this->lastError = 'Invalid JSON response from OpenAI';
return '';
}
if (!empty($resultData['error'])) {
$this->lastError = "OpenAI Error: " . ($resultData['error']['message'] ?? 'Unknown error');
return '';
}
// Extract the generated text from chat completions response
if (!isset($resultData['choices'][0]['message']['content'])) {
$this->lastError = 'No completion message found in response';
return '';
}
return $resultData['choices'][0]['message']['content'];
}
}
But if you want to use HuggingFace, their API setup is different and so is there response etc. So you would have to set up a different provider file such as aixo/src/HuggingFaceProvider.php
<?php
namespace MODX\Aixo\Providers;
use MODX\Revolution\modX;
class HuggingFaceProvider implements AixoProviderInterface {
/** @var modX */
protected $modx;
/** @var string Last error message */
protected $lastError = '';
public function __construct(modX $modx) {
$this->modx = $modx;
}
/**
* Returns the unique provider key.
*/
public function getKey(): string {
return 'huggingface';
}
/**
* Returns the human-readable provider name.
*/
public function getName(): string {
return 'HuggingFace API';
}
/**
* Checks if the HuggingFace provider is available (e.g. API key is set).
*/
public function isAvailable(): bool {
$apiKey = trim((string)$this->modx->getOption('aixo.api_key_huggingface', null, ''));
return !empty($apiKey) && function_exists('curl_init');
}
/**
* Processes the AI prompt using the HuggingFace Inference API.
*
* @param string $prompt The input text prompt.
* @param array $options Additional options; expects 'model' to be provided.
* @return string The generated text (or empty string on failure).
*/
public function process(string $prompt, array $options = []): string {
$this->lastError = '';
// Retrieve API key for HuggingFace from system settings.
$apiKey = trim((string)$this->modx->getOption('aixo.api_key_huggingface', null, ''));
if (empty($apiKey)) {
$this->lastError = 'Missing HuggingFace API key';
return '';
}
// Get model name from options or system setting.
$model = $options['model'] ?? $this->modx->getOption('aixo.default_model_huggingface', null, 'gpt2');
if (empty($model)) {
$this->lastError = 'No model specified for HuggingFace';
return '';
}
// Build the endpoint URL.
$endpoint = "https://api-inference.huggingface.co/models/{$model}";
// Prepare the payload.
$data = [
'inputs' => $prompt
];
$payload = json_encode($data);
$ch = curl_init($endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
"Authorization: Bearer {$apiKey}"
]);
// Optional: set timeouts
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 8);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
$responseBody = curl_exec($ch);
if ($responseBody === false) {
$this->lastError = 'cURL Error: ' . curl_error($ch);
curl_close($ch);
return '';
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$this->lastError = "HuggingFace API error (HTTP {$httpCode}): " . $responseBody;
return '';
}
// Decode the JSON response.
$resultData = json_decode($responseBody, true);
if (!$resultData) {
$this->lastError = 'Invalid JSON response from HuggingFace';
return '';
}
if (isset($resultData['error'])) {
$this->lastError = "HuggingFace Error: " . $resultData['error'];
return '';
}
// Assume the generated text is in the 'generated_text' field,
// but adjust based on the actual API response structure.
if (isset($resultData[0]['generated_text'])) {
return $resultData[0]['generated_text'];
} elseif (isset($resultData['generated_text'])) {
return $resultData['generated_text'];
}
$this->lastError = 'No generated text found in HuggingFace response';
return '';
}
/**
* Returns the last error message.
*/
public function getLastError(): string {
return $this->lastError;
}
}
You could do the same for Anthropic, Gemini etc.
Let’s assume we stay with OpenAI. All you now need to do is to set a couple of System Settings:
aixo.api_key_openai : YOUR_API_KEY
aixo.debug : YES/NO // Verbose debug mode including number of tokens etc.
aixo.default_model : gpt-4o // Set this to whatever works for your Provider
aixo.default_provider : openai // You can overwrite this every time if you have multiple Providers
aixo.default_temperature : 0.6 // Default value, again can be overwritten at runtime
aixo.max_tokens : 16384 // I mean, sure, why not?
So now you are ready to build amazing AI things on top of this Multi-Provider LLM wrapper. Here is an example of a simple snippet which just takes a raw prompt and spits back the response:
<?php
/**
* Aixo snippet: Call the Aixo AI service and return its response.
*
* This snippet accepts a prompt as either plain text or as a chunk reference.
* For example:
* Plain text: [[!Aixo? &prompt=`What is the capital of Guatemala?`]]
* Chunk reference: [[!Aixo? &prompt=`[[$question]]`]]
*
* When a chunk reference is detected (by checking if the prompt starts with "[[$"
* and ends with "]]"), the snippet will call $modx->getChunk() to render the chunk,
* then pass its content to the Aixo service.
*
* Additional parameters (like &provider, &model, &temperature) override the global defaults.
*/
// Retrieve the prompt property
$prompt = $modx->getOption('prompt', $scriptProperties, '');
if (empty($prompt)) {
return ''; // No prompt provided.
}
// Check if the prompt is a chunk reference in the format [[$chunkName]]
if (substr($prompt, 0, 4) === '[[$' && substr($prompt, -2) === ']]') {
// Extract the chunk name (remove [[$ and ]])
$chunkName = substr($prompt, 4, -2);
// Render the chunk content
$prompt = $modx->getChunk($chunkName);
if (empty($prompt)) {
return ''; // If the chunk is empty, return nothing.
}
}
// Determine the provider to use (or fallback to the system default)
$provider = $modx->getOption(
'provider',
$scriptProperties,
$modx->getOption('aixo.default_provider', null, 'openai')
);
// Gather additional options, e.g. model and temperature overrides
$options = [];
$model = $modx->getOption('model', $scriptProperties, '');
if (!empty($model)) {
$options['model'] = $model;
}
$temperature = $modx->getOption('temperature', $scriptProperties, '');
if ($temperature !== '') {
$options['temperature'] = is_numeric($temperature) ? (float)$temperature : $temperature;
}
// Retrieve the Aixo service from MODX's DI container
/** @var MODX\Aixo $aixo */
$aixo = $modx->services->get('aixo');
if (!$aixo) {
$modx->log(modX::LOG_LEVEL_ERROR, '[Aixo] Aixo service is not available.');
return '';
}
// Process the prompt through Aixo's AI service
$response = $aixo->process($prompt, $provider, $options);
// Return the AI-generated response (or an empty string on error)
return $response;
Slightly dirty, but it does the job.
But wait - there’s more!
I also created 2 widgets. One for the Status (to see which Providers you have set up correctly, which model you are using by default etc.) and one for Usage which gives the cumulative token usage for all your AI calls.
core/components/aixo/elements/widgets/widget.aixo-status.php
<?php
/**
* Aixo Status Dashboard Widget
* Displays the default configuration and availability of AI providers.
*/
/** @var modX $modx */
$output = '';
// Attempt to get Aixo service
$aixo = $modx->services->has('aixo') ? $modx->services->get('aixo') : null;
if (!$aixo) {
// If Aixo service isn't available, perhaps the extra isn't installed correctly
$output .= '<p style="color:red;"><strong>Aixo service is not initialized.</strong></p>';
return $output;
}
// Get system settings
$defaultProvider = $modx->getOption('aixo.default_provider', null, '(none)');
$defaultModel = $modx->getOption('aixo.default_model', null, '');
$defaultTemp = $modx->getOption('aixo.default_temperature', null, '');
$debugMode = (bool) $modx->getOption('aixo.debug', null, false);
// Start building HTML output
$output .= '<h3>Aixo Configuration</h3>';
$output .= '<p><strong>Default Provider:</strong> ' . htmlspecialchars($defaultProvider) . '</p>';
$output .= '<p><strong>Default Model:</strong> ' . htmlspecialchars($defaultModel) . '</p>';
$output .= '<p><strong>Default Temperature:</strong> ' . htmlspecialchars($defaultTemp) . '</p>';
$output .= '<p><strong>Debug Mode:</strong> ' . ($debugMode ? 'On' : 'Off') . '</p>';
// List available providers and their status
$output .= '<h4>Available Providers:</h4><ul>';
$providers = $aixo->getProviders();
if (!empty($providers)) {
/** @var MODX\Aixo\Providers\AixoProviderInterface $prov */
foreach ($providers as $key => $prov) {
$name = $prov->getName();
$status = $prov->isAvailable() ? '✅ Ready' : '⚠️ Not Configured';
// If this provider is the default, mark it
$mark = ($key === strtolower($defaultProvider)) ? ' (default)' : '';
$output .= '<li><strong>' . htmlspecialchars($name) . ":</strong> {$status}{$mark}</li>";
}
} else {
$output .= '<li>No providers loaded.</li>';
}
$output .= '</ul>';
// You can include additional info if needed, e.g., last run status or version.
return $output;
core/components/aixo/elements/widgets/widget.aixo-usage.php
<?php
/** @var modX $modx */
$modx->addPackage('aixo', $modx->getOption('core_path').'components/aixo/model/');
// Get the most recent usage log
$c = $modx->newQuery('modAixoTokenUsage');
$c->sortby('timestamp', 'DESC');
$c->limit(1);
$lastEntry = $modx->getObject('modAixoTokenUsage', $c);
if ($lastEntry) {
$lastProvider = $lastEntry->get('provider');
$lastModel = $lastEntry->get('model');
$lastTokens = $lastEntry->get('tokens');
$lastTime = $lastEntry->get('timestamp');
$lastInfo = "Last Request: {$lastProvider} (model {$lastModel}) used {$lastTokens} tokens at {$lastTime}.";
} else {
$lastInfo = "Last Request: (no data yet)";
}
// Aggregate total tokens per provider+model
$statsList = [];
$q = $modx->newQuery('modAixoTokenUsage');
$q->select([
'provider',
'model',
'SUM(`tokens`) AS total_tokens',
]);
$q->groupby('provider');
$q->groupby('model');
if ($q->prepare() && $q->stmt->execute()) {
$rows = $q->stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
$prov = $row['provider'] ?: 'Unknown';
$mod = $row['model'] ?: 'Unknown';
$total = (int) $row['total_tokens'];
$statsList[] = "{$prov} (model {$mod}): {$total} tokens";
}
}
// Build HTML output
$output = "<div class='aixo-token-stats'>";
$output .= "<p><strong>{$lastInfo}</strong></p>";
if (!empty($statsList)) {
$output .= "<h4>Total Tokens Used (by Provider/Model):</h4><ul>";
foreach ($statsList as $line) {
$output .= "<li>{$line}</li>";
}
$output .= "</ul>";
}
$output .= "</div>";
return $output;
Hopefully, with these tools at your disposal, you can build some cool AI stuff in modx.
Also, if anyone feels like this might make a good Extra to be installable via the Package Manager, let me know - as I have tried and failed to get this to build properly. Any assistance appreciated. Thx, and enjoy.