- {SETTINGS_FIELDS.map((f) => {
- const set = typeof settings[f.key] === 'number'
- const effective = set ? (settings[f.key] as number) : f.defaultValue
- return (
-
-
- {f.label}
-
-
{
- 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)}`}
+
setBudgetsOpen((open) => !open)}
+ >
+
+ {budgetsOpen ? '▾' : '▸'}
+
+ stream budgets
+ timeout · retries · token cap
+
+ {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 (
+
+
+ {f.label}
+
+
{
+ 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 })}
- />
-
- →
-
- update({ provider: id })}
- />
-
- commit({
- routing_heuristics: heuristics.filter((_, j) => j !== i),
- })
- }
- >
- ×
-
-
- )
- })}
- commit({
- routing_heuristics: [...heuristics, { pattern: '', provider: '' }],
- })
- }
+ className="llmr-cfg-disclosure"
+ aria-expanded={heuristicsOpen}
+ onClick={() => setHeuristicsOpen((open) => !open)}
>
- + add heuristic
+
+ {heuristicsOpen ? '▾' : '▸'}
+
+ routing heuristics
+
+ {heuristics.length === 0 ? 'none' : `${heuristics.length} ${heuristics.length === 1 ? 'rule' : 'rules'}`}
+
+ {heuristicsOpen ? (
+ <>
+
+ First pattern (substring or regex) matching the requested model wins; no match falls through to the default
+ provider. Catalog ownership still wins first after save — this preview is the draft table only.
+
+
+ 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 })}
+ />
+
+ →
+
+ update({ provider: id })}
+ />
+ commit({ routing_heuristics: moveItem(heuristics, i, i - 1) })}
+ >
+ ↑
+
+ commit({ routing_heuristics: moveItem(heuristics, i, i + 1) })}
+ >
+ ↓
+
+
+ commit({
+ routing_heuristics: heuristics.filter((_, j) => j !== i),
+ })
+ }
+ >
+ ×
+
+
+
+
+
+ )
+ })}
+
{
+ setHeuristicsOpen(true)
+ commit({
+ routing_heuristics: [...heuristics, { pattern: '', provider: '' }],
+ })
+ }}
+ >
+ + add heuristic
+
+ >
+ ) : null}
- {props.errors && props.errors.size > 0 ? (
+ {leftoverErrors.length > 0 ? (
- {[...props.errors].map(([path, message]) => (
+ {leftoverErrors.map(([path, message]) => (
{path} {message}
@@ -312,147 +420,58 @@ export function LlmRouterConfigForm(props: ConfigFormProps) {
)
}
-function ProviderCard({
- id,
- slice,
- promptDefault,
- onChange,
-}: {
- id: string
- slice: JsonObject
- /** The provider-declared identity prompt (schema default), if any. */
- promptDefault: string | null
- onChange(next: JsonObject): void
-}) {
- const apiKey = asString(slice.api_key)
- // Plain text = something typed that is not an env reference. A partial
- // reference (`sk-${SUFFIX}`) still embeds secret material, so only the
- // pure `${VAR}` shape counts as safe.
- const plainTextKey = apiKey.length > 0 && !isEnvReference(apiKey)
- const systemPrompt = slice.system_prompt
-
- const set = (key: string, v: JsonValue | undefined) => {
- const next = { ...slice }
- if (v === undefined) delete next[key]
- else next[key] = v
- onChange(next)
- }
-
- return (
-
-
-
-
- api key
-
- 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:{' '}
- set('api_key', `\${${suggestedEnvVar(id)}}`)}
- >
- {'${'}
- {suggestedEnvVar(id)}
- {'}'}
- {' '}
- — 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, '')
-
- api url
-
- 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()
-
- max tokens
-
- {
- 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' ? (
- set('system_prompt', undefined)}
- >
- use provider default
-
- ) : (
- set('system_prompt', promptDefault ?? '')}
- >
- override
-
- )}
-
- {typeof systemPrompt === 'string' ? (
-
- )
+ return live
}
+
+export { isEnvReference }
diff --git a/llm-router/ui/src/configuration/pointers.test.ts b/llm-router/ui/src/configuration/pointers.test.ts
new file mode 100644
index 000000000..43a97d52d
--- /dev/null
+++ b/llm-router/ui/src/configuration/pointers.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { errorAt, pointer } from './pointers'
+
+describe('pointer', () => {
+ it('encodes RFC 6901 tokens', () => {
+ expect(pointer('providers', 'anthropic', 'api_key')).toBe('/providers/anthropic/api_key')
+ expect(pointer('routing_heuristics', 0, 'pattern')).toBe('/routing_heuristics/0/pattern')
+ expect(pointer('a/b', 'c~d')).toBe('/a~1b/c~0d')
+ })
+})
+
+describe('errorAt', () => {
+ it('returns the message for a matching pointer', () => {
+ const errors = new Map([['/providers/openai/api_key', 'required']])
+ expect(errorAt(errors, 'providers', 'openai', 'api_key')).toBe('required')
+ expect(errorAt(errors, 'providers', 'anthropic', 'api_key')).toBe(null)
+ expect(errorAt(undefined, 'providers', 'openai', 'api_key')).toBe(null)
+ })
+})
diff --git a/llm-router/ui/src/configuration/pointers.ts b/llm-router/ui/src/configuration/pointers.ts
new file mode 100644
index 000000000..296696645
--- /dev/null
+++ b/llm-router/ui/src/configuration/pointers.ts
@@ -0,0 +1,11 @@
+export function pointer(...parts: Array
): string {
+ return `/${parts.map((p) => String(p).replace(/~/g, '~0').replace(/\//g, '~1')).join('/')}`
+}
+
+export function errorAt(
+ errors: ReadonlyMap | undefined,
+ ...parts: Array
+): string | null {
+ if (!errors) return null
+ return errors.get(pointer(...parts)) ?? null
+}
diff --git a/llm-router/ui/src/configuration/provider-card.tsx b/llm-router/ui/src/configuration/provider-card.tsx
new file mode 100644
index 000000000..3c01d4582
--- /dev/null
+++ b/llm-router/ui/src/configuration/provider-card.tsx
@@ -0,0 +1,265 @@
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogTitle,
+ type JsonValue,
+} from '@iii-dev/console-ui'
+import { useState } from 'react'
+import { FieldError } from './field-error'
+import { errorAt } from './pointers'
+import { apiKeyPlaceholder, type ProviderRuntimeStatus, suggestedEnvVar } from './provider-cards'
+
+type JsonObject = { [key: string]: JsonValue }
+
+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)
+}
+
+function statusClass(status: ProviderRuntimeStatus): string | null {
+ if (status === 'unknown') return null
+ if (status === 'loaded') return 'llmr-cfg-status is-loaded'
+ if (status === 'not-loaded') return 'llmr-cfg-status is-not-loaded'
+ return 'llmr-cfg-status is-not-connected'
+}
+
+function statusLabel(status: ProviderRuntimeStatus): string | null {
+ if (status === 'unknown') return null
+ if (status === 'loaded') return 'loaded'
+ if (status === 'not-loaded') return 'not loaded'
+ return 'not connected'
+}
+
+export function ProviderCard({
+ id,
+ label,
+ status,
+ isDefault,
+ slice,
+ promptDefault,
+ errors,
+ onChange,
+ onSetDefault,
+}: {
+ id: string
+ label: string
+ status: ProviderRuntimeStatus
+ isDefault: boolean
+ slice: JsonObject
+ promptDefault: string | null
+ errors?: ReadonlyMap
+ onChange(next: JsonObject): void
+ onSetDefault(): void
+}) {
+ const apiKey = asString(slice.api_key)
+ const plainTextKey = apiKey.length > 0 && !isEnvReference(apiKey)
+ const systemPrompt = slice.system_prompt
+ const overridden = typeof systemPrompt === 'string'
+ const advancedSet =
+ asString(slice.api_url).length > 0 || typeof slice.max_tokens === 'number' || overridden
+ const [advanced, setAdvanced] = useState(true)
+ const [reveal, setReveal] = useState(false)
+ const [promptOpen, setPromptOpen] = useState(false)
+ const [promptDraft, setPromptDraft] = useState('')
+ const statusCls = statusClass(status)
+ const statusText = statusLabel(status)
+ const effectivePrompt = overridden ? asString(systemPrompt) : promptDefault
+
+ const set = (key: string, v: JsonValue | undefined) => {
+ const next = { ...slice }
+ if (v === undefined) delete next[key]
+ else next[key] = v
+ onChange(next)
+ }
+
+ const openPrompt = () => {
+ setPromptDraft(overridden ? asString(systemPrompt) : (promptDefault ?? ''))
+ setPromptOpen(true)
+ }
+
+ const applyPrompt = () => {
+ set('system_prompt', promptDraft)
+ setPromptOpen(false)
+ }
+
+ const clearPrompt = () => {
+ set('system_prompt', undefined)
+ setPromptOpen(false)
+ }
+
+ const masked = plainTextKey && !reveal
+
+ return (
+
+ )
+}
diff --git a/llm-router/ui/src/configuration/provider-cards.test.ts b/llm-router/ui/src/configuration/provider-cards.test.ts
index 0d5ee3687..788875bd5 100644
--- a/llm-router/ui/src/configuration/provider-cards.test.ts
+++ b/llm-router/ui/src/configuration/provider-cards.test.ts
@@ -1,5 +1,15 @@
import { describe, expect, it } from 'vitest'
-import { providerCardIds } from './provider-cards'
+import {
+ apiKeyPlaceholder,
+ humanizeProviderId,
+ type LiveProvider,
+ parseProviderList,
+ providerBucket,
+ providerCardIds,
+ providerDisplayName,
+ providerRuntimeStatus,
+ visibleProviderIds,
+} from './provider-cards'
const schema = {
properties: {
@@ -30,3 +40,144 @@ describe('providerCardIds', () => {
expect(providerCardIds({ properties: {} }, {})).toEqual([])
})
})
+
+describe('parseProviderList', () => {
+ it('reads id, display_name and available from router::provider::list', () => {
+ expect(
+ parseProviderList({
+ providers: [
+ {
+ id: 'anthropic',
+ display_name: 'Anthropic',
+ available: true,
+ },
+ { id: 'openai', available: false },
+ ],
+ }),
+ ).toEqual([
+ { id: 'anthropic', display_name: 'Anthropic', available: true },
+ { id: 'openai', display_name: 'openai', available: false },
+ ])
+ })
+
+ it('treats a missing available flag as loaded (older routers)', () => {
+ expect(parseProviderList({ providers: [{ id: 'kimi' }] })).toEqual([
+ { id: 'kimi', display_name: 'kimi', available: true },
+ ])
+ })
+
+ it('returns an empty list for a malformed payload', () => {
+ expect(parseProviderList(null)).toEqual([])
+ expect(parseProviderList({ providers: 'nope' })).toEqual([])
+ })
+})
+
+describe('providerRuntimeStatus', () => {
+ const schemaIds = ['anthropic', 'openai']
+ const live: LiveProvider[] = [
+ { id: 'anthropic', display_name: 'Anthropic', available: true },
+ { id: 'openai', display_name: 'OpenAI', available: false },
+ ]
+
+ it('is unknown until the live list has been fetched', () => {
+ expect(providerRuntimeStatus('anthropic', null, schemaIds)).toBe('unknown')
+ })
+
+ it('is loaded when the worker is in the list and available', () => {
+ expect(providerRuntimeStatus('anthropic', live, schemaIds)).toBe('loaded')
+ })
+
+ it('is not-loaded when the worker is declared but unavailable', () => {
+ expect(providerRuntimeStatus('openai', live, schemaIds)).toBe('not-loaded')
+ })
+
+ it('is not-loaded when the schema lists a provider the live list does not', () => {
+ expect(providerRuntimeStatus('kimi', [], ['kimi'])).toBe('not-loaded')
+ })
+
+ it('is not-connected when the id exists only in saved config', () => {
+ expect(providerRuntimeStatus('old-local', live, schemaIds)).toBe('not-connected')
+ })
+})
+
+describe('providerBucket and visibleProviderIds', () => {
+ const live: LiveProvider[] = [
+ { id: 'anthropic', display_name: 'Anthropic', available: true },
+ { id: 'openai', display_name: 'OpenAI', available: true },
+ { id: 'kimi', display_name: 'Kimi', available: false },
+ ]
+ const schemaIds = ['anthropic', 'openai', 'kimi']
+ const ids = ['kimi', 'anthropic', 'openai', 'old-local']
+ const hasKey = (id: string) => id === 'anthropic' || id === 'old-local'
+
+ it('buckets loaded+key as ready, loaded+empty as needs-key, anything else as not-loaded', () => {
+ expect(providerBucket('loaded', true)).toBe('ready')
+ expect(providerBucket('loaded', false)).toBe('needs-key')
+ expect(providerBucket('not-loaded', true)).toBe('not-loaded')
+ expect(providerBucket('not-connected', false)).toBe('not-loaded')
+ expect(providerBucket('unknown', false)).toBe('needs-key')
+ })
+
+ it('sorts needs-key first, then ready, then not-loaded', () => {
+ expect(
+ visibleProviderIds({
+ ids,
+ schemaIds,
+ live,
+ hasKey,
+ filter: 'all',
+ }),
+ ).toEqual(['openai', 'anthropic', 'kimi', 'old-local'])
+ })
+
+ it('filters to a single bucket', () => {
+ expect(
+ visibleProviderIds({
+ ids,
+ schemaIds,
+ live,
+ hasKey,
+ filter: 'needs-key',
+ }),
+ ).toEqual(['openai'])
+ expect(
+ visibleProviderIds({
+ ids,
+ schemaIds,
+ live,
+ hasKey,
+ filter: 'ready',
+ }),
+ ).toEqual(['anthropic'])
+ expect(
+ visibleProviderIds({
+ ids,
+ schemaIds,
+ live,
+ hasKey,
+ filter: 'not-loaded',
+ }),
+ ).toEqual(['kimi', 'old-local'])
+ })
+})
+
+describe('providerDisplayName', () => {
+ it('prefers a live display_name that is not the raw id', () => {
+ expect(providerDisplayName('anthropic', 'Anthropic')).toBe('Anthropic')
+ })
+
+ it('humanizes kebab ids when the live name is missing or equal to the id', () => {
+ expect(humanizeProviderId('openai-codex')).toBe('OpenAI Codex')
+ expect(humanizeProviderId('github-copilot')).toBe('GitHub Copilot')
+ expect(humanizeProviderId('llamacpp')).toBe('llama.cpp')
+ expect(providerDisplayName('openai-codex', 'openai-codex')).toBe('OpenAI Codex')
+ expect(providerDisplayName('kimi')).toBe('Kimi')
+ })
+})
+
+describe('apiKeyPlaceholder', () => {
+ it('does not look like a saved env reference', () => {
+ expect(apiKeyPlaceholder('anthropic')).toBe('env var, e.g. ${' + 'ANTHROPIC_API_KEY}')
+ expect(apiKeyPlaceholder('anthropic').startsWith('${')).toBe(false)
+ })
+})
diff --git a/llm-router/ui/src/configuration/provider-cards.ts b/llm-router/ui/src/configuration/provider-cards.ts
index 027ad9dff..f677f4384 100644
--- a/llm-router/ui/src/configuration/provider-cards.ts
+++ b/llm-router/ui/src/configuration/provider-cards.ts
@@ -2,33 +2,143 @@
* Build the provider card list from both the registered schema and the saved
* configuration. A fresh installation has a null configuration value, while
* its schema already contains the providers that operators can configure.
+ *
+ * Runtime status comes from `router::provider::list`: a card that cannot
+ * serve must not look identical to one that can.
*/
type JsonObject = Record
function asObject(value: unknown): JsonObject {
- return value !== null && typeof value === 'object' && !Array.isArray(value)
- ? (value as JsonObject)
- : {}
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}
}
-function schemaProviderIds(
- schema: Record | null,
-): string[] {
+export function schemaProviderIds(schema: Record | null): string[] {
const properties = asObject(schema?.properties)
const providers = asObject(properties.providers)
return Object.keys(asObject(providers.properties))
}
-export function providerCardIds(
- schema: Record | null,
- value: unknown,
-): string[] {
+export function providerCardIds(schema: Record | null, value: unknown): string[] {
const configuredProviders = asObject(asObject(value).providers)
- return [
- ...new Set([
- ...schemaProviderIds(schema),
- ...Object.keys(configuredProviders),
- ]),
- ]
+ return [...new Set([...schemaProviderIds(schema), ...Object.keys(configuredProviders)])]
+}
+
+export type LiveProvider = {
+ id: string
+ display_name: string
+ available: boolean
+}
+
+export type ProviderRuntimeStatus = 'unknown' | 'loaded' | 'not-loaded' | 'not-connected'
+
+export type ProviderFilter = 'all' | 'needs-key' | 'ready' | 'not-loaded'
+
+export type ProviderBucket = Exclude
+
+/** Wire shape of `router::provider::list`. */
+export function parseProviderList(raw: unknown): LiveProvider[] {
+ const rows = asObject(raw).providers
+ if (!Array.isArray(rows)) return []
+ const out: LiveProvider[] = []
+ for (const row of rows) {
+ const o = asObject(row)
+ const id = typeof o.id === 'string' ? o.id : ''
+ if (!id) continue
+ out.push({
+ id,
+ display_name: typeof o.display_name === 'string' ? o.display_name : id,
+ // Absent on older routers — treat as available (previous behavior).
+ available: o.available !== false,
+ })
+ }
+ return out
+}
+
+/**
+ * `live === null` means the list has not been fetched yet, so the form
+ * must not flash "not loaded" on every mount.
+ */
+export function providerRuntimeStatus(
+ id: string,
+ live: LiveProvider[] | null,
+ schemaIds: string[],
+): ProviderRuntimeStatus {
+ if (live === null) return 'unknown'
+ const row = live.find((p) => p.id === id)
+ if (row) return row.available ? 'loaded' : 'not-loaded'
+ if (schemaIds.includes(id)) return 'not-loaded'
+ return 'not-connected'
+}
+
+export function providerBucket(status: ProviderRuntimeStatus, hasKey: boolean): ProviderBucket {
+ if (status === 'not-loaded' || status === 'not-connected') return 'not-loaded'
+ return hasKey ? 'ready' : 'needs-key'
+}
+
+const BUCKET_ORDER: Record = {
+ 'needs-key': 0,
+ ready: 1,
+ 'not-loaded': 2,
+}
+
+export function visibleProviderIds(args: {
+ ids: string[]
+ schemaIds: string[]
+ live: LiveProvider[] | null
+ hasKey: (id: string) => boolean
+ filter: ProviderFilter
+}): string[] {
+ const ranked = args.ids.map((id) => {
+ const status = providerRuntimeStatus(id, args.live, args.schemaIds)
+ const bucket = providerBucket(status, args.hasKey(id))
+ return { id, bucket }
+ })
+ const filtered = args.filter === 'all' ? ranked : ranked.filter((row) => row.bucket === args.filter)
+ return filtered
+ .sort((a, b) => {
+ const d = BUCKET_ORDER[a.bucket] - BUCKET_ORDER[b.bucket]
+ return d !== 0 ? d : a.id.localeCompare(b.id)
+ })
+ .map((row) => row.id)
+}
+
+const TITLE_PARTS: Record = {
+ openai: 'OpenAI',
+ github: 'GitHub',
+ llamacpp: 'llama.cpp',
+ xai: 'xAI',
+ zai: 'Z.ai',
+}
+
+export function humanizeProviderId(id: string): string {
+ return id
+ .split(/[-_]+/)
+ .map((part) => {
+ const mapped = TITLE_PARTS[part.toLowerCase()]
+ if (mapped) return mapped
+ return part.charAt(0).toUpperCase() + part.slice(1)
+ })
+ .join(' ')
+}
+
+export function providerDisplayName(id: string, liveName?: string): string {
+ if (liveName && liveName !== id) return liveName
+ return humanizeProviderId(id)
+}
+
+export function sliceHasKey(slice: unknown): boolean {
+ const key = asObject(slice).api_key
+ return typeof key === 'string' && key.trim().length > 0
+}
+
+/** Suggest an env-var name for a provider: `ANTHROPIC_API_KEY`. */
+export function suggestedEnvVar(providerId: string): string {
+ const slug = providerId.toUpperCase().replace(/[^A-Z0-9]+/g, '_')
+ return `${slug || 'PROVIDER'}_API_KEY`
+}
+
+/** Empty-field hint — must not look like a value already saved in the box. */
+export function apiKeyPlaceholder(providerId: string): string {
+ return `env var, e.g. \${${suggestedEnvVar(providerId)}}`
}
diff --git a/llm-router/ui/styles.css b/llm-router/ui/styles.css
index 8f98c8fdf..3f5177edd 100644
--- a/llm-router/ui/styles.css
+++ b/llm-router/ui/styles.css
@@ -1,79 +1,190 @@
/* llm-router configuration form — every rule scoped under the loader's
- * wrapper attribute; colors come from the console's design tokens so both
- * themes work with no extra rules. */
+ * wrapper attribute. Follows the console surface + type roles:
+ * sans labels, headings, buttons, status
+ * mono ids, field values, placeholders
+ * Hierarchy is fills (surface / muted tokens), not 1px rules — those
+ * tokens are transparent. Radius matches the console (6px). */
[data-iii-ui='llm-router'] .llmr-cfg {
display: flex;
flex-direction: column;
- gap: 8px;
- font-family: var(--font-mono, monospace);
-}
-
-[data-iii-ui='llm-router'] .llmr-cfg-caption {
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
- color: var(--color-ink-ghost);
+ gap: 16px;
+ font-family: var(--font-sans, Inter, ui-sans-serif, sans-serif);
}
[data-iii-ui='llm-router'] .llmr-cfg-heading {
- margin: 12px 0 0;
- font-size: 13px;
+ margin: 4px 0 0;
+ font-size: 15px;
font-weight: 600;
color: var(--color-ink);
}
[data-iii-ui='llm-router'] .llmr-cfg-label {
display: block;
- margin-top: 6px;
- font-size: 11px;
- text-transform: uppercase;
- letter-spacing: 0.06em;
+ margin-top: 10px;
+ margin-bottom: 4px;
+ font-size: 13px;
+ font-weight: 500;
color: var(--color-ink-faint);
}
[data-iii-ui='llm-router'] .llmr-cfg-input {
width: 100%;
- padding: 6px 10px;
- font: inherit;
+ padding: 8px 12px;
+ font-family: var(--font-mono, ui-monospace, monospace);
font-size: 13px;
color: var(--color-ink);
background: var(--color-bg);
- border: 1px solid var(--color-rule);
- border-radius: 2px;
+ border: 0;
+ border-radius: var(--radius-md, 6px);
}
-[data-iii-ui='llm-router'] .llmr-cfg-input:focus-visible {
- outline: 2px solid var(--color-ring);
- outline-offset: -1px;
+[data-iii-ui='llm-router'] .llmr-cfg-input::placeholder {
+ color: var(--color-ink-ghost);
+ opacity: 1;
}
-[data-iii-ui='llm-router'] .llmr-cfg-textarea {
- resize: vertical;
+[data-iii-ui='llm-router'] .llmr-cfg-input:focus-visible {
+ outline: 2px solid var(--color-rule-focus, var(--color-accent-border));
+ outline-offset: 0;
}
[data-iii-ui='llm-router'] .llmr-cfg-card {
- padding: 10px 12px 12px;
- border: 1px solid var(--color-rule);
- background: var(--color-panel);
+ padding: 14px 16px 16px;
+ border: 0;
+ background: var(--color-surface);
+ border-radius: var(--radius-md, 6px);
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 2px;
}
[data-iii-ui='llm-router'] .llmr-cfg-card-head {
- font-size: 13px;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+ font-size: 15px;
font-weight: 600;
color: var(--color-ink);
}
-[data-iii-ui='llm-router'] .llmr-cfg-warning {
- padding: 6px 10px;
+[data-iii-ui='llm-router'] .llmr-cfg-card-title {
+ font-weight: 600;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-id {
+ font-family: var(--font-mono, ui-monospace, monospace);
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--color-ink-ghost);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-default,
+[data-iii-ui='llm-router'] .llmr-cfg-status {
+ padding: 2px 8px;
font-size: 12px;
+ font-weight: 500;
+ border-radius: var(--radius-md, 6px);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-card-head .llmr-cfg-toggle {
+ margin-left: auto;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-default {
+ color: var(--color-accent);
+ background: var(--color-accent-muted);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-status.is-loaded {
+ color: var(--color-ok);
+ background: var(--color-ok-muted);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-status.is-not-loaded {
+ color: var(--color-warn);
+ background: var(--color-warn-muted);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-status.is-not-connected {
+ color: var(--color-alert);
+ background: var(--color-alert-muted);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-status.is-overridden {
+ color: var(--color-accent);
+ background: var(--color-accent-muted);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-key-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-key-row .llmr-cfg-input {
+ flex: 1;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-advanced {
+ align-self: flex-start;
+ margin-top: 10px;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-disclosure {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 4px 0;
+ font: inherit;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-ink);
+ background: none;
+ border: 0;
+ cursor: pointer;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-disclosure:hover .llmr-cfg-disclosure-chevron {
+ color: var(--color-ink);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-disclosure-chevron {
+ display: inline-block;
+ width: 0.9em;
+ color: var(--color-ink-faint);
+ font-weight: 400;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-disclosure-hint {
+ font-weight: 400;
+ font-size: 13px;
+ color: var(--color-ink-ghost);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-field-error {
+ margin-top: 4px;
+ font-size: 13px;
+ color: var(--color-alert);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-heuristic.is-win {
+ outline: 2px solid var(--color-ok);
+ outline-offset: 2px;
+ border-radius: var(--radius-md, 6px);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-warning {
+ margin-top: 6px;
+ padding: 8px 10px;
+ font-size: 13px;
line-height: 1.5;
color: var(--color-ink);
- background: color-mix(in srgb, var(--color-warn) 12%, transparent);
- border-left: 2px solid var(--color-warn);
+ background: var(--color-warn-muted);
+ border-radius: var(--radius-md, 6px);
}
[data-iii-ui='llm-router'] .llmr-cfg-warning strong {
@@ -81,18 +192,20 @@
}
[data-iii-ui='llm-router'] .llmr-cfg-envok {
- font-size: 11px;
+ margin-top: 4px;
+ font-size: 12px;
color: var(--color-ok);
}
[data-iii-ui='llm-router'] .llmr-cfg-envfix {
- padding: 0 4px;
+ padding: 1px 6px;
font: inherit;
font-size: 12px;
+ font-family: var(--font-mono, ui-monospace, monospace);
color: var(--color-accent);
- background: var(--color-paper-2);
- border: 1px solid var(--color-rule-2);
- border-radius: 2px;
+ background: var(--color-accent-muted);
+ border: 0;
+ border-radius: var(--radius-md, 6px);
cursor: pointer;
}
@@ -119,13 +232,13 @@
[data-iii-ui='llm-router'] .llmr-cfg-remove,
[data-iii-ui='llm-router'] .llmr-cfg-add,
[data-iii-ui='llm-router'] .llmr-cfg-toggle {
- padding: 4px 10px;
+ padding: 5px 10px;
font: inherit;
- font-size: 12px;
+ font-size: 13px;
color: var(--color-ink-faint);
- background: var(--color-bg);
- border: 1px solid var(--color-rule);
- border-radius: 2px;
+ background: var(--color-surface-hover);
+ border: 0;
+ border-radius: var(--radius-md, 6px);
cursor: pointer;
}
@@ -133,6 +246,12 @@
[data-iii-ui='llm-router'] .llmr-cfg-add:hover,
[data-iii-ui='llm-router'] .llmr-cfg-toggle:hover {
color: var(--color-ink);
+ background: var(--color-surface-active);
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-remove:disabled {
+ opacity: 0.4;
+ cursor: default;
}
[data-iii-ui='llm-router'] .llmr-cfg-add {
@@ -146,9 +265,34 @@
gap: 10px;
}
+[data-iii-ui='llm-router'] .llmr-cfg-prompt-head .llmr-cfg-toggle {
+ margin-left: auto;
+}
+
+[data-iii-ui='llm-router'].llmr-cfg-prompt-dialog {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-prompt-editor {
+ min-height: min(50vh, 28rem);
+ resize: vertical;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-dialog-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-dialog-actions-spacer {
+ flex: 1;
+}
+
[data-iii-ui='llm-router'] .llmr-cfg-echo {
- margin-top: 2px;
- font-size: 11px;
+ margin-top: 4px;
+ font-size: 12px;
color: var(--color-ink-ghost);
font-variant-numeric: tabular-nums;
}
@@ -160,30 +304,43 @@
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
- padding: 6px 10px;
- font-size: 12px;
+ width: 100%;
+ margin: 0;
+ padding: 8px 12px;
+ font: inherit;
+ font-size: 13px;
line-height: 1.5;
+ text-align: left;
color: var(--color-ink-faint);
- background: var(--color-paper-2);
- border-left: 2px solid var(--color-rule);
+ background: var(--color-bg);
+ border: 0;
+ border-radius: var(--radius-md, 6px);
+ cursor: pointer;
+}
+
+[data-iii-ui='llm-router'] .llmr-cfg-prompt-preview:hover {
+ color: var(--color-ink);
+ background: var(--color-surface-hover);
}
[data-iii-ui='llm-router'] .llmr-cfg-hint {
margin: 0;
- font-size: 11px;
+ font-size: 13px;
color: var(--color-ink-ghost);
}
[data-iii-ui='llm-router'] .llmr-cfg-empty {
- padding: 10px 12px;
- font-size: 12px;
+ padding: 12px 14px;
+ font-size: 13px;
color: var(--color-ink-faint);
- border: 1px dashed var(--color-rule);
+ background: var(--color-surface);
+ border-radius: var(--radius-md, 6px);
}
[data-iii-ui='llm-router'] .llmr-cfg-errors {
- padding: 6px 10px;
- font-size: 12px;
+ padding: 8px 12px;
+ font-size: 13px;
color: var(--color-alert);
- border-left: 2px solid var(--color-alert);
+ background: var(--color-alert-muted);
+ border-radius: var(--radius-md, 6px);
}