Files
LocalAI/core/http/react-ui/e2e/middleware-page.spec.js
Richard Palethorpe 6a80e23733 feat(middleware): Model routing, PII filtering, Cloud model proxies (#9802)
Add a routing middleware stack and a cloud-proxy backend.

* cloud-proxy: a Go gRPC backend that forwards OpenAI- and
  Anthropic-shaped chat requests to upstream providers, with an
  optional translate mode (OpenAI request -> Anthropic /v1/messages
  -> OpenAI response) and full tool-calling support.

* routing: admission control, content-aware model routing
  (embedding cache + classifier + rerank + Arch-Router score),
  PII detection/redaction (regex + NER) with streaming filter and
  OpenAI/Anthropic adapters, and a per-user/per-key billing recorder
  backed by GORM or in-memory storage.

* middleware: UsageMiddleware records usage via the billing recorder,
  plus admission, route-model, usage-stamp and trace middlewares.

* observability: BackendTrace ring buffer stores full request bodies
  (capped), MITM proxy emits structured trace events, and router
  classifier decisions surface at /api/router/decide.

* gallery: Arch-Router-1.5B (Q4_K_M and Q8_0).

* UI: cloud-proxy model-editor fields, classifier system-prompt and
  score-normalization config, and a Traces page rendering request
  bodies.

Assisted-by: claude-code:claude-opus-4-7 [Read] [Edit] [Bash]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-05-25 09:28:27 +02:00

309 lines
13 KiB
JavaScript

import { test, expect } from '@playwright/test'
// Mocked fixture covering the three things the page renders:
// - PII pattern catalogue (action badges, action-change buttons)
// - Per-model resolved PII state (one with default off, one with proxy default on, one with explicit YAML)
// - Recent events feed (the page must NEVER show the redacted content)
const MOCK_STATUS = {
pii: {
enabled_globally: true,
default_enabled_for_backends: ['cloud-proxy'],
patterns: [
{ id: 'email', description: 'Email addresses', action: 'mask', max_match_length: 254 },
{ id: 'ssn', description: 'US Social Security Numbers', action: 'mask', max_match_length: 11 },
{ id: 'api_key_prefix', description: 'API key prefixes', action: 'block', max_match_length: 200 },
],
models: [
{ name: 'qwen-7b', backend: 'llama-cpp', enabled: false, explicit: false, default_for_backend: false, overrides: null },
{ name: 'claude-sonnet', backend: 'cloud-proxy', enabled: true, explicit: false, default_for_backend: true, overrides: null },
{ name: 'claude-strict', backend: 'cloud-proxy', enabled: true, explicit: true, default_for_backend: true, overrides: { ssn: 'block' } },
],
recent_event_count: 2,
},
router: {
configured: true,
models: [
{
name: 'smart-router',
classifier: 'score',
fallback: 'qwen-7b',
policies: [
{ label: 'casual-chat', description: 'small talk' },
{ label: 'code-generation', description: 'writing or debugging code' },
],
candidates: [
{ model: 'qwen-3b', labels: ['casual-chat'] },
{ model: 'qwen-coder', labels: ['code-generation', 'casual-chat'] },
],
embedding_cache: {
embedding_model: 'nomic-embed-text-v1.5',
similarity_threshold: 0.80,
confidence_threshold: 0.60,
store_name: '',
stats: {
hits: 31,
misses: 1,
near_misses: 56,
low_confidence: 29,
embedder_errors: 0,
store_errors: 0,
// peak [0.4, 0.6) for paraphrases, secondary in [0.8, 1.0) for near-exact matches
similarity_buckets: [0, 0, 0, 1, 22, 16, 3, 7, 19, 19],
},
},
},
],
recent_decision_count: 1,
available_classifiers: ['score'],
},
}
const MOCK_DECISIONS = {
decisions: [
{
id: 'rd_a1', correlation_id: 'corr-1', user_id: 'local',
router_model: 'smart-router', requested_model: 'smart-router', served_model: 'qwen-3b',
classifier: 'score', label: 'casual-chat', score: 0.91, latency_ms: 15,
cached: true, cache_similarity: 0.92,
created_at: '2026-05-06T11:00:00Z',
},
],
}
const MOCK_EVENTS = {
events: [
{
id: 'pii_aaa', kind: 'pii', correlation_id: 'corr-1', user_id: 'local',
direction: 'in', pattern_id: 'email', byte_offset: 12, length: 17,
hash_prefix: 'ff8d9819', action: 'mask',
created_at: '2026-05-06T10:00:00Z',
},
{
id: 'proxy_connect_1', kind: 'proxy_connect',
host: 'api.openai.com', intercepted: true,
created_at: '2026-05-06T10:01:00Z',
},
{
id: 'proxy_connect_2', kind: 'proxy_connect',
host: 'github.com', intercepted: false,
created_at: '2026-05-06T10:02:00Z',
},
{
id: 'proxy_traffic_1', kind: 'proxy_traffic', correlation_id: 'corr-2',
host: 'api.openai.com',
bytes_sent: 412, bytes_received: 1228, status_code: 200, duration_ms: 240,
created_at: '2026-05-06T10:03:00Z',
},
],
}
test.describe('Middleware page — admin in no-auth mode', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/auth/status', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({ authEnabled: false, staticApiKeyRequired: false, providers: [] }),
})
)
await page.route('**/api/middleware/status', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_STATUS) })
)
await page.route('**/api/pii/events?**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_EVENTS) })
)
await page.route('**/api/router/decisions?**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(MOCK_DECISIONS) })
)
})
test('Filtering tab renders pattern catalogue and per-model state', async ({ page }) => {
await page.goto('/app/middleware')
// Pattern table — at least one pattern id visible.
await expect(page.getByText('email').first()).toBeVisible()
await expect(page.getByText('api_key_prefix').first()).toBeVisible()
// Per-model state — each model's name is visible.
await expect(page.getByText('qwen-7b').first()).toBeVisible()
await expect(page.getByText('claude-strict').first()).toBeVisible()
// Default-policy banner names the backends with PII on by default.
await expect(page.getByText(/cloud-proxy/).first()).toBeVisible()
})
test('Routing tab renders configured routers and recent decisions', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Routing/i }).click()
// Active router model name visible.
await expect(page.getByText('smart-router').first()).toBeVisible()
// Candidate model names visible.
await expect(page.getByText('qwen-coder').first()).toBeVisible()
await expect(page.getByText('qwen-3b').first()).toBeVisible()
// Decision row visible — label and served model.
await expect(page.getByText('casual-chat').first()).toBeVisible()
})
test('Routing tab renders embedding-cache stats and similarity histogram', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Routing/i }).click()
// Embedding model name surfaces in the cache column.
await expect(page.getByText('nomic-embed-text-v1.5').first()).toBeVisible()
// Hit-rate badge: 31 hits / (31 + 56 + 1) = 35% rounded.
await expect(page.getByText(/35% hit/i).first()).toBeVisible()
// h/n/m counter row visible.
await expect(page.getByText(/31h\/56n\/1m/).first()).toBeVisible()
// Skipped (low-confidence) counter visible.
await expect(page.getByText(/29 skipped/).first()).toBeVisible()
// Threshold marker text matches the configured 0.80.
await expect(page.getByText(/sim ≥ 0\.8/).first()).toBeVisible()
// Histogram bars rendered with hover titles that include the
// bucket range and count. Bucket 4 (peak) has count 22; the
// <div> with that exact title is the structural assertion.
await expect(
page.locator('div[title="[0.4, 0.5): 22"]')
).toBeVisible()
// Bucket 8 (just at threshold) has count 19.
await expect(
page.locator('div[title="[0.8, 0.9): 19"]')
).toBeVisible()
})
test('Routing tab shows a cached decision with cache_similarity', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Routing/i }).click()
// The decision row exposes the cached flag and the cosine that
// produced the hit so admins can correlate with the histogram.
await expect(page.getByText('corr-1')).toBeVisible()
})
test('Events tab renders rows but never the redacted content', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// Hash prefix is visible — that's how admins audit recurring leaks.
await expect(page.getByText('ff8d9819')).toBeVisible()
// The page only ever shows fields the EventStore stores. The matched
// value (e.g. "alice@example.com") would never appear because it's
// not in the payload — explicit asserting absence here is the
// contract the design relies on.
await expect(page.getByText(/@example\.com/)).toHaveCount(0)
})
test('Events tab renders proxy_connect rows with intercept decision', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// Both intercept and tunnel decisions visible.
const interceptRow = page.locator('tr').filter({ hasText: 'api.openai.com' }).first()
await expect(interceptRow).toContainText(/intercepted/i)
const tunnelRow = page.locator('tr').filter({ hasText: 'github.com' }).first()
await expect(tunnelRow).toContainText(/tunneled/i)
})
test('Events tab renders proxy_traffic byte counts and status', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// The traffic row formats as "HTTP 200 · ↑412B ↓1.2KB · 240ms".
// We assert on the durable parts: status code, byte values, duration unit.
const trafficRow = page.locator('tr').filter({ hasText: 'corr-2' }).first()
await expect(trafficRow).toContainText('HTTP 200')
await expect(trafficRow).toContainText('412B')
await expect(trafficRow).toContainText(/1\.2\s*KB/i)
await expect(trafficRow).toContainText('240ms')
})
test('Events kind filter narrows the table to the chosen kind', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// Default = All: pii row + 2 connect rows + 1 traffic row visible.
await expect(page.getByText('ff8d9819')).toBeVisible()
await expect(page.getByText('github.com')).toBeVisible()
// Click "PII" filter — proxy rows must disappear.
await page.getByRole('button', { name: /^PII$/ }).click()
await expect(page.getByText('ff8d9819')).toBeVisible()
await expect(page.getByText('github.com')).toHaveCount(0)
await expect(page.getByText('HTTP 200')).toHaveCount(0)
// Click "Proxy traffic" — only the traffic row remains.
await page.getByRole('button', { name: /Proxy traffic/i }).click()
await expect(page.getByText('HTTP 200')).toBeVisible()
await expect(page.getByText('ff8d9819')).toHaveCount(0)
await expect(page.getByText('github.com')).toHaveCount(0)
// Click "Proxy connect" — both connect rows visible, no PII or traffic.
await page.getByRole('button', { name: /Proxy connect/i }).click()
await expect(page.locator('tr').filter({ hasText: 'github.com' })).toHaveCount(1)
await expect(page.locator('tr').filter({ hasText: 'api.openai.com' }).filter({ hasText: 'intercepted' })).toHaveCount(1)
await expect(page.getByText('HTTP 200')).toHaveCount(0)
await expect(page.getByText('ff8d9819')).toHaveCount(0)
// Click "All" — everything back.
await page.getByRole('button', { name: /^All$/ }).click()
await expect(page.getByText('ff8d9819')).toBeVisible()
await expect(page.getByText('HTTP 200')).toBeVisible()
})
test('Events tab shows the kind badge for each row', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// The Kind column header is present.
await expect(page.locator('th').filter({ hasText: /^Kind$/ })).toBeVisible()
// At least one cell renders each of the three kinds. Scope to
// <span> elements so the "PII" filter button doesn't match.
await expect(page.locator('span').getByText(/^pii$/i).first()).toBeVisible()
await expect(page.getByText(/^proxy connect$/i).first()).toBeVisible()
await expect(page.getByText(/^proxy traffic$/i).first()).toBeVisible()
})
test('PUT /api/pii/patterns/:id fires when an action button is clicked', async ({ page }) => {
let putHit = null
await page.route('**/api/pii/patterns/email', (route) => {
if (route.request().method() === 'PUT') {
putHit = JSON.parse(route.request().postData() || '{}')
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'email', action: putHit.action, persisted: false }) })
} else {
route.continue()
}
})
await page.goto('/app/middleware')
// Click the email row's "block" button (currently mask, so block is
// enabled). Use a precise locator that matches the inner button.
const emailRow = page.locator('tr').filter({ hasText: 'email' }).first()
await emailRow.getByRole('button', { name: 'block' }).click()
await expect.poll(() => putHit).toEqual({ action: 'block' })
})
})
test.describe('Middleware page — non-admin under auth-on', () => {
test('redirects to /app when the user is not admin', async ({ page }) => {
await page.route('**/api/auth/status', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
authEnabled: true,
staticApiKeyRequired: false,
providers: ['local'],
user: { id: 'bob', name: 'Bob', role: 'user', provider: 'local' },
}),
})
)
await page.goto('/app/middleware')
// RequireAdmin redirects non-admin viewers; the URL must not stay on /middleware.
await page.waitForURL(/\/app(?!\/middleware)/, { timeout: 5000 })
expect(page.url()).not.toMatch(/\/middleware/)
})
})