mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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
Reference in New Issue
Block a user