Files
LocalAI/core/http/react-ui/e2e/router-template.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

220 lines
11 KiB
JavaScript

import { test, expect } from '@playwright/test'
// Router template + structured editor regression tests.
//
// The historical regression was: the "Create routing model" button
// loaded the model editor with an array-shaped `router.candidates`
// value, which crashed when a code-editor field received it instead
// of a string ("(intermediate value).split is not a function").
//
// The current schema is also covered:
// - classifier=score is the only shipped classifier
// - router.policies surfaces in its own structured editor (label +
// description rows with duplicate detection)
// - router.candidates is the structured {model, labels[]} editor;
// labels are chips populated from router.policies via FormContext
// - router.embedding_cache.* surface as labelled fields with the
// correct components (model-select / slider)
// - router.activation_threshold and the two embedding_cache slider
// fields render with slider min/max/step from the registry
const ROUTER_METADATA = {
sections: [
{ id: 'general', label: 'General', icon: 'settings', order: 0 },
{ id: 'other', label: 'Other', icon: 'more-horizontal', order: 100 },
],
fields: [
{ path: 'name', yaml_key: 'name', go_type: 'string', ui_type: 'string',
section: 'general', label: 'Model Name', component: 'input', order: 0 },
{
path: 'router.classifier', yaml_key: 'classifier', go_type: 'string', ui_type: 'string',
section: 'other', label: 'Classifier', component: 'select',
options: [{ value: 'score', label: 'Score (Arch-Router-style)' }],
description: 'Picks a candidate by scoring every policy label against the prompt. Only "score" is shipped today.',
order: 230,
},
{
path: 'router.classifier_model', yaml_key: 'classifier_model', go_type: 'string', ui_type: 'string',
section: 'other', label: 'Classifier Model', component: 'model-select', autocomplete_provider: 'models:chat',
description: 'Loaded LocalAI model the score classifier asks to rank each policy label.',
order: 231,
},
{
path: 'router.fallback', yaml_key: 'fallback', go_type: 'string', ui_type: 'string',
section: 'other', label: 'Fallback Model', component: 'model-select', autocomplete_provider: 'models:chat',
description: 'Model used when no candidate covers the active label set.',
order: 232,
},
{
path: 'router.activation_threshold', yaml_key: 'activation_threshold', go_type: 'float64', ui_type: 'float',
section: 'other', label: 'Activation Threshold', component: 'slider',
min: 0, max: 1, step: 0.05,
description: 'Softmax-probability floor a policy must clear to join the active label set.',
order: 233,
},
{
path: 'router.policies', yaml_key: 'policies', go_type: '[]RouterPolicy', ui_type: 'object',
section: 'other', label: 'Policies', component: 'router-policies',
description: 'Label vocabulary the classifier scores over.',
order: 235,
},
{
path: 'router.candidates', yaml_key: 'candidates', go_type: '[]RouterCandidate', ui_type: 'object',
section: 'other', label: 'Candidates', component: 'router-candidates',
description: 'Routing table: each entry binds a downstream model to a set of policy labels.',
order: 236,
},
{
path: 'router.embedding_cache.embedding_model', yaml_key: 'embedding_model', go_type: 'string', ui_type: 'string',
section: 'other', label: 'L2 Cache: Embedding Model', component: 'model-select', autocomplete_provider: 'models',
description: 'Embedding model used by the L2 decision cache.',
order: 237,
},
{
path: 'router.embedding_cache.similarity_threshold', yaml_key: 'similarity_threshold', go_type: 'float64', ui_type: 'float',
section: 'other', label: 'L2 Cache: Similarity Threshold', component: 'slider',
min: 0, max: 1, step: 0.01,
description: 'Cosine-similarity floor a cache candidate must clear to count as a hit.',
order: 238,
},
],
}
const MIDDLEWARE_STATUS = {
pii: { enabled_globally: false, patterns: [], models: [], recent_event_count: 0 },
router: { configured: false, models: [], recent_decision_count: 0, available_classifiers: ['score'] },
mitm: { running: false, listen_addr: '', configured_addr: '', host_owners: {}, host_conflicts: {}, models: [], ca_available: false, ca_cert_url: '' },
}
test.describe('Router template — create flow', () => {
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(MIDDLEWARE_STATUS) })
)
await page.route('**/api/router/decisions?**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ decisions: [] }) })
)
await page.route('**/api/pii/events?**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ events: [] }) })
)
await page.route('**/api/models/config-metadata*', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify(ROUTER_METADATA) })
)
await page.route('**/api/models/config-metadata/autocomplete/**', (route) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ values: [] }) })
)
// Surface any uncaught render-time error so the assertion fails
// with a useful message rather than the test silently passing.
page.on('pageerror', (err) => {
throw new Error(`uncaught page error: ${err.message}`)
})
})
test('Routing tab links to the model editor with the router template loaded', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Routing/i }).click()
// Empty-state button is the primary CTA.
await page.getByRole('button', { name: /Create routing model/i }).click()
// Editor loads on a /app/model-editor URL with template=router.
await expect(page).toHaveURL(/\/app\/model-editor.*template=router/)
})
test('Router template renders without crashing on structured candidates/policies', async ({ page }) => {
// Navigate straight to the create-with-template URL. This was the
// regression that crashed with "(intermediate value).split is not
// a function" when the template's array-shaped router.candidates
// fell into a code-editor wrapper.
await page.goto('/app/model-editor?template=router')
// The react-router error overlay must not appear.
await expect(page.getByText(/Unexpected Application Error/i)).toHaveCount(0)
// Editor surface visible. Template URL is "create mode", so the
// heading reads "Add Model" rather than "Model Editor".
await expect(page.locator('h1.page-title')).toBeVisible({ timeout: 10_000 })
// Top-level field labels seeded by the template are visible.
// embedding_cache.* fields are surfaced via "Add Field" search
// rather than active by default — separate spec covers them.
await expect(page.getByText('Classifier').first()).toBeVisible()
await expect(page.getByText('Policies').first()).toBeVisible()
await expect(page.getByText('Candidates').first()).toBeVisible()
await expect(page.getByText('Activation Threshold').first()).toBeVisible()
})
test('Classifier select offers only the score option', async ({ page }) => {
await page.goto('/app/model-editor?template=router')
// SearchableSelect renders the current option's *label* inside the
// trigger button. After the schema cleanup the only option is
// "Score (Arch-Router-style)", pre-selected by the template.
await expect(page.getByText('Score (Arch-Router-style)').first()).toBeVisible({ timeout: 10_000 })
})
test('Policies editor renders structured rows with label + description fields', async ({ page }) => {
await page.goto('/app/model-editor?template=router')
// The template seeds three example policies. Their labels are
// pre-populated in input fields with monospace styling — the
// editor signature is "Add policy" button + label/description
// input pairs.
await expect(page.getByRole('button', { name: /Add policy/i }).first()).toBeVisible()
// Pre-seeded labels visible as input values. RouterPoliciesEditor
// renders each label in an input with a recognisable placeholder;
// assert on their values by position.
const labelInputs = page.locator('input[placeholder^="label ("]')
await expect(labelInputs.nth(0)).toHaveValue('code-generation')
await expect(labelInputs.nth(1)).toHaveValue('casual-chat')
await expect(labelInputs.nth(2)).toHaveValue('math-reasoning')
})
test('Candidates editor renders {model, labels} rows with policy-aware label chips', async ({ page }) => {
await page.goto('/app/model-editor?template=router')
// "Add candidate" is the signature of the new RouterCandidatesEditor.
await expect(page.getByRole('button', { name: /Add candidate/i }).first()).toBeVisible()
// Each candidate row should expose move-up/move-down controls,
// a model picker, and label chips. The chip for a known policy
// label appears as a button with the policy's label text.
// Pre-seeded template: candidate[0] has labels=['casual-chat'];
// candidate[1] has labels=['code-generation', 'casual-chat', 'math-reasoning'].
//
// The chips appear inside a flex row of buttons. Using getByRole
// with the exact name catches typos/regressions cleanly.
await expect(page.getByRole('button', { name: 'casual-chat' }).first()).toBeVisible()
await expect(page.getByRole('button', { name: 'code-generation' }).first()).toBeVisible()
await expect(page.getByRole('button', { name: 'math-reasoning' }).first()).toBeVisible()
})
test('Adding a duplicate policy label flags the duplicate row', async ({ page }) => {
await page.goto('/app/model-editor?template=router')
// Add a new empty policy row, then type a duplicate of the
// existing 'casual-chat'. The duplicate detection in
// RouterPoliciesEditor sets a warning border via inline style.
await page.getByRole('button', { name: /Add policy/i }).first().click()
// Find the newly-added empty label input (placeholder catches it).
const newLabel = page.locator('input[placeholder*="label (e.g. code-generation)"]').last()
await newLabel.fill('casual-chat')
// Both rows now hold the same label. The duplicate-detection
// logic flags the row visually; we assert on the title attribute
// RouterPoliciesEditor sets on the input when duplicate=true.
await expect(
page.locator('input[title="Duplicate label — candidates won\'t be able to distinguish them"]').first()
).toBeVisible()
})
})