Files
coder/site/src/modules/aiModels/modelConfigFormLogic.ts
T
Jake Howell 257c6f1dca refactor: move shared AI model UI into modules/aiModels (#29040)
> 🤖 Refactoring with Cursor.

AI settings was importing model form fields, ModelSelector, and
ProviderIcon from AgentsPage. Those now live in
`site/src/modules/aiModels`.

No user-facing behavior change.

- Move ProviderIcon, ModelSelector, and the model admin form fields into
`modules/aiModels`
- Point AgentsPage and AI settings at the new paths
- Drop the leftover ChatModelAdminPanel ProviderIcon duplicate and the
knownModels barrel
- Update the known-models catalog generator paths
2026-09-08 14:34:51 +10:00

604 lines
18 KiB
TypeScript

import * as Yup from "yup";
import {
type FieldSchema,
getGeneralFields,
getProviderFields,
getProviderNames,
isFieldVisibleForProvider,
resolveProvider,
snakeToCamel,
} from "#/api/chatModelOptions";
import type * as TypesGen from "#/api/typesGenerated";
// ── Preserved public types ─────────────────────────────────────
export type ModelConfigFormState = Record<
string,
string | Record<string, unknown>
>;
export type ModelConfigFormBuildResult = {
modelConfig?: TypesGen.ChatModelCallConfig;
fieldErrors: Record<string, string>;
};
export type ModelFormValues = {
model: string;
displayName: string;
enabled: boolean;
contextLimit: string;
compressionThreshold: string;
isDefault: boolean;
config: ModelConfigFormState;
};
// ── Preserved parsing utilities ────────────────────────────────
export const parsePositiveInteger = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
};
export const parseThresholdInteger = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) return null;
return parsed;
};
// ── Internal helpers ───────────────────────────────────────────
/**
* Set a value inside a nested object, creating intermediate
* objects along the way. The path is an array of string keys.
*/
export function deepSet(
obj: Record<string, unknown>,
path: string[],
value: unknown,
): void {
let current = obj;
for (let i = 0; i < path.length - 1; i++) {
const key = path[i];
if (
current[key] === undefined ||
current[key] === null ||
typeof current[key] !== "object"
) {
current[key] = {};
}
current = current[key] as Record<string, unknown>;
}
current[path[path.length - 1]] = value;
}
/**
* Get a value from a nested object using an array of string keys.
* Returns `undefined` if any intermediate key is missing.
*/
export 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;
}
const hasObjectKeys = (value: Record<string, unknown>): boolean =>
Object.keys(value).length > 0;
export const isVisibleWhenSatisfied = (
field: FieldSchema,
readSiblingValue: (jsonName: string) => unknown,
): boolean =>
!field.visible_when || readSiblingValue(field.visible_when) === "true";
// A field counts as "set" when it holds a non-empty value. JSON array fields
// serialize to "[]" when empty, so that is treated as unset too.
export const hasFieldValue = (raw: unknown): boolean => {
if (typeof raw !== "string") {
return false;
}
const trimmed = raw.trim();
return trimmed.length > 0 && trimmed !== "[]";
};
/**
* conflicts_with: disable the field while a mutually exclusive sibling holds
* a value, unless this field also has one so a both-set state stays
* recoverable.
*/
export const isFieldConflictDisabled = (
field: FieldSchema,
readSiblingValue: (jsonName: string) => unknown,
): boolean =>
Boolean(field.conflicts_with) &&
!hasFieldValue(readSiblingValue(field.json_name)) &&
(field.conflicts_with ?? []).some((sibling) =>
hasFieldValue(readSiblingValue(sibling)),
);
/**
* Convert a form string value to its API representation based on
* the field schema type. Empty strings yield `undefined` so
* callers can conditionally include fields.
*/
function convertFormValue(value: string, field: FieldSchema): unknown {
const trimmed = value.trim();
if (!trimmed) return undefined;
switch (field.type) {
case "integer":
return Number.parseInt(trimmed, 10);
case "number":
return Number(trimmed);
case "boolean":
return trimmed === "true";
case "array":
case "object":
return JSON.parse(trimmed);
default:
return trimmed;
}
}
/**
* Convert an API value to a form string. Arrays and objects are
* pretty-printed as JSON; null/undefined become empty strings.
*/
function toFormString(v: unknown): string {
if (v === undefined || v === null) return "";
if (typeof v === "object") return JSON.stringify(v, null, 2);
return String(v);
}
// ── Schema-driven form state generation ────────────────────────
/**
* Build an empty form state object for a single provider by
* walking its field schemas. Nested `json_name` values
* (e.g. `"thinking.budget_tokens"`) produce nested objects with
* camelCase keys matching `toFormFieldKey`.
*/
function buildEmptyProviderState(provider: string): Record<string, unknown> {
const state: Record<string, unknown> = {};
for (const field of getProviderFields(provider)) {
const camelSegments = field.json_name.split(".").map(snakeToCamel);
deepSet(state, camelSegments, "");
}
return state;
}
/**
* The empty form state, generated from the schema at module load.
* Contains general fields as top-level camelCase keys and one
* nested object per canonical provider.
*/
export const emptyModelConfigFormState: ModelConfigFormState = (() => {
const state: ModelConfigFormState = {};
for (const field of getGeneralFields()) {
const camelSegments = field.json_name.split(".").map(snakeToCamel);
deepSet(state, camelSegments, "");
}
// Provider sub-objects.
for (const provider of getProviderNames()) {
state[provider] = buildEmptyProviderState(provider);
}
return state;
})();
// ── Extract form state from an existing API model ──────────────
export const extractModelConfigFormState = (
model: TypesGen.ChatModel,
): ModelConfigFormState => {
const config = model.model_config;
if (!config) {
return structuredClone(emptyModelConfigFormState);
}
const state: ModelConfigFormState = {};
for (const field of getGeneralFields()) {
const snakeSegments = field.json_name.split(".");
const camelSegments = snakeSegments.map(snakeToCamel);
const apiValue = deepGet(config, snakeSegments);
deepSet(state, camelSegments, toFormString(apiValue));
}
// Provider sub-objects.
const po = config.provider_options;
for (const provider of getProviderNames()) {
const providerData = (po as Record<string, unknown> | undefined)?.[
provider
] as Record<string, unknown> | undefined;
const providerState: Record<string, unknown> = {};
for (const field of getProviderFields(provider)) {
// Navigate into the API response using snake_case segments.
const snakeSegments = field.json_name.split(".");
const apiValue = providerData
? deepGet(providerData, snakeSegments)
: undefined;
// Store under camelCase segments to match the form field key
// structure produced by toFormFieldKey.
const camelSegments = field.json_name.split(".").map(snakeToCamel);
deepSet(providerState, camelSegments, toFormString(apiValue));
}
state[provider] = providerState;
}
return state;
};
// ── Build initial model form values ────────────────────────────
export const buildInitialModelFormValues = (
sourceModel?: TypesGen.ChatModel,
): ModelFormValues => ({
model: sourceModel?.model ?? "",
displayName: sourceModel?.display_name ?? "",
enabled: sourceModel?.enabled ?? true,
contextLimit: sourceModel ? String(sourceModel.context_limit) : "",
compressionThreshold: sourceModel
? String(sourceModel.compression_threshold)
: "",
isDefault: sourceModel?.is_default ?? false,
config: sourceModel
? extractModelConfigFormState(sourceModel)
: structuredClone(emptyModelConfigFormState),
});
const reasoningEffortEnum =
getGeneralFields().find(
(field) => field.json_name === "reasoning_effort.default",
)?.enum ?? [];
const reasoningEffortRank = (value: string): number =>
reasoningEffortEnum.indexOf(value.trim().toLowerCase());
function isValidOptionalNumber(value: string | undefined): boolean {
const trimmed = value?.trim();
if (!trimmed) {
return true;
}
return Number.isFinite(Number(trimmed));
}
// ── Schema-driven Yup validation ───────────────────────────────
/**
* Build a Yup string test for a single field schema. All form
* values are strings; the test checks whether a non-empty value
* can be parsed according to the field's declared type and enum.
*/
function yupTestForField(field: FieldSchema): Yup.StringSchema {
const label = field.description ?? field.go_name;
switch (field.type) {
case "integer":
return Yup.string().test(
"optional-integer",
`${label} must be a valid integer.`,
(value) => {
const trimmed = value?.trim();
if (!trimmed) return true;
return Number.isFinite(Number.parseInt(trimmed, 10));
},
);
case "number":
return Yup.string().test(
"optional-number",
`${label} must be a valid number.`,
(value) => isValidOptionalNumber(value),
);
case "boolean":
return Yup.string().test(
"optional-boolean",
`${label} must be true or false.`,
(value) => {
const trimmed = value?.trim();
if (!trimmed) return true;
return trimmed === "true" || trimmed === "false";
},
);
case "string":
if (field.enum && field.enum.length > 0) {
const allowed = field.enum;
return Yup.string().test(
"optional-select",
`${label} has an invalid value.`,
(value) => {
const trimmed = value?.trim();
if (!trimmed) return true;
return allowed.includes(trimmed);
},
);
}
// Plain strings are always valid.
return Yup.string();
case "array":
return Yup.string().test(
"optional-json-array",
"",
function validate(value) {
const trimmed = value?.trim();
if (!trimmed) return true;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return this.createError({
message: `${label} must be valid JSON.`,
});
}
if (!Array.isArray(parsed)) {
return this.createError({
message: `${label} must be a JSON array.`,
});
}
return true;
},
);
case "object":
return Yup.string().test(
"optional-json-object",
"",
function validate(value) {
const trimmed = value?.trim();
if (!trimmed) return true;
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return this.createError({
message: `${label} must be valid JSON.`,
});
}
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
return this.createError({
message: `${label} must be a JSON object.`,
});
}
return true;
},
);
}
}
/**
* Build a Yup object schema for a list of field schemas. Each
* field is keyed by the **leaf** camelCase name of its json_name.
* For nested fields (e.g. `thinking.budget_tokens`) we build a
* nested Yup object shape.
*/
function buildYupSchema(
fields: FieldSchema[],
): Yup.ObjectSchema<Record<string, unknown>> {
const shape: Record<string, Yup.AnySchema> = {};
// Group fields by their first camelCase segment so we can
// build nested Yup.object() shapes for dot-notation names.
const nested = new Map<string, FieldSchema[]>();
for (const field of fields) {
const segments = field.json_name.split(".");
if (segments.length === 1) {
// Top-level field.
shape[snakeToCamel(segments[0])] = yupTestForField(field);
} else {
// Nested — group by first segment.
const parentKey = snakeToCamel(segments[0]);
if (!nested.has(parentKey)) {
nested.set(parentKey, []);
}
nested.get(parentKey)!.push(field);
}
}
// Build nested object schemas.
for (const [parentKey, nestedFields] of nested) {
const nestedShape: Record<string, Yup.AnySchema> = {};
for (const field of nestedFields) {
const segments = field.json_name.split(".");
// Use the tail segments (after the first) as the nested key.
const leafKey = segments.slice(1).map(snakeToCamel).join(".");
nestedShape[leafKey] = yupTestForField(field);
}
shape[parentKey] = Yup.object(nestedShape);
}
return Yup.object(shape) as Yup.ObjectSchema<Record<string, unknown>>;
}
// Pre-built general-fields schema with reasoning effort bounds.
const generalFieldsSchema = buildYupSchema(getGeneralFields()).test(
"reasoning-effort-default-lte-max",
"Default reasoning effort must not exceed the max reasoning effort.",
function validate(value) {
const efforts = deepGet(value, ["reasoningEffort"]);
const defaultValue = deepGet(efforts, ["default"]);
const maxValue = deepGet(efforts, ["max"]);
const defaultSet =
typeof defaultValue === "string" && defaultValue.trim() !== "";
const maxSet = typeof maxValue === "string" && maxValue.trim() !== "";
if (defaultSet !== maxSet) {
return this.createError({
path: defaultSet ? "reasoningEffort.max" : "reasoningEffort.default",
message: "Default and max reasoning effort must both be set.",
});
}
if (!defaultSet || !maxSet) {
return true;
}
const defaultRank = reasoningEffortRank(defaultValue);
const maxRank = reasoningEffortRank(maxValue);
// Unset or invalid values are covered by the per-field enum tests.
if (defaultRank < 0 || maxRank < 0 || defaultRank <= maxRank) {
return true;
}
return this.createError({
path: "reasoningEffort.default",
message:
"Default reasoning effort must not exceed the max reasoning effort.",
});
},
);
// Cache of per-provider Yup schemas, built lazily.
const providerSchemaCache = new Map<
string,
Yup.ObjectSchema<Record<string, unknown>>
>();
function getProviderYupSchema(
provider: string,
): Yup.ObjectSchema<Record<string, unknown>> {
const resolved = resolveProvider(provider);
let schema = providerSchemaCache.get(resolved);
if (!schema) {
schema = buildYupSchema(getProviderFields(resolved));
providerSchemaCache.set(resolved, schema);
}
return schema;
}
// ── Yup error collection ───────────────────────────────────────
type FieldErrors = Record<string, string>;
function collectYupErrors(
schema: Yup.ObjectSchema<Record<string, unknown>>,
data: Record<string, unknown>,
fieldErrors: FieldErrors,
prefix?: string,
): void {
try {
schema.validateSync(data, { abortEarly: false });
} catch (err) {
if (err instanceof Yup.ValidationError) {
for (const inner of err.inner) {
const key = prefix ? `${prefix}.${inner.path}` : (inner.path ?? "");
fieldErrors[key] = inner.message;
}
}
}
}
// ── Form → model config builder ────────────────────────────────
export const buildModelConfigFromForm = (
provider: string | null | undefined,
form: ModelConfigFormState,
): ModelConfigFormBuildResult => {
const fieldErrors: FieldErrors = {};
// Validate general (top-level) fields.
collectYupErrors(
generalFieldsSchema,
form as Record<string, unknown>,
fieldErrors,
);
const rawProvider = (provider ?? "").trim().toLowerCase();
// Resolve the canonical provider name through the alias table.
const resolved = resolveProvider(rawProvider);
// Validate provider-specific fields.
const providerFormState = form[resolved];
if (providerFormState && typeof providerFormState === "object") {
collectYupErrors(
getProviderYupSchema(resolved),
providerFormState as Record<string, unknown>,
fieldErrors,
resolved,
);
}
if (Object.keys(fieldErrors).length > 0) {
return { fieldErrors };
}
// ── Transform general fields ──────────────────────────────
const modelConfig: Record<string, unknown> = {};
for (const field of getGeneralFields()) {
// Skip fields scoped to other providers so stale values left in form
// state are not serialized.
if (!isFieldVisibleForProvider(field, rawProvider)) continue;
const camelSegments = field.json_name.split(".").map(snakeToCamel);
const formValue = deepGet(form, camelSegments);
if (typeof formValue !== "string") continue;
const converted = convertFormValue(formValue, field);
if (converted !== undefined) {
deepSet(modelConfig, field.json_name.split("."), converted);
}
}
// ── Transform provider-specific fields ────────────────────
if (providerFormState && typeof providerFormState === "object") {
const providerPayload: Record<string, unknown> = {};
const readProviderValue = (jsonName: string): unknown =>
deepGet(providerFormState, jsonName.split(".").map(snakeToCamel));
for (const field of getProviderFields(resolved)) {
// Skip fields hidden by an unsatisfied `visible_when` gate so
// stale values left in form state are not serialized.
if (!isVisibleWhenSatisfied(field, readProviderValue)) continue;
// Read the form value from the nested camelCase structure.
const camelSegments = field.json_name.split(".").map(snakeToCamel);
const formValue = deepGet(providerFormState, camelSegments);
if (typeof formValue !== "string") continue;
const converted = convertFormValue(formValue, field);
if (converted === undefined) continue;
// Write into the API payload using the snake_case
// json_name segments.
const snakeSegments = field.json_name.split(".");
deepSet(providerPayload, snakeSegments, converted);
}
if (hasObjectKeys(providerPayload)) {
modelConfig.provider_options = { [resolved]: providerPayload };
}
}
if (!hasObjectKeys(modelConfig)) {
return { fieldErrors: {} };
}
return {
modelConfig: modelConfig as TypesGen.ChatModelCallConfig,
fieldErrors: {},
};
};