diff --git a/open-sse/services/toolSchemaSanitizer.ts b/open-sse/services/toolSchemaSanitizer.ts index 1606be64e4..76a25ef28f 100644 --- a/open-sse/services/toolSchemaSanitizer.ts +++ b/open-sse/services/toolSchemaSanitizer.ts @@ -22,6 +22,26 @@ function isPlainObject(v: unknown): v is Record { return v !== null && typeof v === "object" && !Array.isArray(v); } +function hasOwn(obj: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function keepOpaqueObjectSchemasOpen(schema: Record): 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 { if (depth > MAX_RECURSION_DEPTH) return {}; if (!isPlainObject(value)) return {}; @@ -85,15 +105,17 @@ function sanitizeSchema(value: unknown, depth = 0): Record { 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 { diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 28f9b27cf5..9be3d930b6 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -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; } diff --git a/tests/unit/openai-tool-opaque-object-schema.test.ts b/tests/unit/openai-tool-opaque-object-schema.test.ts new file mode 100644 index 0000000000..e3e868f354 --- /dev/null +++ b/tests/unit/openai-tool-opaque-object-schema.test.ts @@ -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 + ); +}); diff --git a/tests/unit/tool-schema-sanitizer.test.mjs b/tests/unit/tool-schema-sanitizer.test.mjs index a83965136d..904d657dcd 100644 --- a/tests/unit/tool-schema-sanitizer.test.mjs +++ b/tests/unit/tool-schema-sanitizer.test.mjs @@ -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", () => { diff --git a/tests/unit/translator-claude-to-openai.test.ts b/tests/unit/translator-claude-to-openai.test.ts index c5a01a9db1..e2d4dc2ee4 100644 --- a/tests/unit/translator-claude-to-openai.test.ts +++ b/tests/unit/translator-claude-to-openai.test.ts @@ -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 }, }, }, ]);