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}

+ )}