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%).**
This commit is contained in:
Kyle Carberry
2026-03-03 17:52:35 -05:00
committed by GitHub
parent 4ee5306eca
commit 47199ab475
5 changed files with 912 additions and 1072 deletions
+1 -1
View File
@@ -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());
}
@@ -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: () => {
@@ -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 (
<div className="flex min-w-0 flex-col gap-1.5">
@@ -68,6 +99,9 @@ const InputField: FC<
>
{label}
</Label>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
<Input
id={fieldKey}
className={cn(
@@ -92,12 +126,23 @@ const InputField: FC<
const SelectField: FC<
FieldRenderContext & {
fieldKey: string;
errorKey?: string;
label: string;
description?: string;
options: readonly string[];
}
> = ({ 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 (
<div className="flex min-w-0 flex-col gap-1.5">
@@ -107,6 +152,9 @@ const SelectField: FC<
>
{label}
</Label>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
<Select
value={currentValue || unsetSelectValue}
onValueChange={(value) =>
@@ -149,12 +197,23 @@ const SelectField: FC<
const JSONField: 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 (
<div className="flex min-w-0 flex-col gap-1.5">
@@ -164,6 +223,9 @@ const JSONField: FC<
>
{label}
</Label>
{description && (
<p className="m-0 text-xs text-content-secondary">{description}</p>
)}
<Textarea
id={fieldKey}
className={cn(
@@ -185,213 +247,64 @@ const JSONField: FC<
);
};
// ── Provider-specific field sets ───────────────────────────────
// ── Schema-driven field renderer ───────────────────────────────
const OpenAIFields: FC<FieldRenderContext> = (props) => (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<SelectField
{...props}
fieldKey="config.openai.reasoningEffort"
label="Reasoning Effort"
options={modelConfigReasoningEffortOptions}
/>
<SelectField
{...props}
fieldKey="config.openai.parallelToolCalls"
label="Parallel Tool Calls"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openai.textVerbosity"
label="Text Verbosity"
options={modelConfigTextVerbosityOptions}
/>
<InputField
{...props}
fieldKey="config.openai.serviceTier"
label="Service Tier"
placeholder="auto"
/>
<InputField
{...props}
fieldKey="config.openai.reasoningSummary"
label="Reasoning Summary"
placeholder="detailed"
/>
<InputField
{...props}
fieldKey="config.openai.user"
label="User"
placeholder="end-user-id"
/>
</div>
);
/**
* Render a single field from the schema using the appropriate
* generic renderer based on its `input_type`.
*/
function renderSchemaField(
field: FieldSchema,
fieldKey: string,
errorKey: string,
ctx: FieldRenderContext,
): React.ReactNode {
const label = snakeToPrettyLabel(field.json_name);
const AnthropicFields: FC<FieldRenderContext> = (props) => (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<SelectField
{...props}
fieldKey="config.anthropic.effort"
label="Output Effort"
options={modelConfigAnthropicEffortOptions}
/>
<InputField
{...props}
fieldKey="config.anthropic.thinkingBudgetTokens"
label="Thinking Budget Tokens"
placeholder="4000"
/>
<SelectField
{...props}
fieldKey="config.anthropic.sendReasoning"
label="Send Reasoning"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.anthropic.disableParallelToolUse"
label="Disable Parallel Tool Use"
options={["true", "false"]}
/>
</div>
);
const GoogleFields: FC<FieldRenderContext> = (props) => (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<InputField
{...props}
fieldKey="config.google.thinkingBudget"
label="Thinking Budget"
placeholder="1024"
/>
<SelectField
{...props}
fieldKey="config.google.includeThoughts"
label="Include Thoughts"
options={["true", "false"]}
/>
<InputField
{...props}
fieldKey="config.google.cachedContent"
label="Cached Content"
placeholder="cached-contents/abc123"
/>
<JSONField
{...props}
fieldKey="config.google.safetySettingsJSON"
label="Safety Settings JSON"
placeholder={`[
{"category":"HARM_CATEGORY_DANGEROUS_CONTENT","threshold":"BLOCK_ONLY_HIGH"}
]`}
/>
</div>
);
const OpenAICompatFields: FC<FieldRenderContext> = (props) => (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<SelectField
{...props}
fieldKey="config.openaicompat.reasoningEffort"
label="Reasoning Effort"
options={modelConfigReasoningEffortOptions}
/>
<InputField
{...props}
fieldKey="config.openaicompat.user"
label="User"
placeholder="end-user-id"
/>
</div>
);
const OpenRouterFields: FC<FieldRenderContext> = (props) => (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<SelectField
{...props}
fieldKey="config.openrouter.reasoningEnabled"
label="Reasoning Enabled"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openrouter.reasoningEffort"
label="Reasoning Effort"
options={modelConfigReasoningEffortOptions}
/>
<InputField
{...props}
fieldKey="config.openrouter.reasoningMaxTokens"
label="Reasoning Max Tokens"
placeholder="2048"
/>
<SelectField
{...props}
fieldKey="config.openrouter.reasoningExclude"
label="Reasoning Exclude"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openrouter.parallelToolCalls"
label="Parallel Tool Calls"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.openrouter.includeUsage"
label="Include Usage"
options={["true", "false"]}
/>
<InputField
{...props}
fieldKey="config.openrouter.user"
label="User"
placeholder="end-user-id"
/>
</div>
);
const VercelFields: FC<FieldRenderContext> = (props) => (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<SelectField
{...props}
fieldKey="config.vercel.reasoningEnabled"
label="Reasoning Enabled"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.vercel.reasoningEffort"
label="Reasoning Effort"
options={modelConfigReasoningEffortOptions}
/>
<InputField
{...props}
fieldKey="config.vercel.reasoningMaxTokens"
label="Reasoning Max Tokens"
placeholder="2048"
/>
<SelectField
{...props}
fieldKey="config.vercel.reasoningExclude"
label="Reasoning Exclude"
options={["true", "false"]}
/>
<SelectField
{...props}
fieldKey="config.vercel.parallelToolCalls"
label="Parallel Tool Calls"
options={["true", "false"]}
/>
<InputField
{...props}
fieldKey="config.vercel.user"
label="User"
placeholder="end-user-id"
/>
</div>
);
switch (field.input_type) {
case "input":
return (
<InputField
key={fieldKey}
{...ctx}
fieldKey={fieldKey}
errorKey={errorKey}
label={label}
description={field.description}
placeholder={placeholderForField(field)}
/>
);
case "select": {
const options: readonly string[] =
field.enum ?? (field.type === "boolean" ? ["true", "false"] : []);
return (
<SelectField
key={fieldKey}
{...ctx}
fieldKey={fieldKey}
errorKey={errorKey}
label={label}
description={field.description}
options={options}
/>
);
}
case "json":
return (
<JSONField
key={fieldKey}
{...ctx}
fieldKey={fieldKey}
errorKey={errorKey}
label={label}
description={field.description}
placeholder={placeholderForField(field)}
/>
);
default:
return null;
}
}
// ── Main component ─────────────────────────────────────────────
@@ -405,6 +318,9 @@ type ModelConfigFieldsProps = {
/**
* Provider-specific fields (reasoning, tool calls, etc.) that
* should be visible at the top level of the model form.
*
* Fields and their input types are driven by the auto-generated
* schema in `api/chatModelOptions`.
*/
export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
provider,
@@ -412,34 +328,33 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
fieldErrors,
disabled,
}) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
const normalized = normalizeProvider(provider);
const resolved = resolveProvider(normalized);
const fields = getVisibleProviderFields(normalized);
switch (normalized) {
case "openai":
return <OpenAIFields {...ctx} />;
case "azure":
return <OpenAIFields {...ctx} />;
case "anthropic":
return <AnthropicFields {...ctx} />;
case "bedrock":
return <AnthropicFields {...ctx} />;
case "google":
return <GoogleFields {...ctx} />;
case "openaicompat":
return <OpenAICompatFields {...ctx} />;
case "openrouter":
return <OpenRouterFields {...ctx} />;
case "vercel":
return <VercelFields {...ctx} />;
default:
return null;
if (fields.length === 0) {
return null;
}
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
return (
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
{fields.map((field) => {
const fieldKey = `config.${toFormFieldKey(resolved, field.json_name)}`;
const errorKey = toFormFieldKey(resolved, field.json_name);
return renderSchemaField(field, fieldKey, errorKey, ctx);
})}
</div>
);
};
/**
* General model config fields (max output tokens, temperature,
* top P, etc.) intended to be shown under an "Advanced" section.
*
* Fields are driven by the auto-generated schema in
* `api/chatModelOptions`.
*/
export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
form,
@@ -447,45 +362,30 @@ export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
disabled,
}) => {
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
const fields = getVisibleGeneralFields();
return (
<>
<InputField
{...ctx}
fieldKey="config.maxOutputTokens"
label="Max Output Tokens"
placeholder="32000"
/>
<InputField
{...ctx}
fieldKey="config.temperature"
label="Temperature"
placeholder="0.2"
/>
<InputField
{...ctx}
fieldKey="config.topP"
label="Top P"
placeholder="0.95"
/>
<InputField
{...ctx}
fieldKey="config.topK"
label="Top K"
placeholder="40"
/>
<InputField
{...ctx}
fieldKey="config.presencePenalty"
label="Presence Penalty"
placeholder="0"
/>
<InputField
{...ctx}
fieldKey="config.frequencyPenalty"
label="Frequency Penalty"
placeholder="0"
/>
{fields.map((field) => {
// General field keys use camelCase of the json_name directly
// under "config.", matching the existing form state shape:
// config.maxOutputTokens, config.temperature, etc.
const camelName = snakeToCamel(field.json_name);
const fieldKey = `config.${camelName}`;
const label = snakeToPrettyLabel(field.json_name);
return (
<InputField
key={fieldKey}
{...ctx}
fieldKey={fieldKey}
errorKey={camelName}
label={label}
description={field.description}
placeholder={placeholderForField(field)}
/>
);
})}
</>
);
};
@@ -1,74 +1,81 @@
import type * as TypesGen from "api/typesGenerated";
import { describe, expect, it } from "vitest";
import {
type AnthropicFormState,
buildModelConfigFromForm,
emptyAnthropicFormState,
emptyGoogleFormState,
emptyModelConfigFormState,
emptyOpenAICompatFormState,
emptyOpenAIFormState,
emptyOpenRouterFormState,
emptyVercelFormState,
extractModelConfigFormState,
type GoogleFormState,
type ModelConfigFormState,
type OpenAICompatFormState,
type OpenAIFormState,
type OpenRouterFormState,
parsePositiveInteger,
parseThresholdInteger,
type VercelFormState,
} from "./modelConfigFormLogic";
// ── Helpers ────────────────────────────────────────────────────
/**
* Return an empty form state with the given top-level overrides
* applied. Provider sub-objects can be partially overridden.
* Return an empty form state with the given overrides applied.
* Provider sub-objects are deep-merged so callers can pass
* partial overrides for nested fields.
*/
const formWith = (
overrides: Partial<
Omit<
ModelConfigFormState,
| "openai"
| "anthropic"
| "google"
| "openaicompat"
| "openrouter"
| "vercel"
> & {
openai: Partial<OpenAIFormState>;
anthropic: Partial<AnthropicFormState>;
google: Partial<GoogleFormState>;
openaicompat: Partial<OpenAICompatFormState>;
openrouter: Partial<OpenRouterFormState>;
vercel: Partial<VercelFormState>;
}
>,
): ModelConfigFormState => {
const formWith = (overrides: Record<string, unknown>): ModelConfigFormState => {
const base = structuredClone(emptyModelConfigFormState);
const {
openai,
anthropic,
google,
openaicompat,
openrouter,
vercel,
...topLevel
} = overrides;
return {
...base,
...topLevel,
openai: { ...base.openai, ...openai },
anthropic: { ...base.anthropic, ...anthropic },
google: { ...base.google, ...google },
openaicompat: { ...base.openaicompat, ...openaicompat },
openrouter: { ...base.openrouter, ...openrouter },
vercel: { ...base.vercel, ...vercel },
};
for (const [key, val] of Object.entries(overrides)) {
if (val && typeof val === "object" && !Array.isArray(val)) {
// Deep-merge provider sub-objects.
base[key] = deepMerge(
(base[key] as Record<string, unknown>) ?? {},
val as Record<string, unknown>,
);
} else {
(base as Record<string, unknown>)[key] = val;
}
}
return base;
};
/** Simple recursive merge for plain objects. */
function deepMerge(
target: Record<string, unknown>,
source: Record<string, unknown>,
): Record<string, unknown> {
const result = { ...target };
for (const [key, val] of Object.entries(source)) {
if (
val &&
typeof val === "object" &&
!Array.isArray(val) &&
result[key] &&
typeof result[key] === "object" &&
!Array.isArray(result[key])
) {
result[key] = deepMerge(
result[key] as Record<string, unknown>,
val as Record<string, unknown>,
);
} else {
result[key] = val;
}
}
return result;
}
/** Helper to read a nested value from the form state. */
function deepGet(obj: unknown, path: string[]): unknown {
let current = obj;
for (const key of path) {
if (
current === undefined ||
current === null ||
typeof current !== "object"
) {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
/** Minimal ChatModelConfig with no model_config. */
const baseChatModelConfig: TypesGen.ChatModelConfig = {
id: "test-id",
@@ -215,12 +222,13 @@ describe("extractModelConfigFormState", () => {
},
};
const result = extractModelConfigFormState(model);
expect(result.openai.reasoningEffort).toBe("high");
expect(result.openai.parallelToolCalls).toBe("true");
expect(result.openai.textVerbosity).toBe("medium");
expect(result.openai.serviceTier).toBe("auto");
expect(result.openai.reasoningSummary).toBe("concise");
expect(result.openai.user).toBe("test-user");
const openai = result.openai as Record<string, unknown>;
expect(openai.reasoningEffort).toBe("high");
expect(openai.parallelToolCalls).toBe("true");
expect(openai.textVerbosity).toBe("medium");
expect(openai.serviceTier).toBe("auto");
expect(openai.reasoningSummary).toBe("concise");
expect(openai.user).toBe("test-user");
});
it("extracts Anthropic provider options with thinking", () => {
@@ -238,10 +246,11 @@ describe("extractModelConfigFormState", () => {
},
};
const result = extractModelConfigFormState(model);
expect(result.anthropic.effort).toBe("high");
expect(result.anthropic.thinkingBudgetTokens).toBe("1024");
expect(result.anthropic.sendReasoning).toBe("true");
expect(result.anthropic.disableParallelToolUse).toBe("false");
const anthropic = result.anthropic as Record<string, unknown>;
expect(anthropic.effort).toBe("high");
expect(deepGet(anthropic, ["thinking", "budgetTokens"])).toBe("1024");
expect(anthropic.sendReasoning).toBe("true");
expect(anthropic.disableParallelToolUse).toBe("false");
});
it("extracts Google provider options with safety settings", () => {
@@ -262,12 +271,11 @@ describe("extractModelConfigFormState", () => {
},
};
const result = extractModelConfigFormState(model);
expect(result.google.thinkingBudget).toBe("2048");
expect(result.google.includeThoughts).toBe("true");
expect(result.google.cachedContent).toBe("cache-123");
expect(result.google.safetySettingsJSON).toBe(
JSON.stringify(safetySettings, null, 2),
);
const google = result.google as Record<string, unknown>;
expect(deepGet(google, ["thinkingConfig", "thinkingBudget"])).toBe("2048");
expect(deepGet(google, ["thinkingConfig", "includeThoughts"])).toBe("true");
expect(google.cachedContent).toBe("cache-123");
expect(google.safetySettings).toBe(JSON.stringify(safetySettings, null, 2));
});
it("returns empty string for google safety settings when absent", () => {
@@ -280,7 +288,8 @@ describe("extractModelConfigFormState", () => {
},
};
const result = extractModelConfigFormState(model);
expect(result.google.safetySettingsJSON).toBe("");
const google = result.google as Record<string, unknown>;
expect(google.safetySettings).toBe("");
});
it("extracts OpenAI-compatible provider options", () => {
@@ -296,8 +305,9 @@ describe("extractModelConfigFormState", () => {
},
};
const result = extractModelConfigFormState(model);
expect(result.openaicompat.reasoningEffort).toBe("low");
expect(result.openaicompat.user).toBe("compat-user");
const openaicompat = result.openaicompat as Record<string, unknown>;
expect(openaicompat.reasoningEffort).toBe("low");
expect(openaicompat.user).toBe("compat-user");
});
it("extracts OpenRouter provider options", () => {
@@ -320,13 +330,14 @@ describe("extractModelConfigFormState", () => {
},
};
const result = extractModelConfigFormState(model);
expect(result.openrouter.reasoningEnabled).toBe("true");
expect(result.openrouter.reasoningEffort).toBe("medium");
expect(result.openrouter.reasoningMaxTokens).toBe("500");
expect(result.openrouter.reasoningExclude).toBe("false");
expect(result.openrouter.parallelToolCalls).toBe("true");
expect(result.openrouter.includeUsage).toBe("true");
expect(result.openrouter.user).toBe("router-user");
const openrouter = result.openrouter as Record<string, unknown>;
expect(deepGet(openrouter, ["reasoning", "enabled"])).toBe("true");
expect(deepGet(openrouter, ["reasoning", "effort"])).toBe("medium");
expect(deepGet(openrouter, ["reasoning", "maxTokens"])).toBe("500");
expect(deepGet(openrouter, ["reasoning", "exclude"])).toBe("false");
expect(openrouter.parallelToolCalls).toBe("true");
expect(openrouter.includeUsage).toBe("true");
expect(openrouter.user).toBe("router-user");
});
it("extracts Vercel provider options", () => {
@@ -348,12 +359,13 @@ describe("extractModelConfigFormState", () => {
},
};
const result = extractModelConfigFormState(model);
expect(result.vercel.reasoningEnabled).toBe("false");
expect(result.vercel.reasoningEffort).toBe("high");
expect(result.vercel.reasoningMaxTokens).toBe("1000");
expect(result.vercel.reasoningExclude).toBe("true");
expect(result.vercel.parallelToolCalls).toBe("false");
expect(result.vercel.user).toBe("vercel-user");
const vercel = result.vercel as Record<string, unknown>;
expect(deepGet(vercel, ["reasoning", "enabled"])).toBe("false");
expect(deepGet(vercel, ["reasoning", "effort"])).toBe("high");
expect(deepGet(vercel, ["reasoning", "maxTokens"])).toBe("1000");
expect(deepGet(vercel, ["reasoning", "exclude"])).toBe("true");
expect(vercel.parallelToolCalls).toBe("false");
expect(vercel.user).toBe("vercel-user");
});
it("handles missing provider_options gracefully", () => {
@@ -366,19 +378,23 @@ describe("extractModelConfigFormState", () => {
const result = extractModelConfigFormState(model);
expect(result.temperature).toBe("0.5");
// All provider-specific fields should be empty.
expect(result.openai.reasoningEffort).toBe("");
expect(result.anthropic.effort).toBe("");
expect(result.google.thinkingBudget).toBe("");
const openai = result.openai as Record<string, unknown>;
expect(openai.reasoningEffort).toBe("");
const anthropic = result.anthropic as Record<string, unknown>;
expect(anthropic.effort).toBe("");
const google = result.google as Record<string, unknown>;
expect(deepGet(google, ["thinkingConfig", "thinkingBudget"])).toBe("");
});
it("returns deep copies of provider sub-objects", () => {
const result = extractModelConfigFormState(baseChatModelConfig);
expect(result.openai).not.toBe(emptyOpenAIFormState);
expect(result.anthropic).not.toBe(emptyAnthropicFormState);
expect(result.google).not.toBe(emptyGoogleFormState);
expect(result.openaicompat).not.toBe(emptyOpenAICompatFormState);
expect(result.openrouter).not.toBe(emptyOpenRouterFormState);
expect(result.vercel).not.toBe(emptyVercelFormState);
const empty = emptyModelConfigFormState;
expect(result.openai).not.toBe(empty.openai);
expect(result.anthropic).not.toBe(empty.anthropic);
expect(result.google).not.toBe(empty.google);
expect(result.openaicompat).not.toBe(empty.openaicompat);
expect(result.openrouter).not.toBe(empty.openrouter);
expect(result.vercel).not.toBe(empty.vercel);
});
});
@@ -448,8 +464,8 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({ maxOutputTokens: "abc" }),
);
expect(result.fieldErrors.maxOutputTokens).toBe(
"Max output tokens must be a valid number.",
expect(result.fieldErrors.maxOutputTokens).toContain(
"must be a valid integer",
);
expect(result.modelConfig).toBeUndefined();
});
@@ -459,8 +475,8 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({ temperature: "hot" }),
);
expect(result.fieldErrors.temperature).toBe(
"Temperature must be a valid number.",
expect(result.fieldErrors.temperature).toContain(
"must be a valid number",
);
});
@@ -469,7 +485,7 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({ topP: "not-a-number" }),
);
expect(result.fieldErrors.topP).toBe("Top P must be a valid number.");
expect(result.fieldErrors.topP).toContain("must be a valid number");
});
it("reports error for non-numeric topK", () => {
@@ -477,7 +493,7 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({ topK: "xyz" }),
);
expect(result.fieldErrors.topK).toBe("Top K must be a valid number.");
expect(result.fieldErrors.topK).toContain("must be a valid integer");
});
it("skips empty string fields (no undefined values in output)", () => {
@@ -550,8 +566,8 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({ openai: { reasoningEffort: "invalid_value" } }),
);
expect(result.fieldErrors["openai.reasoningEffort"]).toBe(
"Reasoning effort has an invalid value.",
expect(result.fieldErrors["openai.reasoningEffort"]).toContain(
"invalid value",
);
});
@@ -560,8 +576,8 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({ openai: { parallelToolCalls: "maybe" } }),
);
expect(result.fieldErrors["openai.parallelToolCalls"]).toBe(
"Parallel tool calls must be true or false.",
expect(result.fieldErrors["openai.parallelToolCalls"]).toContain(
"must be true or false",
);
});
@@ -570,8 +586,8 @@ describe("buildModelConfigFromForm", () => {
"openai",
formWith({ openai: { textVerbosity: "invalid" } }),
);
expect(result.fieldErrors["openai.textVerbosity"]).toBe(
"Text verbosity has an invalid value.",
expect(result.fieldErrors["openai.textVerbosity"]).toContain(
"invalid value",
);
});
@@ -611,7 +627,9 @@ describe("buildModelConfigFromForm", () => {
it("builds Anthropic options with thinking budget", () => {
const result = buildModelConfigFromForm(
"anthropic",
formWith({ anthropic: { thinkingBudgetTokens: "2048" } }),
formWith({
anthropic: { thinking: { budgetTokens: "2048" } },
}),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.anthropic).toEqual({
@@ -625,7 +643,7 @@ describe("buildModelConfigFromForm", () => {
formWith({
anthropic: {
effort: "max",
thinkingBudgetTokens: "1024",
thinking: { budgetTokens: "1024" },
sendReasoning: "false",
disableParallelToolUse: "true",
},
@@ -645,18 +663,18 @@ describe("buildModelConfigFromForm", () => {
"anthropic",
formWith({ anthropic: { effort: "ultra" } }),
);
expect(result.fieldErrors["anthropic.effort"]).toBe(
"Output effort has an invalid value.",
);
expect(result.fieldErrors["anthropic.effort"]).toContain("invalid value");
});
it("reports error for non-numeric thinking budget tokens", () => {
const result = buildModelConfigFromForm(
"anthropic",
formWith({ anthropic: { thinkingBudgetTokens: "lots" } }),
formWith({
anthropic: { thinking: { budgetTokens: "lots" } },
}),
);
expect(result.fieldErrors["anthropic.thinkingBudgetTokens"]).toBe(
"Thinking budget tokens must be a valid number.",
expect(result.fieldErrors["anthropic.thinking.budgetTokens"]).toContain(
"must be a valid integer",
);
});
});
@@ -665,7 +683,9 @@ describe("buildModelConfigFromForm", () => {
it("builds Google provider options with thinking budget", () => {
const result = buildModelConfigFromForm(
"google",
formWith({ google: { thinkingBudget: "4096" } }),
formWith({
google: { thinkingConfig: { thinkingBudget: "4096" } },
}),
);
expect(result.fieldErrors).toEqual({});
expect(result.modelConfig?.provider_options?.google).toEqual({
@@ -676,7 +696,9 @@ describe("buildModelConfigFromForm", () => {
it("builds Google options with include_thoughts", () => {
const result = buildModelConfigFromForm(
"google",
formWith({ google: { includeThoughts: "true" } }),
formWith({
google: { thinkingConfig: { includeThoughts: "true" } },
}),
);
expect(result.fieldErrors).toEqual({});
const google = result.modelConfig?.provider_options?.google as Record<
@@ -691,8 +713,10 @@ describe("buildModelConfigFromForm", () => {
"google",
formWith({
google: {
thinkingBudget: "2048",
includeThoughts: "false",
thinkingConfig: {
thinkingBudget: "2048",
includeThoughts: "false",
},
},
}),
);
@@ -722,7 +746,7 @@ describe("buildModelConfigFromForm", () => {
const settings = [{ category: "harm", threshold: "block" }];
const result = buildModelConfigFromForm(
"google",
formWith({ google: { safetySettingsJSON: JSON.stringify(settings) } }),
formWith({ google: { safetySettings: JSON.stringify(settings) } }),
);
expect(result.fieldErrors).toEqual({});
const google = result.modelConfig?.provider_options?.google as Record<
@@ -735,31 +759,33 @@ describe("buildModelConfigFromForm", () => {
it("reports error for invalid JSON in safety settings", () => {
const result = buildModelConfigFromForm(
"google",
formWith({ google: { safetySettingsJSON: "not-json" } }),
formWith({ google: { safetySettings: "not-json" } }),
);
expect(result.fieldErrors["google.safetySettingsJSON"]).toBe(
"Safety settings JSON must be valid JSON.",
expect(result.fieldErrors["google.safetySettings"]).toContain(
"must be valid JSON",
);
});
it("reports error when safety settings JSON is an object (not array)", () => {
const result = buildModelConfigFromForm(
"google",
formWith({ google: { safetySettingsJSON: '{"key":"value"}' } }),
formWith({ google: { safetySettings: '{"key":"value"}' } }),
);
expect(result.fieldErrors["google.safetySettingsJSON"]).toBe(
"Safety settings JSON must be an array.",
expect(result.fieldErrors["google.safetySettings"]).toContain(
"must be a JSON array",
);
});
it("reports error for non-numeric thinking budget", () => {
const result = buildModelConfigFromForm(
"google",
formWith({ google: { thinkingBudget: "abc" } }),
);
expect(result.fieldErrors["google.thinkingBudget"]).toBe(
"Thinking budget must be a valid number.",
formWith({
google: { thinkingConfig: { thinkingBudget: "abc" } },
}),
);
expect(
result.fieldErrors["google.thinkingConfig.thinkingBudget"],
).toContain("must be a valid integer");
});
});
@@ -786,8 +812,8 @@ describe("buildModelConfigFromForm", () => {
"openaicompat",
formWith({ openaicompat: { reasoningEffort: "super" } }),
);
expect(result.fieldErrors["openaicompat.reasoningEffort"]).toBe(
"Reasoning effort has an invalid value.",
expect(result.fieldErrors["openaicompat.reasoningEffort"]).toContain(
"invalid value",
);
});
@@ -806,10 +832,12 @@ describe("buildModelConfigFromForm", () => {
"openrouter",
formWith({
openrouter: {
reasoningEnabled: "true",
reasoningEffort: "high",
reasoningMaxTokens: "500",
reasoningExclude: "false",
reasoning: {
enabled: "true",
effort: "high",
maxTokens: "500",
exclude: "false",
},
},
}),
);
@@ -855,20 +883,24 @@ describe("buildModelConfigFromForm", () => {
it("reports error for invalid reasoning effort", () => {
const result = buildModelConfigFromForm(
"openrouter",
formWith({ openrouter: { reasoningEffort: "turbo" } }),
formWith({
openrouter: { reasoning: { effort: "turbo" } },
}),
);
expect(result.fieldErrors["openrouter.reasoningEffort"]).toBe(
"Reasoning effort has an invalid value.",
expect(result.fieldErrors["openrouter.reasoning.effort"]).toContain(
"invalid value",
);
});
it("reports error for invalid boolean in reasoning enabled", () => {
const result = buildModelConfigFromForm(
"openrouter",
formWith({ openrouter: { reasoningEnabled: "yes" } }),
formWith({
openrouter: { reasoning: { enabled: "yes" } },
}),
);
expect(result.fieldErrors["openrouter.reasoningEnabled"]).toBe(
"Reasoning enabled must be true or false.",
expect(result.fieldErrors["openrouter.reasoning.enabled"]).toContain(
"must be true or false",
);
});
});
@@ -879,10 +911,12 @@ describe("buildModelConfigFromForm", () => {
"vercel",
formWith({
vercel: {
reasoningEnabled: "true",
reasoningEffort: "medium",
reasoningMaxTokens: "1000",
reasoningExclude: "true",
reasoning: {
enabled: "true",
effort: "medium",
maxTokens: "1000",
exclude: "true",
},
},
}),
);
File diff suppressed because it is too large Load Diff