518 lines
15 KiB
JavaScript
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");
|
|
});
|
|
});
|