Files
diegosouzapw--omniroute/tests/unit/tool-schema-sanitizer.test.mjs
2026-07-13 13:39:12 +08:00

518 lines
15 KiB
JavaScript

import { describe, it } from "node:test";
import assert from "node:assert/strict";
const { sanitizeOpenAITool, sanitizeOpenAITools } =
await import("../../open-sse/services/toolSchemaSanitizer.ts");
describe("toolSchemaSanitizer", () => {
describe("enum null filter (the actual fix)", () => {
it("strips null entries from enum (ForgeCode + Moonshot reproduction)", () => {
const tool = {
type: "function",
function: {
name: "fs_search",
parameters: {
type: "object",
properties: {
output_mode: {
type: "string",
enum: ["content", "files_with_matches", "count", null],
nullable: true,
},
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.output_mode.enum, [
"content",
"files_with_matches",
"count",
]);
});
it("strips undefined entries from enum", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: { mode: { type: "string", enum: ["a", undefined, "b"] } },
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.mode.enum, ["a", "b"]);
});
it("preserves enum without null/undefined", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: { mode: { type: "string", enum: ["a", "b", "c"] } },
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.mode.enum, ["a", "b", "c"]);
});
});
describe("required[] filter", () => {
it("drops required keys that are absent from properties", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: { a: { type: "string" } },
required: ["a", "b", "c"],
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.a, { type: "string" });
assert.deepEqual(out.function.parameters.required, ["a"]);
});
it("filters non-string entries in required[]", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: { a: { type: "string" } },
required: ["a", null, 123, undefined],
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.required, ["a"]);
});
});
describe("items normalization", () => {
it("collapses tuple items to first plain-object schema", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
list: { type: "array", items: [{ type: "string" }, { type: "number" }] },
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.list.items, {
type: "string",
});
});
it("falls back to empty schema when tuple has no plain-object entries", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
list: { type: "array", items: [null, "junk", 1] },
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.list.items, {});
});
it("preserves single-object items unchanged", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
list: { type: "array", items: { type: "string" } },
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.list.items, {
type: "string",
});
});
});
describe("parameters shape normalization", () => {
it("creates empty object schema when parameters is null", () => {
const tool = {
type: "function",
function: { name: "x", parameters: null },
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters, {
type: "object",
properties: {},
additionalProperties: true,
});
});
it("creates empty object schema when parameters is missing", () => {
const tool = { type: "function", function: { name: "x" } };
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters, {
type: "object",
properties: {},
additionalProperties: true,
});
});
it("replaces non-object/non-boolean property values with empty schema", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: { a: null, b: 1, c: "string-not-object" },
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.a, {});
assert.deepEqual(out.function.parameters.properties.b, {});
assert.deepEqual(out.function.parameters.properties.c, {});
});
it("preserves valid property schemas", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
a: { type: "string", description: "first" },
b: { type: "integer", minimum: 0 },
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.a, {
type: "string",
description: "first",
});
assert.deepEqual(out.function.parameters.properties.b, {
type: "integer",
minimum: 0,
});
});
});
describe("anyOf / oneOf / allOf recursion", () => {
it("strips null from enum nested inside anyOf (Moonshot validates here)", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
mode: {
anyOf: [{ type: "string", enum: ["a", "b", null] }, { type: "null" }],
},
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.mode.anyOf[0].enum, ["a", "b"]);
});
it("strips null from enum nested inside oneOf", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
mode: {
oneOf: [{ type: "string", enum: ["a", null] }, { type: "integer" }],
},
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.mode.oneOf[0].enum, ["a"]);
});
it("strips null from enum nested inside allOf", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
mode: {
allOf: [{ type: "string", enum: ["a", null] }],
},
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.mode.allOf[0].enum, ["a"]);
});
it("replaces non-object entries inside anyOf/oneOf/allOf with empty schema", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
mode: { anyOf: [{ type: "string" }, null, "junk", 1] },
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.mode.anyOf, [
{ type: "string" },
{},
{},
{},
]);
});
});
describe("additionalProperties recursion", () => {
it("strips null from enum nested inside additionalProperties (Moonshot validates here)", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
map: {
type: "object",
additionalProperties: { type: "string", enum: ["a", null] },
},
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.map.additionalProperties.enum, ["a"]);
});
it("preserves boolean additionalProperties", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: {
map: { type: "object", additionalProperties: false },
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.properties.map.additionalProperties, false);
});
});
describe("boolean property schemas (JSON Schema 2019)", () => {
it("preserves true / false property values rather than coercing to {}", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: { allow_anything: true, deny_anything: false },
},
},
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.properties.allow_anything, true);
assert.equal(out.function.parameters.properties.deny_anything, false);
});
it("still coerces other non-object property values (null, string, number) to {}", () => {
const tool = {
type: "function",
function: {
name: "x",
parameters: {
type: "object",
properties: { a: null, b: "junk", c: 1 },
},
},
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters.properties.a, {});
assert.deepEqual(out.function.parameters.properties.b, {});
assert.deepEqual(out.function.parameters.properties.c, {});
});
});
describe("Responses API tool shape (top-level parameters)", () => {
it("strips null from enum in Responses-format tool (top-level parameters)", () => {
// /v1/responses tools have { type, name, parameters } at top level — no
// `function` wrapper. They reach chatCore in this shape before the
// request translator unwraps them, so the sanitizer must handle both.
const tool = {
type: "function",
name: "fs_search",
description: "search",
parameters: {
type: "object",
properties: {
output_mode: {
type: "string",
enum: ["a", "b", null],
},
},
},
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.name, "fs_search");
assert.deepEqual(out.parameters.properties.output_mode.enum, ["a", "b"]);
});
it("normalizes missing parameters in Responses-format tool", () => {
const tool = { type: "function", name: "x" };
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.parameters, {
type: "object",
properties: {},
additionalProperties: true,
});
});
it("does not touch Responses-format tool without type=function", () => {
const tool = { type: "web_search", name: "websearch" };
assert.deepEqual(sanitizeOpenAITool(tool), {
type: "web_search",
name: "websearch",
});
});
});
describe("non-function tools", () => {
it("leaves Responses API built-in tool types untouched", () => {
const tool = { type: "web_search" };
assert.deepEqual(sanitizeOpenAITool(tool), { type: "web_search" });
});
});
describe("sanitizeOpenAITools", () => {
it("maps over an array", () => {
const tools = [
{
type: "function",
function: {
name: "a",
parameters: {
type: "object",
properties: { m: { type: "string", enum: ["x", null] } },
},
},
},
{
type: "function",
function: {
name: "b",
parameters: {
type: "object",
properties: { n: { type: "string" } },
},
},
},
];
const out = sanitizeOpenAITools(tools);
assert.deepEqual(out[0].function.parameters.properties.m.enum, ["x"]);
assert.deepEqual(out[1].function.parameters.properties.n, { type: "string" });
});
});
});
describe("root type coercion (#6359)", () => {
it("coerces root `type: null` to \"object\" (Codex codex_app__automation_update reproduction)", () => {
const tool = {
type: "function",
function: {
name: "codex_app__automation_update",
parameters: {
type: null,
properties: { schedule: { type: "string" } },
required: ["schedule"],
},
},
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, "object");
assert.deepEqual(out.function.parameters.properties.schedule, { type: "string" });
});
it("adds `type: \"object\"` when the root schema has properties but no type", () => {
const tool = {
type: "function",
function: { name: "x", parameters: { properties: { a: { type: "number" } } } },
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, "object");
});
it("coerces root type on Responses-shape tools too", () => {
const tool = {
type: "function",
name: "y",
parameters: { type: null, properties: {} },
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.parameters.type, "object");
});
it("does not inject a root type when the root is a combinator (anyOf)", () => {
const tool = {
type: "function",
function: {
name: "z",
parameters: { anyOf: [{ type: "object", properties: {} }] },
},
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, undefined);
});
it("leaves an explicit root `type: \"object\"` untouched", () => {
const tool = {
type: "function",
function: { name: "w", parameters: { type: "object", properties: { b: { type: "boolean" } } } },
};
const out = sanitizeOpenAITool(tool);
assert.equal(out.function.parameters.type, "object");
});
});