Provider model picker
Providers as monogram tiles and models with speed, cost and recommendation tags.
Language model
Latency is time to first token. Cost is per minute of conversation at typical turn length.
GPT-4o mini from OpenAI is recommended. It follows Cedarline's payment scripts closely at the lowest latency for its accuracy.
import { type ModelProvider, ProviderModelPicker, type ProviderModelValue,} from "@oration/canon/components/provider-model-picker";import { SettingsGroup, SettingsSection } from "@oration/canon/components/settings-section";import * as React from "react";export function Hero() { const providers: ModelProvider[] = [ { id: "openai", name: "OpenAI", monogram: "Oa", tint: "gray", models: [ { id: "gpt-4o-mini", name: "GPT-4o mini", description: "Follows payment scripts closely and calls tools reliably.", tags: ["Tool calling", "Lowest cost"], latency: 320, cost: "$0.004/min", contextWindow: 128_000, }, { id: "gpt-4.1", name: "GPT-4.1", description: "Strongest reasoning for disputes and questions about several invoices.", tags: ["Most accurate"], latency: 540, cost: "$0.021/min", contextWindow: 1_000_000, }, ], }, { id: "anthropic", name: "Anthropic", monogram: "An", tint: "gray", models: [ { id: "claude-3-5-haiku", name: "Claude 3.5 Haiku", description: "Quick, careful replies for short payment status calls.", tags: ["Fastest"], latency: 290, cost: "$0.007/min", contextWindow: 200_000, }, { id: "claude-sonnet-4", name: "Claude Sonnet 4", description: "Explains a split remittance across several invoices without losing the thread.", latency: 610, cost: "$0.028/min", contextWindow: 200_000, }, ], }, { id: "google", name: "Google", monogram: "Go", tint: "gray", models: [ { id: "gemini-2-0-flash", name: "Gemini 2.0 Flash", description: "Low latency with a very long context window.", tags: ["Fastest"], latency: 280, cost: "$0.003/min", contextWindow: 1_000_000, }, ], }, ]; const [value, setValue] = React.useState<ProviderModelValue>({ provider: "anthropic", model: "claude-3-5-haiku", }); return ( <SettingsSection title="Language model" description="Latency is time to first token. Cost is per minute of conversation at typical turn length." className="w-full max-w-2xl py-0" > <SettingsGroup className="p-4"> <ProviderModelPicker providers={providers} value={value} onChange={setValue} recommendation={{ model: "gpt-4o-mini", reason: "It follows Cedarline's payment scripts closely at the lowest latency for its accuracy.", }} /> </SettingsGroup> </SettingsSection> );}Usage#
Provider model picker chooses a provider, then one of its models, for an agent's language model, transcriber or voice. Providers sit in a segmented row of monogram tiles; models are radio rows with latency, cost, context and language meta, tags and an optional recommendation. Switching provider always selects a model, so the value is never half set. The mistake to avoid is dressing providers in their brand logos or colors: a provider is a neutral monogram tile, and the decision is made on the numbers.
When to use
- To pick the language model behind an agent, with latency and cost per minute side by side.
- To pick a speech-to-text model for the transcriber, where language coverage matters.
- To pick a text-to-speech model before choosing a voice from it.
- When there is a recommended model for this agent and a one-line reason for it.
When not to use
- For choosing a model in a chat or prompt bar, as a compact menu. Use Model picker
- For choosing a voice, with previews. Use Voice picker
- For a flat list of options with no grouping. Use Select
- For two to five options that are just labels. Use Segmented control
- For a few rich options that don't belong to a provider. Use Choice card
Monograms, not logos
tint: "gray"; any other tint is an identity hue from the option palette, never the vendor's own color.The Option Hue Rule
Numbers decide
Anatomy#
- Provider label. 13px medium text that names the provider group.
- Provider tray. A 10px-corner well (
bg-muted/70) holding the provider radios. Two columns on small screens, a wrapping row above. - Monogram tile. 20px with 6px corners and 11px semibold letters, from
monogramor the first letter of the name. - Selected pill. A white pill with
shadow-borderbehind the checked provider, sliding between providers. - Model count. Tabular count of the provider's models.
- Model label. 13px medium text that names the model group.
- Model row. A radio row with a 16px radio, the model name at 14px medium and meta on the right.
- Meta. 12px tabular muted text: 180 ms, $0.004/min, 128K context, 60 languages.
- Description and tags. 13px description and 20px gray tags such as Fastest or Multilingual.
- Recommendation. A lightbulb note naming the recommended model and why, with Use recommended when it isn't selected.
Examples#
Speech to text
languages as a number reads as a count; a short list is written out. Retired models stay in the list, marked legacy in their name.
RTV5 is recommended. It hears long invoice numbers most accurately on phone audio.
import { type ModelProvider, ProviderModelPicker, type ProviderModelValue,} from "@oration/canon/components/provider-model-picker";import { SettingsGroup } from "@oration/canon/components/settings-section";import * as React from "react";export function Transcriber() { const providers: ModelProvider[] = [ { id: "soniox", name: "Soniox", monogram: "So", tint: "gray", models: [ { id: "stt-rt-v5", name: "RTV5", description: "Best balance of accuracy and speed for invoice numbers, amounts and supplier names.", tags: ["Multilingual"], latency: 180, cost: "$0.002/min", languages: 60, }, { id: "stt-rt-v4", name: "RTV4", description: "Previous generation. Stable for agents already tuned against it.", tags: ["Multilingual"], latency: 210, cost: "$0.002/min", languages: 60, }, ], }, { id: "deepgram", name: "Deepgram", monogram: "Dg", tint: "gray", models: [ { id: "nova-3", name: "Nova-3", description: "Highest English accuracy on noisy lines from warehouses and shop floors.", tags: ["Most accurate"], latency: 160, cost: "$0.0077/min", languages: 9, }, { id: "nova-2", name: "Nova-2 (legacy)", description: "Very low first-word latency. Weaker on rare words.", tags: ["Fastest"], latency: 140, cost: "$0.0058/min", languages: 9, }, ], }, { id: "azure-stt", name: "Azure", monogram: "Az", tint: "gray", models: [ { id: "azure-realtime", name: "Real-time", description: "For tenants that keep audio in their own Azure region.", latency: 240, cost: "$0.0167/min", languages: ["English", "Spanish", "French"], }, ], }, ]; const [value, setValue] = React.useState<ProviderModelValue>({ provider: "soniox", model: "stt-rt-v5", }); return ( <SettingsGroup className="w-full max-w-2xl p-4"> <ProviderModelPicker providers={providers} value={value} onChange={setValue} recommendation={{ model: "stt-rt-v5", reason: "It hears long invoice numbers most accurately on phone audio.", }} /> </SettingsGroup> );}Text to speech
providerLabel and modelLabel rename the groups. Without a recommendation there is no note.
import { type ModelProvider, ProviderModelPicker, type ProviderModelValue,} from "@oration/canon/components/provider-model-picker";import { SettingsGroup } from "@oration/canon/components/settings-section";import * as React from "react";export function Voice() { const providers: ModelProvider[] = [ { id: "elevenlabs", name: "ElevenLabs", monogram: "El", tint: "gray", models: [ { id: "eleven-flash-v2-5", name: "Flash v2.5", description: "The quickest first word, so Nora never leaves a supplier waiting.", tags: ["Fastest"], latency: 75, cost: "$0.050/min", languages: 32, }, { id: "eleven-multilingual-v2", name: "Multilingual v2", description: "The most natural reading of amounts and dates, at a higher latency.", tags: ["Most natural"], latency: 300, cost: "$0.100/min", languages: 29, }, ], }, { id: "cartesia", name: "Cartesia", monogram: "Ca", tint: "gray", models: [ { id: "sonic-2", name: "Sonic 2", description: "Low latency with steady pacing on long numbers.", tags: ["Lowest cost"], latency: 90, cost: "$0.030/min", languages: ["English", "Spanish"], }, ], }, ]; const [value, setValue] = React.useState<ProviderModelValue>({ provider: "elevenlabs", model: "eleven-flash-v2-5", }); return ( <SettingsGroup className="w-full max-w-2xl p-4"> <ProviderModelPicker providers={providers} value={value} onChange={setValue} providerLabel="Voice provider" modelLabel="Voice model" /> </SettingsGroup> );}No models and disabled
A provider with no models says so in place of the list. Disabled keeps the choice visible; say who can change it.
Azure OpenAI has no models available.
Only admins can change Nora's model.
import { type ModelProvider, ProviderModelPicker, type ProviderModelValue,} from "@oration/canon/components/provider-model-picker";import { SettingsGroup } from "@oration/canon/components/settings-section";import * as React from "react";export function EmptyAndDisabled() { const providers: ModelProvider[] = [ { id: "openai", name: "OpenAI", monogram: "Oa", tint: "gray", models: [ { id: "gpt-4o-mini", name: "GPT-4o mini", latency: 320, cost: "$0.004/min", contextWindow: 128_000, }, ], }, { id: "azure-openai", name: "Azure OpenAI", monogram: "Ao", tint: "gray", models: [], }, ]; const [empty, setEmpty] = React.useState<ProviderModelValue>({ provider: "azure-openai", model: "", }); const [locked, setLocked] = React.useState<ProviderModelValue>({ provider: "openai", model: "gpt-4o-mini", }); return ( <div className="grid w-full gap-4 md:grid-cols-2"> <SettingsGroup className="p-4"> <ProviderModelPicker providers={providers} value={empty} onChange={setEmpty} /> </SettingsGroup> <div className="flex flex-col gap-2"> <SettingsGroup className="p-4"> <ProviderModelPicker providers={providers} value={locked} onChange={setLocked} disabled /> </SettingsGroup> <p className="text-13 text-muted-foreground"> Only admins can change Nora's model. </p> </div> </div> );}States#
| State | Treatment |
|---|---|
| Provider rest | Muted label beside its tile. |
| Provider hover | A shared highlight at 5% ink follows the pointer along the tray and the label turns ink. |
| Provider selected | The white pill slides in on a 0.16s spring and the label turns medium ink. |
| Model hover | The shared highlight follows the pointer down the list. |
| Model selected | A 6% indigo tint with a 22% indigo inset edge, and the radio fills with a 5px indigo ring. |
| Focus visible | A 3px Focus Indigo ring at 40% on the provider or model. |
| Recommended | Recommended after the model name, and the note below. Use recommended disappears once it's selected. |
| No models | Azure has no models available. in place of the list. |
| Disabled | Every radio and the note's button at 50% and inert; the groups carry aria-disabled. |
Behavior#
- It is controlled: pass
valueas{ provider, model }and update it inonChange. - Choosing a provider selects its recommended model if the recommendation belongs to it, otherwise its first model. Choosing the provider that is already selected does nothing.
- Both groups behave like native radios. Only the checked item is in the tab order; arrow keys move and select, wrapping at the ends; Home and End jump.
- If
value.modelisn't in the list, nothing is checked and the first row takes focus. - Numbers are formatted for you:
latency180 reads 180 ms and 1200 reads 1.2 s;contextWindow128000 reads 128K context;languagesas a number reads 60 languages, as a list of up to three it is joined, longer lists become a count.costis shown as written. - Use recommended sets both provider and model at once, even across providers.
- Each picker has its own
LayoutGroup, so pills in two pickers on one page never animate into each other.
Do and don't#
GPT-4o mini is recommended. It follows Cedarline's payment scripts at the lowest latency.
GPT-4o mini is recommended. Best model.
Content#
- Use the provider's own model names and casing: GPT-4o mini, Nova-3, Flash v2.5.
- Descriptions say when to pick the model, in Cedarline's terms: Best accuracy on invoice numbers and supplier names.
- Tags are one or two words that compare models: Fastest, Lowest cost, Most accurate, Multilingual. Don't repeat Recommended as a tag; the component writes it.
- Write cost as a rate with its unit: $0.004/min or $0.60 per 1M tokens.
- The recommendation reason is one sentence that finishes is recommended.
- Mark retired models in the name, Nova-2 (legacy), rather than hiding them from agents that use them.
Accessibility#
- Providers and models are two
role="radiogroup"s labelled by the visible Provider and Model labels. Rename them withproviderLabelandmodelLabel. - The monogram is
aria-hidden; the provider name is the accessible name, followed by the model count with a visually hidden models. - Each meta value has a visually hidden label, so it reads Latency: 180 ms, Cost: $0.004/min.
- Selection is shown by the radio dot, the tint and the edge together, not by color alone.
- The pill slide and hover highlight run on springs that don't check reduced motion today.
| Keys | Action |
|---|---|
| Tab | Moves to the checked provider, then the checked model, then Use recommended. |
| ↑↓ | Move and select within a group. Left and Right do the same. |
| Home | Selects the first item in the group. End selects the last. |
| Enter | Selects the focused item. Space does the same. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Provider tray at 70% |
--background | Selected provider pill |
shadow-border | Pill edge |
--foreground | Provider hover highlight at 5% |
--primary | Selected model tint at 6%, inset edge at 22%, radio fill |
--input | Radio ring at rest |
--ring | Focus ring at 40% |
--tag-gray | Neutral monogram tiles and model tags |
--tag-* | Identity tints on monogram tiles |
spring.moderate | Pill slide, 0.16s |
API reference#
ProviderModelPicker
Two radio groups: providers, then the selected provider's models.
Other props spread onto Doesn't spread props; renders <div data-slot="provider-model-picker">.
| Prop | Type | Default | Description |
|---|---|---|---|
providersRequired | ModelProvider[] | No default | Providers in display order, each with its models. |
valueRequired | ProviderModelValue | No default | { provider, model } ids. |
onChangeRequired | (value: ProviderModelValue) => void | No default | Called with a complete value on every change. |
recommendation | { model: string; reason: ReactNode } | No default | A model id from any provider and why. Shows the note and the Recommended label. |
providerLabel | string | "Provider" | Label of the provider group. |
modelLabel | string | "Model" | Label of the model group. |
disabled | boolean | No default | Disables both groups and the note's button. |
className | string | No default | Merged onto the root. |
ModelProvider
One provider.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Stable id, used in the value. |
nameRequired | string | No default | Shown beside the tile. |
monogram | string | No default | One or two characters. Defaults to the first letter of name. |
tint | ProviderTint | No default | Tile hue. Without it the hue is hashed from id. Use "gray" for a neutral tile. |
modelsRequired | ProviderModel[] | No default | The provider's models, in order. |
ProviderModel
One model row.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Stable id, used in the value and recommendation.model. |
nameRequired | string | No default | The model's display name. |
description | string | No default | When to pick it. |
tags | string[] | No default | Short comparison tags. |
latency | number | string | No default | Time to first token. Numbers are milliseconds. |
cost | string | No default | Preformatted, like $0.004/min. |
contextWindow | number | string | No default | Numbers are tokens and read as 128K context. |
languages | number | string[] | No default | A count or a list of language names. |
ProviderTint
Type export.
| Prop | Type | Default | Description |
|---|---|---|---|
ProviderTint | "gray" | "blue" | "violet" | "pink" | "red" | "orange" | "amber" | "green" | "teal" | No default | Option hues for the tile. There is no indigo. |
ProviderModelValue
Type export.
| Prop | Type | Default | Description |
|---|---|---|---|
ProviderModelValue | { provider: string; model: string } | No default | The selected ids. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Without tint, the tile hue is hashed from the provider id into seven hues, so tiles are colorful by default rather than neutral. The app maps its catalog colors onto tints instead of using gray.
Provider buttons pass text-13 next to a text color through the cn package, which drops the size, so provider names render at the inherited size instead of 13px.
Model tags are hand-drawn gray spans, not the Tag component.
The pill slide and the hover highlight have no reduced-motion handling of their own. They rely on the app-wide MotionConfig reducedMotion="user", which drops the movement.
When value.model matches no model, nothing says so; the list just has no selection.