mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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:
1 parent
caf84b4673
commit
0421ebeb16
11 files changed
+235
No files matched your search
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
|
||||
Reference in new issue
Block a user