From 47199ab475cc13cc912d34bc7baab6ffeb57a583 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Tue, 3 Mar 2026 17:52:35 -0500 Subject: [PATCH] refactor(site): replace bespoke chat model provider UI with schema-driven rendering (#22577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace hand-coded per-provider field components, form state types, validation schemas, and builder functions with generic schema-driven code that reads from the auto-generated `chatModelOptionsGenerated.json`. ## Changes ### `ModelConfigFields.tsx` (492 → 341 lines) - Remove 6 per-provider components (`OpenAIFields`, `AnthropicFields`, `GoogleFields`, `OpenAICompatFields`, `OpenRouterFields`, `VercelFields`) - Remove exported option arrays (`modelConfigReasoningEffortOptions`, etc.) - Add `renderSchemaField()` that dispatches to `InputField`/`SelectField`/`JSONField` based on `field.input_type` from the generated schema - `ModelConfigFields` now calls `getVisibleProviderFields()` instead of a switch statement - `GeneralModelConfigFields` now calls `getVisibleGeneralFields()` instead of hard-coding 6 InputField instances ### `modelConfigFormLogic.ts` (742 → 525 lines) - Remove 6 per-provider form state types and empty defaults - Remove 6 per-provider Yup validation schemas - Remove 6 per-provider builder functions (`buildOpenAIOptions`, etc.) - Remove 2 switch-case dispatch blocks (validation + build) - Add `buildEmptyProviderState()` that walks schema fields to create empty form state - Add schema-driven `extractModelConfigFormState()` and `buildModelConfigFromForm()` - Add `yupTestForField()` + `buildYupSchema()` generating Yup validation from field metadata - Lazy-cache per-provider Yup schemas for performance ### `modelConfigFormLogic.test.ts` - All 83 tests updated for the new nested state shape - Uses `toContain` for error message assertions since labels now come from schema descriptions ## Motivation The auto-generated schema (`chatModelOptionsGenerated.json`) was merged in #22568 but not yet consumed by the UI. This PR wires it up so that when a new provider or field is added in Go (`codersdk/chats.go`), running `make gen` regenerates the JSON schema and the UI automatically picks up the new fields — no manual TypeScript changes needed. **Production code reduced from 1234 to 866 lines (-30%).** --- site/src/api/chatModelOptions.ts | 2 +- .../ChatModelAdminPanel.stories.tsx | 132 +++ .../ChatModelAdminPanel/ModelConfigFields.tsx | 478 +++----- .../modelConfigFormLogic.test.ts | 324 ++--- .../modelConfigFormLogic.ts | 1048 +++++++---------- 5 files changed, 912 insertions(+), 1072 deletions(-) diff --git a/site/src/api/chatModelOptions.ts b/site/src/api/chatModelOptions.ts index 6757720956..9e6233ee16 100644 --- a/site/src/api/chatModelOptions.ts +++ b/site/src/api/chatModelOptions.ts @@ -102,7 +102,7 @@ export function isKnownProvider(provider: string): boolean { * Only the first character after each underscore is uppercased; * the leading character stays lowercase. */ -function snakeToCamel(s: string): string { +export function snakeToCamel(s: string): string { return s.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase()); } diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx index 236cf1b834..6d059d75d3 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx @@ -489,6 +489,138 @@ export const SubmitModelConfigExplicitly: Story = { }, }; +// ── Per-provider model form stories ──────────────────────────── +// Each story opens the "Add model" form for a specific provider +// so you can visually verify the schema-driven fields render. + +const providerFormSetup = (provider: string, displayName: string) => ({ + args: { section: "models" as ChatModelAdminSection }, + beforeEach: () => { + setupChatSpies({ + providerConfigs: [ + createProviderConfig({ + id: `provider-${provider}`, + provider, + display_name: displayName, + source: "database", + has_api_key: true, + }), + ], + modelConfigs: [], + modelCatalog: { providers: [] }, + }); + }, +}); + +export const ModelFormOpenAI: Story = { + ...providerFormSetup("openai", "OpenAI"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "OpenAI"); + await expect( + await body.findByLabelText(/Reasoning Effort/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Parallel Tool Calls/i), + ).toBeInTheDocument(); + }, +}; + +export const ModelFormAnthropic: Story = { + ...providerFormSetup("anthropic", "Anthropic"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "Anthropic"); + await expect( + await body.findByLabelText(/Send Reasoning/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Thinking Budget Tokens/i), + ).toBeInTheDocument(); + }, +}; + +export const ModelFormGoogle: Story = { + ...providerFormSetup("google", "Google"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "Google"); + await expect( + await body.findByLabelText(/Thinking Config Thinking Budget/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Thinking Config Include Thoughts/i), + ).toBeInTheDocument(); + }, +}; + +export const ModelFormOpenAICompat: Story = { + ...providerFormSetup("openaicompat", "OpenAI-compatible"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "OpenAI-compatible"); + await expect( + await body.findByLabelText(/Reasoning Effort/i), + ).toBeInTheDocument(); + }, +}; + +export const ModelFormOpenRouter: Story = { + ...providerFormSetup("openrouter", "OpenRouter"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "OpenRouter"); + await expect( + await body.findByLabelText(/Reasoning Enabled/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Reasoning Max Tokens/i), + ).toBeInTheDocument(); + }, +}; + +export const ModelFormVercel: Story = { + ...providerFormSetup("vercel", "Vercel AI Gateway"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "Vercel AI Gateway"); + await expect( + await body.findByLabelText(/Reasoning Enabled/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Parallel Tool Calls/i), + ).toBeInTheDocument(); + }, +}; + +export const ModelFormAzure: Story = { + ...providerFormSetup("azure", "Azure OpenAI"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "Azure OpenAI"); + // Azure aliases to OpenAI fields. + await expect( + await body.findByLabelText(/Reasoning Effort/i), + ).toBeInTheDocument(); + await expect( + await body.findByLabelText(/Service Tier/i), + ).toBeInTheDocument(); + }, +}; + +export const ModelFormBedrock: Story = { + ...providerFormSetup("bedrock", "AWS Bedrock"), + play: async ({ canvasElement }) => { + const body = within(canvasElement.ownerDocument.body); + await openAddModelForm(body, "AWS Bedrock"); + // Bedrock aliases to Anthropic fields. + await expect( + await body.findByLabelText(/Send Reasoning/i), + ).toBeInTheDocument(); + await expect(await body.findByLabelText(/Effort/i)).toBeInTheDocument(); + }, +}; + export const ValidatesModelConfigFields: Story = { args: { section: "models" as ChatModelAdminSection }, beforeEach: () => { diff --git a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx index ff7f3ad84b..b2ca74ecd5 100644 --- a/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx +++ b/site/src/pages/AgentsPage/ChatModelAdminPanel/ModelConfigFields.tsx @@ -1,3 +1,11 @@ +import { + type FieldSchema, + getVisibleGeneralFields, + getVisibleProviderFields, + resolveProvider, + snakeToCamel, + toFormFieldKey, +} from "api/chatModelOptions"; import { Input } from "components/Input/Input"; import { Label } from "components/Label/Label"; import { @@ -17,31 +25,43 @@ import type { ModelFormValues, } from "./modelConfigFormLogic"; -export const modelConfigReasoningEffortOptions = [ - "minimal", - "low", - "medium", - "high", - "xhigh", - "none", -] as const; - -export const modelConfigAnthropicEffortOptions = [ - "low", - "medium", - "high", - "max", -] as const; - -export const modelConfigTextVerbosityOptions = [ - "low", - "medium", - "high", -] as const; - /** Sentinel value for Select components to represent "no selection". */ const unsetSelectValue = "__unset__"; +// ── Helpers ──────────────────────────────────────────────────── + +/** + * Convert a dot-and-underscore-separated json_name into a + * human-readable label. + * + * @example + * snakeToPrettyLabel("thinking.budget_tokens") // "Thinking Budget Tokens" + * snakeToPrettyLabel("reasoning_effort") // "Reasoning Effort" + */ +function snakeToPrettyLabel(jsonName: string): string { + return jsonName + .split(/[._]/) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * Derive a sensible placeholder from the field schema type. + */ +function placeholderForField(field: FieldSchema): string { + switch (field.type) { + case "integer": + case "number": + return ""; + case "array": + return "[]"; + case "object": + return "{}"; + default: + return ""; + } +} + // ── Generic field renderers ──────────────────────────────────── type FieldRenderContext = { @@ -53,12 +73,23 @@ type FieldRenderContext = { const InputField: FC< FieldRenderContext & { fieldKey: string; + errorKey?: string; label: string; + description?: string; placeholder: string; } -> = ({ form, fieldErrors, disabled, fieldKey, label, placeholder }) => { +> = ({ + form, + fieldErrors, + disabled, + fieldKey, + errorKey, + label, + description, + placeholder, +}) => { const errorId = `${fieldKey}-error`; - const fieldError = fieldErrors[fieldKey]; + const fieldError = fieldErrors[errorKey ?? fieldKey]; const fieldProps = form.getFieldProps(fieldKey); return (
@@ -68,6 +99,9 @@ const InputField: FC< > {label} + {description && ( +

{description}

+ )} = ({ form, fieldErrors, disabled, fieldKey, label, options }) => { +> = ({ + form, + fieldErrors, + disabled, + fieldKey, + errorKey, + label, + description, + options, +}) => { const errorId = `${fieldKey}-error`; - const fieldError = fieldErrors[fieldKey]; + const fieldError = fieldErrors[errorKey ?? fieldKey]; const currentValue = (getIn(form.values, fieldKey) as string) || ""; return (
@@ -107,6 +152,9 @@ const SelectField: FC< > {label} + {description && ( +

{description}

+ )}