feat: polish model config form UI (#24047)

Polishes the AI model configuration form (add/edit model) with tighter
layout and better input affordances.

**Frontend changes:**
- Replace "Unset" with "Default" in select dropdowns to communicate
system fallback
- Show pricing fields inline instead of behind a collapsible toggle
- Use flat section dividers (`border-t`) instead of bordered fieldsets
- Move field descriptions into info-icon tooltips to fix input
misalignment
- Add InputGroup adornments: `$` prefix + `/1M` suffix on pricing,
`tokens` suffix on token fields, `%` suffix on compression threshold,
range placeholders on temperature/penalty fields
- Shorter pricing labels (Input, Output, Cache Read, Cache Write)
- Compact JSON textareas (1-row height, resizable)
- Smart grid layouts by field type (3-col provider, 4-col pricing, 3-col
advanced)
- Boolean fields render as a segmented control (Default · On · Off)
instead of a dropdown

**Backend changes:**
- Add `enum` tags to OpenAI `service_tier`
(`auto,default,flex,scale,priority`) and `reasoning_summary`
(`auto,concise,detailed`) so they render as select dropdowns instead of
free-text inputs

> 🤖 Generated by Coder Agents
This commit is contained in:
Kyle Carberry
2026-04-06 10:49:42 -04:00
committed by GitHub
parent baba9e6ede
commit 500fc5e2a4
7 changed files with 625 additions and 261 deletions
+6 -6
View File
@@ -626,7 +626,7 @@ type ChatModelOpenAIProviderOptions struct {
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" description:"Whether the model may make multiple tool calls in parallel"`
User *string `json:"user,omitempty" description:"Unique identifier for the end user for abuse monitoring" hidden:"true"`
ReasoningEffort *string `json:"reasoning_effort,omitempty" description:"Controls the level of reasoning effort" enum:"none,minimal,low,medium,high,xhigh"`
ReasoningSummary *string `json:"reasoning_summary,omitempty" description:"Controls whether reasoning tokens are summarized in the response"`
ReasoningSummary *string `json:"reasoning_summary,omitempty" description:"Controls whether reasoning tokens are summarized in the response" enum:"auto,concise,detailed"`
MaxCompletionTokens *int64 `json:"max_completion_tokens,omitempty" description:"Upper bound on tokens the model may generate"`
TextVerbosity *string `json:"text_verbosity,omitempty" description:"Controls the verbosity of the text response" enum:"low,medium,high"`
Prediction map[string]any `json:"prediction,omitempty" description:"Predicted output content to speed up responses" hidden:"true"`
@@ -634,12 +634,12 @@ type ChatModelOpenAIProviderOptions struct {
Metadata map[string]any `json:"metadata,omitempty" description:"Arbitrary metadata to attach to the request" hidden:"true"`
PromptCacheKey *string `json:"prompt_cache_key,omitempty" description:"Key for enabling cross-request prompt caching"`
SafetyIdentifier *string `json:"safety_identifier,omitempty" description:"Developer-specific safety identifier for the request" hidden:"true"`
ServiceTier *string `json:"service_tier,omitempty" description:"Latency tier to use for processing the request"`
ServiceTier *string `json:"service_tier,omitempty" description:"Latency tier to use for processing the request" enum:"auto,default,flex,scale,priority"`
StructuredOutputs *bool `json:"structured_outputs,omitempty" description:"Whether to enable structured JSON output mode" hidden:"true"`
StrictJSONSchema *bool `json:"strict_json_schema,omitempty" description:"Whether to enforce strict adherence to the JSON schema" hidden:"true"`
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable OpenAI web search tool for grounding responses with real-time information"`
SearchContextSize *string `json:"search_context_size,omitempty" description:"Amount of search context to use" enum:"low,medium,high"`
AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains"`
AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains"`
}
// ChatModelAnthropicThinkingOptions configures Anthropic thinking budget.
@@ -651,11 +651,11 @@ type ChatModelAnthropicThinkingOptions struct {
type ChatModelAnthropicProviderOptions struct {
SendReasoning *bool `json:"send_reasoning,omitempty" description:"Whether to include reasoning content in the response"`
Thinking *ChatModelAnthropicThinkingOptions `json:"thinking,omitempty" description:"Configuration for extended thinking"`
Effort *string `json:"effort,omitempty" description:"Controls the level of reasoning effort" enum:"low,medium,high,max"`
Effort *string `json:"effort,omitempty" label:"Reasoning Effort" description:"Controls the level of reasoning effort" enum:"low,medium,high,max"`
DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty" description:"Whether to disable parallel tool execution"`
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"`
AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains (cannot be used with blocked_domains)"`
BlockedDomains []string `json:"blocked_domains,omitempty" description:"Block web search on these domains (cannot be used with allowed_domains)"`
AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains (cannot be used with blocked_domains)"`
BlockedDomains []string `json:"blocked_domains,omitempty" label:"Web Search: Blocked Domains" description:"Block web search on these domains (cannot be used with allowed_domains)"`
}
// ChatModelGoogleThinkingConfig configures Google thinking behavior.
+3
View File
@@ -18,6 +18,7 @@ type SchemaField struct {
GoName string `json:"go_name"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
Label string `json:"label,omitempty"`
Required bool `json:"required"`
Enum []string `json:"enum,omitempty"`
InputType string `json:"input_type"`
@@ -135,6 +136,7 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro
typeName := goTypeToSchemaType(f.Type)
description := f.Tag.Get("description")
label := f.Tag.Get("label")
enumTag := f.Tag.Get("enum")
var enumValues []string
@@ -150,6 +152,7 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro
GoName: goFieldPath(prefix, f.Name, t, fullJSONName),
Type: typeName,
Description: description,
Label: label,
Required: required,
Enum: enumValues,
InputType: inputType,
+2
View File
@@ -13,6 +13,8 @@ export interface FieldSchema {
type: "string" | "integer" | "number" | "boolean" | "array" | "object";
/** Human-readable description of the field. May be absent for some fields. */
description?: string;
/** Optional display label override. When absent, derive from json_name. */
label?: string;
/** Whether this field is required when configuring the provider. */
required: boolean;
/** Hint for how the frontend should render the input control. */
+8 -2
View File
@@ -107,6 +107,7 @@
"go_name": "Effort",
"type": "string",
"description": "Controls the level of reasoning effort",
"label": "Reasoning Effort",
"required": false,
"enum": ["low", "medium", "high", "max"],
"input_type": "select"
@@ -132,6 +133,7 @@
"go_name": "AllowedDomains",
"type": "array",
"description": "Restrict web search to these domains (cannot be used with blocked_domains)",
"label": "Web Search: Allowed Domains",
"required": false,
"input_type": "json"
},
@@ -140,6 +142,7 @@
"go_name": "BlockedDomains",
"type": "array",
"description": "Block web search on these domains (cannot be used with allowed_domains)",
"label": "Web Search: Blocked Domains",
"required": false,
"input_type": "json"
}
@@ -286,7 +289,8 @@
"type": "string",
"description": "Controls whether reasoning tokens are summarized in the response",
"required": false,
"input_type": "input"
"enum": ["auto", "concise", "detailed"],
"input_type": "select"
},
{
"json_name": "max_completion_tokens",
@@ -354,7 +358,8 @@
"type": "string",
"description": "Latency tier to use for processing the request",
"required": false,
"input_type": "input"
"enum": ["auto", "default", "flex", "scale", "priority"],
"input_type": "select"
},
{
"json_name": "structured_outputs",
@@ -396,6 +401,7 @@
"go_name": "AllowedDomains",
"type": "array",
"description": "Restrict web search to these domains",
"label": "Web Search: Allowed Domains",
"required": false,
"input_type": "json"
}
@@ -827,6 +827,14 @@ const openAddModelForm = async (
});
};
/** Expand a collapsible section by clicking its header button. */
const expandSection = async (body: ReturnType<typeof within>, name: string) => {
const btn = await body.findByRole("button", {
name: new RegExp(name, "i"),
});
await userEvent.click(btn);
};
export const NoModelConfigByDefault: Story = {
args: {
section: "models" as ChatModelAdminSection,
@@ -900,18 +908,18 @@ export const SubmitModelConfigExplicitly: Story = {
"gpt-5-pro-custom",
);
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
// Max output tokens and provider options are under "Advanced".
await userEvent.click(body.getByText("Advanced"));
// Max output tokens is under "Advanced".
await expandSection(body, "Advanced");
await userEvent.type(
await body.findByLabelText(/Max output tokens/i),
"32000",
);
await userEvent.click(
body.getByRole("combobox", {
name: "Reasoning Effort",
}),
);
await userEvent.click(await body.findByRole("option", { name: "high" }));
// Reasoning Effort is a provider option under "Provider Configuration".
await expandSection(body, "Provider Configuration");
const effortGroup = await body.findByRole("radiogroup", {
name: "Reasoning Effort",
});
await userEvent.click(within(effortGroup).getByText("High"));
await userEvent.click(body.getByRole("button", { name: "Add model" }));
await waitFor(() => {
@@ -1002,6 +1010,7 @@ export const ModelFormOpenAI: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI");
await expandSection(body, "Provider Configuration");
await expect(
await body.findByLabelText(/Reasoning Effort/i),
).toBeInTheDocument();
@@ -1016,6 +1025,7 @@ export const ModelFormAnthropic: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Anthropic");
await expandSection(body, "Provider Configuration");
await expect(
await body.findByLabelText(/Send Reasoning/i),
).toBeInTheDocument();
@@ -1030,6 +1040,7 @@ export const ModelFormGoogle: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Google");
await expandSection(body, "Provider Configuration");
await expect(
await body.findByLabelText(/Thinking Config Thinking Budget/i),
).toBeInTheDocument();
@@ -1044,6 +1055,7 @@ export const ModelFormOpenAICompat: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenAI-compatible");
await expandSection(body, "Provider Configuration");
await expect(
await body.findByLabelText(/Reasoning Effort/i),
).toBeInTheDocument();
@@ -1055,6 +1067,7 @@ export const ModelFormOpenRouter: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "OpenRouter");
await expandSection(body, "Provider Configuration");
await expect(
await body.findByLabelText(/Reasoning Enabled/i),
).toBeInTheDocument();
@@ -1069,6 +1082,7 @@ export const ModelFormVercel: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Vercel AI Gateway");
await expandSection(body, "Provider Configuration");
await expect(
await body.findByLabelText(/Reasoning Enabled/i),
).toBeInTheDocument();
@@ -1083,6 +1097,7 @@ export const ModelFormAzure: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "Azure OpenAI");
await expandSection(body, "Provider Configuration");
// Azure aliases to OpenAI fields.
await expect(
await body.findByLabelText(/Reasoning Effort/i),
@@ -1098,6 +1113,7 @@ export const ModelFormBedrock: Story = {
play: async ({ canvasElement }) => {
const body = within(canvasElement.ownerDocument.body);
await openAddModelForm(body, "AWS Bedrock");
await expandSection(body, "Provider Configuration");
// Bedrock aliases to Anthropic fields.
await expect(
await body.findByLabelText(/Send Reasoning/i),
@@ -1,4 +1,5 @@
import { type FormikContextType, getIn } from "formik";
import { InfoIcon } from "lucide-react";
import type { FC } from "react";
import {
type FieldSchema,
@@ -9,6 +10,11 @@ import {
toFormFieldKey,
} from "#/api/chatModelOptions";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import {
Select,
@@ -18,6 +24,11 @@ import {
SelectValue,
} from "#/components/Select/Select";
import { Textarea } from "#/components/Textarea/Textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import { normalizeProvider } from "./helpers";
import type {
@@ -34,16 +45,61 @@ const unsetSelectValue = "__unset__";
// ── Helpers ────────────────────────────────────────────────────
/** Short display labels for pricing fields to avoid overly verbose names. */
const shortLabelOverrides: Record<string, string> = {
"cost.input_price_per_million_tokens": "Input",
"cost.output_price_per_million_tokens": "Output",
"cost.cache_read_price_per_million_tokens": "Cache Read",
"cost.cache_write_price_per_million_tokens": "Cache Write",
};
/**
* Suffix units displayed inside the input control. When present,
* the field renders as an InputGroup with the suffix appended.
*/
const fieldSuffix: Record<string, string> = {
max_output_tokens: "tokens",
top_k: "tokens",
"thinking.budget_tokens": "tokens",
"thinking_config.thinking_budget": "tokens",
max_completion_tokens: "tokens",
"reasoning.max_tokens": "tokens",
max_tool_calls: "calls",
};
/**
* Placeholder overrides with range hints for numeric fields
* where the valid range is more useful than an empty box.
*/
const placeholderOverrides: Record<string, string> = {
temperature: "0.0–2.0",
top_p: "0.0–1.0",
presence_penalty: "-2.0–2.0",
frequency_penalty: "-2.0–2.0",
};
/**
* Convert a dot-and-underscore-separated json_name into a
* human-readable label.
* human-readable label. Uses short overrides for pricing fields
* when available.
*
* @example
* snakeToPrettyLabel("thinking.budget_tokens") // "Thinking Budget Tokens"
* snakeToPrettyLabel("reasoning_effort") // "Reasoning Effort"
*/
function snakeToPrettyLabel(jsonName: string): string {
return jsonName
/** Capitalize the first letter of a string. */
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function snakeToPrettyLabel(field: FieldSchema): string {
if (field.label) {
return field.label;
}
if (shortLabelOverrides[field.json_name]) {
return shortLabelOverrides[field.json_name];
}
return field.json_name
.split(/[._]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
@@ -79,6 +135,30 @@ type FieldRenderContext = {
disabled: boolean;
};
/** Label with an optional info tooltip for field descriptions. */
const FieldLabel: FC<{
htmlFor: string;
label: string;
description?: string;
}> = ({ htmlFor, label, description }) => (
<Label
htmlFor={htmlFor}
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
>
{label}
{description && (
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-3 w-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
{description}
</TooltipContent>
</Tooltip>
)}
</Label>
);
const InputField: FC<
FieldRenderContext & {
fieldKey: string;
@@ -86,6 +166,7 @@ const InputField: FC<
label: string;
description?: string;
placeholder: string;
suffix?: string;
}
> = ({
form,
@@ -96,33 +177,48 @@ const InputField: FC<
label,
description,
placeholder,
suffix,
}) => {
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[errorKey ?? fieldKey];
const fieldProps = form.getFieldProps(fieldKey);
return (
<div className="flex min-w-0 flex-col gap-1.5">
<Label
htmlFor={fieldKey}
className="text-[13px] font-medium text-content-primary"
>
{label}
</Label>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
<Input
const inputEl = suffix ? (
<InputGroup
className={cn("h-9", fieldError && "border-border-destructive")}
>
<InputGroupInput
id={fieldKey}
className={cn(
"h-9 min-w-0 text-[13px] placeholder:text-content-disabled",
fieldError && "border-content-destructive",
)}
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
placeholder={placeholder}
{...fieldProps}
disabled={disabled}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">{suffix}</span>
</InputGroupAddon>
</InputGroup>
) : (
<Input
id={fieldKey}
className={cn(
"h-9 min-w-0 text-[13px] placeholder:text-content-disabled",
fieldError && "border-content-destructive",
)}
placeholder={placeholder}
{...fieldProps}
disabled={disabled}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
/>
);
return (
<div className="flex min-w-0 flex-col gap-1.5">
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
{inputEl}
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
@@ -155,15 +251,7 @@ const SelectField: FC<
const currentValue = (getIn(form.values, fieldKey) as string) || "";
return (
<div className="flex min-w-0 flex-col gap-1.5">
<Label
htmlFor={fieldKey}
className="text-[13px] font-medium text-content-primary"
>
{label}
</Label>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
<Select
value={currentValue || unsetSelectValue}
onValueChange={(value) =>
@@ -183,10 +271,10 @@ const SelectField: FC<
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
>
<SelectValue placeholder="Unset" />
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={unsetSelectValue}>Unset</SelectItem>
<SelectItem value={unsetSelectValue}>Default</SelectItem>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
@@ -203,6 +291,73 @@ const SelectField: FC<
);
};
const SegmentedField: FC<
FieldRenderContext & {
fieldKey: string;
errorKey?: string;
label: string;
description?: string;
options: readonly { label: string; value: string }[];
}
> = ({
form,
fieldErrors,
disabled,
fieldKey,
errorKey,
label,
description,
options,
}) => {
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[errorKey ?? fieldKey];
const currentValue = (getIn(form.values, fieldKey) as string) || "";
return (
<div className="flex min-w-0 flex-col gap-1.5">
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
<div
role="radiogroup"
aria-label={label}
className={cn(
"flex h-9 items-stretch rounded-md border border-solid border-border p-0.5",
fieldError && "border-content-destructive",
)}
>
{options.map((opt) => {
const isActive = currentValue === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={isActive}
disabled={disabled}
className={cn(
"h-8 flex-1 cursor-pointer rounded-[5px] border-0 px-3 text-[13px] font-medium transition-colors",
isActive
? "bg-surface-secondary text-content-primary"
: "bg-transparent text-content-secondary hover:text-content-primary",
disabled && "pointer-events-none opacity-60",
)}
onClick={() =>
void form.setFieldValue(fieldKey, isActive ? "" : opt.value)
}
>
{opt.label}
</button>
);
})}
</div>
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
)}
</div>
);
};
const JSONField: FC<
FieldRenderContext & {
fieldKey: string;
@@ -226,19 +381,12 @@ const JSONField: FC<
const fieldProps = form.getFieldProps(fieldKey);
return (
<div className="flex min-w-0 flex-col gap-1.5">
<Label
htmlFor={fieldKey}
className="text-[13px] font-medium text-content-primary"
>
{label}
</Label>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
<Textarea
id={fieldKey}
rows={1}
className={cn(
"min-h-[96px] font-mono text-xs placeholder:text-content-disabled",
"min-h-0 resize-y font-mono text-xs leading-tight placeholder:text-content-disabled",
fieldError && "border-content-destructive",
)}
placeholder={placeholder}
@@ -276,7 +424,7 @@ const SchemaField: FC<SchemaFieldProps> = ({
fieldErrors,
disabled,
}) => {
const label = snakeToPrettyLabel(field.json_name);
const label = snakeToPrettyLabel(field);
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
switch (field.input_type) {
@@ -288,12 +436,42 @@ const SchemaField: FC<SchemaFieldProps> = ({
errorKey={errorKey}
label={label}
description={field.description}
placeholder={placeholderForField(field)}
placeholder={
placeholderOverrides[field.json_name] ?? placeholderForField(field)
}
suffix={fieldSuffix[field.json_name]}
/>
);
case "select": {
const options: readonly string[] =
field.enum ?? (field.type === "boolean" ? ["true", "false"] : []);
if (field.type === "boolean") {
return (
<SegmentedField
{...ctx}
fieldKey={fieldKey}
errorKey={errorKey}
label={label}
description={field.description}
options={[
{ label: "On", value: "true" },
{ label: "Off", value: "false" },
]}
/>
);
}
const options: readonly string[] = field.enum ?? [];
const maxSegmented = 6;
if (options.length > 0 && options.length <= maxSegmented) {
return (
<SegmentedField
{...ctx}
fieldKey={fieldKey}
errorKey={errorKey}
label={label}
description={field.description}
options={options.map((v) => ({ label: capitalize(v), value: v }))}
/>
);
}
return (
<SelectField
{...ctx}
@@ -323,6 +501,30 @@ const SchemaField: FC<SchemaFieldProps> = ({
// ── Main component ─────────────────────────────────────────────
/**
* How many grid columns a field should span in the 3-col layout.
* 1 = default (inputs, booleans, small enums ≤3)
* 3 = full-width (4+ option enums, json textareas)
*/
function colSpan(field: FieldSchema): 1 | 3 {
if (field.input_type === "json") {
return 3;
}
if (
field.input_type === "select" &&
field.type !== "boolean" &&
(field.enum?.length ?? 0) > 3
) {
return 3;
}
return 1;
}
const colSpanClass: Record<1 | 3, string | undefined> = {
1: undefined,
3: "sm:col-span-full",
};
interface ModelConfigFieldsProps {
provider: string;
form: FormikContextType<ModelFormValues>;
@@ -353,19 +555,24 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
// Sort wider fields to the end so compact fields fill the
// grid first, keeping the layout dense.
const sorted = [...fields].sort((a, b) => colSpan(a) - colSpan(b));
return (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
{fields.map((field) => {
<div className="grid min-w-0 gap-3 sm:grid-cols-3">
{sorted.map((field) => {
const fieldKey = `config.${toFormFieldKey(resolved, field.json_name)}`;
const errorKey = toFormFieldKey(resolved, field.json_name);
return (
<SchemaField
key={fieldKey}
field={field}
fieldKey={fieldKey}
errorKey={errorKey}
{...ctx}
/>
<div key={fieldKey} className={colSpanClass[colSpan(field)]}>
<SchemaField
field={field}
fieldKey={fieldKey}
errorKey={errorKey}
{...ctx}
/>
</div>
);
})}
</div>
@@ -379,8 +586,9 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
const GeneralFieldsGroup: FC<
ModelConfigFieldsProps & {
fields: FieldSchema[];
suppressDescriptions?: boolean;
}
> = ({ form, fieldErrors, disabled, fields }) => {
> = ({ form, fieldErrors, disabled, fields, suppressDescriptions }) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
return (
@@ -393,7 +601,7 @@ const GeneralFieldsGroup: FC<
.map(snakeToCamel)
.join(".");
const fieldKey = `config.${camelName}`;
const label = snakeToPrettyLabel(field.json_name);
const label = snakeToPrettyLabel(field);
return (
<InputField
@@ -402,8 +610,12 @@ const GeneralFieldsGroup: FC<
fieldKey={fieldKey}
errorKey={camelName}
label={label}
description={field.description}
placeholder={placeholderForField(field)}
description={suppressDescriptions ? undefined : field.description}
placeholder={
placeholderOverrides[field.json_name] ??
placeholderForField(field)
}
suffix={fieldSuffix[field.json_name]}
/>
);
})}
@@ -412,25 +624,62 @@ const GeneralFieldsGroup: FC<
};
/**
* General pricing fields shown in the main form body so admins can
* define optional pricing metadata without opening the advanced section.
* Pricing fields rendered with $ prefix and /1M suffix using
* InputGroup for a compact, readable layout.
*/
export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
provider,
form,
fieldErrors,
disabled,
}) => {
const fields = getVisibleGeneralFields().filter(({ json_name }) =>
pricingFieldNames.has(json_name),
);
return (
<GeneralFieldsGroup
provider={provider}
form={form}
fieldErrors={fieldErrors}
disabled={disabled}
fields={getVisibleGeneralFields().filter(({ json_name }) =>
pricingFieldNames.has(json_name),
)}
/>
<>
{fields.map((field) => {
const camelName = field.json_name
.split(".")
.map(snakeToCamel)
.join(".");
const fieldKey = `config.${camelName}`;
const label = snakeToPrettyLabel(field);
const errorId = `${fieldKey}-error`;
const fieldError = fieldErrors[camelName];
const fieldProps = form.getFieldProps(fieldKey);
return (
<div key={fieldKey} className="flex min-w-0 flex-col gap-1.5">
<FieldLabel htmlFor={fieldKey} label={label} />
<InputGroup
className={cn("h-9", fieldError && "border-border-destructive")}
>
<InputGroupAddon align="inline-start">$</InputGroupAddon>
<InputGroupInput
id={fieldKey}
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
placeholder="0"
{...fieldProps}
disabled={disabled}
aria-invalid={Boolean(fieldError)}
aria-describedby={fieldError ? errorId : undefined}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">
USD/1M tokens
</span>
</InputGroupAddon>
</InputGroup>
{fieldError && (
<p id={errorId} className="m-0 text-xs text-content-destructive">
{fieldError}
</p>
)}
</div>
);
})}
</>
);
};
@@ -3,6 +3,8 @@ import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
InfoIcon,
PencilIcon,
} from "lucide-react";
import { type FC, useState } from "react";
import * as Yup from "yup";
@@ -17,6 +19,11 @@ import {
DialogTitle,
} from "#/components/Dialog/Dialog";
import { Input } from "#/components/Input/Input";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "#/components/InputGroup/InputGroup";
import { Label } from "#/components/Label/Label";
import {
Select,
@@ -109,8 +116,9 @@ export const ModelForm: FC<ModelFormProps> = ({
}) => {
const isEditing = Boolean(editingModel);
const isDefaultModel = isEditing && editingModel?.is_default === true;
const [showPricing, setShowPricing] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [showPricing, setShowPricing] = useState(false);
const [showProviderConfig, setShowProviderConfig] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const canManageModels = Boolean(
@@ -324,17 +332,30 @@ export const ModelForm: FC<ModelFormProps> = ({
className="h-8 w-8"
/>
)}
<div className="min-w-0 flex-1">
<input
type="text"
{...form.getFieldProps("displayName")}
disabled={isSaving}
className="m-0 w-full border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
placeholder={
isEditing ? (editingModel?.model ?? "Model name") : "Model name"
}
/>
</div>
<div className="inline-flex items-center gap-1">
<div className="relative inline-grid">
<span
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
aria-hidden="true"
>
{form.values.displayName ||
(isEditing
? (editingModel?.model ?? "Model name")
: "Model name")}
</span>
<input
type="text"
{...form.getFieldProps("displayName")}
disabled={isSaving}
spellCheck={false}
className="col-start-1 row-start-1 m-0 min-w-0 border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
placeholder={
isEditing ? (editingModel?.model ?? "Model name") : "Model name"
}
/>
</div>
<PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
</div>{" "}
{editingModel && (
<Tooltip>
<TooltipTrigger asChild>
@@ -361,170 +382,235 @@ export const ModelForm: FC<ModelFormProps> = ({
</div>
<hr className="my-4 border-0 border-t border-solid border-border" />
{/* Form body */}
<form className="flex flex-1 flex-col" onSubmit={form.handleSubmit}>
<div className="space-y-5">
{/* Model ID + Context Limit */}
<div className="grid items-start gap-5 sm:grid-cols-2">
<div className="grid gap-1.5">
<Label
htmlFor={modelField.id}
className="text-sm font-medium text-content-primary"
>
Model Identifier{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</Label>
<p className="m-0 text-xs text-content-secondary">
The model identifier sent to the provider API.
</p>
<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"
<form
className="flex flex-1 flex-col"
onSubmit={form.handleSubmit}
spellCheck={false}
autoComplete="off"
>
<div className="space-y-6">
{/* Model ID + Context Limit + Pricing */}
<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"
>
{modelField.helperText}
</p>
)}
</div>
<div className="grid gap-1.5">
<Label
htmlFor={contextLimitField.id}
className="text-sm font-medium text-content-primary"
>
Context Limit{" "}
<span className="text-xs font-bold text-content-destructive">
*
</span>
</Label>
<p className="m-0 text-xs text-content-secondary">
Max tokens in the context window.
</p>
<Input
id={contextLimitField.id}
name={contextLimitField.name}
className={cn(
"h-9 text-[13px] placeholder:text-content-disabled",
contextLimitField.error && "border-content-destructive",
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>
)}
placeholder="200000"
value={contextLimitField.value}
onChange={contextLimitField.onChange}
onBlur={contextLimitField.onBlur}
disabled={isSaving}
aria-invalid={contextLimitField.error}
/>
{contextLimitField.error && (
<p className="m-0 text-xs text-content-destructive">
{contextLimitField.helperText}
</p>
)}
</div>
<div className="grid gap-1.5">
<Label
htmlFor={contextLimitField.id}
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
>
Context Limit{" "}
<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]">
Max tokens in the context window.
</TooltipContent>
</Tooltip>
</Label>
<InputGroup
className={cn(
"h-9",
contextLimitField.error && "border-border-destructive",
)}
>
<InputGroupInput
id={contextLimitField.id}
name={contextLimitField.name}
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
placeholder="200000"
value={contextLimitField.value}
onChange={contextLimitField.onChange}
onBlur={contextLimitField.onBlur}
disabled={isSaving}
aria-invalid={contextLimitField.error}
/>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">
tokens
</span>
</InputGroupAddon>
</InputGroup>{" "}
{contextLimitField.error && (
<p className="m-0 text-xs text-content-destructive">
{contextLimitField.helperText}
</p>
)}
</div>
</div>
</div>
{/* Provider-specific model config fields */}
<ModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
<div className="space-y-5">
{/* Pricing - toggle */}
<div>
<button
type="button"
onClick={() => setShowPricing((v) => !v)}
className="inline-flex cursor-pointer items-center gap-1 bg-transparent border-0 p-0 text-sm font-medium text-content-secondary transition-colors hover:text-content-primary"
>
{showPricing ? (
<ChevronDownIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
Pricing
</button>
{showPricing && (
<div className="mt-4 space-y-3">
<div>
<p className="m-0 text-xs text-content-secondary">
Optional USD pricing metadata per 1M tokens. Leave any
field blank to keep pricing unset and use provider or
profile defaults when available.
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<PricingModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</div>
</div>
{/* Usage Tracking */}
<div className="border-0 border-t border-solid border-border pt-4">
<button
type="button"
onClick={() => setShowPricing((v) => !v)}
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
>
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
Cost Tracking{" "}
</h3>
<p className="m-0 text-xs text-content-secondary">
Set per-token pricing so Coder can track costs and enforce
spending limits.
</p>
</div>
{showPricing ? (
<ChevronDownIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
) : (
<ChevronRightIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
)}
</div>
</button>
{showPricing && (
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-4">
<PricingModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</div>
)}
</div>
{/* Advanced - toggle */}
<div>
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="inline-flex cursor-pointer items-center gap-1 bg-transparent border-0 p-0 text-sm font-medium text-content-secondary transition-colors hover:text-content-primary"
>
{showAdvanced ? (
<ChevronDownIcon className="h-4 w-4" />
) : (
<ChevronRightIcon className="h-4 w-4" />
)}
Advanced
</button>
{showAdvanced && (
<div className="mt-4 space-y-5">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<GeneralModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label
htmlFor={compressionThresholdField.id}
className="text-sm font-medium text-content-primary"
>
Compression Threshold
</Label>
<p className="m-0 text-xs text-content-secondary">
Percentage at which context is compressed.
</p>
<Input
{/* Provider Configuration */}
<div className="border-0 border-t border-solid border-border pt-4">
<button
type="button"
onClick={() => setShowProviderConfig((v) => !v)}
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
>
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
Provider Configuration
</h3>
<p className="m-0 text-xs text-content-secondary">
Tune provider-specific behavior like reasoning, tool calling,
and web search.
</p>
</div>
{showProviderConfig ? (
<ChevronDownIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
) : (
<ChevronRightIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
)}
</button>
{showProviderConfig && (
<div className="pt-3">
<ModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
</div>
)}
</div>
{/* Advanced */}
<div className="border-0 border-t border-solid border-border pt-4">
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
>
<div>
<h3 className="m-0 text-sm font-medium text-content-primary">
Advanced
</h3>
<p className="m-0 text-xs text-content-secondary">
Low-level parameters like temperature and penalties. Rarely
need changing.
</p>
</div>
{showAdvanced ? (
<ChevronDownIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
) : (
<ChevronRightIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
)}
</button>
{showAdvanced && (
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-3">
<GeneralModelConfigFields
provider={selectedProviderState.provider}
form={form}
fieldErrors={modelConfigFormBuildResult.fieldErrors}
disabled={isSaving}
/>
<div className="flex min-w-0 flex-col gap-1.5">
<Label
htmlFor={compressionThresholdField.id}
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
>
Compression Threshold
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="h-3 w-3 text-content-secondary" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px]">
Percentage at which context is compressed.
</TooltipContent>
</Tooltip>
</Label>
<InputGroup
className={cn(
"h-9",
compressionThresholdField.error &&
"border-border-destructive",
)}
>
<InputGroupInput
id={compressionThresholdField.id}
name={compressionThresholdField.name}
className={cn(
"h-9 text-[13px] placeholder:text-content-disabled",
compressionThresholdField.error &&
"border-content-destructive",
)}
className="h-9 text-[13px] placeholder:text-content-disabled"
placeholder="70"
value={compressionThresholdField.value}
onChange={compressionThresholdField.onChange}
@@ -532,18 +618,20 @@ export const ModelForm: FC<ModelFormProps> = ({
disabled={isSaving}
aria-invalid={compressionThresholdField.error}
/>
{compressionThresholdField.error && (
<p className="m-0 text-xs text-content-destructive">
{compressionThresholdField.helperText}
</p>
)}
</div>
<InputGroupAddon align="inline-end">
<span className="text-xs text-content-disabled">%</span>
</InputGroupAddon>
</InputGroup>
{compressionThresholdField.error && (
<p className="m-0 text-xs text-content-destructive">
{compressionThresholdField.helperText}
</p>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Footer - pushed to bottom */}
<div className="mt-auto py-6">
<hr className="mb-4 border-0 border-t border-solid border-border" />
<div className="flex items-center justify-between">