Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions open-sse/services/toolSchemaSanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +33 to +35

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In JSON Schema, the type property can sometimes be defined as an array of strings (for example, type: ["object", "null"] to represent a nullable object). To ensure these schemas are also correctly identified as object schemas when properties is undefined, we should check if type is either "object" or an array containing "object".

  const properties = schema.properties;
  const type = schema.type;
  const isObjectSchema =
    type === "object" ||
    (Array.isArray(type) && type.includes("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 {};
Expand Down Expand Up @@ -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 };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update sanitizer expectations for open schemas

When parameters is null or omitted, this now returns { type: "object", properties: {}, additionalProperties: true }, but the existing tests/unit/tool-schema-sanitizer.test.mjs cases for missing/null Chat Completions parameters and missing Responses parameters still assert the old { type: "object", properties: {} } shape. That leaves the committed unit suite failing for this intentional behavior change, so the old expectations need to be updated alongside this return value.

Useful? React with 👍 / 👎.

}
return { type: "object", properties: {} };
return { type: "object", properties: {}, additionalProperties: true };
}

export function sanitizeOpenAITool(tool: unknown): unknown {
Expand Down
21 changes: 21 additions & 0 deletions open-sse/translator/helpers/schemaCoercion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +38 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In JSON Schema, the type property can sometimes be defined as an array of strings (for example, type: ["object", "null"] to represent a nullable object). To ensure these schemas are also correctly identified as object schemas when properties is undefined, we should check if type is either "object" or an array containing "object".

  const properties = schema.properties;
  const type = schema.type;
  const isObjectSchema =
    type === "object" ||
    (Array.isArray(type) && type.includes("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();
Expand Down Expand Up @@ -118,6 +137,8 @@ export function coerceSchemaNumericFields(schema: unknown): unknown {
result.else = coerceSchemaNumericFields(result.else);
}

keepOpaqueObjectSchemasOpen(result);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid re-adding unsupported Gemini schema keyword

For Gemini/Antigravity targets, openaiToGeminiBase() has already converted tools via buildGeminiTools(), whose cleanJSONSchemaForAntigravity() strips additionalProperties because Gemini function declarations reject it with Unknown name. This new final pass runs afterward on functionDeclarations.parameters, so an empty object parameter schema gets additionalProperties: true reintroduced and those Gemini tool requests can start failing with 400s.

Useful? React with 👍 / 👎.


return result;
}

Expand Down
98 changes: 98 additions & 0 deletions tests/unit/openai-tool-opaque-object-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
18 changes: 15 additions & 3 deletions tests/unit/tool-schema-sanitizer.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/translator-claude-to-openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},
]);
Expand Down