Skip to content

Commit 47199ab

Browse files
authored
refactor(site): replace bespoke chat model provider UI with schema-driven rendering (#22577)
## 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%).**
1 parent 4ee5306 commit 47199ab

5 files changed

Lines changed: 900 additions & 1060 deletions

File tree

‎site/src/api/chatModelOptions.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export function isKnownProvider(provider: string): boolean {
102102
* Only the first character after each underscore is uppercased;
103103
* the leading character stays lowercase.
104104
*/
105-
function snakeToCamel(s: string): string {
105+
export function snakeToCamel(s: string): string {
106106
return s.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
107107
}
108108

‎site/src/pages/AgentsPage/ChatModelAdminPanel/ChatModelAdminPanel.stories.tsx‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,138 @@ export const SubmitModelConfigExplicitly: Story = {
489489
},
490490
};
491491

492+
// ── Per-provider model form stories ────────────────────────────
493+
// Each story opens the "Add model" form for a specific provider
494+
// so you can visually verify the schema-driven fields render.
495+
496+
const providerFormSetup = (provider: string, displayName: string) => ({
497+
args: { section: "models" as ChatModelAdminSection },
498+
beforeEach: () => {
499+
setupChatSpies({
500+
providerConfigs: [
501+
createProviderConfig({
502+
id: `provider-${provider}`,
503+
provider,
504+
display_name: displayName,
505+
source: "database",
506+
has_api_key: true,
507+
}),
508+
],
509+
modelConfigs: [],
510+
modelCatalog: { providers: [] },
511+
});
512+
},
513+
});
514+
515+
export const ModelFormOpenAI: Story = {
516+
...providerFormSetup("openai", "OpenAI"),
517+
play: async ({ canvasElement }) => {
518+
const body = within(canvasElement.ownerDocument.body);
519+
await openAddModelForm(body, "OpenAI");
520+
await expect(
521+
await body.findByLabelText(/Reasoning Effort/i),
522+
).toBeInTheDocument();
523+
await expect(
524+
await body.findByLabelText(/Parallel Tool Calls/i),
525+
).toBeInTheDocument();
526+
},
527+
};
528+
529+
export const ModelFormAnthropic: Story = {
530+
...providerFormSetup("anthropic", "Anthropic"),
531+
play: async ({ canvasElement }) => {
532+
const body = within(canvasElement.ownerDocument.body);
533+
await openAddModelForm(body, "Anthropic");
534+
await expect(
535+
await body.findByLabelText(/Send Reasoning/i),
536+
).toBeInTheDocument();
537+
await expect(
538+
await body.findByLabelText(/Thinking Budget Tokens/i),
539+
).toBeInTheDocument();
540+
},
541+
};
542+
543+
export const ModelFormGoogle: Story = {
544+
...providerFormSetup("google", "Google"),
545+
play: async ({ canvasElement }) => {
546+
const body = within(canvasElement.ownerDocument.body);
547+
await openAddModelForm(body, "Google");
548+
await expect(
549+
await body.findByLabelText(/Thinking Config Thinking Budget/i),
550+
).toBeInTheDocument();
551+
await expect(
552+
await body.findByLabelText(/Thinking Config Include Thoughts/i),
553+
).toBeInTheDocument();
554+
},
555+
};
556+
557+
export const ModelFormOpenAICompat: Story = {
558+
...providerFormSetup("openaicompat", "OpenAI-compatible"),
559+
play: async ({ canvasElement }) => {
560+
const body = within(canvasElement.ownerDocument.body);
561+
await openAddModelForm(body, "OpenAI-compatible");
562+
await expect(
563+
await body.findByLabelText(/Reasoning Effort/i),
564+
).toBeInTheDocument();
565+
},
566+
};
567+
568+
export const ModelFormOpenRouter: Story = {
569+
...providerFormSetup("openrouter", "OpenRouter"),
570+
play: async ({ canvasElement }) => {
571+
const body = within(canvasElement.ownerDocument.body);
572+
await openAddModelForm(body, "OpenRouter");
573+
await expect(
574+
await body.findByLabelText(/Reasoning Enabled/i),
575+
).toBeInTheDocument();
576+
await expect(
577+
await body.findByLabelText(/Reasoning Max Tokens/i),
578+
).toBeInTheDocument();
579+
},
580+
};
581+
582+
export const ModelFormVercel: Story = {
583+
...providerFormSetup("vercel", "Vercel AI Gateway"),
584+
play: async ({ canvasElement }) => {
585+
const body = within(canvasElement.ownerDocument.body);
586+
await openAddModelForm(body, "Vercel AI Gateway");
587+
await expect(
588+
await body.findByLabelText(/Reasoning Enabled/i),
589+
).toBeInTheDocument();
590+
await expect(
591+
await body.findByLabelText(/Parallel Tool Calls/i),
592+
).toBeInTheDocument();
593+
},
594+
};
595+
596+
export const ModelFormAzure: Story = {
597+
...providerFormSetup("azure", "Azure OpenAI"),
598+
play: async ({ canvasElement }) => {
599+
const body = within(canvasElement.ownerDocument.body);
600+
await openAddModelForm(body, "Azure OpenAI");
601+
// Azure aliases to OpenAI fields.
602+
await expect(
603+
await body.findByLabelText(/Reasoning Effort/i),
604+
).toBeInTheDocument();
605+
await expect(
606+
await body.findByLabelText(/Service Tier/i),
607+
).toBeInTheDocument();
608+
},
609+
};
610+
611+
export const ModelFormBedrock: Story = {
612+
...providerFormSetup("bedrock", "AWS Bedrock"),
613+
play: async ({ canvasElement }) => {
614+
const body = within(canvasElement.ownerDocument.body);
615+
await openAddModelForm(body, "AWS Bedrock");
616+
// Bedrock aliases to Anthropic fields.
617+
await expect(
618+
await body.findByLabelText(/Send Reasoning/i),
619+
).toBeInTheDocument();
620+
await expect(await body.findByLabelText(/Effort/i)).toBeInTheDocument();
621+
},
622+
};
623+
492624
export const ValidatesModelConfigFields: Story = {
493625
args: { section: "models" as ChatModelAdminSection },
494626
beforeEach: () => {

0 commit comments

Comments
 (0)