assistant-ui logo/Docs/Components

Model Selector

Composable model picker with reasoning effort levels, search, and runtime integration.

A picker that lets users switch between AI models and choose a reasoning effort (thinking) level. It is built on Popover + Command, so search, provider grouping, and filtering compose in without being built in. The default export integrates with assistant-ui's ModelContext system, so the selection reaches your backend on every request with no extra wiring.

Outline (default)
Ghost
Muted
With search, provider filters + groups

Getting Started

Add model-selector

npx shadcn@latest add https://r.assistant-ui.com/model-selector.json

Use in your application

Place the ModelSelector inside your thread component, typically in the composer area. Each model needs an id and a display name; everything else is optional:

/components/assistant-ui/thread.tsx
import { ModelSelector } from "@/components/assistant-ui/model-selector";

const ComposerAction: FC = () => {
  return (
    <div className="flex items-center gap-1">
      <ModelSelector
        models={[
          { id: "gpt-5.4-nano", name: "GPT-5.4 Nano", description: "Fast and efficient" },
          { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", description: "Balanced performance" },
          { id: "gpt-5.5", name: "GPT-5.5", description: "Most capable", efforts: true },
        ]}
        defaultValue="gpt-5.4-nano"
        defaultEffort="medium"
        size="sm"
      />
    </div>
  );
};

Read the selection in your API route

The selected model's id arrives as config.modelName, and the effort level as config.reasoningEffort:

app/api/chat/route.ts
export async function POST(req: Request) {
  const { messages, config } = await req.json();

  const result = streamText({
    model: openai(config?.modelName ?? "gpt-5.4-nano"),
    providerOptions: {
      openai:
        config?.reasoningEffort !== undefined
          ? { reasoningEffort: config.reasoningEffort }
          : {},
    },
    messages: await convertToModelMessages(messages),
  });

  return result.toUIMessageStreamResponse();
}

config.reasoningEffort is only present when the selected model supports the chosen level, so the route only forwards it when it exists. See How It Works.

Reasoning Efforts

A model that declares efforts shows a "Thinking" row at the bottom of the popover. efforts: true enables the default Low / Medium / High levels; pass a list of { id, name } objects to define your own:

{
  id: "gpt-5.5",
  name: "GPT-5.5",
  efforts: [
    { id: "minimal", name: "Minimal" },
    { id: "high", name: "High" },
  ],
}

Omit efforts for models without configurable reasoning. The row is hidden while such a model is selected.

Sticky Selection

The effort selection survives model switches. Switching to a model that doesn't support the current level omits reasoningEffort from the request instead of resetting the user's choice, and the level applies again when the user switches back. The exported resolveModelEffort helper applies the same rule if you build your own runtime integration around ModelSelector.Root; see resolveModelEffort.

Custom Effort UI

ModelSelector.Effort lays the levels out as horizontal segments, which overflows the popover width once a model has more than a few. For those cases, or for a different layout such as a slider or a sub-dropdown, build your own control with the useModelSelectorEfforts hook. It exposes the selected model's levels and the active selection:

import { useModelSelectorEfforts } from "@/components/assistant-ui/model-selector";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

function EffortDropdown() {
  const { efforts, effort, setEffort } = useModelSelectorEfforts();
  if (!efforts?.length) return null;

  return (
    <div className="flex items-center justify-between gap-3 border-t px-3 py-2">
      <span className="text-muted-foreground text-xs">Thinking</span>
      <DropdownMenu>
        <DropdownMenuTrigger className="text-xs">
          {efforts.find((e) => e.id === effort)?.name ?? "Select"}
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <DropdownMenuRadioGroup value={effort} onValueChange={setEffort}>
            {efforts.map((option) => (
              <DropdownMenuRadioItem key={option.id} value={option.id}>
                {option.name}
              </DropdownMenuRadioItem>
            ))}
          </DropdownMenuRadioGroup>
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  );
}

Render it inside ModelSelector.Content in place of ModelSelector.Effort. The same hook supports any shape that reads the levels and writes the selection.

Search is opt-in. Pass searchable to the default component:

<ModelSelector models={models} searchable />

Or compose ModelSelector.Search into a custom layout. Matching runs against each model's id, name, and keywords; add the provider name to keywords so typing "openai" finds its models.

Composition

All parts are exported individually. The default popover content is List + Effort; replace it to add search, provider groups, or anything else:

import {
  ModelSelectorRoot,
  ModelSelectorTrigger,
  ModelSelectorContent,
  ModelSelectorSearch,
  ModelSelectorList,
  ModelSelectorEmpty,
  ModelSelectorGroup,
  ModelSelectorItem,
  ModelSelectorEffort,
} from "@/components/assistant-ui/model-selector";

<ModelSelectorRoot
  models={models}
  value={modelId}
  onValueChange={setModelId}
  effort={effort}
  onEffortChange={setEffort}
>
  <ModelSelectorTrigger variant="outline" />
  <ModelSelectorContent>
    <ModelSelectorSearch placeholder="Search models..." />
    <ModelSelectorList>
      <ModelSelectorEmpty />
      <ModelSelectorGroup heading="OpenAI">
        {openaiModels.map((model) => (
          <ModelSelectorItem key={model.id} model={model} />
        ))}
      </ModelSelectorGroup>
      <ModelSelectorGroup heading="Anthropic">
        {anthropicModels.map((model) => (
          <ModelSelectorItem key={model.id} model={model} />
        ))}
      </ModelSelectorGroup>
    </ModelSelectorList>
    <ModelSelectorEffort label="Thinking" />
  </ModelSelectorContent>
</ModelSelectorRoot>
ComponentDescription
ModelSelectorDefault export with runtime integration
ModelSelector.RootPresentational root (no runtime, controlled state)
ModelSelector.TriggerCVA-styled trigger showing the current selection
ModelSelector.ValueSelected model name, icon, and active effort
ModelSelector.ContentPopover content wrapping a Command
ModelSelector.SearchSearch input that filters the list
ModelSelector.ListList of model items (renders all models by default)
ModelSelector.EmptyEmpty state shown when search has no matches
ModelSelector.GroupLabeled group of items (e.g. by provider)
ModelSelector.SeparatorDivider between groups or items
ModelSelector.ItemIndividual model option
ModelSelector.EffortThinking level row for the selected model

ModelSelector.List is a Command list, so filtering and keyboard navigation work across groups automatically. Custom sorting is plain code: order the models before rendering items.

ModelSelector.Content wraps a Command whose root keydown handler claims Enter to select the highlighted model. Interactive elements composed inside it (filter chips, custom effort controls) should stop propagation for Enter in their own onKeyDown so the focused control activates instead. ModelSelector.Effort already does this.

Variants

Use the variant prop to change the trigger's visual style.

<ModelSelector variant="outline" /> // Border (default)
<ModelSelector variant="ghost" />   // No background
<ModelSelector variant="muted" />   // Solid background
VariantDescription
outlineBorder with transparent background (default)
ghostNo background, subtle hover
mutedSolid secondary background

Sizes

Use the size prop to control the trigger dimensions.

<ModelSelector size="sm" />      // Compact (h-8, text-xs)
<ModelSelector size="default" /> // Standard (h-9)
<ModelSelector size="lg" />      // Large (h-10)

How It Works

The default ModelSelector export registers the selection with assistant-ui's ModelContext system:

  1. The component calls aui.modelContext().register() with config.modelName, plus config.reasoningEffort when the selected model supports the chosen level
  2. The AssistantChatTransport includes config in the request body of every chat request
  3. Your API route reads config.modelName and config.reasoningEffort

This works out of the box with @assistant-ui/react-ai-sdk. ModelSelector.Root performs no registration; it is purely presentational, with controlled and uncontrolled props for the value, effort, and open state.

API Reference

ModelSelector

ModelSelectorProps
models : ModelOption[]

Array of available models to display.

defaultValue ?: string

Initial model ID for uncontrolled usage. Defaults to the first model, captured on first render; if models loads asynchronously, control the value instead.

value ?: string

Controlled selected model ID.

onValueChange ?: (value: string) => void

Callback when selected model changes.

defaultEffort ?: string

Initial effort level ID for uncontrolled usage.

effort ?: string

Controlled effort level ID.

onEffortChange ?: (effort: string) => void

Callback when effort level changes.

searchable : boolean = false

Render a search input above the model list.

variant : "outline" | "ghost" | "muted" = "outline"

Visual style of the trigger button.

size : "sm" | "default" | "lg" = "default"

Size of the trigger button.

contentClassName ?: string

Additional class name for the dropdown content.

ModelOption

ModelOption
id : string

Unique identifier sent to the backend as modelName.

name : string

Display name shown in trigger and dropdown.

description ?: string

Optional subtitle shown below the model name.

icon ?: React.ReactNode

Optional icon displayed before the model name.

disabled ?: boolean

Disable selection of this model.

keywords ?: string[]

Extra search terms matched by ModelSelector.Search (e.g. the provider name).

efforts ?: boolean | ModelSelectorEffortOption[]

Reasoning effort levels. true enables the default Low/Medium/High; pass a custom { id, name } list to override. Omit for models without configurable reasoning.

useModelSelectorEfforts

const { efforts, effort, setEffort } = useModelSelectorEfforts();

The selected model's effort levels and the active selection, for building a custom effort UI inside ModelSelector.Content. efforts is undefined for models without configurable reasoning.

resolveModelEffort

resolveModelEffort(models, modelId, effort); // => string | undefined

Returns the effort ID when the given model supports it, otherwise undefined. This is the sticky selection rule the default component applies before registering the selection.

  • Model Context: How registered context (instructions, tools, config) reaches your backend