Model Router
A routing policy panel — ordered first-match-wins rules with testable condition chips, per-rule switches with optimistic rollback, a health-aware fallback chain, and a prompt tester that names the rule which claims a request and the ones never asked.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/model-router.jsonPrompt
Build a React + TypeScript + Tailwind "ModelRouter" component with zod and
lucide-react, reusing a shadcn Button and Input — the routing policy of an LLM
gateway, not a picture of one.
Contract
- A zod schema (`modelRouterSchema` in a sibling contract file) is the single
source of truth: { status, rules[], fallback[], defaultTarget? }.
- A rule is { id, label, description?, conditions[], match?: "all" | "any",
target, enabled }. ARRAY ORDER IS LOAD-BEARING: the router walks the list top
to bottom and the first ENABLED rule whose conditions hold wins, so a rule is
defined as much by what sits above it as by its own conditions.
- A target is { id, name, provider?, note? }. `note` is the per-route override
("temperature 0.2 · 8k max output") and belongs to the ROUTE, not to the model:
the same model reached by two rules can be called two different ways.
- Conditions are DECLARATIVE, not prose, and this is the whole design. Five
kinds, discriminated on `kind`:
keywords { values[] } — any value appears in the prompt
regex { pattern, flags? } — pattern SOURCE, compiled at evaluation time
tokens { op: "gte"|"lt", value } — a size gate on the estimated request
flag { name, expect? } — a named non-textual signal; expect:false
asserts its ABSENCE
always {} — the catch-all
A policy rendered from strings can only ever be a screenshot; a policy made of
predicates can be EVALUATED against a prompt the reader types, which is what
turns "here is the config" into "here is what happens to this request".
- A probe is { text, flags: Record<string, boolean>, tokens }. `tokens` is always
an ESTIMATE and is labelled as one — the real tokenizer is on the other side of
the network.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
panel's own render state, independent of everything inside it: `ready` with a
chain full of down hops is a healthy panel describing a sick fleet, while
`error` means the POLICY could not be read, which is a different failure with a
different remedy from a model failing.
- Props: rules, fallback, defaultTarget, status; onToggleRule?(id, enabled);
onAddRule?; onRetry?; onTest?(probe, matchedRuleId); matchRule?(probe, rules);
estimateTokens?; showTester? (true); defaultProbeText?; testerPlaceholder?;
maxKeywordChips? (4, clamped >= 1); testDelayMs? (400, clamped >= 0);
addRuleLabel?; emptyState?; errorMessage?; label? ("Routing policy"); plus
className and the remaining element props, ref forwarded to the <section>.
Behavior
- FIRST MATCH WINS, AND THE PANEL SHOWS THE CONSEQUENCE. Walking the list yields
one of six outcomes per row: matched, missed, unreached, off, unknown, idle.
`unreached` is the important one — a rule BELOW the winner was never evaluated,
and painting it "no match" would be a lie, since its conditions might hold
perfectly well. It says "not evaluated · rule 3 matched first" instead. `off`
outranks `unreached`: both are true of a switched-off rule below the winner,
and the switch is the fact the operator acted on.
- A missed rule marks the individual condition chips that failed with a cross and
the ones that held with a tick, so "why didn't this fire?" is answered on the
row rather than in a debugger.
- Keyword matching is whole-word and case-insensitive: `code` must not fire on
`decoder`, or every prompt mentioning a decoder lands on the coding model. Do
it with a boundary pattern over Unicode letter/number classes, and escape the
keyword before embedding it.
- A regex condition compiles at evaluation time inside a try/catch. An invalid
pattern is a FIRST-CLASS OUTCOME: the chip turns destructive, says "invalid
pattern", and the condition never matches. A broken regex in a live policy is
configuration the operator must see, not an exception that blanks the panel.
Strip `g` and `y` from the flags — those make `.test` stateful through
lastIndex, so the same prompt would match on every other keystroke.
- An empty condition list is a catch-all under BOTH match modes ("all of nothing"
is true, and `any` must agree with it). Any enabled catch-all that is not the
last rule kills everything below it, and the header says so statically, before
anyone types anything.
- THE TESTER IS DERIVED FROM THE POLICY: one signal chip per distinct `flag` name
some rule actually reads, and an "attached context" number field only when some
rule gates on size. Offering switches nothing consumes teaches the wrong mental
model; typing 200,000 characters to exercise a long-context rule is a chore, so
the size gate takes a number directly.
- An empty probe evaluates to NOTHING: no highlight, no verdict, no callback. A
catch-all "matching" a prompt nobody typed is noise dressed up as an answer. So
is a policy panel with the tester hidden — `showTester={false}` makes the whole
document static.
- The verdict on screen is SYNCHRONOUS (highlighting has to keep up with the
keyboard) while its SIDE EFFECTS are debounced by one timer: announcing every
keystroke turns a screen reader into a stutter and calling `onTest` per
character turns a logger into a firehose. That timer is cleared on every change
and on unmount. It does not fire for the initial probe.
- `matchRule` delegates the decision to the consumer's real router (a cached
server answer, an embedding classifier). When it is supplied the per-condition
marks DISAPPEAR — explaining a verdict the panel did not compute would be
fiction. An id naming a switched-off rule is treated as no match, because the
switch in front of the reader says that rule is off.
- Switches are INTENT, the data is truth. Flipping one moves it immediately
(optimistically), calls the handler, and on rejection snaps back and prints the
reason on the row; the component never mutates `rules`. A per-row in-flight
guard lives in a REF, not in state, because several clicks dispatched inside
one task all read the same stale state and would fire the same policy write
three times. Recorded failures expire on their own: each is stamped with the
rule's server-side `enabled` value and stops rendering the moment your data
disagrees with it. Wrap the handler call in `new Promise(resolve =>
resolve(handler()))`, not `Promise.resolve(handler())` — the latter cannot
catch a synchronous throw and the row stays busy forever.
- The fallback chain is a failure LADDER, not a diagram: the head is whichever
model the policy just chose (the matched rule's target, the default route, or a
dashed placeholder when nothing is being tested), and each hop carries the
reason on the arrow INTO it ("on 429 · overloaded"). A hop marked `down` is
struck through and skipped, and the panel names the first hop still accepting
traffic — the one a retry would actually reach. All hops down is stated
outright: a failed request has nowhere left to go.
- Every count and cap is announced, never silent: a rule with fourteen trigger
words draws four and says "+10 more"; the header states how many rules are on
out of how many exist.
- Envelopes: `loading` renders a skeleton shaped like a rule row (nothing jumps
when data arrives) plus an sr-only status; `error` is role="alert" with an
optional retry; `empty` is a dashed panel with the add-rule CTA. `ready` with
zero rules renders the empty branch too, so a mis-set status can never draw a
header counting zero of zero rules.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary + text-primary-foreground for the ordinal of the winning rule and
the switch, border-primary/50 + bg-primary/5 for the matched row, the landing
hop and met condition chips, text-destructive for unmet chips, broken patterns,
down hops and the envelope failure. No hard-coded colours anywhere.
- Colour never carries a verdict alone: every condition chip pairs its tint with
a tick or a cross plus an sr-only "(met)" / "(not met)", so a monochrome theme
and a colour-blind reader lose nothing.
- The switch is role="switch" + aria-checked with a STATIC accessible name
("Enable rule 3, Long documents") — a label that flipped between Enable and
Disable would read out the opposite of what aria-checked says. It uses
aria-disabled while busy, never the native `disabled` attribute, which would
blur the control mid-action and drop it out of the tab order. Its spinner slot
is always reserved so a row that starts working does not shove its own switch
sideways under the pointer. The signal chips are aria-pressed toggles instead:
they filter a hypothetical request, they are not settings that persist.
- One persistent sr-only role="status" line carries the debounced verdict and
every switch outcome, so a change is announced from an element the screen
reader is already watching. The matched row also carries aria-current.
- Long model names and long rule labels use `wrap-anywhere` plus `min-w-0`, NOT
`truncate` or `break-words`: only overflow-wrap:anywhere lowers the element's
min-content width, which is what actually stops a 68-character model id from
making the panel wider than its container. The chain wraps instead of scrolling
— a chain you have to drag sideways is a chain nobody reads to the end, and the
end is the interesting part.
- Motion is limited to colour/transform transitions and one spinner, all
motion-reduce guarded; nothing about the routing verdict depends on animation.
- cn() merges the consumer's className into the root <section>, which also
spreads the remaining element props and forwards its ref.
Customization levers
- Vocabulary: the five condition kinds are the extension point. Add
`{ kind: "language" }` or `{ kind: "user-tier" }` by extending the zod union,
the icon map, the chip text function and the evaluator — four small places, all
in one file each, and the tester grows its knob automatically if you derive it
the same way.
- Decision source: keep the built-in walk for a policy the client can evaluate,
or pass `matchRule` to mirror a server-side router exactly. Pass
`estimateTokens` to swap the chars/4 heuristic for a real tokenizer — keep the
function stable (module scope or useCallback) so the debounce is not restarted
on every render.
- Density: drop `description`, drop `note`, or hide the whole tester with
`showTester={false}` for a read-only policy document; `maxKeywordChips` decides
how much of a long keyword list is drawn before the explicit "+N more".
- Write surface: omit `onToggleRule` and no switches render at all; omit
`onAddRule` and no add affordance is drawn. Both are honest defaults for a
read-only viewer.
- Latency and noise: `testDelayMs` tunes the debounce (0 for a local demo, 800 in
a page that logs every probe).
- Chrome: `label` names the region, `emptyState` replaces the empty body,
`errorMessage` + `onRetry` own the envelope failure, `addRuleLabel` and
`testerPlaceholder` carry your product's wording.Concepts
- First match wins, so "not evaluated" is a real outcome — a router stops at the first rule that claims a request, which means the rules below the winner were never asked. Painting them "no match" would accuse them of something they never did; naming the rule that got there first is what teaches the operator that ORDER, not just conditions, decides where a request goes.
- Declarative conditions are what make a policy testable — a row rendering the sentence "code tasks" can only ever be a screenshot of a router. A row rendering
{ kind: "keywords", values: [...] }can be evaluated against a prompt somebody types, which is the difference between reading the config and watching it decide. - The tester is derived from the policy — the signal chips and the size field exist because some rule reads that signal, and disappear when no rule does. A test panel offering knobs nothing consumes teaches a mental model the gateway does not have.
- A switch is intent; your data is the truth — flipping one moves it immediately because waiting for a round trip to acknowledge a local preference is what makes a settings toggle feel broken. The optimistic position is held only until the handler settles, a rejection snaps it back with the reason on the row, and the in-flight guard lives in a ref so five fast clicks cannot fire five policy writes.
- The chain is a failure ladder, and health decides where it lands — the arrows carry the reason you moved on ("on 429 · overloaded"), a hop marked down is struck through and skipped, and the panel points at the first hop still accepting traffic. Drawing the ladder without health tells you where retries were SUPPOSED to go.
- Synchronous verdict, debounced side effects — the highlight has to keep up with the keyboard, but announcing every keystroke turns a screen reader into a stutter and a per-character callback turns a logger into a firehose. One timer owns both, and it is cleared on every change and on unmount.
Custom Instructions
An end-user preference form for assistant instructions: autosizing questions with example popovers, per-field character budgets, one switch that gates every field without erasing it, and a Save / Cancel bar whose Cancel stays undoable.
Cache Indicator
A prompt-cache chip for one request — hit / partial / miss / write derived from the token counts, a tooltip breakdown of cached vs fresh tokens and money saved, and a self-correcting TTL ring that flips the chip to expired and fires onExpire exactly once.