fix(tools): keep opaque object schemas open (#3097)

* fix(tools): keep opaque object schemas open

* test: align opaque-object-schema expectations with additionalProperties:true

The opaque-schema fix intentionally injects additionalProperties:true on empty
object schemas (incl. the web_search passthrough shim and null/missing parameter
fallbacks). Update the pre-fix snapshot assertions to match the new behavior.

---------

Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
This commit is contained in:
NMI
2026-06-04 02:29:31 +05:00
committed by GitHub
parent 50896699d3
commit 27b822e412
5 changed files with 159 additions and 6 deletions

View File

@@ -22,6 +22,26 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
return v !== null && typeof v === "object" && !Array.isArray(v);
}
function hasOwn(obj: Record<string, unknown>, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function keepOpaqueObjectSchemasOpen(schema: Record<string, unknown>): void {
const explicitAdditionalProperties = hasOwn(schema, "additionalProperties");
if (explicitAdditionalProperties) return;
const properties = schema.properties;
const isObjectSchema = schema.type === "object" || isPlainObject(properties);
if (!isObjectSchema) return;
if (properties === undefined) {
schema.properties = {};
schema.additionalProperties = true;
} else if (isPlainObject(properties) && Object.keys(properties).length === 0) {
schema.additionalProperties = true;
}
}
function sanitizeSchema(value: unknown, depth = 0): Record<string, unknown> {
if (depth > MAX_RECURSION_DEPTH) return {};
if (!isPlainObject(value)) return {};
@@ -85,15 +105,17 @@ function sanitizeSchema(value: unknown, depth = 0): Record<string, unknown> {
result.required = (result.required as string[]).filter((r) => validKeys.has(r));
}
keepOpaqueObjectSchemasOpen(result);
return result;
}
function normalizeParameters(parameters: unknown): unknown {
if (isPlainObject(parameters)) return sanitizeSchema(parameters);
if (parameters === null || parameters === undefined) {
return { type: "object", properties: {} };
return { type: "object", properties: {}, additionalProperties: true };
}
return { type: "object", properties: {} };
return { type: "object", properties: {}, additionalProperties: true };
}
export function sanitizeOpenAITool(tool: unknown): unknown {

View File

@@ -28,6 +28,25 @@ function isPlainObject(value: unknown): value is JsonRecord {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function hasOwn(obj: JsonRecord, key: string): boolean {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function keepOpaqueObjectSchemasOpen(schema: JsonRecord): void {
if (hasOwn(schema, "additionalProperties")) return;
const properties = schema.properties;
const isObjectSchema = schema.type === "object" || isPlainObject(properties);
if (!isObjectSchema) return;
if (properties === undefined) {
schema.properties = {};
schema.additionalProperties = true;
} else if (isPlainObject(properties) && Object.keys(properties).length === 0) {
schema.additionalProperties = true;
}
}
function coerceNumericString(value: unknown): unknown {
if (typeof value !== "string") return value;
const trimmed = value.trim();
@@ -118,6 +137,8 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
result.else = coerceSchemaNumericFields(result.else);
}
keepOpaqueObjectSchemasOpen(result);
return result;
}

View File

@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { sanitizeOpenAITool } from "../../open-sse/services/toolSchemaSanitizer.ts";
import { coerceToolSchemas } from "../../open-sse/translator/helpers/schemaCoercion.ts";
test("OpenAI sanitizer keeps generic MCP wrapper args open-world", () => {
const sanitized = sanitizeOpenAITool({
type: "function",
function: {
name: "SPLOX_EXECUTE_TOOL",
parameters: {
type: "object",
properties: {
mcp_server_id: { type: "string" },
slug: { type: "string" },
args: { type: "object", properties: {} },
},
required: ["mcp_server_id", "slug", "args"],
},
},
}) as any;
assert.equal(
sanitized.function.parameters.properties.args.additionalProperties,
true
);
assert.deepEqual(sanitized.function.parameters.properties.args.properties, {});
});
test("OpenAI Responses sanitizer keeps opaque execution/schema/additional_vars slots open-world", () => {
const sanitized = sanitizeOpenAITool({
type: "function",
name: "dynamic_tools_register",
parameters: {
type: "object",
properties: {
execution: { type: "object", properties: {} },
schema: { type: "object" },
additional_vars: { type: "object", properties: {} },
},
required: ["execution", "schema"],
},
}) as any;
const props = sanitized.parameters.properties;
assert.equal(props.execution.additionalProperties, true);
assert.equal(props.schema.additionalProperties, true);
assert.deepEqual(props.schema.properties, {});
assert.equal(props.additional_vars.additionalProperties, true);
});
test("schema coercion opens opaque nested objects after translation", () => {
const coerced = coerceToolSchemas([
{
type: "function",
function: {
name: "remote_server_write_env",
parameters: {
type: "object",
properties: {
path: { type: "string" },
additional_vars: { type: "object", properties: {} },
},
},
},
},
]) as any;
assert.equal(
coerced[0].function.parameters.properties.additional_vars.additionalProperties,
true
);
});
test("explicitly closed object schemas stay closed", () => {
const sanitized = sanitizeOpenAITool({
type: "function",
function: {
name: "closed",
parameters: {
type: "object",
properties: {
payload: {
type: "object",
properties: {},
additionalProperties: false,
},
},
},
},
}) as any;
assert.equal(
sanitized.function.parameters.properties.payload.additionalProperties,
false
);
});

View File

@@ -161,13 +161,21 @@ describe("toolSchemaSanitizer", () => {
function: { name: "x", parameters: null },
};
const out = sanitizeOpenAITool(tool);
assert.deepEqual(out.function.parameters, { type: "object", properties: {} });
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: {} });
assert.deepEqual(out.function.parameters, {
type: "object",
properties: {},
additionalProperties: true,
});
});
it("replaces non-object/non-boolean property values with empty schema", () => {
@@ -395,7 +403,11 @@ describe("toolSchemaSanitizer", () => {
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: {} });
assert.deepEqual(out.parameters, {
type: "object",
properties: {},
additionalProperties: true,
});
});
it("does not touch Responses-format tool without type=function", () => {

View File

@@ -120,7 +120,7 @@ test("translateRequest maps Claude server WebSearch natively only for Responses
function: {
name: "web_search",
description: "",
parameters: { type: "object", properties: {} },
parameters: { type: "object", properties: {}, additionalProperties: true },
},
},
]);