mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
feat(ai): integrate OpenWebUI/Ollama as a real integration_openai backend + chat test
- svc-ai-ollama variant-0: preload smollm2:135m (smallest instruct model for CI)
- integration_openai meta: Service URL -> {base}/api for the OpenWebUI backend
- web-app-openwebui: enable_api_keys config flag (services.yml + vars) rendered into
ENABLE_API_KEYS so sk- API keys authenticate against the OpenAI-compatible API
- integration_openai spec: drop the DOM-gated skip, assert the partner backend host
(openwebui chat.ai / flowise flow.ai), distinct from Nextcloud
- openwebui: OLLAMA_SERVICE_ENABLED playwright flag + test-ollama-chat.js asserting
the preloaded model answers a chat completion over OpenWebUI -> Ollama
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
51fbe1a52a
commit
06aa25c095
9 files changed
+77
-47
No files matched your search
@@ -7,11 +7,8 @@
|
||||
# extra rounds.
|
||||
# See docs/contributing/artefact/files/role/variants.md for the file format.
|
||||
|
||||
# variant 0: shrink the official ollama image to alpine/ollama and
|
||||
# keep the model preload list empty so GitHub Actions runners can
|
||||
# complete the deploy within their disk and time budget.
|
||||
|
||||
- services:
|
||||
ollama:
|
||||
preload_models: []
|
||||
preload_models:
|
||||
- smollm2:135m
|
||||
image: alpine/ollama
|
||||
@@ -2,22 +2,6 @@ const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessAddonEnabled } = require("../addon-gating");
|
||||
const shared = require("../_shared");
|
||||
|
||||
// FULL-coupling check for the OpenAI-compatible AI backend integration
|
||||
// (web-app-openwebui / web-app-flowise). Upstream nextcloud/integration_openai
|
||||
// renders its admin section via Admin::getSection() === "ai", i.e. on
|
||||
// settings/admin/ai, inside the wrapper id="openai_prefs". The connection is an
|
||||
// admin-level form, not an OAuth consent flow: id="openai-url" (label
|
||||
// "Service URL") + id="openai-api-key" (label "API key ..."). The plugin loader
|
||||
// enables the app and writes the Service URL via config:app:set; the addon hook
|
||||
// additionally provisions the api_key for the Flowise/LiteLLM backend. Upstream
|
||||
// Admin.php masks a stored api_key to the literal "dummyApiKey" and an unset key
|
||||
// to "" in initial state, so the #openai-api-key field value is a deterministic
|
||||
// "key set / key missing" signal. This test proves coupling: the app is enabled,
|
||||
// the openai_prefs section renders on the AI admin page, the Service URL field
|
||||
// holds the partner endpoint (a valid http(s) URL), and (when the active backend
|
||||
// is Flowise/LiteLLM, INTEGRATION_OPENAI_EXPECT_API_KEY=true) the api_key field
|
||||
// is non-empty. It FAILS if the app is not enabled, the URL was never wired, or
|
||||
// the Flowise api_key the hook must provision is missing.
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
const expectApiKey =
|
||||
@@ -33,11 +17,6 @@ test("integration integration_openai: Nextcloud is configured and coupled to the
|
||||
try {
|
||||
await shared.loginToStandaloneNextcloud(page);
|
||||
|
||||
// The connection is configured admin-side. Upstream registers the section
|
||||
// with getSection() === "ai", so it renders at settings/admin/ai inside
|
||||
// #openai_prefs with the Service URL field #openai-url. The rendering of
|
||||
// this section IS the activation signal (the lazy settings/apps/enabled
|
||||
// list false-negatives on enabled integrations, so it is not used here).
|
||||
await page.goto(
|
||||
new URL("settings/admin/ai", shared.env.nextcloudBaseUrl).toString(),
|
||||
{ waitUntil: "domcontentloaded", timeout: 60_000 }
|
||||
@@ -45,18 +24,10 @@ test("integration integration_openai: Nextcloud is configured and coupled to the
|
||||
await shared.dismissBlockingNextcloudModals(page, page);
|
||||
|
||||
const openaiSection = page.locator("#openai_prefs").first();
|
||||
// App-present signal via the app's OWN section (NOT the lazy settings/apps/enabled
|
||||
// list). Bounded wait: if the section never mounts the app is disabled/absent and
|
||||
// the integration is unconfigured — skip rather than fail. On the kept stack the
|
||||
// app IS enabled and the section renders, so the coupling asserts below.
|
||||
const sectionRendered = await openaiSection
|
||||
.waitFor({ state: "visible", timeout: 30_000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
test.skip(
|
||||
!sectionRendered,
|
||||
"integration_openai admin section (#openai_prefs) absent (app disabled/unconfigured) — nothing to couple"
|
||||
);
|
||||
await expect(
|
||||
openaiSection,
|
||||
"the integration_openai admin section (#openai_prefs) must render on settings/admin/ai when the addon is enabled — its absence means the app was never installed/enabled and the coupling failed to provision"
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
|
||||
const serviceUrlField = openaiSection
|
||||
.locator("#openai-url")
|
||||
@@ -81,18 +52,13 @@ test("integration integration_openai: Nextcloud is configured and coupled to the
|
||||
const configuredHost = new URL(configuredUrl).host;
|
||||
expect(
|
||||
configuredHost,
|
||||
"the Service URL host must be the AI backend partner (flow.ai.*), distinct from the Nextcloud host, proving real cross-host coupling"
|
||||
).toMatch(/^flow\.ai\./i);
|
||||
"the Service URL host must be the AI backend partner (openwebui chat.ai.* or flowise flow.ai.*), proving the config:app:set actually wired the partner endpoint rather than a placeholder"
|
||||
).toMatch(/^(chat|flow)\.ai\./i);
|
||||
expect(
|
||||
configuredHost,
|
||||
"the Service URL must point at the partner backend, not back at Nextcloud itself"
|
||||
"the Service URL must point at the partner backend, distinct from the Nextcloud host — proving real cross-host coupling, not a self-pointing/unconfigured value"
|
||||
).not.toBe(new URL(shared.env.nextcloudBaseUrl).host);
|
||||
|
||||
// Flowise/LiteLLM backend: the /v1 proxy is master-key protected, so the
|
||||
// addon hook must provision config:app:set integration_openai api_key.
|
||||
// Upstream renders a stored key as the non-empty placeholder "dummyApiKey"
|
||||
// and an unset key as "", so an empty field here proves the hook did not
|
||||
// wire the api_key and the integration would 401 on every request.
|
||||
if (expectApiKey) {
|
||||
const apiKeyField = openaiSection
|
||||
.locator("#openai-api-key")
|
||||
|
||||
@@ -11,4 +11,4 @@ config:
|
||||
plugin_configuration:
|
||||
- appid: "integration_openai"
|
||||
configkey: "url"
|
||||
configvalue: "{{ lookup('tls', 'web-app-openwebui', 'url.base') if ('web-app-openwebui' in group_names) else lookup('tls', 'web-app-flowise', 'url.base') }}"
|
||||
configvalue: "{{ (lookup('tls', 'web-app-openwebui', 'url.base') | regex_replace('/+$', '') ~ '/api') if ('web-app-openwebui' in group_names) else lookup('tls', 'web-app-flowise', 'url.base') }}"
|
||||
@@ -13,4 +13,5 @@ require("./test-admin-native-login").register(shared);
|
||||
require("./test-admin-ldap-login").register(shared);
|
||||
require("./test-biber-ldap-login").register(shared);
|
||||
require("./test-guest-persona").register(shared);
|
||||
require("./test-ollama-chat").register(shared);
|
||||
require("./test-seaweedfs");
|
||||
@@ -0,0 +1,60 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
|
||||
exports.register = function (shared) {
|
||||
test("ollama chat: the preloaded model answers a chat completion via OpenWebUI", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("ollama");
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await shared.signInViaDashboardOidc(
|
||||
page,
|
||||
shared.env.adminUsername,
|
||||
shared.env.adminPassword,
|
||||
"administrator"
|
||||
);
|
||||
|
||||
const token = await page.evaluate(() => window.localStorage.getItem("token"));
|
||||
expect(
|
||||
token,
|
||||
"OpenWebUI must store a session token in localStorage after login (used to authenticate its OpenAI-compatible API)"
|
||||
).toBeTruthy();
|
||||
|
||||
const base = shared.env.openwebuiBaseUrl.replace(/\/+$/, "");
|
||||
const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
|
||||
|
||||
const modelsResp = await page.request.get(`${base}/api/models`, { headers });
|
||||
expect(
|
||||
modelsResp.ok(),
|
||||
`OpenWebUI /api/models must respond to the authenticated session (HTTP ${modelsResp.status()})`
|
||||
).toBeTruthy();
|
||||
|
||||
const modelsBody = await modelsResp.json();
|
||||
const modelIds = (Array.isArray(modelsBody?.data) ? modelsBody.data : [])
|
||||
.map((m) => String(m?.id ?? m?.name ?? ""));
|
||||
const ollamaModel = modelIds.find((id) => id.startsWith("smollm2"));
|
||||
expect(
|
||||
ollamaModel,
|
||||
`the preloaded Ollama model (smollm2) must be served by OpenWebUI, proving the Ollama backend is reachable and the model pulled (got ${JSON.stringify(modelIds)})`
|
||||
).toBeTruthy();
|
||||
|
||||
const chatResp = await page.request.post(`${base}/api/chat/completions`, {
|
||||
headers,
|
||||
data: {
|
||||
model: ollamaModel,
|
||||
messages: [{ role: "user", content: "Reply with exactly the word: pong" }],
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
chatResp.ok(),
|
||||
`the ${ollamaModel} chat completion must return 200 over OpenWebUI -> Ollama (HTTP ${chatResp.status()})`
|
||||
).toBeTruthy();
|
||||
|
||||
const chatBody = await chatResp.json();
|
||||
const content = (chatBody?.choices?.[0]?.message?.content ?? "").trim();
|
||||
expect(
|
||||
content.length,
|
||||
`the model must return a non-empty assistant response over OpenWebUI -> Ollama -> ${ollamaModel} (got ${JSON.stringify(chatBody).slice(0, 300)})`
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
};
|
||||
@@ -24,6 +24,7 @@ openwebui:
|
||||
min_storage: 5GB
|
||||
offline_mode: false
|
||||
hf_hub_offline: false
|
||||
enable_api_keys: true
|
||||
cpus: 2.0
|
||||
mem_reservation: 1g
|
||||
mem_limit: 4g
|
||||
|
||||
@@ -35,6 +35,8 @@ SHOW_ADMIN_DETAILS=true
|
||||
# Set explicitly so native / LDAP users skip OpenWebUI's default `pending` Awaiting-activation gate.
|
||||
DEFAULT_USER_ROLE=user
|
||||
|
||||
ENABLE_API_KEYS={{ OPENWEBUI_ENABLE_API_KEYS | string | lower }}
|
||||
|
||||
{% set email = lookup('email', application_id) %}
|
||||
{% if email.enabled | bool %}
|
||||
# =========================
|
||||
|
||||
@@ -27,6 +27,8 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
|
||||
# nocheck: playwright-service-gate — consumed via shared persona helpers (roles/test-e2e-playwright/files/personas/)
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
|
||||
OLLAMA_SERVICE_ENABLED={{ lookup('config', application_id, 'services.ollama.enabled') | string | lower }}
|
||||
|
||||
OIDC_ISSUER_URL={{ OIDC.CLIENT.ISSUER_URL | dotenv_quote }}
|
||||
|
||||
OPENWEBUI_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
|
||||
|
||||
@@ -10,6 +10,7 @@ OPENWEBUI_IMAGE: "{{ lookup('config', application_id, 'services.openwebui.image'
|
||||
OPENWEBUI_CONTAINER: "{{ lookup('config', application_id, 'services.openwebui.name') }}"
|
||||
OPENWEBUI_OFFLINE_MODE: "{{ lookup('config', application_id, 'services.openwebui.offline_mode') }}"
|
||||
OPENWEBUI_HF_HUB_OFFLINE: "{{ lookup('config', application_id, 'services.openwebui.hf_hub_offline') }}"
|
||||
OPENWEBUI_ENABLE_API_KEYS: "{{ lookup('config', application_id, 'services.openwebui.enable_api_keys') }}"
|
||||
OPENWEBUI_VOLUME: "{{ lookup('config', application_id, 'volumes.openwebui') }}"
|
||||
OPENWEBUI_PORT_PUBLIC: "{{ lookup('config', application_id, 'services.openwebui.ports.local.http') }}"
|
||||
OPENWEBUI_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}"
|
||||
|
||||
Reference in new issue
Block a user