diff --git a/.github/pr-screenshots/llm-router-config-ux/provider-cards.png b/.github/pr-screenshots/llm-router-config-ux/provider-cards.png new file mode 100644 index 000000000..b347bcf8b Binary files /dev/null and b/.github/pr-screenshots/llm-router-config-ux/provider-cards.png differ diff --git a/.github/pr-screenshots/llm-router-config-ux/system-prompt-dialog.png b/.github/pr-screenshots/llm-router-config-ux/system-prompt-dialog.png new file mode 100644 index 000000000..faa923a30 Binary files /dev/null and b/.github/pr-screenshots/llm-router-config-ux/system-prompt-dialog.png differ diff --git a/.github/pr-screenshots/llm-router-config-ux/worker-configuration.png b/.github/pr-screenshots/llm-router-config-ux/worker-configuration.png new file mode 100644 index 000000000..2dffe0a01 Binary files /dev/null and b/.github/pr-screenshots/llm-router-config-ux/worker-configuration.png differ diff --git a/llm-router/ui/page.tsx b/llm-router/ui/page.tsx index f08ae3693..ab6e0de5a 100644 --- a/llm-router/ui/page.tsx +++ b/llm-router/ui/page.tsx @@ -9,5 +9,5 @@ import type { Host } from '@iii-dev/console-ui' import { LlmRouterConfigForm } from './src/configuration' export default function setup(host: Host) { - host.configForms.register('llm-router', LlmRouterConfigForm) + host.configForms.register('llm-router', (props) => ) } diff --git a/llm-router/ui/src/configuration/duration.test.ts b/llm-router/ui/src/configuration/duration.test.ts new file mode 100644 index 000000000..f9aef30b6 --- /dev/null +++ b/llm-router/ui/src/configuration/duration.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { minutesToMs, msToMinutes } from './duration' + +describe('timeout minutes', () => { + it('converts the router defaults without rounding error', () => { + expect(msToMinutes(300_000)).toBe(5) + expect(msToMinutes(120_000)).toBe(2) + expect(minutesToMs(5)).toBe(300_000) + expect(minutesToMs(2)).toBe(120_000) + }) + + it('round-trips fractional minutes to whole milliseconds', () => { + expect(minutesToMs(1.5)).toBe(90_000) + expect(msToMinutes(90_000)).toBe(1.5) + }) +}) diff --git a/llm-router/ui/src/configuration/duration.ts b/llm-router/ui/src/configuration/duration.ts new file mode 100644 index 000000000..e46a72e87 --- /dev/null +++ b/llm-router/ui/src/configuration/duration.ts @@ -0,0 +1,9 @@ +/** Stream/idle timeouts are stored as milliseconds; the form edits minutes. */ + +export function msToMinutes(ms: number): number { + return ms / 60_000 +} + +export function minutesToMs(minutes: number): number { + return Math.round(minutes * 60_000) +} diff --git a/llm-router/ui/src/configuration/field-error.tsx b/llm-router/ui/src/configuration/field-error.tsx new file mode 100644 index 000000000..b4494a445 --- /dev/null +++ b/llm-router/ui/src/configuration/field-error.tsx @@ -0,0 +1,10 @@ +export { errorAt, pointer } from './pointers' + +export function FieldError({ message }: { message: string | null }) { + if (!message) return null + return ( +
+ {message} +
+ ) +} diff --git a/llm-router/ui/src/configuration/heuristics.test.ts b/llm-router/ui/src/configuration/heuristics.test.ts new file mode 100644 index 000000000..edea6d664 --- /dev/null +++ b/llm-router/ui/src/configuration/heuristics.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { moveItem, winningHeuristicIndex } from './heuristics' + +describe('winningHeuristicIndex', () => { + const rows = [ + { pattern: '^gpt-', provider: 'openai' }, + { pattern: 'claude', provider: 'anthropic' }, + { pattern: '(', provider: 'broken' }, + { pattern: '', provider: 'openai' }, + ] + + it('returns the first regex that matches the model id', () => { + expect(winningHeuristicIndex('gpt-4.1', rows)).toBe(0) + expect(winningHeuristicIndex('claude-sonnet-4', rows)).toBe(1) + }) + + it('skips invalid regex and empty patterns', () => { + expect(winningHeuristicIndex('nope', rows)).toBe(null) + }) + + it('returns null when the model id is empty', () => { + expect(winningHeuristicIndex('', rows)).toBe(null) + expect(winningHeuristicIndex(' ', rows)).toBe(null) + }) +}) + +describe('moveItem', () => { + it('moves a row up or down and no-ops out of range', () => { + expect(moveItem(['a', 'b', 'c'], 2, 0)).toEqual(['c', 'a', 'b']) + expect(moveItem(['a', 'b', 'c'], 0, 1)).toEqual(['b', 'a', 'c']) + expect(moveItem(['a', 'b', 'c'], 0, -1)).toEqual(['a', 'b', 'c']) + expect(moveItem(['a', 'b', 'c'], 0, 3)).toEqual(['a', 'b', 'c']) + }) +}) diff --git a/llm-router/ui/src/configuration/heuristics.ts b/llm-router/ui/src/configuration/heuristics.ts new file mode 100644 index 000000000..cbba460ed --- /dev/null +++ b/llm-router/ui/src/configuration/heuristics.ts @@ -0,0 +1,24 @@ +/** Draft-only heuristic preview and row reorder. Order is routing priority. */ + +export function winningHeuristicIndex(model: string, rows: { pattern: string; provider: string }[]): number | null { + const needle = model.trim() + if (!needle) return null + for (let i = 0; i < rows.length; i++) { + const pattern = rows[i].pattern + if (!pattern || !rows[i].provider) continue + try { + if (new RegExp(pattern).test(needle)) return i + } catch { + // An invalid operator regex never takes the router down. + } + } + return null +} + +export function moveItem(items: T[], from: number, to: number): T[] { + if (to < 0 || to >= items.length || from === to) return items + const next = [...items] + const [row] = next.splice(from, 1) + next.splice(to, 0, row) + return next +} diff --git a/llm-router/ui/src/configuration/index.tsx b/llm-router/ui/src/configuration/index.tsx index 65879b8cf..0374466a2 100644 --- a/llm-router/ui/src/configuration/index.tsx +++ b/llm-router/ui/src/configuration/index.tsx @@ -3,12 +3,11 @@ * registered through `host.configForms`, replacing the console's generic * schema-driven form for this worker only. * - * One card per provider (api_key / api_url / max_tokens / system_prompt), - * a default-provider picker fed by the configured provider ids, the stream - * budget knobs, and the routing-heuristics table. The form edits the - * working draft via `onChange`; dirty tracking, save/reset, validation and - * the SaveBar stay host-owned. Mirrors compose_entry_schema - * (llm-router/src/config/schema.rs). + * One card per provider (api key first; url / max tokens behind advanced; + * system prompt edited in a dialog), a default-provider picker, stream budgets, and the + * routing-heuristics table. The form edits the working draft via + * `onChange`; dirty tracking, save/reset, validation and the SaveBar stay + * host-owned. Mirrors compose_entry_schema (llm-router/src/config/schema.rs). * * The one opinion this form adds over the generic one: an api_key that * holds a PLAIN-TEXT secret gets a warning steering the operator to @@ -17,14 +16,23 @@ * secret itself. */ -import { useEffect, useRef } from 'react' +import { type ConfigFormProps, type Host, type JsonValue, Select, type SelectOption } from '@iii-dev/console-ui' +import { useEffect, useId, useRef, useState } from 'react' +import { minutesToMs, msToMinutes } from './duration' +import { FieldError } from './field-error' +import { moveItem, winningHeuristicIndex } from './heuristics' +import { errorAt, pointer } from './pointers' +import { isEnvReference, ProviderCard } from './provider-card' import { - type ConfigFormProps, - type JsonValue, - Select, - type SelectOption, -} from '@iii-dev/console-ui' -import { providerCardIds } from './provider-cards' + type LiveProvider, + parseProviderList, + providerCardIds, + providerDisplayName, + providerRuntimeStatus, + schemaProviderIds, + sliceHasKey, + visibleProviderIds, +} from './provider-cards' type JsonObject = { [key: string]: JsonValue } @@ -36,35 +44,14 @@ function asString(v: JsonValue | undefined): string { return typeof v === 'string' ? v : '' } -/** `${VAR}` (exactly one reference, nothing else) — the recommended shape. */ -export function isEnvReference(v: string): boolean { - return /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(v.trim()) -} - -/** Mentions an env reference somewhere (partial templating still counts). */ -function hasEnvReference(v: string): boolean { - return /\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(v) -} - -/** Suggest an env-var name for a provider: `ANTHROPIC_API_KEY`. */ -function suggestedEnvVar(providerId: string): string { - const slug = providerId.toUpperCase().replace(/[^A-Z0-9]+/g, '_') - return `${slug || 'PROVIDER'}_API_KEY` -} - /** * The provider-declared identity prompt, from the entry schema — the * registration rides it in as the `system_prompt` field's `default` * (llm-router/src/config/schema.rs, system_prompt_schema). */ -function providerPromptDefault( - schema: Record | null, - id: string, -): string | null { +export function providerPromptDefault(schema: Record | null, id: string): string | null { const get = (o: unknown, k: string): unknown => - o && typeof o === 'object' && !Array.isArray(o) - ? (o as Record)[k] - : undefined + o && typeof o === 'object' && !Array.isArray(o) ? (o as Record)[k] : undefined const field = get( get(get(get(get(get(schema, 'properties'), 'providers'), 'properties'), id), 'properties'), 'system_prompt', @@ -73,20 +60,16 @@ function providerPromptDefault( return typeof dflt === 'string' && dflt.length > 0 ? dflt : null } -/** - * Options for a provider picker: the configured ids, plus the current - * value when it names a provider that is not connected (so an operator - * can SEE a stale selection instead of the placeholder lying about it). - */ -function providerOptions( - providerIds: string[], - current: string, -): SelectOption[] { - const options = providerIds.map((id) => ({ value: id, label: id })) +function providerOptions(providerIds: string[], current: string, live: LiveProvider[] | null): SelectOption[] { + const names = new Map((live ?? []).map((p) => [p.id, p.display_name])) + const options = providerIds.map((id) => ({ + value: id, + label: providerDisplayName(id, names.get(id)), + })) if (current && !providerIds.includes(current)) { options.push({ value: current, - label: `${current} (not connected)`, + label: `${providerDisplayName(current)} (not connected)`, }) } return options @@ -104,21 +87,23 @@ function formatMs(ms: number): string { /** * The stream-budget knobs (mirrors RouterSettings defaults, - * llm-router/src/settings.rs). `echo` renders the wire value in the - * operator's unit so nobody has to parse 300000 as five minutes. + * llm-router/src/settings.rs). Timeouts are edited in minutes and stored + * as milliseconds; `echo` still renders the wire value. */ const SETTINGS_FIELDS = [ { key: 'stream_timeout_ms', - label: 'stream timeout (ms)', + label: 'stream timeout (min)', defaultValue: 300_000, echo: formatMs, + scale: 'minutes' as const, }, { key: 'idle_timeout_ms', - label: 'idle timeout (ms)', + label: 'idle timeout (min)', defaultValue: 120_000, echo: formatMs, + scale: 'minutes' as const, }, { key: 'retry_max', @@ -134,17 +119,66 @@ const SETTINGS_FIELDS = [ }, ] as const -export function LlmRouterConfigForm(props: ConfigFormProps) { +export function LlmRouterConfigForm(props: ConfigFormProps & { host?: Host }) { const value = asObject(props.value) const providers = asObject(value.providers) - const providerIds = providerCardIds(props.schema, props.value) + const allIds = providerCardIds(props.schema, props.value) + const schemaIds = schemaProviderIds(props.schema) const settings = asObject(value.settings) - const heuristics = Array.isArray(value.routing_heuristics) - ? (value.routing_heuristics as JsonValue[]) - : [] + const heuristics = Array.isArray(value.routing_heuristics) ? (value.routing_heuristics as JsonValue[]) : [] + const defaultProvider = asString(value.default_provider) + const live = useProviderRuntime(props.host) + const [budgetsOpen, setBudgetsOpen] = useState(true) + const [heuristicsOpen, setHeuristicsOpen] = useState(true) + const [probe, setProbe] = useState('') const commit = (patch: JsonObject) => props.onChange({ ...value, ...patch }) + const hasKey = (id: string) => sliceHasKey(providers[id]) + const visibleIds = visibleProviderIds({ + ids: allIds, + schemaIds, + live, + hasKey, + filter: 'all', + }) + + const heuristicRows = heuristics.map((h) => { + const row = asObject(h) + return { pattern: asString(row.pattern), provider: asString(row.provider) } + }) + const winner = winningHeuristicIndex(probe, heuristicRows) + + const liveById = new Map((live ?? []).map((p) => [p.id, p])) + const shownErrors = new Set([ + pointer('default_provider'), + ...allIds.flatMap((id) => [ + pointer('providers', id, 'api_key'), + pointer('providers', id, 'api_url'), + pointer('providers', id, 'max_tokens'), + pointer('providers', id, 'system_prompt'), + ]), + ...SETTINGS_FIELDS.map((f) => pointer('settings', f.key)), + ...heuristics.flatMap((_, i) => [ + pointer('routing_heuristics', i, 'pattern'), + pointer('routing_heuristics', i, 'provider'), + ]), + ]) + const leftoverErrors = [...(props.errors ?? [])].filter(([path]) => path && !shownErrors.has(path)) + const budgetsHaveErrors = SETTINGS_FIELDS.some((f) => errorAt(props.errors, 'settings', f.key) !== null) + const heuristicsHaveErrors = heuristics.some( + (_, i) => + errorAt(props.errors, 'routing_heuristics', i, 'pattern') !== null || + errorAt(props.errors, 'routing_heuristics', i, 'provider') !== null, + ) + + useEffect(() => { + if (budgetsHaveErrors) setBudgetsOpen(true) + }, [budgetsHaveErrors]) + useEffect(() => { + if (heuristicsHaveErrors) setHeuristicsOpen(true) + }, [heuristicsHaveErrors]) + // Deep-link focus (#/workers/configuration/llm-router/): the host // only scroll-focuses the generic form's DOM ids, so honoring the request // is this override's job. @@ -152,25 +186,19 @@ export function LlmRouterConfigForm(props: ConfigFormProps) { useEffect(() => { const field = props.focusField?.[0] if (!field || !rootRef.current) return - const el = rootRef.current.querySelector( - `[data-field="${CSS.escape(field)}"]`, - ) + const el = rootRef.current.querySelector(`[data-field="${CSS.escape(field)}"]`) el?.scrollIntoView({ block: 'center' }) el?.focus() }, [props.focusField]) return (
- - custom form · shipped by the llm-router worker - - default provider
{ - const next = { ...settings } - const n = Number(e.target.value) - if (e.target.value === '' || Number.isNaN(n)) { - delete next[f.key] - } else { - next[f.key] = n - } - commit({ settings: next }) - }} - /> -
- {set ? `= ${f.echo(effective)}` : `default · ${f.echo(effective)}`} + + {budgetsOpen ? ( +
+ {SETTINGS_FIELDS.map((f) => { + const set = typeof settings[f.key] === 'number' + const effective = set ? (settings[f.key] as number) : f.defaultValue + const scale = 'scale' in f ? f.scale : undefined + const display = scale === 'minutes' ? msToMinutes(effective) : effective + return ( +
+ + { + const next = { ...settings } + const n = Number(e.target.value) + if (e.target.value === '' || Number.isNaN(n)) { + delete next[f.key] + } else { + next[f.key] = scale === 'minutes' ? minutesToMs(n) : n + } + commit({ settings: next }) + }} + /> +
{set ? `= ${f.echo(effective)}` : `default · ${f.echo(effective)}`}
+
-
- ) - })} -
+ ) + })} +
+ ) : null} -

routing heuristics

-

- First pattern (substring or regex) matching the requested model wins; - no match falls through to the default provider. -

- {heuristics.map((h, i) => { - const row = asObject(h) - const update = (patch: JsonObject) => { - const next = [...heuristics] - next[i] = { ...row, ...patch } - commit({ routing_heuristics: next }) - } - return ( - // Rows have no id; order IS the routing priority, so index keys - // are the honest choice here. - // biome-ignore lint/suspicious/noArrayIndexKey: positional rows -
- update({ pattern: e.target.value })} - /> - - → - - setProbe(e.target.value)} + /> +
+ {probe.trim() ? ( +
+ {winner === null + ? 'no heuristic match — falls through to the default provider' + : `row ${winner + 1} → ${heuristicRows[winner].provider}`} +
+ ) : null} + {heuristics.map((h, i) => { + const row = asObject(h) + const update = (patch: JsonObject) => { + const next = [...heuristics] + next[i] = { ...row, ...patch } + commit({ routing_heuristics: next }) + } + return ( + // Rows have no id; order IS the routing priority, so index keys + // are the honest choice here. +
+
+ update({ pattern: e.target.value })} + /> + + → + + set('api_key', e.target.value || undefined)} - /> - {plainTextKey ? ( -
- plain-text secret — this key is stored verbatim in - the configuration entry (and every export of it). Set it as an - environment variable on the engine host and reference it instead:{' '} - {' '} - — the value expands when the entry is read, so the secret never - lands in the store. -
- ) : isEnvReference(apiKey) ? ( -
- env reference — expands on read, secret stays out of the store -
- ) : hasEnvReference(apiKey) ? ( -
- partial env reference — the literal part is still stored as plain - text. Move the whole key into one variable. -
- ) : null} +/** + * Live `router::provider::list`, kept fresh via `router::provider::changed`. + * `on()` namespaces as `::`; the trigger must name that same + * id — do not prefix browserId twice. + */ +function useProviderRuntime(host: Host | undefined): LiveProvider[] | null { + const [live, setLive] = useState(null) + const instance = useId().replace(/[^a-zA-Z0-9]/g, '') - - set('api_url', e.target.value || undefined)} - /> + useEffect(() => { + if (!host) { + setLive([]) + return + } + let cancelled = false + const refresh = async () => { + try { + const raw = await host.iii.trigger('router::provider::list', {}) + if (!cancelled) setLive(parseProviderList(raw)) + } catch { + if (!cancelled) setLive([]) + } + } + void refresh() - - { - const n = Number(e.target.value) - set( - 'max_tokens', - e.target.value === '' || Number.isNaN(n) ? undefined : n, - ) - }} - /> + const localFn = `iii::llm-router-ui::providers-changed::${instance}` + const boundFn = `${localFn}::${host.iii.browserId}` + const off = host.iii.on(localFn, () => { + void refresh() + }) + let unreg: (() => void) | undefined + try { + unreg = host.iii.registerTrigger({ + type: 'router::provider::changed', + function_id: boundFn, + config: {}, + }) + } catch { + // Older router without this trigger type — list snapshot still stands. + } + return () => { + cancelled = true + off() + try { + unreg?.() + } catch { + // SDK already disposed. + } + } + }, [host, instance]) -
- system prompt - {typeof systemPrompt === 'string' ? ( - - ) : ( - - )} -
- {typeof systemPrompt === 'string' ? ( -