fix(ui): coerce form values to schema types before config.set (#13468)
Co-authored-by: Gustavo Madeira Santana <gumadeiras@users.noreply.github.com>
This commit is contained in:
@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
|
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
|
||||||
- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc.
|
- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc.
|
||||||
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
|
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
|
||||||
|
- Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr.
|
||||||
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
|
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
|
||||||
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.
|
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.
|
||||||
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
|
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
applyConfigSnapshot,
|
applyConfigSnapshot,
|
||||||
applyConfig,
|
applyConfig,
|
||||||
runUpdate,
|
runUpdate,
|
||||||
|
saveConfig,
|
||||||
updateConfigFormValue,
|
updateConfigFormValue,
|
||||||
type ConfigState,
|
type ConfigState,
|
||||||
} from "./config.ts";
|
} from "./config.ts";
|
||||||
@@ -157,6 +158,124 @@ describe("applyConfig", () => {
|
|||||||
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
sessionKey: "agent:main:whatsapp:dm:+15555550123",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coerces schema-typed values before config.apply in form mode", async () => {
|
||||||
|
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||||
|
if (method === "config.get") {
|
||||||
|
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const state = createState();
|
||||||
|
state.connected = true;
|
||||||
|
state.client = { request } as unknown as ConfigState["client"];
|
||||||
|
state.applySessionKey = "agent:main:web:dm:test";
|
||||||
|
state.configFormMode = "form";
|
||||||
|
state.configForm = {
|
||||||
|
gateway: { port: "18789", debug: "true" },
|
||||||
|
};
|
||||||
|
state.configSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
gateway: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
port: { type: "number" },
|
||||||
|
debug: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.configSnapshot = { hash: "hash-apply-1" };
|
||||||
|
|
||||||
|
await applyConfig(state);
|
||||||
|
|
||||||
|
expect(request.mock.calls[0]?.[0]).toBe("config.apply");
|
||||||
|
const params = request.mock.calls[0]?.[1] as {
|
||||||
|
raw: string;
|
||||||
|
baseHash: string;
|
||||||
|
sessionKey: string;
|
||||||
|
};
|
||||||
|
const parsed = JSON.parse(params.raw) as {
|
||||||
|
gateway: { port: unknown; debug: unknown };
|
||||||
|
};
|
||||||
|
expect(typeof parsed.gateway.port).toBe("number");
|
||||||
|
expect(parsed.gateway.port).toBe(18789);
|
||||||
|
expect(parsed.gateway.debug).toBe(true);
|
||||||
|
expect(params.baseHash).toBe("hash-apply-1");
|
||||||
|
expect(params.sessionKey).toBe("agent:main:web:dm:test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveConfig", () => {
|
||||||
|
it("coerces schema-typed values before config.set in form mode", async () => {
|
||||||
|
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||||
|
if (method === "config.get") {
|
||||||
|
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const state = createState();
|
||||||
|
state.connected = true;
|
||||||
|
state.client = { request } as unknown as ConfigState["client"];
|
||||||
|
state.configFormMode = "form";
|
||||||
|
state.configForm = {
|
||||||
|
gateway: { port: "18789", enabled: "false" },
|
||||||
|
};
|
||||||
|
state.configSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
gateway: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
port: { type: "number" },
|
||||||
|
enabled: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.configSnapshot = { hash: "hash-save-1" };
|
||||||
|
|
||||||
|
await saveConfig(state);
|
||||||
|
|
||||||
|
expect(request.mock.calls[0]?.[0]).toBe("config.set");
|
||||||
|
const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string };
|
||||||
|
const parsed = JSON.parse(params.raw) as {
|
||||||
|
gateway: { port: unknown; enabled: unknown };
|
||||||
|
};
|
||||||
|
expect(typeof parsed.gateway.port).toBe("number");
|
||||||
|
expect(parsed.gateway.port).toBe(18789);
|
||||||
|
expect(parsed.gateway.enabled).toBe(false);
|
||||||
|
expect(params.baseHash).toBe("hash-save-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips coercion when schema is not an object", async () => {
|
||||||
|
const request = vi.fn().mockImplementation(async (method: string) => {
|
||||||
|
if (method === "config.get") {
|
||||||
|
return { config: {}, valid: true, issues: [], raw: "{\n}\n" };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
const state = createState();
|
||||||
|
state.connected = true;
|
||||||
|
state.client = { request } as unknown as ConfigState["client"];
|
||||||
|
state.configFormMode = "form";
|
||||||
|
state.configForm = {
|
||||||
|
gateway: { port: "18789" },
|
||||||
|
};
|
||||||
|
state.configSchema = "invalid-schema";
|
||||||
|
state.configSnapshot = { hash: "hash-save-2" };
|
||||||
|
|
||||||
|
await saveConfig(state);
|
||||||
|
|
||||||
|
expect(request.mock.calls[0]?.[0]).toBe("config.set");
|
||||||
|
const params = request.mock.calls[0]?.[1] as { raw: string; baseHash: string };
|
||||||
|
const parsed = JSON.parse(params.raw) as {
|
||||||
|
gateway: { port: unknown };
|
||||||
|
};
|
||||||
|
expect(parsed.gateway.port).toBe("18789");
|
||||||
|
expect(params.baseHash).toBe("hash-save-2");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runUpdate", () => {
|
describe("runUpdate", () => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||||
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts";
|
import type { ConfigSchemaResponse, ConfigSnapshot, ConfigUiHints } from "../types.ts";
|
||||||
|
import type { JsonSchema } from "../views/config-form.shared.ts";
|
||||||
|
import { coerceFormValues } from "./config/form-coerce.ts";
|
||||||
import {
|
import {
|
||||||
cloneConfigObject,
|
cloneConfigObject,
|
||||||
removePathValue,
|
removePathValue,
|
||||||
@@ -99,6 +101,32 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asJsonSchema(value: unknown): JsonSchema | null {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value as JsonSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the form state for submission to `config.set` / `config.apply`.
|
||||||
|
*
|
||||||
|
* HTML `<input>` elements produce string `.value` properties, so numeric and
|
||||||
|
* boolean config fields can leak into `configForm` as strings. We coerce
|
||||||
|
* them back to their schema-defined types before JSON serialization so the
|
||||||
|
* gateway's Zod validation always sees correctly typed values.
|
||||||
|
*/
|
||||||
|
function serializeFormForSubmit(state: ConfigState): string {
|
||||||
|
if (state.configFormMode !== "form" || !state.configForm) {
|
||||||
|
return state.configRaw;
|
||||||
|
}
|
||||||
|
const schema = asJsonSchema(state.configSchema);
|
||||||
|
const form = schema
|
||||||
|
? (coerceFormValues(state.configForm, schema) as Record<string, unknown>)
|
||||||
|
: state.configForm;
|
||||||
|
return serializeConfigForm(form);
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveConfig(state: ConfigState) {
|
export async function saveConfig(state: ConfigState) {
|
||||||
if (!state.client || !state.connected) {
|
if (!state.client || !state.connected) {
|
||||||
return;
|
return;
|
||||||
@@ -106,10 +134,7 @@ export async function saveConfig(state: ConfigState) {
|
|||||||
state.configSaving = true;
|
state.configSaving = true;
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
try {
|
try {
|
||||||
const raw =
|
const raw = serializeFormForSubmit(state);
|
||||||
state.configFormMode === "form" && state.configForm
|
|
||||||
? serializeConfigForm(state.configForm)
|
|
||||||
: state.configRaw;
|
|
||||||
const baseHash = state.configSnapshot?.hash;
|
const baseHash = state.configSnapshot?.hash;
|
||||||
if (!baseHash) {
|
if (!baseHash) {
|
||||||
state.lastError = "Config hash missing; reload and retry.";
|
state.lastError = "Config hash missing; reload and retry.";
|
||||||
@@ -132,10 +157,7 @@ export async function applyConfig(state: ConfigState) {
|
|||||||
state.configApplying = true;
|
state.configApplying = true;
|
||||||
state.lastError = null;
|
state.lastError = null;
|
||||||
try {
|
try {
|
||||||
const raw =
|
const raw = serializeFormForSubmit(state);
|
||||||
state.configFormMode === "form" && state.configForm
|
|
||||||
? serializeConfigForm(state.configForm)
|
|
||||||
: state.configRaw;
|
|
||||||
const baseHash = state.configSnapshot?.hash;
|
const baseHash = state.configSnapshot?.hash;
|
||||||
if (!baseHash) {
|
if (!baseHash) {
|
||||||
state.lastError = "Config hash missing; reload and retry.";
|
state.lastError = "Config hash missing; reload and retry.";
|
||||||
|
|||||||
160
ui/src/ui/controllers/config/form-coerce.ts
Normal file
160
ui/src/ui/controllers/config/form-coerce.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { schemaType, type JsonSchema } from "../../views/config-form.shared.ts";
|
||||||
|
|
||||||
|
function coerceNumberString(value: string, integer: boolean): number | undefined | string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (integer && !Number.isInteger(parsed)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceBooleanString(value: string): boolean | string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed === "true") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (trimmed === "false") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk a form value tree alongside its JSON Schema and coerce string values
|
||||||
|
* to their schema-defined types (number, boolean).
|
||||||
|
*
|
||||||
|
* HTML `<input>` elements always produce string `.value` properties. Even
|
||||||
|
* though the form rendering code converts values correctly for most paths,
|
||||||
|
* some interactions (map-field repopulation, re-renders, paste, etc.) can
|
||||||
|
* leak raw strings into the config form state. This utility acts as a
|
||||||
|
* safety net before serialization so that `config.set` always receives
|
||||||
|
* correctly typed JSON.
|
||||||
|
*/
|
||||||
|
export function coerceFormValues(value: unknown, schema: JsonSchema): unknown {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schema.allOf && schema.allOf.length > 0) {
|
||||||
|
let next: unknown = value;
|
||||||
|
for (const segment of schema.allOf) {
|
||||||
|
next = coerceFormValues(next, segment);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = schemaType(schema);
|
||||||
|
|
||||||
|
// Handle anyOf/oneOf — try to match the value against a variant
|
||||||
|
if (schema.anyOf || schema.oneOf) {
|
||||||
|
const variants = (schema.anyOf ?? schema.oneOf ?? []).filter(
|
||||||
|
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (variants.length === 1) {
|
||||||
|
return coerceFormValues(value, variants[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try number/boolean coercion for string values
|
||||||
|
if (typeof value === "string") {
|
||||||
|
for (const variant of variants) {
|
||||||
|
const variantType = schemaType(variant);
|
||||||
|
if (variantType === "number" || variantType === "integer") {
|
||||||
|
const coerced = coerceNumberString(value, variantType === "integer");
|
||||||
|
if (coerced === undefined || typeof coerced === "number") {
|
||||||
|
return coerced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (variantType === "boolean") {
|
||||||
|
const coerced = coerceBooleanString(value);
|
||||||
|
if (typeof coerced === "boolean") {
|
||||||
|
return coerced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-string values (objects, arrays), try to recurse into matching variant
|
||||||
|
for (const variant of variants) {
|
||||||
|
const variantType = schemaType(variant);
|
||||||
|
if (variantType === "object" && typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
return coerceFormValues(value, variant);
|
||||||
|
}
|
||||||
|
if (variantType === "array" && Array.isArray(value)) {
|
||||||
|
return coerceFormValues(value, variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "number" || type === "integer") {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const coerced = coerceNumberString(value, type === "integer");
|
||||||
|
if (coerced === undefined || typeof coerced === "number") {
|
||||||
|
return coerced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "boolean") {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const coerced = coerceBooleanString(value);
|
||||||
|
if (typeof coerced === "boolean") {
|
||||||
|
return coerced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "object") {
|
||||||
|
if (typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
const props = schema.properties ?? {};
|
||||||
|
const additional =
|
||||||
|
schema.additionalProperties && typeof schema.additionalProperties === "object"
|
||||||
|
? schema.additionalProperties
|
||||||
|
: null;
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
const propSchema = props[key] ?? additional;
|
||||||
|
const coerced = propSchema ? coerceFormValues(val, propSchema) : val;
|
||||||
|
// Omit undefined — "clear field = unset" for optional properties
|
||||||
|
if (coerced !== undefined) {
|
||||||
|
result[key] = coerced;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "array") {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (Array.isArray(schema.items)) {
|
||||||
|
// Tuple form: each index has its own schema
|
||||||
|
const tuple = schema.items;
|
||||||
|
return value.map((item, i) => {
|
||||||
|
const s = i < tuple.length ? tuple[i] : undefined;
|
||||||
|
return s ? coerceFormValues(item, s) : item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const itemsSchema = schema.items;
|
||||||
|
if (!itemsSchema) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return value.map((item) => coerceFormValues(item, itemsSchema)).filter((v) => v !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
471
ui/src/ui/controllers/config/form-utils.node.test.ts
Normal file
471
ui/src/ui/controllers/config/form-utils.node.test.ts
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user