diff --git a/codersdk/chats.go b/codersdk/chats.go index 41f1ea3d1f..55e5e11662 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -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. diff --git a/scripts/modeloptionsgen/main.go b/scripts/modeloptionsgen/main.go index 5f6746bbb5..f7446bd335 100644 --- a/scripts/modeloptionsgen/main.go +++ b/scripts/modeloptionsgen/main.go @@ -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, diff --git a/site/src/api/chatModelOptions.ts b/site/src/api/chatModelOptions.ts index f287b5b0ad..67c60da120 100644 --- a/site/src/api/chatModelOptions.ts +++ b/site/src/api/chatModelOptions.ts @@ -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. */ diff --git a/site/src/api/chatModelOptionsGenerated.json b/site/src/api/chatModelOptionsGenerated.json index 2098a6e6f0..8abb65ead6 100644 --- a/site/src/api/chatModelOptionsGenerated.json +++ b/site/src/api/chatModelOptionsGenerated.json @@ -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" } diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index fa84610123..01236e1ec6 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -827,6 +827,14 @@ const openAddModelForm = async ( }); }; +/** Expand a collapsible section by clicking its header button. */ +const expandSection = async (body: ReturnType, 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), diff --git a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx index 0f492447eb..e6e44452f2 100644 --- a/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/components/ChatModelAdminPanel/ModelConfigFields.tsx @@ -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 = { + "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 = { + 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 = { + 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 }) => ( + +); + 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 ( -
- - {description && ( -

{description}

- )} - + + + {suffix} + + + ) : ( + + ); + + return ( +
+ + {inputEl} {fieldError && (

{fieldError} @@ -155,15 +251,7 @@ const SelectField: FC< const currentValue = (getIn(form.values, fieldKey) as string) || ""; return (

- - {description && ( -

{description}

- )} +