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:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-06-21 06:03:15 +02:00
1 parent 51fbe1a52a
commit 06aa25c095
9 files changed
+77 -47

No files matched your search

+2 -5
View File
@@ -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
+2
View File
@@ -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 }}
+1
View File
@@ -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') }}"