Skip to content

Provider model picker

Providers as monogram tiles and models with speed, cost and recommendation tags.

Status
Beta
Category
Selection
Adoption
Not used yet
import { ProviderModelPicker } from "@oration/canon/components/provider-model-picker";
packages/canon/src/components/provider-model-picker.tsx

Language model

Latency is time to first token. Cost is per minute of conversation at typical turn length.

Provider
Model

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

A provider is a one or two letter monogram (Oa, An, Dg) on a neutral tile, never a brand logo or brand color. Pass tint: "gray"; any other tint is an identity hue from the option palette, never the vendor's own color.

The Option Hue Rule

Monogram tiles may carry an option hue as identity. The hue never signals quality, speed or status; tags and meta do that in words.

Numbers decide

Meta is tabular and comparable across rows: time to first token, cost per minute, context window, languages. Say in the section description what the numbers mean.

Anatomy#

  1. Provider label. 13px medium text that names the provider group.
  2. Provider tray. A 10px-corner well (bg-muted/70) holding the provider radios. Two columns on small screens, a wrapping row above.
  3. Monogram tile. 20px with 6px corners and 11px semibold letters, from monogram or the first letter of the name.
  4. Selected pill. A white pill with shadow-border behind the checked provider, sliding between providers.
  5. Model count. Tabular count of the provider's models.
  6. Model label. 13px medium text that names the model group.
  7. Model row. A radio row with a 16px radio, the model name at 14px medium and meta on the right.
  8. Meta. 12px tabular muted text: 180 ms, $0.004/min, 128K context, 60 languages.
  9. Description and tags. 13px description and 20px gray tags such as Fastest or Multilingual.
  10. 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.

Provider
Model

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.

Voice provider
Voice 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 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.

Provider
Model

Azure OpenAI has no models available.

Provider
Model

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#

States
StateTreatment
Provider restMuted label beside its tile.
Provider hoverA shared highlight at 5% ink follows the pointer along the tray and the label turns ink.
Provider selectedThe white pill slides in on a 0.16s spring and the label turns medium ink.
Model hoverThe shared highlight follows the pointer down the list.
Model selectedA 6% indigo tint with a 22% indigo inset edge, and the radio fills with a 5px indigo ring.
Focus visibleA 3px Focus Indigo ring at 40% on the provider or model.
RecommendedRecommended after the model name, and the note below. Use recommended disappears once it's selected.
No modelsAzure has no models available. in place of the list.
DisabledEvery radio and the note's button at 50% and inert; the groups carry aria-disabled.

Behavior#

  • It is controlled: pass value as { provider, model } and update it in onChange.
  • 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.model isn't in the list, nothing is checked and the first row takes focus.
  • Numbers are formatted for you: latency 180 reads 180 ms and 1200 reads 1.2 s; contextWindow 128000 reads 128K context; languages as a number reads 60 languages, as a list of up to three it is joined, longer lists become a count. cost is 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#

Provider
Model
Do. Draw every provider as a neutral monogram and let the meta and tags carry the comparison.
Provider
Model
Don't. Color tiles in brand colors. It reads as an endorsement, and the hues fight the selection state.
Provider
Model

GPT-4o mini is recommended. It follows Cedarline's payment scripts at the lowest latency.

Do. Give the recommendation a reason specific to this agent: it follows Cedarline's payment scripts at the lowest latency.
Provider
Model

GPT-4o mini is recommended. Best model.

Don't. Recommend without a reason, or with a generic one like 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 with providerLabel and modelLabel.
  • 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.
Keyboard interactions
KeysAction
TabMoves to the checked provider, then the checked model, then Use recommended.
↑↓Move and select within a group. Left and Right do the same.
HomeSelects the first item in the group. End selects the last.
EnterSelects the focused item. Space does the same.

Design tokens#

Design tokens
TokenUsed for
--mutedProvider tray at 70%
--backgroundSelected provider pill
shadow-borderPill edge
--foregroundProvider hover highlight at 5%
--primarySelected model tint at 6%, inset edge at 22%, radio fill
--inputRadio ring at rest
--ringFocus ring at 40%
--tag-grayNeutral monogram tiles and model tags
--tag-*Identity tints on monogram tiles
spring.moderatePill 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">.

Props of ProviderModelPicker
PropTypeDefaultDescription
providersRequiredModelProvider[]No defaultProviders in display order, each with its models.
valueRequiredProviderModelValueNo default{ provider, model } ids.
onChangeRequired(value: ProviderModelValue) => voidNo defaultCalled with a complete value on every change.
recommendation{ model: string; reason: ReactNode }No defaultA model id from any provider and why. Shows the note and the Recommended label.
providerLabelstring"Provider"Label of the provider group.
modelLabelstring"Model"Label of the model group.
disabledbooleanNo defaultDisables both groups and the note's button.
classNamestringNo defaultMerged onto the root.

ModelProvider

One provider.

Props of ModelProvider
PropTypeDefaultDescription
idRequiredstringNo defaultStable id, used in the value.
nameRequiredstringNo defaultShown beside the tile.
monogramstringNo defaultOne or two characters. Defaults to the first letter of name.
tintProviderTintNo defaultTile hue. Without it the hue is hashed from id. Use "gray" for a neutral tile.
modelsRequiredProviderModel[]No defaultThe provider's models, in order.

ProviderModel

One model row.

Props of ProviderModel
PropTypeDefaultDescription
idRequiredstringNo defaultStable id, used in the value and recommendation.model.
nameRequiredstringNo defaultThe model's display name.
descriptionstringNo defaultWhen to pick it.
tagsstring[]No defaultShort comparison tags.
latencynumber | stringNo defaultTime to first token. Numbers are milliseconds.
coststringNo defaultPreformatted, like $0.004/min.
contextWindownumber | stringNo defaultNumbers are tokens and read as 128K context.
languagesnumber | string[]No defaultA count or a list of language names.

ProviderTint

Type export.

Props of ProviderTint
PropTypeDefaultDescription
ProviderTint"gray" | "blue" | "violet" | "pink" | "red" | "orange" | "amber" | "green" | "teal"No defaultOption hues for the tile. There is no indigo.

ProviderModelValue

Type export.

Props of ProviderModelValue
PropTypeDefaultDescription
ProviderModelValue{ provider: string; model: string }No defaultThe 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.