mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
98
tests/unit/openai-tool-opaque-object-schema.test.ts
Normal file
98
tests/unit/openai-tool-opaque-object-schema.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user