feat(site/src): add Known Model autocomplete and frontend defaults (#24842)

Replaces the blank Model Identifier free-text input on the **Add Model**
page with provider-scoped Known Model autocomplete and frontend-only
metadata defaults for native OpenAI and Anthropic providers. Selecting a
Known Model, or typing an exact canonical identifier and blurring the
field, prefills `contextLimit`, the appropriate max-output-tokens field,
and flat base pricing in the existing form. Edit mode, duplicate mode,
and unsupported providers preserve the existing plain `Input` behavior
and submit payload byte-for-byte.

The catalog is curated TypeScript records sourced from `models.dev`,
scoped initially to 6 OpenAI and 5 Anthropic models in declared display
order. The pure `applyKnownModelDefaults` helper only writes a field
when its current value still equals the form's initial value (or was
last applied by Known Model defaulting in this form session, tracked
cumulatively across selections). It never sets `compressionThreshold` or
any reasoning/thinking fields, ignores tiered pricing, and never writes
to the `model` field (canonicalization is the caller's responsibility).

This PR also makes two narrow, additive changes outside the panel
directory:

- `site/src/components/Autocomplete/Autocomplete.tsx` gains optional
`triggerAriaInvalid`, `triggerAriaDescribedBy`, and `onEscapeKeyDown`
props so the new catalog branch can preserve `aria-invalid` /
`aria-describedby` parity with the plain input and observe Escape close
intent reliably across the Radix portal. Existing `Autocomplete`
consumers are unaffected; `stopPropagation` is gated on
`onEscapeKeyDown` being provided.
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts`
exports `deepGet` / `deepSet` so the defaulting helper can reuse them
instead of re-implementing the same path traversal.

No backend, API, SDK, or DB changes. No edits to `ModelsSection.tsx`,
`ModelConfigFields.tsx`, `pricingFields.ts`, or
`providerPolicyDefaults.ts`.

## Validation

- 37 colocated unit tests across `knownModels/` (catalog, search,
exact-canonical lookup, exact-alias lookup, badge, defaulting helper).
- 134 unit tests across the full ChatModelAdminPanel directory pass.
- 50 Storybook play tests on `ChatModelAdminPanel.stories.tsx` pass,
including 17 DEREM-traceable interaction tests covering each plan-listed
and review-driven scenario (open-no-error, Escape cancellation,
sequential selection, double-apply guard, blur-canonical, alias
cancellation, provider-change reset, ARIA parity, no-options copy,
off-catalog substring commit, stale-cost-field, off-catalog
interleaving, chain tracking, keyboard selection, clearable-disabled,
off-catalog punctuation variant).
- `tsc -p .` passes.

## Dogfooding

Storybook was run locally and the user-facing flows were exercised
end-to-end via `agent-browser`, capturing screenshots for:

1. OpenAI happy path (selection → defaults applied note → populated
fields).
2. Anthropic happy path (selection → populated fields,
reasoning/thinking blank).
3. Unsupported provider fallback (Google plain input, no popover).
4. OpenAI suggestion popover at empty focus (declared catalog order,
context badges).
5. OpenAI search filter (typing `5.4` filters to GPT-5.4 / 5.4 mini /
5.4 nano).
6. Edit mode plain input (autocomplete correctly gated to add mode
only).
7. DEREM-3: empty popover open on Add Model — no premature `Model ID is
required.` error.
8. DEREM-1: autocomplete trigger `aria-invalid="true"` and
`aria-describedby` matching the rendered error element.
9. DEREM-6: exact `No matching known models. You can still use this
identifier.` copy.


---

<details>
<summary>📋 Implementation Plan</summary>

# Plan: Known Model autocomplete and frontend-only defaults for Chat
Model Admin

## Goal

Improve the admin Add Model onboarding flow by replacing the blank Model
Identifier experience with provider-scoped Known Model discovery
suggestions for native OpenAI and Anthropic providers. Selecting a Known
Model, or typing an exact canonical Known Model identifier and blurring
the field, should prefill safe objective model metadata in the existing
form without changing backend APIs, database schema, or runtime
behavior.

The primary UX goal is discovery for admins who do not know exact
provider model identifiers or metadata. Typing convenience is a
secondary benefit.

## Evidence and current code facts

- The current Model Identifier field is a plain free-text `Input` in
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx`.
It submits as `model` and is only validated as a non-empty string.
- The provider selector is disabled in edit and duplicate modes. In add
mode, `ModelsSection.tsx` keys the form by provider, so provider changes
remount `ModelForm`.
- The shared `site/src/components/Autocomplete/Autocomplete.tsx`
primitive already supports free-text input with suggestions and is the
right UI primitive for this feature.
- `modelConfigFormLogic.ts` owns form initialization via
`buildInitialModelFormValues(...)`, and `modelConfigFormLogic.test.ts`
already covers this pure logic area.
- No frontend or backend Known Model catalog exists today.
- The database has a non-unique `(provider, model)` index, not a
uniqueness constraint. Multiple Model Configs can share the same
Provider and Model Identifier, so suggestions must not hide
already-configured models.
- `models.dev/api.json` has provider-keyed model metadata with canonical
IDs, names, limits, pricing, release dates, and `last_updated` values.
The Phase 1 catalog should copy a curated subset into TypeScript
records, not fetch at runtime.

## Domain language

Use these terms consistently in code, tests, docs, and review
discussion:

- **Provider**: configured external AI service such as native `openai`
or `anthropic`.
- **Model Config**: persisted admin-defined config row used by Coder
chat runtime.
- **Model Identifier**: exact provider API string submitted as `model`,
such as `gpt-5.5`.
- **Known Model**: curated frontend catalog entry with advisory metadata
for one canonical Model Identifier.
- **Model Catalog**: checked-in frontend-only list of Known Models.
- **Off-catalog Model Identifier**: user-entered Model Identifier that
does not match any Known Model and remains valid.
- **Default application**: copying advisory Known Model metadata into a
draft add-mode Model Config form.

## Resolved design decisions

### UX scope

- Implement this on the Add Model page/form only.
- Do not add provider success popups, provider-side calls to action, or
new deep-link behavior in this pass.
- Use `Autocomplete` only when all are true:
  - form mode is add;
  - selected Provider is native `openai` or native `anthropic`;
  - that Provider has Known Models.
- Edit mode, duplicate mode, and unsupported providers keep the existing
free-text input behavior.

### Suggestion behavior

- Suggestions open on focus only when the Model Identifier field is
empty.
- Once the field has text, suggestions open while typing or interacting
with the autocomplete.
- Empty unsupported-provider catalogs degrade silently to the existing
plain input behavior.
- When a supported provider has zero matches for a non-empty query, show
a non-blocking empty state such as: `No matching known models. You can
still use this identifier.`
- Suggestion rows show:
  - display name;
  - canonical Model Identifier;
  - context-window badge, for example `1.05M context`.
- Format context badges with a deterministic helper covered by tests,
for example `200K context`, `400K context`, and `1.05M context`.
- Do not show pricing, recommendations, capability tags, or
large-context caveats in suggestion rows.
- Keep catalog display order as product ordering. Do not show a visible
`Recommended` badge.

### Canonical IDs and aliases

- Selecting a Known Model always writes its canonical Model Identifier
into the form.
- Use non-date latest aliases as canonical onboarding IDs when the
provider exposes them, such as `gpt-5.5` or `claude-sonnet-4-6`.
- Date-pinned IDs may be aliases for search, but selecting a Known Model
writes the non-date canonical ID.
- Typing aliases filters suggestions but does not rewrite the field and
does not apply defaults by itself.
- Search over canonical ID, display name, and explicit aliases.
- Search is case-insensitive and normalizes spaces, hyphens,
underscores, and dots before substring matching.
- Aliases are objective name or identifier variants only. Do not include
editorial intent tags such as `best`, `cheap`, `fast`, `coding`, or
`reasoning`.
- Do not implement typo-tolerant fuzzy search in Phase 1.

### Default application rules

- Default application only runs in add mode.
- Explicit Known Model selection applies defaults immediately.
- Exact typed or pasted canonical Model Identifier applies defaults on
blur, not on every keystroke. This avoids prematurely applying `gpt-5.5`
while the admin is typing `gpt-5.5-pro`.
- Defaults fill only target fields whose current values still equal this
form session's initial values.
- Do not use Formik touched state as the source of truth for safety.
- Do not implement field-level provenance tracking in Phase 1.
- Capture an immutable `initialValuesRef` at `ModelForm` mount/remount
and compare against that snapshot for safe default application. Do not
compare against a live Formik reference that can drift.
- Do not reapply repeatedly for the same provider/model pair in a single
form session.
- The defaulting helper must return both the next values and the list of
applied form paths:

```ts
interface ApplyKnownModelDefaultsResult {
  values: ModelFormValues;
  appliedFields: readonly string[];
}
```

- Treat Model Identifier canonicalization separately from metadata
default application. `appliedFields` tracks populated metadata/form
paths only, not the `model` field change caused by selecting a Known
Model.
- Show an inline note near Model Identifier only when
`appliedFields.length > 0`, such as: `Defaults applied from GPT-5.5.
Review and adjust before saving.`
- Do not show a note for off-catalog identifiers, no-op Known Model
selections, or selections that only canonicalize the Model Identifier.

### Initial Model Catalog

Use curated TypeScript records with source metadata copied from
models.dev. Do not check in the full `models.dev/api.json` snapshot and
do not add a generator in Phase 1. Add a file-level comment that array
order controls suggestion order so future cleanup does not accidentally
change onboarding UX.

Initial native OpenAI entries, in display order:

1. `gpt-5.5`
2. `gpt-5.5-pro`
3. `gpt-5.4`
4. `gpt-5.4-mini`
5. `gpt-5.4-nano`
6. `gpt-5.3-codex`

Initial native Anthropic entries, in display order:

1. `claude-opus-4-7`
2. `claude-opus-4-6`
3. `claude-sonnet-4-6`
4. `claude-haiku-4-5`
5. `claude-sonnet-4-5`

Do not include GPT-4.x, pre-5.3 GPT models, or Claude models older than
4.5 in this onboarding catalog unless product intentionally expands
scope.

Each Known Model record should include:

- provider;
- canonical Model Identifier;
- display name;
- aliases;
- source metadata, including `sourceName: "models.dev"`,
`sourceRetrievedAt`, and the model record's `last_updated` value;
- `contextLimit` from `limit.context`;
- `maxOutputTokens` from `limit.output`;
- flat base pricing from supported `cost.*` fields.

### Field mapping

- `models.dev.limit.context` maps to `contextLimit`.
- `models.dev.limit.output` maps to the selected provider's exact
max-output-tokens field when one exists, otherwise to generic
`config.maxOutputTokens`.
- Never fill both generic and provider-specific output-token fields for
the same Known Model.
- Ignore `models.dev.limit.input` unless the current form schema already
exposes an exact matching field.
- Map only flat base pricing fields that the existing form can persist:
  - `cost.input`;
  - `cost.output`;
  - `cost.cache_read`;
  - `cost.cache_write`.
- Reuse `pricingFields.ts` or the existing pricing field descriptors
instead of hard-coding cost form paths.
- If `cache_read` or `cache_write` is absent from a models.dev entry,
leave the corresponding field at its initial value and do not include it
in `appliedFields`.
- Ignore tiered pricing such as `context_over_200k` in Phase 1. Add a
code comment in the adapter explaining that Coder currently persists
flat pricing only.
- Do not show a UI caveat for tiered pricing in Phase 1.
- Do not set `compressionThreshold` from Known Models.
- Do not prefill provider-specific reasoning or thinking fields in Phase
1, including:
  - OpenAI `reasoningEffort` and `reasoningSummary`;
  - Anthropic `sendReasoning`, `effort`, and `thinking.budgetTokens`.

## Proposed file structure

Use `knownModels/` rather than `modelDefaults/` because the data powers
both discovery and default application.

New files:

-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/types.ts`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/openai.ts`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/anthropic.ts`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/index.ts`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/applyKnownModelDefaults.ts`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelIdentifierField.tsx`

Existing files to modify:

-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelForm.tsx`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.ts`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic.test.ts`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx`

Documentation artifacts to keep in sync if implementing from a clean
workspace:

- `site/src/pages/AgentsPage/components/ChatModelAdminPanel/CONTEXT.md`
-
`site/src/pages/AgentsPage/components/ChatModelAdminPanel/docs/adr/0001-frontend-known-model-catalog.md`

## Implementation plan

### Phase 1: Red, define pure behavior first

1. Add tests in `modelConfigFormLogic.test.ts` or a colocated
`knownModels` test file for:
   - provider-scoped lookup;
   - normalized alias search;
   - canonicalization on selection;
   - unknown model leaves values unchanged;
   - exact canonical ID lookup;
   - safe initial-value patching;
- `appliedFields` output that excludes Model Identifier
canonicalization;
   - tiered pricing ignored;
   - missing cache pricing fields left at initial values;
   - compression threshold not populated;
   - reasoning/thinking fields not populated;
- output-token mapping prefers provider-specific exact field and never
fills both;
   - context badge formatting.
2. Add lifecycle tests where feasible:
- provider change in add mode remounts the form and resets
`initialValuesRef`, `lastAppliedProviderModelRef`, and inline
default-feedback state.
3. Add edge-case tests for event and reapplication semantics:
- selecting `gpt-5.5` then blurring does not apply defaults a second
time;
- typing `gpt-5.5-pro` then blurring applies only pro defaults, never
prefix `gpt-5.5` defaults;
- selecting one Known Model, then another, does not overwrite fields
already populated by the first selection because they no longer match
initial values;
- typing an alias then blurring does not canonicalize or apply defaults;
- an Off-catalog value for a supported provider remains valid and
preserves existing required-field validation behavior.
4. Add tests for the initial OpenAI and Anthropic catalog entries to
ensure IDs, source metadata, and display order remain intentional.

Quality gate: targeted unit tests fail for missing implementation.

### Phase 2: Green, add Known Model catalog and pure helpers

1. Add `knownModels/types.ts` with readonly types for catalog records
and source metadata.
2. Add `knownModels/openai.ts` and `knownModels/anthropic.ts` with the
initial catalog entries and file-level refresh comments.
3. Add lookup and search helpers in `knownModels/index.ts`.
4. Add `applyKnownModelDefaults(...)` as a pure helper that accepts:
   - current form values;
   - initial form values;
   - selected provider;
   - Known Model;
   - provider field mapping helpers if needed.
5. Ensure assertions or explicit guards make impossible cases fail fast
during tests, for example missing provider, missing canonical ID, or
invalid source metadata.

Quality gate: targeted unit tests pass.

### Phase 3: Wire Model Identifier autocomplete UX

Autocomplete integration constraints:

- Control the shared `Autocomplete` with `inputValue` for the free-text
Model Identifier string and `value: KnownModel | null` for selected
suggestions.
- Pass pre-filtered Known Model options to `Autocomplete`; do not rely
on `cmdk` internal filtering once `inputValue` is controlled.
- Clear the selected `KnownModel | null` value whenever the admin types
arbitrary text that no longer corresponds to the selected Known Model.
- Guard selection and blur event ordering so selecting a row does not
cause the input blur handler to apply defaults a second time.
- Run exact-match blur behavior only when focus leaves the whole
field/combobox, not when focus moves into the suggestion list.
- Store the last-applied provider/model pair in form-local state or a
ref so add-mode provider remounts reset it naturally.
- Preserve the existing field contract: label, tooltip/help text,
`name`, validation error rendering, `aria-invalid`, `aria-describedby`,
disabled state, Formik blur/touched behavior, and submitted request
shape.

1. Add `ModelIdentifierField.tsx`.
2. Preserve existing plain `Input` markup for edit mode, duplicate mode,
and unsupported providers.
3. For add-mode supported providers, render `Autocomplete` with:
   - controlled free-text value tied to Formik's `model` field;
- custom row rendering with display name, canonical ID, and context
badge;
   - open-on-empty-focus behavior;
- non-blocking no-match copy for non-empty supported-provider queries;
   - keyboard support inherited from `Autocomplete`.
4. On Known Model selection:
   - set the form's `model` field to the canonical ID;
   - apply defaults immediately;
   - show inline feedback only if fields changed.
5. On blur:
- if the final field value exactly equals a Known Model canonical ID,
apply defaults safely;
   - do not auto-apply aliases on blur.
6. Track the last applied provider/model pair in the form session to
avoid repeated reapplication.

Quality gate: Storybook stories compile and the main interaction paths
work locally.

### Phase 4: Storybook and UX coverage

Add or extend `ChatModelAdminPanel.stories.tsx` with three user-visible
flows:

1. OpenAI happy path:
   - open Add Model for OpenAI;
   - focus empty Model Identifier;
   - suggestions appear;
   - select `GPT-5.5`;
   - assert `gpt-5.5` is in the input;
   - assert inline defaults note appears;
   - assert visible context limit and max output fields populate;
- expand the pricing section before asserting pricing fields, or keep
detailed pricing assertions in unit tests if the Storybook UI would
become brittle.
2. Anthropic happy path:
   - open Add Model for Anthropic;
   - select `Claude Opus 4.7`;
- assert canonical ID, visible context limit, and output field populate;
- expand the pricing section before asserting pricing fields, or keep
detailed pricing assertions in unit tests if the Storybook UI would
become brittle;
   - assert Anthropic reasoning/thinking fields remain blank.
3. Unsupported provider fallback:
   - open Add Model for Azure or openai-compat;
- assert Model Identifier behaves as plain free text and no suggestion
popover appears.

If practical, include one keyboard selection path in Storybook or manual
dogfooding:

- tab/focus Model Identifier;
- arrow to a suggestion;
- press Enter;
- verify canonicalization and defaults.

Quality gate: Storybook interaction tests pass for touched stories.

### Phase 5: Refactor and documentation pass

1. Keep catalog data isolated from UI rendering code.
2. Keep provider field mapping in one helper so future Google, Bedrock,
OpenRouter, or Azure support does not require editing defaulting logic
everywhere.
3. Ensure comments explain why tiered pricing and reasoning defaults are
excluded.
4. Update `CONTEXT.md` and ADR if implementation changes any design
decision captured there.
5. Run formatting and linting for touched frontend files.

Quality gate: no broad refactors beyond this feature's files.

## Validation commands

Use the repo's existing frontend validation commands, scoped where
possible:

- `pnpm -C site test <targeted ChatModelAdminPanel pattern>`
- `pnpm -C site test <targeted modelConfigFormLogic pattern>`
- `pnpm -C site test:storybook`
- `pnpm -C site lint:types`
- `pnpm -C site check`

If command names differ in this workspace, inspect `site/package.json`
and use the closest existing targeted commands. Do not claim success
until the actual commands run and pass.

## Dogfooding plan

Primary dogfood path is Storybook because this is a form-level UI
improvement using mocked admin data.

1. Run Storybook for the Chat Model Admin Panel.
2. Record a short video showing:
- OpenAI Add Model, focus empty Model Identifier, suggestions appear,
select `GPT-5.5`, defaults note appears, fields populate;
- Anthropic Add Model, select `Claude Opus 4.7`, fields populate,
reasoning/thinking fields remain blank;
- unsupported provider Add Model, Model Identifier stays free text with
no suggestions.
3. Capture screenshots for the final state of each flow and attach them
for review.
4. If implementation touches routing, `ModelsSection` URL state, or
provider pages, also run the local UI and record
`/agents/settings/models?newModel=openai` exercising the same OpenAI
flow.

## Acceptance criteria

- Add-mode native OpenAI and Anthropic Model Identifier fields provide
discovery suggestions from the curated Known Model catalog.
- Suggestions appear on empty focus and filter as the admin types.
- Unsupported providers, edit mode, and duplicate mode preserve the
current plain input behavior.
- Selecting a Known Model canonicalizes the field and safely applies
objective defaults.
- Exact typed/pasted canonical IDs apply defaults on blur.
- Off-catalog Model Identifiers remain valid and non-blocking.
- Display name, context limit, output-token field, and flat pricing fill
only when target fields still match initial values.
- Compression threshold, tiered pricing, and provider-specific
reasoning/thinking fields are not populated by Phase 1 defaults.
- Inline feedback appears only when default application changed at least
one field.
- Unit tests, Storybook coverage, typecheck, formatting, and lint/check
commands pass.
- Dogfooding includes screenshots and video recordings.

## Risks and mitigations

- **Catalog staleness**: models change frequently. Mitigate with source
metadata and clear file-level refresh comments.
- **Provider namespace mistakes**: Azure, Bedrock, OpenRouter, and
openai-compat use different identifier semantics. Mitigate by supporting
only native OpenAI and Anthropic in Phase 1.
- **Auto-fill surprise**: defaults can feel magical. Mitigate with
selection-first UX, blur-only exact-match behavior, initial-value safety
checks, and inline feedback.
- **Pricing inaccuracy for tiered models**: current form persists flat
prices only. Mitigate by mapping base flat prices only and documenting
tiered pricing as out of scope.
- **Reasoning option overreach**: generic source metadata does not map
cleanly to provider-specific controls. Mitigate by leaving
reasoning/thinking fields blank in Phase 1.
- **Overbroad UI changes**: replacing an input can affect accessibility
and keyboard users. Mitigate by using the shared Autocomplete primitive,
preserving plain Input fallback, and dogfooding keyboard selection.


</details>

---
_Generated with [`mux`](https://github.com/coder/mux) • Model:
`anthropic:claude-opus-4-7` • Thinking: `max`_
This commit is contained in:
Thomas Kosiewski
2026-05-04 16:40:11 +02:00
committed by GitHub
parent 6149fc3619
commit 69610cca75
16 changed files with 2957 additions and 59 deletions
@@ -221,6 +221,106 @@ export const SearchAndFilter: Story = {
},
};
export const InlineSearch: Story = {
args: {
onEnterEmpty: fn<() => void>(),
},
render: function InlineSearchStory(args) {
const [value, setValue] = useState<SimpleOption | null>(null);
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const filteredOptions = simpleOptions.filter((option) =>
option.name.toLowerCase().includes(inputValue.toLowerCase()),
);
const handleChange = (newValue: SimpleOption | null) => {
setValue(newValue);
setInputValue(newValue?.name ?? "");
};
return (
<div className="w-80 space-y-2">
<Autocomplete
value={value}
onChange={handleChange}
options={filteredOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Search fruits"
open={open}
onOpenChange={setOpen}
inputValue={inputValue}
onInputChange={setInputValue}
onEnterEmpty={() => {
args.onEnterEmpty?.();
setValue({ id: `custom-${inputValue}`, name: inputValue });
setOpen(false);
}}
inlineSearch
clearable={false}
noOptionsText="No fruits found"
/>
<div>Selected: {value?.name ?? "None"}</div>
</div>
);
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const input = canvas.getByRole("combobox");
const onEnterEmptySpy = args.onEnterEmpty as ReturnType<
typeof fn<() => void>
>;
onEnterEmptySpy.mockClear();
expect(canvas.queryByRole("button")).not.toBeInTheDocument();
await userEvent.click(input);
await expect(input).toHaveFocus();
await expect(input).toHaveAttribute("aria-expanded", "true");
await expect(
await screen.findByRole("option", { name: "Mango" }),
).toBeInTheDocument();
await userEvent.type(input, "an");
await waitFor(() => {
expect(screen.getByRole("option", { name: "Mango" })).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Banana" }),
).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: "Pineapple" }),
).not.toBeInTheDocument();
});
await userEvent.keyboard("{ArrowDown}{ArrowUp}{ArrowDown}{Enter}");
await expect(input).toHaveFocus();
await expect(
await canvas.findByText("Selected: Banana"),
).toBeInTheDocument();
await userEvent.click(input);
await expect(input).toHaveAttribute("aria-expanded", "true");
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(input).toHaveAttribute("aria-expanded", "false"),
);
await userEvent.click(input);
await userEvent.clear(input);
await userEvent.type(input, "dragonfruit");
await waitFor(() => {
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(screen.queryByText("No fruits found")).not.toBeInTheDocument();
});
await expect(input).toHaveAttribute("aria-expanded", "false");
await userEvent.keyboard("{Enter}");
await waitFor(() => expect(onEnterEmptySpy).toHaveBeenCalledTimes(1));
await expect(
await canvas.findByText("Selected: dragonfruit"),
).toBeInTheDocument();
},
};
export const ClearSelection: Story = {
args: {
onChange: fn<(value: unknown) => void>(),
@@ -2,7 +2,11 @@ import { CheckIcon, XIcon } from "lucide-react";
import {
type KeyboardEvent,
type ReactNode,
type SyntheticEvent,
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
@@ -16,6 +20,7 @@ import {
} from "#/components/Command/Command";
import {
Popover,
PopoverAnchor,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
@@ -37,10 +42,15 @@ interface AutocompleteProps<TOption> {
onOpenChange?: (open: boolean) => void;
inputValue?: string;
onInputChange?: (value: string) => void;
onEscapeKeyDown?: () => void;
onEnterEmpty?: () => void;
inlineSearch?: boolean;
clearable?: boolean;
disabled?: boolean;
startAdornment?: ReactNode;
className?: string;
triggerAriaInvalid?: boolean;
triggerAriaDescribedBy?: string;
id?: string;
"data-testid"?: string;
}
@@ -60,16 +70,30 @@ export function Autocomplete<TOption>({
onOpenChange,
inputValue: controlledInputValue,
onInputChange,
onEscapeKeyDown,
onEnterEmpty,
inlineSearch = false,
clearable = true,
disabled = false,
startAdornment,
className,
triggerAriaInvalid,
triggerAriaDescribedBy,
id,
"data-testid": testId,
}: AutocompleteProps<TOption>) {
const inlineInputRef = useRef<HTMLInputElement>(null);
const highlightedValueRef = useRef<string | null>(null);
const [managedOpen, setManagedOpen] = useState(false);
const [managedInputValue, setManagedInputValue] = useState("");
const [highlightedValue, setHighlightedValue] = useState<string | null>(null);
const generatedListboxId = useId();
const listboxId = `${generatedListboxId}-listbox`;
const updateHighlightedValue = useCallback((newValue: string | null) => {
highlightedValueRef.current = newValue;
setHighlightedValue(newValue);
}, []);
const isOpen = controlledOpen ?? managedOpen;
const inputValue = controlledInputValue ?? managedInputValue;
@@ -77,11 +101,14 @@ export function Autocomplete<TOption>({
(newOpen: boolean) => {
setManagedOpen(newOpen);
onOpenChange?.(newOpen);
if (!newOpen) {
updateHighlightedValue(null);
}
if (!newOpen && controlledInputValue === undefined) {
setManagedInputValue("");
}
},
[onOpenChange, controlledInputValue],
[onOpenChange, controlledInputValue, updateHighlightedValue],
);
const handleInputChange = useCallback(
@@ -116,7 +143,7 @@ export function Autocomplete<TOption>({
);
const handleClear = useCallback(
(e: React.SyntheticEvent) => {
(e: SyntheticEvent) => {
e.stopPropagation();
onChange(null);
handleInputChange("");
@@ -125,16 +152,227 @@ export function Autocomplete<TOption>({
);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
(e: KeyboardEvent<HTMLElement>) => {
if (e.key === "Escape") {
// cmdk consumes Escape unless default is prevented before its handler.
e.preventDefault();
if (onEscapeKeyDown) {
e.stopPropagation();
onEscapeKeyDown();
}
handleOpenChange(false);
}
},
[handleOpenChange],
[handleOpenChange, onEscapeKeyDown],
);
useEffect(() => {
if (
highlightedValue !== null &&
!options.some((option) => getOptionValue(option) === highlightedValue)
) {
updateHighlightedValue(null);
}
}, [highlightedValue, options, getOptionValue, updateHighlightedValue]);
const displayValue = value ? getOptionLabel(value) : "";
const showClearButton = clearable && value && !disabled;
const highlightedIndex = options.findIndex(
(option) => getOptionValue(option) === highlightedValue,
);
const activeDescendant =
highlightedIndex >= 0
? `${listboxId}-option-${highlightedIndex}`
: undefined;
const handleInlineKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (disabled) {
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!isOpen) {
handleOpenChange(true);
}
if (options.length === 0) {
updateHighlightedValue(null);
return;
}
const currentIndex = options.findIndex(
(option) => getOptionValue(option) === highlightedValueRef.current,
);
const nextIndex =
e.key === "ArrowDown"
? (currentIndex + 1) % options.length
: (currentIndex <= 0 ? options.length : currentIndex) - 1;
const nextOption = options[nextIndex];
if (!nextOption) {
updateHighlightedValue(null);
return;
}
updateHighlightedValue(getOptionValue(nextOption));
return;
}
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
if (!loading && options.length === 0) {
onEnterEmpty?.();
return;
}
const highlightedOption = options.find(
(option) => getOptionValue(option) === highlightedValueRef.current,
);
if (highlightedOption) {
handleSelect(highlightedOption);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
if (onEscapeKeyDown) {
e.stopPropagation();
onEscapeKeyDown();
}
handleOpenChange(false);
}
};
const renderOptionContent = (option: TOption) => {
const optionLabel = getOptionLabel(option);
const selected = isSelected(option);
return renderOption ? (
renderOption(option, selected)
) : (
<>
<span className="flex-1">{optionLabel}</span>
{selected && <CheckIcon className="size-4 shrink-0" />}
</>
);
};
const isInlineInputTarget = (target: EventTarget | null) =>
target instanceof Node &&
inlineInputRef.current !== null &&
inlineInputRef.current.contains(target);
if (inlineSearch) {
const inlineInputValue = isOpen ? inputValue : displayValue;
const hasResults = loading || options.length > 0;
const showPopover = isOpen && hasResults;
return (
<Popover open={showPopover} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<input
ref={inlineInputRef}
type="text"
id={id}
data-testid={testId}
role="combobox"
aria-expanded={showPopover}
aria-controls={showPopover ? listboxId : undefined}
aria-activedescendant={showPopover ? activeDescendant : undefined}
aria-haspopup="listbox"
aria-invalid={triggerAriaInvalid}
aria-describedby={triggerAriaDescribedBy}
disabled={disabled}
placeholder={placeholder}
value={inlineInputValue}
onFocus={() => {
if (!disabled && !isOpen) {
handleOpenChange(true);
}
}}
onMouseDown={() => {
if (!disabled && !isOpen) {
handleOpenChange(true);
}
}}
onChange={(event) => {
if (disabled) {
return;
}
if (!isOpen) {
handleOpenChange(true);
}
handleInputChange(event.currentTarget.value);
}}
onKeyDownCapture={handleInlineKeyDown}
className={cn(
`flex h-10 w-full items-center rounded-md border border-border border-solid
bg-transparent px-3 py-2 text-sm shadow-sm transition-colors
placeholder:text-content-secondary text-content-primary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:cursor-not-allowed disabled:opacity-50`,
className,
)}
/>
</PopoverAnchor>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
onKeyDownCapture={handleInlineKeyDown}
onOpenAutoFocus={(event) => event.preventDefault()}
onCloseAutoFocus={(event) => event.preventDefault()}
onInteractOutside={(event) => {
if (isInlineInputTarget(event.target)) {
event.preventDefault();
return;
}
handleOpenChange(false);
}}
>
<Command
shouldFilter={false}
value={highlightedValue ?? ""}
onValueChange={(newValue) => {
if (newValue) {
updateHighlightedValue(newValue);
}
}}
>
<CommandList id={listboxId} role="listbox">
{loading ? (
<div className="flex items-center justify-center py-6">
<Spinner size="sm" loading />
</div>
) : (
<>
<CommandEmpty>{noOptionsText}</CommandEmpty>
<CommandGroup>
{options.map((option, index) => {
const optionValue = getOptionValue(option);
return (
<CommandItem
role="option"
id={`${listboxId}-option-${index}`}
key={optionValue}
value={optionValue}
onSelect={() => handleSelect(option)}
className="cursor-pointer"
>
{renderOptionContent(option)}
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
@@ -145,6 +383,8 @@ export function Autocomplete<TOption>({
data-testid={testId}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-invalid={triggerAriaInvalid}
aria-describedby={triggerAriaDescribedBy}
disabled={disabled}
className={cn(
`flex h-10 w-full items-center justify-between gap-2
@@ -199,13 +439,13 @@ export function Autocomplete<TOption>({
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
onKeyDownCapture={handleKeyDown}
>
<Command shouldFilter={controlledInputValue === undefined}>
<CommandInput
placeholder={placeholder}
value={inputValue}
onValueChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<CommandList>
{loading ? (
+2
View File
@@ -17,6 +17,8 @@ export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverAnchor = PopoverPrimitive.Anchor;
export const PopoverContent: React.FC<PopoverContentProps> = ({
className,
align = "center",
@@ -1,12 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type ComponentProps, useState } from "react";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
expect,
fireEvent,
fn,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated";
import {
ChatModelAdminPanel,
type ChatModelAdminSection,
} from "./ChatModelAdminPanel";
import { formatContextBadge, getKnownModelsForProvider } from "./knownModels";
// ── Helpers ────────────────────────────────────────────────────
@@ -990,6 +999,20 @@ const expandSection = async (body: ReturnType<typeof within>, name: string) => {
await userEvent.click(btn);
};
const enterModelIdentifier = async (
body: ReturnType<typeof within>,
value: string,
) => {
const field = await body.findByLabelText(/Model Identifier/i);
if (field instanceof HTMLInputElement) {
await userEvent.type(field, value);
return;
}
await userEvent.click(field);
await userEvent.type(await body.findByRole("combobox"), value);
};
export const NoModelConfigByDefault: Story = {
args: {
section: "models" as ChatModelAdminSection,
@@ -1009,7 +1032,7 @@ export const NoModelConfigByDefault: Story = {
// Open "Add model" dropdown and select the OpenAI provider.
await openAddModelForm(body, "OpenAI");
await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro");
await enterModelIdentifier(body, "gpt-5-pro");
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
// Max output tokens is under the "Advanced" toggle.
@@ -1058,10 +1081,7 @@ export const SubmitModelConfigExplicitly: Story = {
// Open "Add model" dropdown and select the OpenAI provider.
await openAddModelForm(body, "OpenAI");
await userEvent.type(
body.getByLabelText(/Model Identifier/i),
"gpt-5-pro-custom",
);
await enterModelIdentifier(body, "gpt-5-pro-custom");
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
// Max output tokens is under "Advanced".
await expandSection(body, "Advanced");
@@ -1160,6 +1180,927 @@ const providerFormSetup = (provider: string, displayName: string) => ({
},
});
const findOptionByText = (options: HTMLElement[], text: string) => {
for (const option of options) {
if (option.textContent?.includes(text)) {
return option;
}
}
throw new Error(`Expected visible option containing ${text}.`);
};
const expectKnownModelOptionsInOrder = async (
body: ReturnType<typeof within>,
provider: string,
) => {
const knownModels = getKnownModelsForProvider(provider);
const options = await body.findAllByRole("option");
expect(options.length).toBeGreaterThanOrEqual(knownModels.length);
for (const [index, knownModel] of knownModels.entries()) {
const option = options[index];
if (!option) {
throw new Error(`Expected option at index ${index}.`);
}
expect(option).toHaveTextContent(knownModel.displayName);
expect(option).toHaveTextContent(knownModel.modelIdentifier);
if (knownModel.contextLimit !== undefined) {
expect(option).toHaveTextContent(
formatContextBadge(knownModel.contextLimit),
);
}
}
return options;
};
const knownModelDefaultsFeedback = (displayName: string) =>
`Defaults applied from ${displayName}. Review and adjust before saving.`;
const noMatchingKnownModelsText =
"No matching known models. You can still use this identifier.";
const openKnownModelPopover = async (body: ReturnType<typeof within>) => {
await userEvent.click(await body.findByLabelText(/Model Identifier/i));
const input = await body.findByRole("combobox");
await expect(input).toHaveFocus();
return input;
};
const expectKnownModelPopoverClosed = async (
body: ReturnType<typeof within>,
) => {
await waitFor(() => {
expect(body.queryByRole("listbox")).not.toBeInTheDocument();
expect(body.queryAllByRole("option")).toHaveLength(0);
expect(body.queryByText(noMatchingKnownModelsText)).not.toBeInTheDocument();
});
};
const closeKnownModelPopoverToContextLimit = async (
body: ReturnType<typeof within>,
) => {
await userEvent.click(body.getByLabelText(/Context limit/i));
await expectKnownModelPopoverClosed(body);
};
const selectKnownModel = async (
body: ReturnType<typeof within>,
modelIdentifier: string,
) => {
const input = await openKnownModelPopover(body);
await userEvent.clear(input);
await expect(input).toHaveValue("");
const options = await body.findAllByRole("option");
await userEvent.click(findOptionByText(options, modelIdentifier));
await expectModelIdentifierValue(body, modelIdentifier);
};
const clearAndTypeKnownModelSearch = async (
body: ReturnType<typeof within>,
value: string,
) => {
let input = await body.findByRole("combobox");
await userEvent.clear(input);
input = await body.findByRole("combobox");
await expect(input).toHaveValue("");
await expect(input).toHaveFocus();
await userEvent.keyboard(value);
input = await body.findByRole("combobox");
await expect(input).toHaveValue(value);
return input;
};
const expectModelIdentifierValue = async (
body: ReturnType<typeof within>,
value: string,
) => {
const control = await body.findByLabelText(/Model Identifier/i);
if (control.matches("input,textarea")) {
await waitFor(() => expect(control).toHaveValue(value));
return;
}
await waitFor(() => expect(control).toHaveTextContent(value));
};
const getDefaultsFeedback = (
body: ReturnType<typeof within>,
message: string,
) =>
body
.queryAllByRole("status")
.filter((el: HTMLElement) => el.textContent === message);
const expectDefaultsFeedbackCount = (
body: ReturnType<typeof within>,
message: string,
count: number,
) => {
expect(getDefaultsFeedback(body, message)).toHaveLength(count);
};
const expectOffCatalogModelCommitted = async (
body: ReturnType<typeof within>,
value: string,
) => {
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, value);
await closeKnownModelPopoverToContextLimit(body);
await expectModelIdentifierValue(body, value);
expect(body.queryByRole("status")).not.toBeInTheDocument();
expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument();
};
const ensureCostTrackingOpen = async (body: ReturnType<typeof within>) => {
if (body.queryByLabelText(/^Input$/i)) {
return;
}
await expandSection(body, "Cost Tracking");
await body.findByLabelText(/^Input$/i);
};
const expectPricingValue = async (
body: ReturnType<typeof within>,
label: RegExp,
value: string,
) => {
await expect(await body.findByLabelText(label)).toHaveValue(value);
};
const expectReasoningEffort = async (
body: ReturnType<typeof within>,
value: string,
) => {
const reasoningEffortGroup = await body.findByRole("radiogroup", {
name: "Reasoning Effort",
});
if (value === "") {
for (const option of within(reasoningEffortGroup).getAllByRole("radio")) {
await expect(option).toHaveAttribute("aria-checked", "false");
}
return;
}
const label = value.charAt(0).toUpperCase() + value.slice(1);
await expect(
within(reasoningEffortGroup).getByRole("radio", { name: label }),
).toHaveAttribute("aria-checked", "true");
};
type OpenAIDefaultExpectations = {
modelIdentifier: string;
contextLimit: string;
maxCompletionTokens: string;
reasoningEffort: string;
inputCost: string;
outputCost: string;
cacheReadCost?: string;
cacheWriteCost?: string;
};
const gpt55Defaults = {
modelIdentifier: "gpt-5.5",
contextLimit: "1050000",
maxCompletionTokens: "128000",
reasoningEffort: "medium",
inputCost: "5",
outputCost: "30",
cacheReadCost: "0.5",
} satisfies OpenAIDefaultExpectations;
const gpt55ProDefaults = {
modelIdentifier: "gpt-5.5-pro",
contextLimit: "1050000",
maxCompletionTokens: "128000",
reasoningEffort: "high",
inputCost: "30",
outputCost: "180",
} satisfies OpenAIDefaultExpectations;
const gpt54MiniDefaults = {
modelIdentifier: "gpt-5.4-mini",
contextLimit: "400000",
maxCompletionTokens: "128000",
reasoningEffort: "medium",
inputCost: "0.75",
outputCost: "4.5",
cacheReadCost: "0.075",
} satisfies OpenAIDefaultExpectations;
const ensureProviderConfigurationOpen = async (
body: ReturnType<typeof within>,
) => {
if (body.queryByLabelText(/Max Completion Tokens/i)) {
return;
}
await expandSection(body, "Provider Configuration");
await body.findByLabelText(/Max Completion Tokens/i);
};
const expectOpenAIKnownModelDefaults = async (
body: ReturnType<typeof within>,
expectations: OpenAIDefaultExpectations,
) => {
await expectModelIdentifierValue(body, expectations.modelIdentifier);
await expect(body.getByLabelText(/Context limit/i)).toHaveValue(
expectations.contextLimit,
);
await ensureProviderConfigurationOpen(body);
await expect(
await body.findByLabelText(/Max Completion Tokens/i),
).toHaveValue(expectations.maxCompletionTokens);
await expectReasoningEffort(body, expectations.reasoningEffort);
await ensureCostTrackingOpen(body);
await expectPricingValue(body, /^Input$/i, expectations.inputCost);
await expectPricingValue(body, /^Output$/i, expectations.outputCost);
await expectPricingValue(
body,
/^Cache Read$/i,
expectations.cacheReadCost ?? "",
);
await expectPricingValue(
body,
/^Cache Write$/i,
expectations.cacheWriteCost ?? "",
);
};
export const OpenAIKnownModelHappyPath: Story = {
...providerFormSetup("openai", "OpenAI"),
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await openKnownModelPopover(body);
const options = await expectKnownModelOptionsInOrder(body, "openai");
await userEvent.click(findOptionByText(options, "gpt-5.5"));
await expectModelIdentifierValue(body, "gpt-5.5");
await expect(await body.findByRole("status")).toHaveTextContent(
"Defaults applied from GPT-5.5. Review and adjust before saving.",
);
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000");
await expandSection(body, "Provider Configuration");
await expect(
await body.findByLabelText(/Max Completion Tokens/i),
).toHaveValue("128000");
},
};
export const OpenAIKnownModelKeyboardSelection: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-25: keyboard selection applies defaults",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
const input = await openKnownModelPopover(body);
fireEvent.keyDown(input, { key: "ArrowDown" });
await userEvent.keyboard("{Enter}");
await expectOpenAIKnownModelDefaults(body, gpt55ProDefaults);
await expect(await body.findByRole("status")).toHaveTextContent(
knownModelDefaultsFeedback("GPT-5.5 Pro"),
);
},
};
export const OpenAIKnownModelReclickSelectedDoesNotClearField: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-26: re-clicking selected Known Model does not clear field",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await openKnownModelPopover(body);
const options = await body.findAllByRole("option");
await userEvent.click(findOptionByText(options, "gpt-5.5"));
await expectModelIdentifierValue(body, "gpt-5.5");
await expectKnownModelPopoverClosed(body);
expect(
body.queryByRole("button", { name: /clear/i }),
).not.toBeInTheDocument();
},
};
export const AnthropicKnownModelHappyPath: Story = {
...providerFormSetup("anthropic", "Anthropic"),
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
await openKnownModelPopover(body);
const options = await body.findAllByRole("option");
await userEvent.click(findOptionByText(options, "claude-opus-4-7"));
await expectModelIdentifierValue(body, "claude-opus-4-7");
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1000000");
await expandSection(body, "Advanced");
await expect(await body.findByLabelText(/Max Output Tokens/i)).toHaveValue(
"128000",
);
await expandSection(body, "Provider Configuration");
const sendReasoningGroup = await body.findByRole("radiogroup", {
name: "Send Reasoning",
});
await expect(
within(sendReasoningGroup).getByRole("radio", { name: "On" }),
).toHaveAttribute("aria-checked", "false");
await expect(
within(sendReasoningGroup).getByRole("radio", { name: "Off" }),
).toHaveAttribute("aria-checked", "false");
await expect(
await body.findByLabelText(/Thinking Budget Tokens/i),
).toHaveValue("");
await expectReasoningEffort(body, "high");
},
};
export const AnthropicHaikuKnownModelUsesThinkingBudgetNotEffort: Story = {
...providerFormSetup("anthropic", "Anthropic"),
name: "Add mode / DEREM-43: Haiku 4.5 sets thinking budget instead of effort",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
await selectKnownModel(body, "claude-haiku-4-5");
await expandSection(body, "Provider Configuration");
// Reasoning Effort should remain empty because Haiku 4.5 uses the
// thinking budget path instead of Anthropic adaptive thinking.
await expectReasoningEffort(body, "");
await expect(
await body.findByLabelText(/Thinking Budget Tokens/i),
).toHaveValue("8192");
},
};
export const OpenAIKnownModelDoesNotPreFireRequiredError: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-3: open does not pre-fire required error",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await openKnownModelPopover(body);
expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument();
},
};
export const OpenAIKnownModelOpenDoesNotFlashInvalidBorder: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-31: open does not flash invalid border on trigger",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
const trigger = await body.findByLabelText(/Model Identifier/i);
await openKnownModelPopover(body);
expect([null, "false"]).toContain(trigger.getAttribute("aria-invalid"));
expect(trigger).not.toHaveClass("border-content-destructive");
expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument();
},
};
export const KnownModelClickOffEmptyDoesNotFireRequired: Story = {
...providerFormSetup("anthropic", "Anthropic"),
name: "Add mode / DEREM-47: clicking off empty model does not fire required error",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
const trigger = await body.findByLabelText(/Model Identifier/i);
await openKnownModelPopover(body);
// Click another field to close the popover without typing or selecting.
// Mirrors the QA-reported flow: focus the field, change your mind, click
// elsewhere; the empty value should NOT surface "Model ID is required."
// before the user has actually attempted to commit anything.
await closeKnownModelPopoverToContextLimit(body);
expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument();
expect([null, "false"]).toContain(trigger.getAttribute("aria-invalid"));
expect(trigger).not.toHaveClass("border-content-destructive");
},
};
export const OpenAIKnownModelEscapeCancelsSearch: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-5: Escape cancels and preserves committed value",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const feedback = knownModelDefaultsFeedback("GPT-5.5");
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await expectModelIdentifierValue(body, "gpt-5.5");
expectDefaultsFeedbackCount(body, feedback, 1);
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, "cod");
await userEvent.keyboard("{Escape}");
await expectKnownModelPopoverClosed(body);
await expectModelIdentifierValue(body, "gpt-5.5");
expectDefaultsFeedbackCount(body, feedback, 1);
const reopenedInput = await openKnownModelPopover(body);
await expect(reopenedInput).toHaveValue("gpt-5.5");
await userEvent.keyboard("{Escape}");
},
};
export const OpenAIKnownModelEscapeDoesNotReapplyDefaultsFeedback: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-30: type-then-Escape does not re-apply defaults feedback",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const feedback = knownModelDefaultsFeedback("GPT-5.5");
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
expectDefaultsFeedbackCount(body, feedback, 1);
const initialFeedback = getDefaultsFeedback(body, feedback)[0];
if (!initialFeedback) {
throw new Error("Expected Known Model defaults feedback.");
}
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, "a");
await userEvent.keyboard("{Escape}");
await expectKnownModelPopoverClosed(body);
await userEvent.click(body.getByLabelText(/Context limit/i));
expectDefaultsFeedbackCount(body, feedback, 1);
expect(getDefaultsFeedback(body, feedback)[0]).toBe(initialFeedback);
await expectModelIdentifierValue(body, "gpt-5.5");
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000");
},
};
export const OpenAIKnownModelSequentialSelectionReplacesDefaults: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-4, DEREM-10: sequential selection replaces catalog defaults",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await selectKnownModel(body, "gpt-5.4-mini");
await expectModelIdentifierValue(body, "gpt-5.4-mini");
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("400000");
await ensureCostTrackingOpen(body);
await expectPricingValue(body, /^Input$/i, "0.75");
await expectPricingValue(body, /^Output$/i, "4.5");
},
};
export const OpenAIKnownModelReasoningEffortClearsForNonReasoningModel: Story =
{
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-31: reasoningEffort clears when switching to non-reasoning model",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await ensureProviderConfigurationOpen(body);
await expectReasoningEffort(body, "medium");
await selectKnownModel(body, "gpt-5.4");
await expectReasoningEffort(body, "");
},
};
export const OpenAIKnownModelStaleCostFieldDoesNotPersist: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-24: stale cost fields do not persist",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5-pro");
await selectKnownModel(body, "gpt-5.4-mini");
await selectKnownModel(body, "gpt-5.5");
await expectOpenAIKnownModelDefaults(body, gpt55Defaults);
},
};
export const OpenAIKnownModelOffCatalogInterleavingKeepsTracking: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-24: off-catalog interleaving keeps tracking",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, "my-custom-fine-tune");
await closeKnownModelPopoverToContextLimit(body);
await expectModelIdentifierValue(body, "my-custom-fine-tune");
await selectKnownModel(body, "gpt-5.4-mini");
await expectOpenAIKnownModelDefaults(body, gpt54MiniDefaults);
},
};
export const OpenAIKnownModelChainTrackingDoesNotLoseFields: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-24: chained selections retain tracking",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await selectKnownModel(body, "gpt-5.5-pro");
await expectOpenAIKnownModelDefaults(body, gpt55ProDefaults);
await selectKnownModel(body, "gpt-5.4-mini");
await expectOpenAIKnownModelDefaults(body, gpt54MiniDefaults);
},
};
export const OpenAIKnownModelDoubleApplyGuard: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-10: double-apply guard keeps defaults stable",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
const feedback = knownModelDefaultsFeedback("GPT-5.5");
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await expect(await body.findByRole("status")).toHaveTextContent(feedback);
await ensureCostTrackingOpen(body);
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000");
await expectPricingValue(body, /^Input$/i, "5");
await expectPricingValue(body, /^Output$/i, "30");
await openKnownModelPopover(body);
await closeKnownModelPopoverToContextLimit(body);
expectDefaultsFeedbackCount(body, feedback, 1);
await expectModelIdentifierValue(body, "gpt-5.5");
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000");
await expectPricingValue(body, /^Input$/i, "5");
await expectPricingValue(body, /^Output$/i, "30");
},
};
export const OpenAIKnownModelExactCanonicalBlurAppliesDefaults: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-10: exact canonical blur applies defaults",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, "gpt-5.5-pro");
await closeKnownModelPopoverToContextLimit(body);
await expectModelIdentifierValue(body, "gpt-5.5-pro");
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("1050000");
await ensureCostTrackingOpen(body);
await expectPricingValue(body, /^Input$/i, "30");
await expectPricingValue(body, /^Output$/i, "180");
await expect(await body.findByRole("status")).toHaveTextContent(
knownModelDefaultsFeedback("GPT-5.5 Pro"),
);
},
};
export const AnthropicKnownModelAliasTypedValueCancels: Story = {
...providerFormSetup("anthropic", "Anthropic"),
name: "Add mode / DEREM-10: alias typed value cancels",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, "custom-anthropic-model");
await closeKnownModelPopoverToContextLimit(body);
await expectModelIdentifierValue(body, "custom-anthropic-model");
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, "claude-haiku-4-5-20251001");
const filteredOptions = await body.findAllByRole("option");
expect(
findOptionByText(filteredOptions, "Claude Haiku 4.5"),
).toHaveTextContent("claude-haiku-4-5");
await closeKnownModelPopoverToContextLimit(body);
await expectModelIdentifierValue(body, "custom-anthropic-model");
expect(body.queryByRole("status")).not.toBeInTheDocument();
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("");
},
};
export const AnthropicKnownModelPunctuationVariantCommits: Story = {
...providerFormSetup("anthropic", "Anthropic"),
name: "Add mode / DEREM-27: off-catalog with punctuation variant commits",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
await expectOffCatalogModelCommitted(body, "claude.haiku.4.5.20251001");
},
};
export const KnownModelOffCatalogSubstringCommits: Story = {
args: {
section: "models" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-anthropic-known-model-substring",
provider: "anthropic",
display_name: "Anthropic",
source: "database",
has_api_key: true,
}),
createProviderConfig({
id: "provider-openai-known-model-substring",
provider: "openai",
display_name: "OpenAI",
source: "database",
has_api_key: true,
}),
],
},
name: "Add mode / DEREM-19: off-catalog identifier substring-matching catalog metadata commits",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
await expectOffCatalogModelCommitted(body, "haiku");
await userEvent.click(body.getByRole("button", { name: /^Cancel$/i }));
await waitFor(() => {
expect(
body.queryByLabelText(/Model Identifier/i),
).not.toBeInTheDocument();
});
await openAddModelForm(body, "OpenAI");
await expectOffCatalogModelCommitted(body, "mini");
await expectOffCatalogModelCommitted(body, "pro");
await expectOffCatalogModelCommitted(body, "gpt-5");
},
};
export const KnownModelProviderChangeResetsDefaultsFeedback: Story = {
args: {
section: "models" as ChatModelAdminSection,
providerConfigsData: [
createProviderConfig({
id: "provider-openai-known-model-reset",
provider: "openai",
display_name: "OpenAI",
source: "database",
has_api_key: true,
}),
createProviderConfig({
id: "provider-anthropic-known-model-reset",
provider: "anthropic",
display_name: "Anthropic",
source: "database",
has_api_key: true,
}),
],
},
name: "Add mode / DEREM-10: provider change resets Known Model defaults",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await selectKnownModel(body, "gpt-5.5");
await expect(await body.findByRole("status")).toHaveTextContent(
knownModelDefaultsFeedback("GPT-5.5"),
);
await userEvent.click(body.getByRole("button", { name: /^Cancel$/i }));
await waitFor(() => {
expect(
body.queryByLabelText(/Model Identifier/i),
).not.toBeInTheDocument();
});
await openAddModelForm(body, "Anthropic");
await selectKnownModel(body, "claude-haiku-4-5");
await expectModelIdentifierValue(body, "claude-haiku-4-5");
await expect(body.getByLabelText(/Context limit/i)).toHaveValue("200000");
await ensureCostTrackingOpen(body);
await expectPricingValue(body, /^Input$/i, "1");
await expectPricingValue(body, /^Output$/i, "5");
await expect(await body.findByRole("status")).toHaveTextContent(
knownModelDefaultsFeedback("Claude Haiku 4.5"),
);
},
};
export const OpenAIKnownModelTriggerAriaParity: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-1: aria parity on autocomplete trigger",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
// Surface the required-field error through a real user action:
// open the popover, type then clear the search, and click off. The
// off-catalog close path commits an empty string and marks the field
// touched, so Formik validation renders "Model ID is required." This
// verifies the inline-search trigger forwards aria-invalid +
// aria-describedby with the same parity as the plain <Input>
// fallback used in edit/duplicate modes.
await openKnownModelPopover(body);
const input = await clearAndTypeKnownModelSearch(body, "x");
await userEvent.clear(input);
await closeKnownModelPopoverToContextLimit(body);
const trigger = await body.findByLabelText(/Model Identifier/i);
const error = await body.findByText("Model ID is required.");
expect(error.id).toBeTruthy();
await expect(trigger).toHaveAttribute("aria-invalid", "true");
await expect(trigger).toHaveAttribute("aria-describedby", error.id);
},
};
export const OpenAIKnownModelNoOptionsCopy: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-6: no-options auto-hides popover",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await openKnownModelPopover(body);
await clearAndTypeKnownModelSearch(body, "zzzzzzz");
await expectKnownModelPopoverClosed(body);
expect(body.queryByText(noMatchingKnownModelsText)).not.toBeInTheDocument();
},
};
export const AnthropicKnownModelEnterCommitsOffCatalogIdentifier: Story = {
...providerFormSetup("anthropic", "Anthropic"),
name: "Add mode / DEREM-34: Enter commits off-catalog identifier",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
const input = await openKnownModelPopover(body);
await userEvent.type(input, "claude-opus-4-5");
await expectKnownModelPopoverClosed(body);
await expect(input).toHaveAttribute("aria-expanded", "false");
await userEvent.keyboard("{Enter}");
await expectKnownModelPopoverClosed(body);
await expectModelIdentifierValue(body, "claude-opus-4-5");
expect(body.queryByRole("status")).not.toBeInTheDocument();
},
};
export const KnownModelAutoHidePopoverWhenNoMatches: Story = {
...providerFormSetup("anthropic", "Anthropic"),
name: "Add mode / DEREM-42: popover auto-hides when search has no matches",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
const input = await body.findByRole("combobox", {
name: /Model Identifier/i,
});
await userEvent.click(input);
await expect(await body.findByText("Claude Opus 4.7")).toBeInTheDocument();
await userEvent.clear(input);
await userEvent.type(input, "claude-opus-4-5");
await waitFor(() => {
expect(body.queryByRole("listbox")).not.toBeInTheDocument();
});
expect(
body.queryByText(/No matching known models/i),
).not.toBeInTheDocument();
await expect(input).toHaveAttribute("aria-expanded", "false");
await userEvent.keyboard("{Enter}");
await expectModelIdentifierValue(body, "claude-opus-4-5");
},
};
export const KnownModelBlurAfterAutoHideCommitsOffCatalog: Story = {
...providerFormSetup("anthropic", "Anthropic"),
name: "Add mode / DEREM-45: blur after auto-hide commits off-catalog identifier",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
const input = await body.findByRole("combobox", {
name: /Model Identifier/i,
});
await userEvent.click(input);
await userEvent.clear(input);
await userEvent.type(input, "claude-opus-4-5");
// Popover should auto-hide for the unmatched query.
await waitFor(() => {
expect(body.queryByRole("listbox")).not.toBeInTheDocument();
});
await expect(input).toHaveAttribute("aria-expanded", "false");
// Blur via Tab: focus moves to the next field, exercising the
// handleBlur auto-hide path that calls handleOpenChange(false).
await userEvent.tab();
await expectModelIdentifierValue(body, "claude-opus-4-5");
// No defaults feedback for off-catalog identifiers.
expect(body.queryByRole("status")).not.toBeInTheDocument();
// Critical: in the buggy variant where handleBlur skips the
// handleOpenChange(false) branch for the auto-hidden popover,
// the inline input still visually shows the typed search text
// (via the controlled inputValue prop), so any DOM-value
// assertion would pass vacuously. The committed form value is
// what diverges, surfaced here via the required-field error:
// markTouched() runs in the buggy path with form.values.model
// still empty, producing "Model ID is required." The fixed path
// commits the typed text via setFieldValue first, clearing the
// validation error.
expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument();
},
};
export const OpenAIKnownModelTriggerInputIsTypedField: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-35: trigger input is the typed field",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
const input = await openKnownModelPopover(body);
await userEvent.type(input, "5.4");
await expect(input).toHaveFocus();
await expect(input).toHaveValue("5.4");
const options = await body.findAllByRole("option");
expect(findOptionByText(options, "gpt-5.4")).toBeInTheDocument();
expect(findOptionByText(options, "gpt-5.4-mini")).toBeInTheDocument();
expect(findOptionByText(options, "gpt-5.4-nano")).toBeInTheDocument();
expect(body.queryByText("gpt-5.5")).not.toBeInTheDocument();
expect(body.queryByText("gpt-5.5-pro")).not.toBeInTheDocument();
expect(body.queryByText("gpt-5.3-codex")).not.toBeInTheDocument();
},
};
export const OpenAIKnownModelArrowDownEnterSelectsHighlighted: Story = {
...providerFormSetup("openai", "OpenAI"),
name: "Add mode / DEREM-36: ArrowDown Enter selects highlighted option",
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
const input = await openKnownModelPopover(body);
fireEvent.keyDown(input, { key: "ArrowDown" });
await userEvent.keyboard("{Enter}");
await expectModelIdentifierValue(body, "gpt-5.5-pro");
await expectOpenAIKnownModelDefaults(body, gpt55ProDefaults);
await expect(await body.findByRole("status")).toHaveTextContent(
knownModelDefaultsFeedback("GPT-5.5 Pro"),
);
},
};
export const UnsupportedProviderFallback: Story = {
...providerFormSetup("google", "Google"),
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Google");
const modelInput = await body.findByLabelText(/Model Identifier/i);
await userEvent.click(modelInput);
expect(body.queryByRole("option")).not.toBeInTheDocument();
await userEvent.type(modelInput, "gemini-custom-model");
await userEvent.tab();
await expect(modelInput).toHaveValue("gemini-custom-model");
expect(body.queryByText("Model ID is required.")).not.toBeInTheDocument();
expect(body.queryByRole("status")).not.toBeInTheDocument();
},
};
export const ModelFormOpenAI: Story = {
...providerFormSetup("openai", "OpenAI"),
play: async ({ canvasElement }) => {
@@ -1559,7 +2500,7 @@ export const ValidatesModelConfigFields: Story = {
// Open "Add model" dropdown and select the OpenAI provider.
await openAddModelForm(body, "OpenAI");
await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro");
await enterModelIdentifier(body, "gpt-5-pro");
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
// Max output tokens is under the "Advanced" toggle.
await userEvent.click(body.getByText("Advanced"));
@@ -9,7 +9,6 @@ import { type FC, useState } from "react";
import * as Yup from "yup";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
InputGroupAddon,
@@ -40,6 +39,7 @@ import {
ModelConfigFields,
PricingModelConfigFields,
} from "./ModelConfigFields";
import { ModelIdentifierField } from "./ModelIdentifierField";
import {
buildInitialModelFormValues,
buildModelConfigFromForm,
@@ -135,6 +135,11 @@ export const ModelForm: FC<ModelFormProps> = ({
const formDescription = isDuplicating
? "Review the copied settings, then save to create a new model."
: undefined;
const mode: "add" | "edit" | "duplicate" = (() => {
if (isEditing) return "edit";
if (isDuplicating) return "duplicate";
return "add";
})();
const form = useFormik<ModelFormValues>({
initialValues,
@@ -385,50 +390,13 @@ export const ModelForm: FC<ModelFormProps> = ({
<div className="space-y-4">
<div className="grid items-start gap-4 sm:grid-cols-2">
{" "}
<div className="grid gap-1.5">
<Label
htmlFor={modelField.id}
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
>
Model Identifier{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-3 w-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
The model identifier sent to the provider API.
</TooltipContent>
</Tooltip>
</Label>
<Input
id={modelField.id}
name={modelField.name}
className={cn(
"h-9 text-[13px] placeholder:text-content-disabled",
modelField.error && "border-content-destructive",
)}
placeholder="e.g. gpt-5, claude-sonnet-4-5"
value={modelField.value}
onChange={modelField.onChange}
onBlur={modelField.onBlur}
disabled={isSaving}
aria-invalid={modelField.error}
aria-describedby={
modelField.error ? `${modelField.id}-error` : undefined
}
/>
{modelField.error && (
<p
id={`${modelField.id}-error`}
className="m-0 text-xs text-content-destructive"
>
{modelField.helperText}
</p>
)}
</div>
<ModelIdentifierField
form={form}
modelField={modelField}
mode={mode}
selectedProvider={selectedProvider}
disabled={isSaving}
/>
<div className="grid gap-1.5">
<Label
htmlFor={contextLimitField.id}
@@ -0,0 +1,490 @@
import type { FormikContextType } from "formik";
import { CheckIcon, InfoIcon } from "lucide-react";
import {
type FocusEvent,
type KeyboardEvent,
useEffect,
useRef,
useState,
} from "react";
import { Autocomplete } from "#/components/Autocomplete/Autocomplete";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import type { FormHelpers } from "#/utils/formUtils";
import { normalizeProvider } from "./helpers";
import {
findKnownModelByCanonicalId,
findKnownModelByExactAlias,
formatContextBadge,
getKnownModelsForProvider,
type KnownModel,
searchKnownModels,
} from "./knownModels";
import { applyKnownModelDefaults } from "./knownModels/applyKnownModelDefaults";
import { deepGet, deepSet, type ModelFormValues } from "./modelConfigFormLogic";
type ModelFormMode = "add" | "edit" | "duplicate";
type ModelIdentifierFieldProps = {
form: FormikContextType<ModelFormValues>;
modelField: FormHelpers;
mode: ModelFormMode;
selectedProvider: string | null;
disabled: boolean;
};
type ModelIdentifierOption = {
model: string;
displayName: string;
contextLimit?: number;
knownModel?: KnownModel;
};
type AppliedModel = {
provider: string;
modelIdentifier: string;
};
type PreviouslyAppliedDefaults = {
provider: string;
fields: Record<string, unknown>;
};
const knownModelToOption = (knownModel: KnownModel): ModelIdentifierOption => ({
model: knownModel.modelIdentifier,
displayName: knownModel.displayName,
contextLimit: knownModel.contextLimit,
knownModel,
});
export const ModelIdentifierField = ({
form,
modelField,
mode,
selectedProvider,
disabled,
}: ModelIdentifierFieldProps) => {
const [initialFormValues] = useState(() => form.initialValues);
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [feedback, setFeedback] = useState<string | null>(null);
// Mirror of `open` for synchronous reads from blur handlers; React state may not have committed when Radix shifts focus on open.
const openRef = useRef(false);
const searchValueRef = useRef("");
const searchDirtyRef = useRef(false);
const justSelectedRef = useRef(false);
const closeIntentRef = useRef<"escape" | null>(null);
const lastAppliedProviderModelRef = useRef<AppliedModel | null>(null);
const previouslyAppliedRef = useRef<PreviouslyAppliedDefaults | null>(null);
const normalizedProvider = normalizeProvider(selectedProvider ?? "");
const providerKnownModels = getKnownModelsForProvider(normalizedProvider);
const usesKnownModelCatalog =
mode === "add" && providerKnownModels.length > 0;
const currentModel = String(form.values.model ?? "");
const activeSearchQuery = open ? searchValue : currentModel;
const knownModelOptions = searchKnownModels(
normalizedProvider,
activeSearchQuery,
).map(knownModelToOption);
const selectedKnownModel = findKnownModelByCanonicalId(
normalizedProvider,
currentModel,
);
const selectedOption: ModelIdentifierOption | null = (() => {
if (selectedKnownModel) return knownModelToOption(selectedKnownModel);
if (currentModel) return { model: currentModel, displayName: currentModel };
return null;
})();
const hasError = Boolean(modelField.error);
const errorId = hasError ? `${modelField.id}-error` : undefined;
// biome-ignore lint/correctness/useExhaustiveDependencies: Provider reset.
useEffect(() => {
setFeedback(null);
lastAppliedProviderModelRef.current = null;
justSelectedRef.current = false;
closeIntentRef.current = null;
setSearchValue("");
searchValueRef.current = "";
searchDirtyRef.current = false;
previouslyAppliedRef.current = null;
}, [normalizedProvider]);
useEffect(() => {
if (!usesKnownModelCatalog || !open) {
return;
}
const markEscapeCloseIntent = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
closeIntentRef.current = "escape";
}
};
window.addEventListener("keydown", markEscapeCloseIntent, true);
document.addEventListener("keydown", markEscapeCloseIntent, true);
return () => {
window.removeEventListener("keydown", markEscapeCloseIntent, true);
document.removeEventListener("keydown", markEscapeCloseIntent, true);
};
}, [open, usesKnownModelCatalog]);
const markTouched = () => {
void form.setFieldTouched("model", true);
};
const setSearchSnapshot = (value: string) => {
setSearchValue(value);
searchValueRef.current = value;
};
const clearSearchSnapshot = () => {
setSearchSnapshot("");
searchDirtyRef.current = false;
};
const clearAppliedModelFeedback = () => {
setFeedback(null);
lastAppliedProviderModelRef.current = null;
};
const applyDefaultsForKnownModel = (knownModel: KnownModel) => {
if (knownModel.provider !== normalizedProvider) {
return;
}
const nextValuesForHelper = {
...form.values,
model: knownModel.modelIdentifier,
};
let effectiveInitialValues = initialFormValues;
const previouslyApplied = previouslyAppliedRef.current;
const previouslyAppliedFields: Record<string, unknown> =
previouslyApplied?.provider === normalizedProvider
? { ...previouslyApplied.fields }
: {};
if (previouslyApplied?.provider === normalizedProvider) {
effectiveInitialValues = structuredClone(initialFormValues);
// This map persists for the form session and stores the last value
// written by Known Model defaulting for each path. A path is safe
// to overwrite only when the current value still matches either
// the original initial value or this stored Known Model value.
for (const [field, value] of Object.entries(previouslyApplied.fields)) {
const segments = field.split(".");
if (deepGet(nextValuesForHelper, segments) !== value) {
continue;
}
deepSet(
effectiveInitialValues as Record<string, unknown>,
segments,
value,
);
}
}
const result = applyKnownModelDefaults({
values: nextValuesForHelper,
initialValues: effectiveInitialValues,
provider: normalizedProvider,
knownModel,
});
const appliedFields = new Set(result.appliedFields);
for (const [field, value] of Object.entries(previouslyAppliedFields)) {
if (appliedFields.has(field)) {
continue;
}
const segments = field.split(".");
if (deepGet(result.values, segments) !== value) {
continue;
}
const initialValue = deepGet(initialFormValues, segments);
deepSet(result.values as Record<string, unknown>, segments, initialValue);
previouslyAppliedFields[field] = initialValue;
}
for (const field of result.appliedFields) {
previouslyAppliedFields[field] = deepGet(result.values, field.split("."));
}
void form.setValues(result.values);
// Selecting and blurring can both observe the same canonical model. This
// ref skips the repeat apply so feedback does not flicker or duplicate.
lastAppliedProviderModelRef.current = {
provider: normalizedProvider,
modelIdentifier: knownModel.modelIdentifier,
};
previouslyAppliedRef.current = {
provider: normalizedProvider,
fields: previouslyAppliedFields,
};
setFeedback(
result.appliedFields.length > 0
? `Defaults applied from ${knownModel.displayName}. Review and adjust before saving.`
: null,
);
};
const applyDefaultsOnExactCanonicalModel = () => {
const found = findKnownModelByCanonicalId(normalizedProvider, currentModel);
if (!found) {
return;
}
const lastApplied = lastAppliedProviderModelRef.current;
if (
lastApplied?.provider === normalizedProvider &&
lastApplied.modelIdentifier === found.modelIdentifier
) {
return;
}
// Exact canonical-id blur is safe because the submitted value already
// matches the catalog id. Aliases stay free text and do not apply defaults.
applyDefaultsForKnownModel(found);
};
const handleChange = (option: ModelIdentifierOption | null) => {
if (!option) {
void form.setFieldValue("model", "");
setSearchSnapshot("");
clearAppliedModelFeedback();
return;
}
if (!option.knownModel) {
void form.setFieldValue("model", option.model);
clearAppliedModelFeedback();
return;
}
justSelectedRef.current = true;
setSearchSnapshot(option.knownModel.modelIdentifier);
applyDefaultsForKnownModel(option.knownModel);
};
const handleInputChange = (value: string) => {
setSearchSnapshot(value);
searchDirtyRef.current = true;
};
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
openRef.current = true;
setSearchSnapshot(currentModel);
searchDirtyRef.current = false;
closeIntentRef.current = null;
setOpen(true);
return;
}
openRef.current = false;
setOpen(false);
const justSelected = justSelectedRef.current;
const closeIntent = closeIntentRef.current;
justSelectedRef.current = false;
closeIntentRef.current = null;
const typed = searchValueRef.current;
// A selection already wrote the canonical model via handleSelect; mark
// touched so validation reflects the committed value.
if (justSelected) {
markTouched();
clearSearchSnapshot();
return;
}
// Escape and open-then-close-without-typing must leave validation
// untouched. Marking touched here would surface "Model ID is required."
// for an admin who clicked the field, changed their mind, and clicked
// off without ever attempting to commit a value.
if (closeIntent === "escape" || !searchDirtyRef.current) {
clearSearchSnapshot();
return;
}
// All remaining paths commit a value, so mark touched to surface validation.
markTouched();
const exactKnownModel = findKnownModelByCanonicalId(
normalizedProvider,
typed,
);
if (exactKnownModel) {
const lastApplied = lastAppliedProviderModelRef.current;
const alreadyApplied =
lastApplied?.provider === normalizedProvider &&
lastApplied.modelIdentifier === exactKnownModel.modelIdentifier;
if (!alreadyApplied) {
applyDefaultsForKnownModel(exactKnownModel);
}
clearSearchSnapshot();
return;
}
const aliasKnownModel = findKnownModelByExactAlias(
normalizedProvider,
typed,
);
if (aliasKnownModel) {
clearSearchSnapshot();
return;
}
void form.setFieldValue("model", typed);
clearAppliedModelFeedback();
clearSearchSnapshot();
};
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
const relatedTarget = event.relatedTarget;
if (
relatedTarget instanceof Node &&
event.currentTarget.contains(relatedTarget)
) {
return;
}
// Popover is rendered, so let Radix handle the close.
if (openRef.current && knownModelOptions.length > 0) {
return;
}
// Popover is auto-hidden with no matches, but isOpen is still true.
// Treat blur as close intent so close logic can commit or restore.
if (openRef.current) {
handleOpenChange(false);
return;
}
// Only mark touched once a value is in play. An empty currentModel
// here means the user left the wrapper without committing anything,
// so leave validation untouched (Formik flips touched on submit).
if (currentModel !== "") {
markTouched();
}
applyDefaultsOnExactCanonicalModel();
};
const handleKeyDownCapture = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
closeIntentRef.current = "escape";
}
};
const renderControl = () => {
if (!usesKnownModelCatalog) {
return (
<Input
id={modelField.id}
name={modelField.name}
className={cn(
"h-9 text-[13px] placeholder:text-content-disabled",
hasError && "border-content-destructive",
)}
placeholder="e.g. gpt-5, claude-sonnet-4-5"
value={modelField.value}
onChange={modelField.onChange}
onBlur={modelField.onBlur}
disabled={disabled}
aria-invalid={hasError}
aria-describedby={errorId}
/>
);
}
// Required field; off-catalog typing is the supported clear or replace path.
return (
<Autocomplete
id={modelField.id}
clearable={false}
value={selectedOption}
onChange={handleChange}
options={knownModelOptions}
getOptionValue={(option) => option.model}
getOptionLabel={(option) => option.model}
isOptionEqualToValue={(option, value) => option.model === value.model}
renderOption={(option, isSelected) => (
<div className="flex w-full min-w-0 items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm text-content-primary">
{option.displayName}
</div>
<div className="truncate text-xs text-content-secondary">
{option.model}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-content-secondary">
{option.contextLimit !== undefined && (
<span>{formatContextBadge(option.contextLimit)}</span>
)}
{isSelected && <CheckIcon className="size-4 shrink-0" />}
</div>
</div>
)}
open={open}
onOpenChange={handleOpenChange}
inputValue={activeSearchQuery}
onInputChange={handleInputChange}
onEscapeKeyDown={() => {
closeIntentRef.current = "escape";
}}
inlineSearch
onEnterEmpty={() => handleOpenChange(false)}
placeholder="e.g. gpt-5, claude-sonnet-4-5"
className={cn(
"h-9 text-[13px] placeholder:text-content-disabled",
hasError && "border-content-destructive",
)}
triggerAriaInvalid={hasError}
triggerAriaDescribedBy={errorId}
disabled={disabled}
/>
);
};
return (
<div
className="grid gap-1.5"
onBlur={usesKnownModelCatalog ? handleBlur : undefined}
onKeyDownCapture={
usesKnownModelCatalog ? handleKeyDownCapture : undefined
}
>
<Label
htmlFor={modelField.id}
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
>
Model Identifier{" "}
<span className="text-xs font-bold text-content-destructive">*</span>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-3 w-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
The model identifier sent to the provider API.
</TooltipContent>
</Tooltip>
</Label>
{renderControl()}
{hasError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{modelField.helperText}
</p>
)}
{feedback && (
<p
className="m-0 text-xs text-content-secondary"
role="status"
aria-live="polite"
>
{feedback}
</p>
)}
</div>
);
};
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { anthropicKnownModels } from "./anthropic";
import { getKnownModelsForProvider } from "./index";
import type { KnownModel } from "./types";
const anthropicKnownModelList: readonly KnownModel[] = anthropicKnownModels;
const requireAnthropicKnownModel = (modelIdentifier: string): KnownModel => {
const knownModel = anthropicKnownModelList.find(
(knownModel) => knownModel.modelIdentifier === modelIdentifier,
);
if (knownModel === undefined) {
throw new Error(`missing Anthropic Known Model: ${modelIdentifier}`);
}
return knownModel;
};
describe("anthropicKnownModels", () => {
it("returns Anthropic canonical IDs in declared order", () => {
expect(
getKnownModelsForProvider("anthropic").map(
(knownModel) => knownModel.modelIdentifier,
),
).toEqual([
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-sonnet-4-5",
]);
});
it("declares Anthropic reasoning defaults by API support", () => {
for (const modelIdentifier of ["claude-opus-4-7", "claude-opus-4-6"]) {
const knownModel = requireAnthropicKnownModel(modelIdentifier);
expect(knownModel.reasoningEffort).toBe("high");
expect(knownModel.thinkingBudgetTokens).toBeUndefined();
}
const sonnet46 = requireAnthropicKnownModel("claude-sonnet-4-6");
expect(sonnet46.reasoningEffort).toBe("medium");
expect(sonnet46.thinkingBudgetTokens).toBeUndefined();
for (const modelIdentifier of ["claude-haiku-4-5", "claude-sonnet-4-5"]) {
const knownModel = requireAnthropicKnownModel(modelIdentifier);
expect(knownModel.reasoningEffort).toBeUndefined();
expect(knownModel.thinkingBudgetTokens).toBe(8192);
}
});
it("has source metadata, provider equality, and declared order", () => {
expect(
anthropicKnownModels.map((knownModel) => knownModel.modelIdentifier),
).toEqual([
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-haiku-4-5",
"claude-sonnet-4-5",
]);
for (const knownModel of anthropicKnownModels) {
expect(knownModel.provider).toBe("anthropic");
expect(knownModel.sourceMetadata.sourceName).toBe("models.dev");
expect(knownModel.sourceMetadata.sourceRetrievedAt).toBe("2026-04-30");
expect(knownModel.sourceMetadata.lastUpdated).not.toBe("");
}
});
});
@@ -0,0 +1,111 @@
import type { KnownModel } from "./types";
// Array order controls suggestion order. Keep sourceMetadata.lastUpdated in
// sync with the corresponding models.dev last_updated value for each model.
// Coder currently persists flat pricing only. Tiered models.dev pricing,
// such as context_over_200k, is intentionally omitted.
//
// The `reasoningEffort` value is editorial, not from models.dev. It reflects
// the provider's documented default for reasoning-capable models in this
// catalog and should be reviewed when the catalog is refreshed.
//
// Reasoning configuration is split per model based on Anthropic API support:
// models that support adaptive thinking (Opus 4.7, Opus 4.6, Sonnet 4.6)
// carry `reasoningEffort`, which Coder maps to `thinking.type: "adaptive"`
// with the `effort` parameter. Models that do not (Haiku 4.5, Sonnet 4.5)
// carry `thinkingBudgetTokens` instead, which Coder maps to the legacy
// `thinking.type: "enabled"` path with `budget_tokens`. Setting `effort` on
// the legacy path produces an "adaptive thinking is not supported on this
// model" HTTP 400 from Anthropic.
export const anthropicKnownModels = [
{
provider: "anthropic",
modelIdentifier: "claude-opus-4-7",
displayName: "Claude Opus 4.7",
aliases: [],
contextLimit: 1_000_000,
maxOutputTokens: 128_000,
reasoningEffort: "high",
inputCost: 5,
outputCost: 25,
cacheReadCost: 0.5,
cacheWriteCost: 6.25,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-04-16",
},
},
{
provider: "anthropic",
modelIdentifier: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
aliases: [],
contextLimit: 1_000_000,
maxOutputTokens: 128_000,
reasoningEffort: "high",
inputCost: 5,
outputCost: 25,
cacheReadCost: 0.5,
cacheWriteCost: 6.25,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-03-13",
},
},
{
provider: "anthropic",
modelIdentifier: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
aliases: [],
contextLimit: 1_000_000,
maxOutputTokens: 64_000,
reasoningEffort: "medium",
inputCost: 3,
outputCost: 15,
cacheReadCost: 0.3,
cacheWriteCost: 3.75,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-03-13",
},
},
{
provider: "anthropic",
modelIdentifier: "claude-haiku-4-5",
displayName: "Claude Haiku 4.5",
aliases: ["claude-haiku-4-5-20251001"],
contextLimit: 200_000,
maxOutputTokens: 64_000,
thinkingBudgetTokens: 8192,
inputCost: 1,
outputCost: 5,
cacheReadCost: 0.1,
cacheWriteCost: 1.25,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2025-10-15",
},
},
{
provider: "anthropic",
modelIdentifier: "claude-sonnet-4-5",
displayName: "Claude Sonnet 4.5",
aliases: ["claude-sonnet-4-5-20250929"],
contextLimit: 200_000,
maxOutputTokens: 64_000,
thinkingBudgetTokens: 8192,
inputCost: 3,
outputCost: 15,
cacheReadCost: 0.3,
cacheWriteCost: 3.75,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2025-09-29",
},
},
] as const satisfies readonly KnownModel[];
@@ -0,0 +1,383 @@
import { describe, expect, it } from "vitest";
import { buildInitialModelFormValues } from "../modelConfigFormLogic";
import { pricingFieldNameList } from "../pricingFields";
import {
type ApplyKnownModelDefaultsParameters,
type ApplyKnownModelDefaultsResult,
applyKnownModelDefaults,
} from "./applyKnownModelDefaults";
import {
findKnownModelByCanonicalId,
type KnownModel,
type KnownModelSourceMetadata,
} from "./index";
const requireKnownModel = (
provider: string,
modelIdentifier: string,
): KnownModel => {
const knownModel = findKnownModelByCanonicalId(provider, modelIdentifier);
if (knownModel === undefined) {
throw new Error(`missing test Known Model: ${provider}/${modelIdentifier}`);
}
return knownModel;
};
const getPath = (value: unknown, path: string): unknown => {
let current = value;
for (const segment of path.split(".")) {
if (
current === null ||
current === undefined ||
typeof current !== "object"
) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
};
const setPath = <T>(value: T, path: string, nextValue: unknown): T => {
const clone = structuredClone(value);
let current = clone as Record<string, unknown>;
const segments = path.split(".");
for (const segment of segments.slice(0, -1)) {
const child = current[segment];
if (child === null || child === undefined || typeof child !== "object") {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
}
const leaf = segments.at(-1);
if (leaf === undefined) {
throw new Error("test path must not be empty");
}
current[leaf] = nextValue;
return clone;
};
const applyDefaults = (
parameters: ApplyKnownModelDefaultsParameters,
): ApplyKnownModelDefaultsResult => applyKnownModelDefaults(parameters);
const testSourceMetadata = (): KnownModelSourceMetadata => ({
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-04-30",
});
const customKnownModel = (overrides: Partial<KnownModel>): KnownModel => ({
provider: "openai",
modelIdentifier: "test-model",
displayName: "Test Model",
aliases: [],
sourceMetadata: testSourceMetadata(),
...overrides,
});
describe("applyKnownModelDefaults", () => {
it("returns unchanged values and no applied fields for mismatched provider", () => {
const values = buildInitialModelFormValues();
const initialValues = buildInitialModelFormValues();
const result = applyDefaults({
values,
initialValues,
provider: "anthropic",
knownModel: requireKnownModel("openai", "gpt-5.5"),
});
expect(result.values).toBe(values);
expect(result.appliedFields).toEqual([]);
});
it("returns unchanged values and no applied fields for empty provider", () => {
const values = buildInitialModelFormValues();
const initialValues = buildInitialModelFormValues();
const result = applyDefaults({
values,
initialValues,
provider: " ",
knownModel: requireKnownModel("openai", "gpt-5.5"),
});
expect(result.values).toBe(values);
expect(result.appliedFields).toEqual([]);
});
it("populates context limit when current value still equals initial value", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: customKnownModel({ contextLimit: 400_000 }),
});
expect(result.values.contextLimit).toBe("400000");
expect(result.appliedFields).toContain("contextLimit");
});
it("skips context limit when current value differs from initial value", () => {
const values = setPath(
buildInitialModelFormValues(),
"contextLimit",
"123",
);
const result = applyDefaults({
values,
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: customKnownModel({ contextLimit: 400_000 }),
});
expect(result.values.contextLimit).toBe("123");
expect(result.appliedFields).not.toContain("contextLimit");
});
it("populates OpenAI output tokens in provider-specific field", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: customKnownModel({ maxOutputTokens: 128_000 }),
});
expect(getPath(result.values, "config.openai.maxCompletionTokens")).toBe(
"128000",
);
expect(getPath(result.values, "config.maxOutputTokens")).toBe("");
expect(result.appliedFields).toContain("config.openai.maxCompletionTokens");
expect(result.appliedFields).not.toContain("config.maxOutputTokens");
});
it("populates Anthropic output tokens in generic field", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "anthropic",
knownModel: customKnownModel({
provider: "anthropic",
maxOutputTokens: 64_000,
}),
});
expect(getPath(result.values, "config.maxOutputTokens")).toBe("64000");
expect(
getPath(result.values, "config.anthropic.maxOutputTokens"),
).toBeUndefined();
expect(result.appliedFields).toContain("config.maxOutputTokens");
});
it("populates flat input and output costs through pricing descriptors", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: customKnownModel({ inputCost: 5, outputCost: 30 }),
});
expect(
getPath(result.values, "config.cost.inputPricePerMillionTokens"),
).toBe("5");
expect(
getPath(result.values, "config.cost.outputPricePerMillionTokens"),
).toBe("30");
expect(pricingFieldNameList.slice(0, 2)).toEqual([
"cost.input_price_per_million_tokens",
"cost.output_price_per_million_tokens",
]);
expect(result.appliedFields).toEqual(
expect.arrayContaining([
"config.cost.inputPricePerMillionTokens",
"config.cost.outputPricePerMillionTokens",
]),
);
});
it("populates cache read and cache write costs when present", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "anthropic",
knownModel: customKnownModel({
provider: "anthropic",
cacheReadCost: 0.5,
cacheWriteCost: 6.25,
}),
});
expect(
getPath(result.values, "config.cost.cacheReadPricePerMillionTokens"),
).toBe("0.5");
expect(
getPath(result.values, "config.cost.cacheWritePricePerMillionTokens"),
).toBe("6.25");
expect(result.appliedFields).toEqual(
expect.arrayContaining([
"config.cost.cacheReadPricePerMillionTokens",
"config.cost.cacheWritePricePerMillionTokens",
]),
);
});
it("leaves missing cache costs unchanged and excludes them from applied fields", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: customKnownModel({ inputCost: 30, outputCost: 180 }),
});
expect(
getPath(result.values, "config.cost.cacheReadPricePerMillionTokens"),
).toBe("");
expect(
getPath(result.values, "config.cost.cacheWritePricePerMillionTokens"),
).toBe("");
expect(result.appliedFields).not.toContain(
"config.cost.cacheReadPricePerMillionTokens",
);
expect(result.appliedFields).not.toContain(
"config.cost.cacheWritePricePerMillionTokens",
);
});
it("does not set compressionThreshold", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: requireKnownModel("openai", "gpt-5.5"),
});
expect(result.values.compressionThreshold).toBe("");
expect(result.appliedFields).not.toContain("compressionThreshold");
});
it("does not set OpenAI reasoning fields without catalog defaults", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: requireKnownModel("openai", "gpt-5.4"),
});
expect(getPath(result.values, "config.openai.reasoningEffort")).toBe("");
expect(getPath(result.values, "config.openai.reasoningSummary")).toBe("");
expect(result.appliedFields).not.toContain("config.openai.reasoningEffort");
expect(result.appliedFields).not.toContain(
"config.openai.reasoningSummary",
);
});
it("sets OpenAI reasoning effort for reasoning-capable catalog entries", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: requireKnownModel("openai", "gpt-5.5"),
});
expect(getPath(result.values, "config.openai.reasoningEffort")).toBe(
"medium",
);
expect(getPath(result.values, "config.openai.reasoningSummary")).toBe("");
expect(result.appliedFields).toContain("config.openai.reasoningEffort");
expect(result.appliedFields).not.toContain(
"config.openai.reasoningSummary",
);
});
it("sets Anthropic effort for extended-thinking catalog entries", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "anthropic",
knownModel: requireKnownModel("anthropic", "claude-opus-4-7"),
});
expect(getPath(result.values, "config.anthropic.effort")).toBe("high");
expect(result.appliedFields).toContain("config.anthropic.effort");
});
it.each([
"claude-haiku-4-5",
"claude-sonnet-4-5",
])("sets Anthropic thinking budget instead of effort for %s", (modelIdentifier) => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "anthropic",
knownModel: requireKnownModel("anthropic", modelIdentifier),
});
expect(
getPath(result.values, "config.anthropic.thinking.budgetTokens"),
).toBe("8192");
expect(result.appliedFields).toContain(
"config.anthropic.thinking.budgetTokens",
);
expect(getPath(result.values, "config.anthropic.effort")).toBe("");
expect(result.appliedFields).not.toContain("config.anthropic.effort");
});
it("does not set Anthropic sendReasoning or thinking budget fields", () => {
const result = applyDefaults({
values: buildInitialModelFormValues(),
initialValues: buildInitialModelFormValues(),
provider: "anthropic",
knownModel: requireKnownModel("anthropic", "claude-opus-4-7"),
});
expect(getPath(result.values, "config.anthropic.sendReasoning")).toBe("");
expect(
getPath(result.values, "config.anthropic.thinking.budgetTokens"),
).toBe("");
expect(result.appliedFields).not.toContain(
"config.anthropic.sendReasoning",
);
expect(result.appliedFields).not.toContain(
"config.anthropic.thinking.budgetTokens",
);
});
it("never includes model in applied fields", () => {
const values = setPath(
buildInitialModelFormValues(),
"model",
"typed-model",
);
const result = applyDefaults({
values,
initialValues: buildInitialModelFormValues(),
provider: "openai",
knownModel: requireKnownModel("openai", "gpt-5.5"),
});
const { model: resultModel } = result.values;
expect(resultModel).toBe("typed-model");
expect(result.appliedFields).not.toContain("model");
});
it("does not mutate original values or initialValues", () => {
const values = buildInitialModelFormValues();
const initialValues = buildInitialModelFormValues();
const knownModel = requireKnownModel("openai", "gpt-5.5");
const valuesBefore = structuredClone(values);
const initialValuesBefore = structuredClone(initialValues);
const knownModelBefore = structuredClone(knownModel);
const result = applyDefaults({
values,
initialValues,
provider: "openai",
knownModel,
});
expect(result.values).not.toBe(values);
expect(values).toEqual(valuesBefore);
expect(initialValues).toEqual(initialValuesBefore);
expect(knownModel).toEqual(knownModelBefore);
});
});
@@ -0,0 +1,160 @@
import { toFormFieldKey } from "#/api/chatModelOptions";
import {
deepGet,
deepSet,
type ModelFormValues,
} from "../modelConfigFormLogic";
import { pricingFieldNameList } from "../pricingFields";
import type { KnownModel } from "./types";
export type ApplyKnownModelDefaultsResult = {
values: ModelFormValues;
appliedFields: readonly string[];
};
export type ApplyKnownModelDefaultsParameters = {
values: ModelFormValues;
initialValues: ModelFormValues;
provider: string;
knownModel: KnownModel;
};
type KnownModelCostField =
| "inputCost"
| "outputCost"
| "cacheReadCost"
| "cacheWriteCost";
const pricingModelFieldByName = {
"cost.input_price_per_million_tokens": "inputCost",
"cost.output_price_per_million_tokens": "outputCost",
"cost.cache_read_price_per_million_tokens": "cacheReadCost",
"cost.cache_write_price_per_million_tokens": "cacheWriteCost",
} as const satisfies Record<
(typeof pricingFieldNameList)[number],
KnownModelCostField
>;
const reasoningEffortPathByProvider: Record<string, string> = {
openai: "config.openai.reasoningEffort",
anthropic: "config.anthropic.effort",
};
const thinkingBudgetTokensPathByProvider: Record<string, string> = {
anthropic: "config.anthropic.thinking.budgetTokens",
};
const maybeApplyDefault = ({
appliedFields,
initialValues,
nextValues,
path,
value,
values,
}: {
appliedFields: string[];
initialValues: ModelFormValues;
nextValues: ModelFormValues;
path: string;
value: string;
values: ModelFormValues;
}): void => {
const segments = path.split(".");
if (deepGet(values, segments) !== deepGet(initialValues, segments)) {
return;
}
deepSet(nextValues as Record<string, unknown>, segments, value);
appliedFields.push(path);
};
// Writes Known Model defaults only to fields still at their initial value;
// never overrides user edits. Pure helper independent of Formik touched state.
export const applyKnownModelDefaults = ({
values,
initialValues,
provider,
knownModel,
}: ApplyKnownModelDefaultsParameters): ApplyKnownModelDefaultsResult => {
if (provider.trim() === "" || knownModel.provider !== provider) {
return { values, appliedFields: [] };
}
const nextValues = structuredClone(values);
const appliedFields: string[] = [];
if (knownModel.contextLimit !== undefined) {
maybeApplyDefault({
appliedFields,
initialValues,
nextValues,
path: "contextLimit",
value: String(knownModel.contextLimit),
values,
});
}
if (knownModel.maxOutputTokens !== undefined) {
maybeApplyDefault({
appliedFields,
initialValues,
nextValues,
path:
provider === "openai"
? "config.openai.maxCompletionTokens"
: "config.maxOutputTokens",
value: String(knownModel.maxOutputTokens),
values,
});
}
if (knownModel.reasoningEffort !== undefined) {
// The catalog uses a single `reasoningEffort` field, but each provider
// exposes it under a different form path: OpenAI as `reasoningEffort`,
// Anthropic as `effort`. Providers without a mapping skip this default.
const reasoningEffortPath = reasoningEffortPathByProvider[provider];
if (reasoningEffortPath !== undefined) {
maybeApplyDefault({
appliedFields,
initialValues,
nextValues,
path: reasoningEffortPath,
value: knownModel.reasoningEffort,
values,
});
}
}
if (knownModel.thinkingBudgetTokens !== undefined) {
const path = thinkingBudgetTokensPathByProvider[provider];
if (path !== undefined) {
maybeApplyDefault({
appliedFields,
initialValues,
nextValues,
path,
value: String(knownModel.thinkingBudgetTokens),
values,
});
}
}
for (const fieldName of pricingFieldNameList) {
const knownModelField = pricingModelFieldByName[fieldName];
const cost = knownModel[knownModelField];
if (cost === undefined) {
continue;
}
const path = toFormFieldKey("config", fieldName);
maybeApplyDefault({
appliedFields,
initialValues,
nextValues,
path,
value: String(cost),
values,
});
}
return { values: nextValues, appliedFields };
};
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import {
findKnownModelByCanonicalId,
findKnownModelByExactAlias,
formatContextBadge,
getKnownModelsForProvider,
searchKnownModels,
} from "./index";
const modelIds = (provider: string): readonly string[] =>
getKnownModelsForProvider(provider).map(
(knownModel) => knownModel.modelIdentifier,
);
describe("formatContextBadge", () => {
it("formats 200K context", () => {
expect(formatContextBadge(200_000)).toBe("200K context");
});
it("formats 400K context", () => {
expect(formatContextBadge(400_000)).toBe("400K context");
});
it("formats 1M context without trailing decimals", () => {
expect(formatContextBadge(1_000_000)).toBe("1M context");
});
it("formats 1.05M context", () => {
expect(formatContextBadge(1_050_000)).toBe("1.05M context");
});
it("formats values below 1K", () => {
expect(formatContextBadge(999)).toBe("999 context");
});
it("rejects invalid values", () => {
for (const invalidValue of [
0,
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
]) {
expect(() => formatContextBadge(invalidValue)).toThrow(
"contextLimit must be a positive finite integer",
);
}
});
});
describe("getKnownModelsForProvider", () => {
it("returns unsupported provider as an empty list", () => {
expect(getKnownModelsForProvider("azure")).toEqual([]);
});
it("returns empty provider as an empty list", () => {
expect(getKnownModelsForProvider("")).toEqual([]);
});
});
describe("searchKnownModels", () => {
it("returns provider list in display order for empty search query", () => {
expect(
searchKnownModels("openai", "").map(
(knownModel) => knownModel.modelIdentifier,
),
).toEqual(modelIds("openai"));
});
it("matches canonical Model Identifier", () => {
expect(
searchKnownModels("openai", "gpt-5.4-mini").map(
(knownModel) => knownModel.modelIdentifier,
),
).toEqual(["gpt-5.4-mini"]);
});
it("matches display name", () => {
expect(
searchKnownModels("openai", "codex").map(
(knownModel) => knownModel.modelIdentifier,
),
).toEqual(["gpt-5.3-codex"]);
});
it("matches alias with hyphen, underscore, dot, and whitespace normalization", () => {
expect(
searchKnownModels("anthropic", "haiku 4_5.20251001").map(
(knownModel) => knownModel.modelIdentifier,
),
).toEqual(["claude-haiku-4-5"]);
});
});
describe("findKnownModelByExactAlias", () => {
it("returns verbatim alias lookup case-insensitively", () => {
expect(
findKnownModelByExactAlias("anthropic", "CLAUDE-HAIKU-4-5-20251001")
?.modelIdentifier,
).toBe("claude-haiku-4-5");
});
it("does not normalize punctuation differences", () => {
expect(
findKnownModelByExactAlias("anthropic", "claude.haiku.4.5.20251001"),
).toBeUndefined();
});
it("does not match alias substrings", () => {
expect(findKnownModelByExactAlias("anthropic", "haiku")).toBeUndefined();
});
it("does not match unknown strings", () => {
expect(
findKnownModelByExactAlias("anthropic", "unknown-model"),
).toBeUndefined();
});
it("does not match canonical Model Identifiers", () => {
expect(
findKnownModelByExactAlias("anthropic", "claude-haiku-4-5"),
).toBeUndefined();
});
});
describe("findKnownModelByCanonicalId", () => {
it("returns exact canonical lookup", () => {
expect(findKnownModelByCanonicalId("openai", "gpt-5.5")?.displayName).toBe(
"GPT-5.5",
);
});
it("does not match aliases", () => {
expect(
findKnownModelByCanonicalId("anthropic", "claude-haiku-4-5-20251001"),
).toBeUndefined();
});
});
@@ -0,0 +1,96 @@
import { normalizeProvider } from "../helpers";
import { anthropicKnownModels } from "./anthropic";
import { openAIKnownModels } from "./openai";
import type { KnownModel, KnownModelSourceMetadata } from "./types";
export type { KnownModel, KnownModelSourceMetadata };
const knownModelsByProvider = {
anthropic: anthropicKnownModels,
openai: openAIKnownModels,
} as const satisfies Record<string, readonly KnownModel[]>;
type KnownProvider = keyof typeof knownModelsByProvider;
const isKnownProvider = (provider: string): provider is KnownProvider =>
provider in knownModelsByProvider;
const normalizeSearchText = (value: string): string =>
value.toLowerCase().replace(/[\s._-]/g, "");
export const getKnownModelsForProvider = (
provider: string,
): readonly KnownModel[] => {
const normalizedProvider = normalizeProvider(provider);
if (!isKnownProvider(normalizedProvider)) {
return [];
}
return knownModelsByProvider[normalizedProvider];
};
export const searchKnownModels = (
provider: string,
query: string,
): readonly KnownModel[] => {
const providerModels = getKnownModelsForProvider(provider);
if (query.trim() === "") {
return providerModels;
}
const normalizedQuery = normalizeSearchText(query);
if (normalizedQuery === "") {
return providerModels;
}
return providerModels.filter((knownModel) =>
[
knownModel.modelIdentifier,
knownModel.displayName,
...knownModel.aliases,
].some((value) => normalizeSearchText(value).includes(normalizedQuery)),
);
};
export const findKnownModelByExactAlias = (
provider: string,
value: string,
): KnownModel | undefined => {
const lowercaseValue = value.toLowerCase();
return getKnownModelsForProvider(provider).find((knownModel) =>
knownModel.aliases.some((alias) => alias.toLowerCase() === lowercaseValue),
);
};
export const findKnownModelByCanonicalId = (
provider: string,
modelId: string,
): KnownModel | undefined => {
const normalizedProvider = normalizeProvider(provider);
if (normalizedProvider === "" || modelId === "") {
return undefined;
}
return getKnownModelsForProvider(normalizedProvider).find(
(knownModel) => knownModel.modelIdentifier === modelId,
);
};
const formatCompactNumber = (value: number): string => {
if (Number.isInteger(value)) {
return String(value);
}
return value.toFixed(2).replace(/\.?0+$/, "");
};
export const formatContextBadge = (contextLimit: number): string => {
if (!Number.isInteger(contextLimit) || contextLimit <= 0) {
throw new Error("contextLimit must be a positive finite integer");
}
if (contextLimit < 1_000) {
return `${contextLimit} context`;
}
if (contextLimit < 1_000_000) {
return `${formatCompactNumber(contextLimit / 1_000)}K context`;
}
return `${formatCompactNumber(contextLimit / 1_000_000)}M context`;
};
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { getKnownModelsForProvider, type KnownModel } from "./index";
import { openAIKnownModels } from "./openai";
describe("openAIKnownModels", () => {
it("returns OpenAI canonical IDs in declared order", () => {
expect(
getKnownModelsForProvider("openai").map(
(knownModel) => knownModel.modelIdentifier,
),
).toEqual([
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
]);
});
it("declares reasoning effort only for reasoning-capable models", () => {
const knownModels: readonly KnownModel[] = openAIKnownModels;
const reasoningEffortByModel = Object.fromEntries(
knownModels.map((knownModel) => [
knownModel.modelIdentifier,
knownModel.reasoningEffort,
]),
);
expect(reasoningEffortByModel).toEqual({
"gpt-5.5": "medium",
"gpt-5.5-pro": "high",
"gpt-5.4": undefined,
"gpt-5.4-mini": "medium",
"gpt-5.4-nano": undefined,
"gpt-5.3-codex": "medium",
});
});
it("has source metadata, provider equality, and declared order", () => {
expect(
openAIKnownModels.map((knownModel) => knownModel.modelIdentifier),
).toEqual([
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
]);
for (const knownModel of openAIKnownModels) {
expect(knownModel.provider).toBe("openai");
expect(knownModel.sourceMetadata.sourceName).toBe("models.dev");
expect(knownModel.sourceMetadata.sourceRetrievedAt).toBe("2026-04-30");
expect(knownModel.sourceMetadata.lastUpdated).not.toBe("");
}
});
});
@@ -0,0 +1,111 @@
import type { KnownModel } from "./types";
// Array order controls suggestion order. Keep sourceMetadata.lastUpdated in
// sync with the corresponding models.dev last_updated value for each model.
// Coder currently persists flat pricing only. Tiered models.dev pricing,
// such as context_over_200k, is intentionally omitted.
//
// The `reasoningEffort` value is editorial, not from models.dev. It reflects
// the provider's documented default for reasoning-capable models in this
// catalog and should be reviewed when the catalog is refreshed.
export const openAIKnownModels = [
{
provider: "openai",
modelIdentifier: "gpt-5.5",
displayName: "GPT-5.5",
aliases: [],
contextLimit: 1_050_000,
maxOutputTokens: 128_000,
reasoningEffort: "medium",
inputCost: 5,
outputCost: 30,
cacheReadCost: 0.5,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-04-23",
},
},
{
provider: "openai",
modelIdentifier: "gpt-5.5-pro",
displayName: "GPT-5.5 Pro",
aliases: [],
contextLimit: 1_050_000,
maxOutputTokens: 128_000,
reasoningEffort: "high",
inputCost: 30,
outputCost: 180,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-04-23",
},
},
{
provider: "openai",
modelIdentifier: "gpt-5.4",
displayName: "GPT-5.4",
aliases: [],
contextLimit: 1_050_000,
maxOutputTokens: 128_000,
inputCost: 2.5,
outputCost: 15,
cacheReadCost: 0.25,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-03-05",
},
},
{
provider: "openai",
modelIdentifier: "gpt-5.4-mini",
displayName: "GPT-5.4 mini",
aliases: [],
contextLimit: 400_000,
maxOutputTokens: 128_000,
reasoningEffort: "medium",
inputCost: 0.75,
outputCost: 4.5,
cacheReadCost: 0.075,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-03-17",
},
},
{
provider: "openai",
modelIdentifier: "gpt-5.4-nano",
displayName: "GPT-5.4 nano",
aliases: [],
contextLimit: 400_000,
maxOutputTokens: 128_000,
inputCost: 0.2,
outputCost: 1.25,
cacheReadCost: 0.02,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-03-17",
},
},
{
provider: "openai",
modelIdentifier: "gpt-5.3-codex",
displayName: "GPT-5.3 Codex",
aliases: [],
contextLimit: 400_000,
maxOutputTokens: 128_000,
reasoningEffort: "medium",
inputCost: 1.75,
outputCost: 14,
cacheReadCost: 0.175,
sourceMetadata: {
sourceName: "models.dev",
sourceRetrievedAt: "2026-04-30",
lastUpdated: "2026-02-05",
},
},
] as const satisfies readonly KnownModel[];
@@ -0,0 +1,28 @@
export type KnownModelSourceMetadata = {
sourceName: "models.dev";
sourceRetrievedAt: string;
lastUpdated: string;
};
export type KnownModel = {
provider: string;
modelIdentifier: string;
displayName: string;
aliases: readonly string[];
contextLimit?: number;
maxOutputTokens?: number;
reasoningEffort?: "low" | "medium" | "high";
/**
* Anthropic-only: numeric budget for the legacy
* `thinking.budget_tokens` API.
*
* Use this for Anthropic models that do not support adaptive thinking.
*/
thinkingBudgetTokens?: number;
/** USD per million tokens. Flat base rate from models.dev. */
inputCost?: number;
outputCost?: number;
cacheReadCost?: number;
cacheWriteCost?: number;
sourceMetadata: KnownModelSourceMetadata;
};
@@ -56,7 +56,7 @@ export const parseThresholdInteger = (value: string): number | null => {
* Set a value inside a nested object, creating intermediate
* objects along the way. The path is an array of string keys.
*/
function deepSet(
export function deepSet(
obj: Record<string, unknown>,
path: string[],
value: unknown,
@@ -80,7 +80,7 @@ function deepSet(
* Get a value from a nested object using an array of string keys.
* Returns `undefined` if any intermediate key is missing.
*/
function deepGet(obj: unknown, path: string[]): unknown {
export function deepGet(obj: unknown, path: string[]): unknown {
let current = obj;
for (const key of path) {
if (