472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { JsonSchema } from "../../views/config-form.shared.ts";
|
|
import { coerceFormValues } from "./form-coerce.ts";
|
|
import { cloneConfigObject, serializeConfigForm, setPathValue } from "./form-utils.ts";
|
|
|
|
/**
|
|
* Minimal model provider schema matching the Zod-generated JSON Schema for
|
|
* `models.providers` (see zod-schema.core.ts → ModelDefinitionSchema).
|
|
*/
|
|
const modelDefinitionSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
id: { type: "string" },
|
|
name: { type: "string" },
|
|
reasoning: { type: "boolean" },
|
|
contextWindow: { type: "number" },
|
|
maxTokens: { type: "number" },
|
|
cost: {
|
|
type: "object",
|
|
properties: {
|
|
input: { type: "number" },
|
|
output: { type: "number" },
|
|
cacheRead: { type: "number" },
|
|
cacheWrite: { type: "number" },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const modelProviderSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
baseUrl: { type: "string" },
|
|
apiKey: { type: "string" },
|
|
models: {
|
|
type: "array",
|
|
items: modelDefinitionSchema,
|
|
},
|
|
},
|
|
};
|
|
|
|
const modelsConfigSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
providers: {
|
|
type: "object",
|
|
additionalProperties: modelProviderSchema,
|
|
},
|
|
},
|
|
};
|
|
|
|
const topLevelSchema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
gateway: {
|
|
type: "object",
|
|
properties: {
|
|
auth: {
|
|
type: "object",
|
|
properties: {
|
|
token: { type: "string" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
models: modelsConfigSchema,
|
|
},
|
|
};
|
|
|
|
function makeConfigWithProvider(): Record<string, unknown> {
|
|
return {
|
|
gateway: { auth: { token: "test-token" } },
|
|
models: {
|
|
providers: {
|
|
xai: {
|
|
baseUrl: "https://api.x.ai/v1",
|
|
models: [
|
|
{
|
|
id: "grok-4",
|
|
name: "Grok 4",
|
|
contextWindow: 131072,
|
|
maxTokens: 8192,
|
|
cost: { input: 0.5, output: 1.0, cacheRead: 0.1, cacheWrite: 0.2 },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("form-utils preserves numeric types", () => {
|
|
it("serializeConfigForm preserves numbers in JSON output", () => {
|
|
const form = makeConfigWithProvider();
|
|
const raw = serializeConfigForm(form);
|
|
const parsed = JSON.parse(raw);
|
|
const model = parsed.models.providers.xai.models[0];
|
|
|
|
expect(typeof model.maxTokens).toBe("number");
|
|
expect(model.maxTokens).toBe(8192);
|
|
expect(typeof model.contextWindow).toBe("number");
|
|
expect(model.contextWindow).toBe(131072);
|
|
expect(typeof model.cost.input).toBe("number");
|
|
expect(model.cost.input).toBe(0.5);
|
|
});
|
|
|
|
it("cloneConfigObject + setPathValue preserves unrelated numeric fields", () => {
|
|
const form = makeConfigWithProvider();
|
|
const cloned = cloneConfigObject(form);
|
|
setPathValue(cloned, ["gateway", "auth", "token"], "new-token");
|
|
|
|
const model = cloned.models as Record<string, unknown>;
|
|
const providers = model.providers as Record<string, unknown>;
|
|
const xai = providers.xai as Record<string, unknown>;
|
|
const models = xai.models as Array<Record<string, unknown>>;
|
|
const first = models[0];
|
|
|
|
expect(typeof first.maxTokens).toBe("number");
|
|
expect(first.maxTokens).toBe(8192);
|
|
expect(typeof first.contextWindow).toBe("number");
|
|
expect(typeof first.cost).toBe("object");
|
|
expect(typeof (first.cost as Record<string, unknown>).input).toBe("number");
|
|
});
|
|
});
|
|
|
|
describe("coerceFormValues", () => {
|
|
it("coerces string numbers to numbers based on schema", () => {
|
|
const form = {
|
|
models: {
|
|
providers: {
|
|
xai: {
|
|
baseUrl: "https://api.x.ai/v1",
|
|
models: [
|
|
{
|
|
id: "grok-4",
|
|
name: "Grok 4",
|
|
contextWindow: "131072",
|
|
maxTokens: "8192",
|
|
cost: { input: "0.5", output: "1.0", cacheRead: "0.1", cacheWrite: "0.2" },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
|
const model = (
|
|
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
|
.xai as Record<string, unknown>
|
|
).models as Array<Record<string, unknown>>;
|
|
const first = model[0];
|
|
|
|
expect(typeof first.maxTokens).toBe("number");
|
|
expect(first.maxTokens).toBe(8192);
|
|
expect(typeof first.contextWindow).toBe("number");
|
|
expect(first.contextWindow).toBe(131072);
|
|
expect(typeof first.cost).toBe("object");
|
|
const cost = first.cost as Record<string, number>;
|
|
expect(typeof cost.input).toBe("number");
|
|
expect(cost.input).toBe(0.5);
|
|
expect(typeof cost.output).toBe("number");
|
|
expect(cost.output).toBe(1);
|
|
expect(typeof cost.cacheRead).toBe("number");
|
|
expect(cost.cacheRead).toBe(0.1);
|
|
expect(typeof cost.cacheWrite).toBe("number");
|
|
expect(cost.cacheWrite).toBe(0.2);
|
|
});
|
|
|
|
it("preserves already-correct numeric values", () => {
|
|
const form = makeConfigWithProvider();
|
|
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
|
const model = (
|
|
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
|
.xai as Record<string, unknown>
|
|
).models as Array<Record<string, unknown>>;
|
|
const first = model[0];
|
|
|
|
expect(typeof first.maxTokens).toBe("number");
|
|
expect(first.maxTokens).toBe(8192);
|
|
});
|
|
|
|
it("does not coerce non-numeric strings to numbers", () => {
|
|
const form = {
|
|
models: {
|
|
providers: {
|
|
xai: {
|
|
baseUrl: "https://api.x.ai/v1",
|
|
models: [
|
|
{
|
|
id: "grok-4",
|
|
name: "Grok 4",
|
|
maxTokens: "not-a-number",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
|
const model = (
|
|
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
|
.xai as Record<string, unknown>
|
|
).models as Array<Record<string, unknown>>;
|
|
const first = model[0];
|
|
|
|
expect(first.maxTokens).toBe("not-a-number");
|
|
});
|
|
|
|
it("coerces string booleans to booleans based on schema", () => {
|
|
const form = {
|
|
models: {
|
|
providers: {
|
|
xai: {
|
|
baseUrl: "https://api.x.ai/v1",
|
|
models: [
|
|
{
|
|
id: "grok-4",
|
|
name: "Grok 4",
|
|
reasoning: "true",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
|
const model = (
|
|
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
|
.xai as Record<string, unknown>
|
|
).models as Array<Record<string, unknown>>;
|
|
expect(model[0].reasoning).toBe(true);
|
|
});
|
|
|
|
it("handles empty string for number fields as undefined", () => {
|
|
const form = {
|
|
models: {
|
|
providers: {
|
|
xai: {
|
|
baseUrl: "https://api.x.ai/v1",
|
|
models: [
|
|
{
|
|
id: "grok-4",
|
|
name: "Grok 4",
|
|
maxTokens: "",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const coerced = coerceFormValues(form, topLevelSchema) as Record<string, unknown>;
|
|
const model = (
|
|
((coerced.models as Record<string, unknown>).providers as Record<string, unknown>)
|
|
.xai as Record<string, unknown>
|
|
).models as Array<Record<string, unknown>>;
|
|
expect(model[0].maxTokens).toBeUndefined();
|
|
});
|
|
|
|
it("passes through null and undefined values untouched", () => {
|
|
expect(coerceFormValues(null, topLevelSchema)).toBeNull();
|
|
expect(coerceFormValues(undefined, topLevelSchema)).toBeUndefined();
|
|
});
|
|
|
|
it("handles anyOf schemas with number variant", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
timeout: {
|
|
anyOf: [{ type: "number" }, { type: "string" }],
|
|
},
|
|
},
|
|
};
|
|
const form = { timeout: "30" };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
expect(typeof coerced.timeout).toBe("number");
|
|
expect(coerced.timeout).toBe(30);
|
|
});
|
|
|
|
it("handles integer schema type", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
count: { type: "integer" },
|
|
},
|
|
};
|
|
const form = { count: "42" };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
expect(typeof coerced.count).toBe("number");
|
|
expect(coerced.count).toBe(42);
|
|
});
|
|
|
|
it("rejects non-integer string for integer schema type", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
count: { type: "integer" },
|
|
},
|
|
};
|
|
const form = { count: "1.5" };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
expect(coerced.count).toBe("1.5");
|
|
});
|
|
|
|
it("does not coerce non-finite numeric strings", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
timeout: { type: "number" },
|
|
},
|
|
};
|
|
const form = { timeout: "Infinity" };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
expect(coerced.timeout).toBe("Infinity");
|
|
});
|
|
|
|
it("supports allOf schema composition", () => {
|
|
const schema: JsonSchema = {
|
|
allOf: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
port: { type: "number" },
|
|
},
|
|
},
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
enabled: { type: "boolean" },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
const form = { port: "8080", enabled: "true" };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
expect(coerced.port).toBe(8080);
|
|
expect(coerced.enabled).toBe(true);
|
|
});
|
|
|
|
it("recurses into object inside anyOf (nullable pattern)", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
settings: {
|
|
anyOf: [
|
|
{
|
|
type: "object",
|
|
properties: {
|
|
port: { type: "number" },
|
|
enabled: { type: "boolean" },
|
|
},
|
|
},
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const form = { settings: { port: "8080", enabled: "true" } };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
const settings = coerced.settings as Record<string, unknown>;
|
|
expect(typeof settings.port).toBe("number");
|
|
expect(settings.port).toBe(8080);
|
|
expect(settings.enabled).toBe(true);
|
|
});
|
|
|
|
it("recurses into array inside anyOf", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
items: {
|
|
anyOf: [
|
|
{
|
|
type: "array",
|
|
items: { type: "object", properties: { count: { type: "number" } } },
|
|
},
|
|
{ type: "null" },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
const form = { items: [{ count: "5" }] };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
const items = coerced.items as Array<Record<string, unknown>>;
|
|
expect(typeof items[0].count).toBe("number");
|
|
expect(items[0].count).toBe(5);
|
|
});
|
|
|
|
it("handles tuple array schemas by index", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
pair: {
|
|
type: "array",
|
|
items: [{ type: "string" }, { type: "number" }],
|
|
},
|
|
},
|
|
};
|
|
const form = { pair: ["hello", "42"] };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
const pair = coerced.pair as unknown[];
|
|
expect(pair[0]).toBe("hello");
|
|
expect(typeof pair[1]).toBe("number");
|
|
expect(pair[1]).toBe(42);
|
|
});
|
|
|
|
it("preserves tuple indexes when a value is cleared", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
tuple: {
|
|
type: "array",
|
|
items: [{ type: "string" }, { type: "number" }, { type: "string" }],
|
|
},
|
|
},
|
|
};
|
|
const form = { tuple: ["left", "", "right"] };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
const tuple = coerced.tuple as unknown[];
|
|
expect(tuple).toHaveLength(3);
|
|
expect(tuple[0]).toBe("left");
|
|
expect(tuple[1]).toBeUndefined();
|
|
expect(tuple[2]).toBe("right");
|
|
});
|
|
|
|
it("omits cleared number field from object output", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
port: { type: "number" },
|
|
},
|
|
};
|
|
const form = { name: "test", port: "" };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
expect(coerced.name).toBe("test");
|
|
expect("port" in coerced).toBe(false);
|
|
});
|
|
|
|
it("filters undefined from array when number item is cleared", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
values: {
|
|
type: "array",
|
|
items: { type: "number" },
|
|
},
|
|
},
|
|
};
|
|
const form = { values: ["1", "", "3"] };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
const values = coerced.values as number[];
|
|
expect(values).toEqual([1, 3]);
|
|
});
|
|
|
|
it("coerces boolean in anyOf union", () => {
|
|
const schema: JsonSchema = {
|
|
type: "object",
|
|
properties: {
|
|
flag: {
|
|
anyOf: [{ type: "boolean" }, { type: "string" }],
|
|
},
|
|
},
|
|
};
|
|
const form = { flag: "true" };
|
|
const coerced = coerceFormValues(form, schema) as Record<string, unknown>;
|
|
expect(coerced.flag).toBe(true);
|
|
});
|
|
});
|