mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-30 03:25:42 -04:00
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>
309 lines
13 KiB
JavaScript
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/)
|
|
})
|
|
})
|