AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowRight,
  Asterisk,
  Check,
  CircleAlert,
  CircleCheck,
  CircleOff,
  Eraser,
  FlaskConical,
  Hash,
  LoaderCircle,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/model-router.json

Prompt

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.

On This Page