mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-30 11:36:31 -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>
220 lines
11 KiB
JavaScript
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()
|
|
})
|
|
})
|