feat(wordpress): bind the AI Engine connector to the platform gateway

WordPress has no AI surface in core, so the requirement applies to whichever
connector the role pins rather than leaving the choice to a site administrator.
The pinned connector is ai-engine, whose `custom` engine type takes an
OpenAI-compatible base URL and an API key per environment.

AI Engine keeps every setting in a single mwai_options array and its REST route
replaces that array wholesale, so the deploy merges instead of assigning:
ai_engine_configure.php filters ai_envs by id, re-appends the managed
environment, and leaves every other environment and API key untouched. A
straight write would delete configuration this deploy never owned.

Both scripts are fed to `wp eval-file -` over stdin with their input passed as
an environment variable. The first attempt used `wp eval` with the code inline,
which cannot survive the quoting: a YAML folded scalar hands Jinja a literal
backslash sequence and PHP fails to parse. That was reproduced in the container,
along with the detail that `wp eval-file -` requires the opening <?php tag,
before the approach was changed. Passing the payload as base64 in an env var
means no shell quoting touches either the code or the virtual key.

ai_engine_endpoint.php reads back the endpoint AI Engine actually stored for the
managed environment, so the assertion compares the plugin's own state rather
than the payload the deploy just sent.

Not verified: the configure script has never run against a live AI Engine
install, so the merge is reasoned from the option shape rather than observed,
and addons/ai-engine.spec.js has executed zero times because the addon bridge
gate resolved svc-ai-litellm against the Playwright role list and skipped it.
ai-engine is third-party, so a change to its option schema would leave this
writing a shape the plugin no longer reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-08-24 13:41:19 +02:00
1 parent caf84b4673
commit 0421ebeb16
11 files changed
+235

No files matched your search

+3
View File
@@ -38,6 +38,7 @@ The diagram places WordPress in the Infinito.Nexus cosmos: the components it dep
```mermaid
flowchart LR
subgraph deps [Dependencies]
dep_svc_ai_litellm["svc-ai-litellm 🐳🐝"]
dep_svc_bkp_volume_2_local["svc-bkp-volume-2-local 💻"]
dep_svc_db_mariadb["svc-db-mariadb 🐳🐝"]
dep_web_app_dashboard["web-app-dashboard 🐳🐝"]
@@ -49,6 +50,7 @@ flowchart LR
dep_web_svc_logout["web-svc-logout 🐳🐝"]
end
subgraph role [web-app-wordpress 🐳🐝]
svc_litellm["litellm"]
svc_sso["sso"]
svc_logout["logout"]
svc_dashboard["dashboard"]
@@ -60,6 +62,7 @@ flowchart LR
svc_prometheus["prometheus"]
svc_container_backup["container_backup"]
end
dep_svc_ai_litellm -. "0..1" .-> svc_litellm
dep_svc_bkp_volume_2_local -. "0..1" .-> svc_container_backup
dep_svc_db_mariadb -. "0..1" .-> svc_mariadb
dep_web_app_dashboard -. "0..1" .-> svc_dashboard
@@ -0,0 +1,30 @@
<?php
/**
* Environment:
* AI_ENGINE_PAYLOAD: base64 of {"environment": {...}, "default_model": "..."}.
*/
$payload = json_decode(base64_decode(getenv('AI_ENGINE_PAYLOAD')), true);
if (!is_array($payload) || empty($payload['environment']['id'])) {
WP_CLI::error('AI_ENGINE_PAYLOAD did not decode to an environment');
}
$options = get_option('mwai_options', array());
if (!is_array($options)) {
$options = array();
}
$existing = (isset($options['ai_envs']) && is_array($options['ai_envs'])) ? $options['ai_envs'] : array();
$kept = array();
foreach ($existing as $env) {
if ((isset($env['id']) ? $env['id'] : '') !== $payload['environment']['id']) {
$kept[] = $env;
}
}
$kept[] = $payload['environment'];
$options['ai_envs'] = $kept;
$options['ai_default_env'] = $payload['environment']['id'];
$options['ai_default_model'] = $payload['default_model'];
update_option('mwai_options', $options);
@@ -0,0 +1,17 @@
<?php
/**
* Environment:
* AI_ENGINE_ENV_ID: id of the environment the deploy manages.
*/
$wanted = getenv('AI_ENGINE_ENV_ID');
$options = get_option('mwai_options', array());
$envs = (is_array($options) && isset($options['ai_envs']) && is_array($options['ai_envs']))
? $options['ai_envs']
: array();
foreach ($envs as $env) {
if ((isset($env['id']) ? $env['id'] : '') === $wanted) {
echo isset($env['endpoint']) ? $env['endpoint'] : '';
}
}
@@ -0,0 +1,95 @@
const { test, expect } = require("@playwright/test");
const { skipUnlessAddonEnabled } = require("../addon-gating");
const shared = require("../_shared");
test("addon ai-engine: WordPress answers a prompt through the in-cluster LiteLLM gateway", async ({ browser }) => {
skipUnlessAddonEnabled("ai-engine");
test.setTimeout(180_000);
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
try {
await shared.wpAdminLoginViaOidc(
page,
shared.env.wpBaseUrl,
shared.env.adminUsername,
shared.env.adminPassword
);
await page.goto(`${shared.env.wpBaseUrl}/wp-admin/admin.php?page=mwai_settings`, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
const bootstrap = await page.waitForFunction(
() => (window.mwai && window.mwai.rest_nonce ? window.mwai : null),
undefined,
{ timeout: 60_000 }
).then((handle) => handle.jsonValue());
expect(
bootstrap.api_url,
"the AI Engine admin bundle must expose its REST base, otherwise the plugin never loaded"
).toBeTruthy();
const envs = (bootstrap.options && bootstrap.options.ai_envs) || [];
const managed = envs.find((entry) => entry && entry.id === "infinito-litellm");
expect(
managed,
"the deploy must have merged the 'infinito-litellm' environment into mwai_options.ai_envs"
).toBeTruthy();
expect(
managed.type,
"the managed environment must use the OpenAI-compatible 'custom' engine, not a vendor engine"
).toBe("custom");
const endpointHost = new URL(managed.endpoint).host.split(":")[0];
expect(
endpointHost,
"the managed endpoint must be the in-cluster litellm service name — a dotted host would resolve through the public ingress and leave the deployment"
).not.toContain(".");
expect(
endpointHost,
"the managed endpoint must point at the svc-ai-litellm gateway, not at WordPress itself"
).not.toBe(new URL(shared.env.wpBaseUrl).host);
expect(
bootstrap.options.ai_default_env,
"the gateway environment must be the site default, so no surface falls back to a third-party provider"
).toBe("infinito-litellm");
const completion = await context.request.post(`${bootstrap.api_url}/ai/completions`, {
headers: {
"X-WP-Nonce": bootstrap.rest_nonce,
"Content-Type": "application/json",
},
data: {
message: "Reply with the single word: pong",
env: "infinito-litellm",
model: bootstrap.options.ai_default_model,
stream: false,
},
timeout: 120_000,
});
expect(
completion.status(),
"POST mwai/v1/ai/completions must succeed — a non-200 means the virtual key or the gateway endpoint is wrong"
).toBe(200);
const body = await completion.json();
expect(
body.success,
`the completion must report success; got: ${JSON.stringify(body).slice(0, 400)}`
).toBe(true);
expect(
String(body.data || "").trim().length,
"the gateway must return a non-empty completion, proving the prompt was answered inside the deployment"
).toBeGreaterThan(0);
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
}
});
@@ -0,0 +1,19 @@
---
enabled: "{{ lookup('config', 'web-app-wordpress', 'services.litellm.enabled') | bool }}"
required: true
mechanism: plugin
source: upstream
bridges:
- litellm
update:
monitored: true
catalog: wordpress-org
upstream_id: ai-engine
config:
environment:
id: infinito-litellm
type: custom
name: Infinito LiteLLM
apikey: "{{ lookup('config', 'web-app-wordpress', 'credentials.litellm_api_key') }}"
endpoint: "{{ LITELLM_OPENAI_BASE_LOCAL_URL }}"
default_model: "{{ LITELLM_CHAT_MODEL }}"
+5
View File
@@ -1,5 +1,10 @@
---
credentials:
litellm_api_key:
description: Per-consumer virtual key for the shared svc-ai-litellm gateway
algorithm: sk_prefixed
validation:
min_length: 50
administrator_password:
description: Initial password for the WordPress admin account
algorithm: sha256
@@ -1,4 +1,9 @@
---
# nocheck: playwright-service-flag - shared gateway consumer; inference wiring is covered by the gateway role
litellm:
bond: 0.5
enabled: "{{ 'svc-ai-litellm' in group_names }}"
shared: "{{ 'svc-ai-litellm' in group_names }}"
# nocheck: seaweedfs-required, object storage not implemented for this role
sso:
bond: 1
@@ -30,6 +30,9 @@
matomo:
enabled: true
shared: true
litellm:
enabled: true
shared: true
sso:
enabled: true
shared: true
@@ -63,6 +66,9 @@
matomo:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
litellm:
enabled: false
shared: false
sso:
enabled: false
shared: false # nocheck: variants-static-override backup shared stays literally true
@@ -0,0 +1,50 @@
---
- name: "🔐 Verify the gateway contract for {{ application_id }}"
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/svc-ai-litellm/tasks/utils/consumer_contract.yml') }}"
- name: "📌 Point the AI Engine default environment at the platform gateway"
ansible.builtin.command:
argv:
- container
- exec
- -i
- --user
- "{{ WORDPRESS_USER }}"
- -e
- "AI_ENGINE_PAYLOAD={{ lookup('config', application_id, 'addons.ai-engine.config') | to_json | b64encode }}"
- "{{ WORDPRESS_CONTAINER_ADDRESS }}" # nocheck: container-exec-resolver
- wp
- eval-file
- "-"
- "--path={{ WORDPRESS_DOCKER_HTML_PATH }}"
stdin: "{{ lookup('file', 'ai_engine_configure.php') }}"
changed_when: true
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "🔎 Read back the configured AI Engine endpoint"
ansible.builtin.command:
argv:
- container
- exec
- -i
- --user
- "{{ WORDPRESS_USER }}"
- -e
- "AI_ENGINE_ENV_ID={{ WORDPRESS_LITELLM_ENV_ID }}"
- "{{ WORDPRESS_CONTAINER_ADDRESS }}" # nocheck: container-exec-resolver
- wp
- eval-file
- "-"
- "--path={{ WORDPRESS_DOCKER_HTML_PATH }}"
stdin: "{{ lookup('file', 'ai_engine_endpoint.php') }}"
register: wordpress_ai_endpoint
changed_when: false
- name: "🛑 Refuse an AI Engine environment that does not answer through the gateway"
ansible.builtin.assert:
that:
- (wordpress_ai_endpoint.stdout | trim) == LITELLM_OPENAI_BASE_LOCAL_URL
fail_msg: >-
The AI Engine environment '{{ WORDPRESS_LITELLM_ENV_ID }}' still resolves to
'{{ wordpress_ai_endpoint.stdout | trim }}' instead of
{{ LITELLM_OPENAI_BASE_LOCAL_URL }}, so prompts would leave the deployment.
@@ -0,0 +1,4 @@
---
- name: "🖥️ WP AI Engine: stack-host container ops"
ansible.builtin.include_tasks: ai-engine-stack-host.yml
when: IS_STACK_HOST | bool
+1
View File
@@ -19,6 +19,7 @@ WORDPRESS_ADDONS_INSTALLED: "{{ (WORDPRESS_PLUGINS + WORDPRESS_MU_PLUGINS) | map
WORDPRESS_ADDONS_REQUIRED: "{{ lookup('config', application_id, 'addons') | dict2items | selectattr('value.required', 'defined') | selectattr('value.required') |
map(attribute='key') | list }}"
WORDPRESS_USER: "www-data"
WORDPRESS_LITELLM_ENV_ID: "{{ lookup('config', application_id, 'addons.ai-engine.config.environment.id') }}"
WORDPRESS_MCP_ADAPTER_VERSION: "{{ lookup('config', application_id, 'addons.mcp-adapter.version') }}"
WORDPRESS_MCP_ACCOUNT: "{{ lookup('users', 'mcp-web-app-wordpress') }}"
WORDPRESS_MCP_USERNAME: "{{ WORDPRESS_MCP_ACCOUNT.username }}"