mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
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.
|
||||
- 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: 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: 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.
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyConfigSnapshot,
|
||||
applyConfig,
|
||||
runUpdate,
|
||||
saveConfig,
|
||||
updateConfigFormValue,
|
||||
type ConfigState,
|
||||
} from "./config.ts";
|
||||
@@ -157,6 +158,124 @@ describe("applyConfig", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { GatewayBrowserClient } from "../gateway.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 {
|
||||
cloneConfigObject,
|
||||
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) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
@@ -106,10 +134,7 @@ export async function saveConfig(state: ConfigState) {
|
||||
state.configSaving = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
const raw = serializeFormForSubmit(state);
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
state.lastError = "Config hash missing; reload and retry.";
|
||||
@@ -132,10 +157,7 @@ export async function applyConfig(state: ConfigState) {
|
||||
state.configApplying = true;
|
||||
state.lastError = null;
|
||||
try {
|
||||
const raw =
|
||||
state.configFormMode === "form" && state.configForm
|
||||
? serializeConfigForm(state.configForm)
|
||||
: state.configRaw;
|
||||
const raw = serializeFormForSubmit(state);
|
||||
const baseHash = state.configSnapshot?.hash;
|
||||
if (!baseHash) {
|
||||
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