From 046dad5bee633c8f937b9bf3ef92be09a4fdf58c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:40:25 -0300 Subject: [PATCH] feat(sse): add optional-enum null-omission idiom for codex strict-mode tools (#7023) (#7233) Codex Responses API strict mode forces every "optional" tool property into `required`, so a model that intends to OMIT an optional enum property (no declared `default`, e.g. Agent.isolation: enum["worktree","remote"]) must still emit a concrete value. Neither of the two ops shipped in #6992 (drop-if-default, generalized drop-if-empty) can catch this: drop-if-default needs a declared default (none exists); drop-if-empty needs an empty string/array (the emitted value is non-empty). Adds a paired request/response transform scoped strictly to targetFormat === OPENAI_RESPONSES: - Request side: injectOptionalEnumOmissionSentinel/-ForTools widen no-default optional enum properties to accept `null` (OpenAI's own documented nullable-union idiom for this exact strict-mode limitation). - Response side: isDroppableNullEntry drops the key when the model emits `null` for a non-required, schema-declared property, reusing the existing #6992 toolSchemas plumbing (no new tracking structure needed). --- .../7023-optional-enum-null-sentinel.md | 1 + open-sse/translator/helpers/schemaCoercion.ts | 66 ++++++ open-sse/translator/index.ts | 4 + .../response/openai-responses/pureHelpers.ts | 11 +- tests/unit/repro-7023.test.ts | 216 ++++++++++++++++++ tests/unit/schema-coercion.test.ts | 55 +++++ 6 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/7023-optional-enum-null-sentinel.md create mode 100644 tests/unit/repro-7023.test.ts diff --git a/changelog.d/features/7023-optional-enum-null-sentinel.md b/changelog.d/features/7023-optional-enum-null-sentinel.md new file mode 100644 index 0000000000..965cb05307 --- /dev/null +++ b/changelog.d/features/7023-optional-enum-null-sentinel.md @@ -0,0 +1 @@ +- **feat(sse):** Add optional-enum `null`-omission idiom for Responses-API (codex) strict-mode tool schemas, closing the #6951 follow-up ([#7023](https://github.com/diegosouzapw/OmniRoute/issues/7023)) diff --git a/open-sse/translator/helpers/schemaCoercion.ts b/open-sse/translator/helpers/schemaCoercion.ts index 60ecfa44a7..3c2aa48bc8 100644 --- a/open-sse/translator/helpers/schemaCoercion.ts +++ b/open-sse/translator/helpers/schemaCoercion.ts @@ -290,6 +290,72 @@ export function coerceToolSchemas(tools: unknown): unknown { }); } +// #7023 — Responses API strict mode forces every "optional" tool property into +// `required`, so a model that intends to OMIT an optional enum property (no declared +// `default`) must still emit a concrete value (e.g. Agent.isolation:"remote"). Neither +// #6992 op (drop-if-default / drop-if-empty) can catch this, so we widen such properties +// to accept `null` on the request side (OpenAI's own documented nullable-union idiom for +// this exact strict-mode limitation) and drop the key response-side when the model emits +// `null` (see pureHelpers.ts::isDroppableNullEntry). Scope: top-level +// `properties[key].enum` only — does not recurse into `items`/`anyOf`/`oneOf` branches +// (no real-world case beyond Agent.isolation is documented; extend with a concrete repro). +function shouldInjectNullOmission(key: string, propSchema: unknown, required: Set): boolean { + return ( + isPlainObject(propSchema) && + Array.isArray(propSchema.enum) && + !required.has(key) && + !hasOwn(propSchema, "default") + ); +} + +function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord { + const widened: JsonRecord = { ...propSchema }; + const enumValues = propSchema.enum as unknown[]; + widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null]; + if (typeof propSchema.type === "string") { + widened.type = [propSchema.type, "null"]; + } else if (Array.isArray(propSchema.type) && !propSchema.type.includes("null")) { + widened.type = [...propSchema.type, "null"]; + } + const note = "null = omit this parameter"; + widened.description = + typeof propSchema.description === "string" && propSchema.description.length > 0 + ? `${propSchema.description} (${note})` + : note; + return widened; +} + +export function injectOptionalEnumOmissionSentinel(schema: unknown): unknown { + if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema; + + const required = new Set(Array.isArray(schema.required) ? schema.required : []); + let changed = false; + const nextProperties: JsonRecord = { ...schema.properties }; + + for (const [key, propSchema] of Object.entries(schema.properties)) { + if (!shouldInjectNullOmission(key, propSchema, required)) continue; + nextProperties[key] = widenPropertyForNullOmission(propSchema as JsonRecord); + changed = true; + } + + if (!changed) return schema; + return { ...schema, properties: nextProperties }; +} + +export function injectOptionalEnumOmissionForTools(tools: unknown): unknown { + if (!Array.isArray(tools)) return tools; + + return tools.map((tool) => { + if (!isPlainObject(tool)) return tool; + + const result: JsonRecord = { ...tool }; + if ("parameters" in result && !isPlainObject(result.function)) { + result.parameters = injectOptionalEnumOmissionSentinel(result.parameters); + } + return result; + }); +} + export function sanitizeToolDescriptions(tools: unknown): unknown { if (!Array.isArray(tools)) return tools; return tools.map((tool) => sanitizeToolDescription(tool)); diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index 5f497591fe..d2e2b5038d 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -13,6 +13,7 @@ import { providerHonorsOpenAIFormatCacheControl } from "../utils/cacheControlPol import { coerceToolSchemas, injectEmptyReasoningContentForToolCalls, + injectOptionalEnumOmissionForTools, sanitizeToolDescriptions, } from "./helpers/schemaCoercion.ts"; import { getRequestTranslator, getResponseTranslator } from "./registry.ts"; @@ -354,6 +355,9 @@ export function translateRequest( if (result.tools !== undefined) { result.tools = coerceToolSchemas(result.tools); result.tools = sanitizeToolDescriptions(result.tools); + if (targetFormat === FORMATS.OPENAI_RESPONSES) { + result.tools = injectOptionalEnumOmissionForTools(result.tools); + } } if (targetFormat === FORMATS.OPENAI && result.messages && Array.isArray(result.messages)) { diff --git a/open-sse/translator/response/openai-responses/pureHelpers.ts b/open-sse/translator/response/openai-responses/pureHelpers.ts index 50e9cfe6c6..e2cc70fce4 100644 --- a/open-sse/translator/response/openai-responses/pureHelpers.ts +++ b/open-sse/translator/response/openai-responses/pureHelpers.ts @@ -56,6 +56,14 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) { return allowlisted || (propSchema != null && !required.has(key)); } +// #7023 — the request-side counterpart (injectOptionalEnumOmissionSentinel) widens +// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own +// nullable-union idiom for Responses-API strict mode). Drop the key when the model +// follows that idiom for a non-required, schema-declared property. +function isDroppableNullEntry(entry, propSchema, required, key) { + return entry === null && propSchema != null && !required.has(key); +} + function stripEmptyOptionalToolArgsObject(value, toolName, schema) { const properties = schemaProperties(schema); const required = schemaRequiredSet(schema); @@ -66,7 +74,8 @@ function stripEmptyOptionalToolArgsObject(value, toolName, schema) { const propSchema = properties ? properties[key] : null; if ( matchesSchemaDefault(propSchema, entry) || - isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) + isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) || + isDroppableNullEntry(entry, propSchema, required, key) ) { delete cleaned[key]; } diff --git a/tests/unit/repro-7023.test.ts b/tests/unit/repro-7023.test.ts new file mode 100644 index 0000000000..94f8116633 --- /dev/null +++ b/tests/unit/repro-7023.test.ts @@ -0,0 +1,216 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #7023 — deferred 3rd op from #6951/#6992. Codex Responses API strict mode forces +// every tool property into `required`, so a model that intends to OMIT an optional +// enum property (no declared `default`) must still emit *some* concrete value (e.g. +// `Agent.isolation: "remote"`). Neither #6992 op catches this: `drop-if-default` needs +// a declared default (none exists here); `drop-if-empty` needs an empty string/array +// (the emitted value is non-empty). This test proves the paired request/response +// transform: request-side widens optional enum properties to accept `null` (OpenAI's +// own documented nullable-union idiom for this exact strict-mode limitation), and +// response-side drops the key when the model emits `null` for a non-required property. + +const { injectOptionalEnumOmissionSentinel, injectOptionalEnumOmissionForTools } = await import( + "../../open-sse/translator/helpers/schemaCoercion.ts" +); +const { stripEmptyOptionalToolArgs } = await import( + "../../open-sse/translator/response/openai-responses/pureHelpers.ts" +); +const { openaiResponsesToOpenAIResponse } = await import( + "../../open-sse/translator/response/openai-responses.ts" +); +const { translateRequest } = await import("../../open-sse/translator/index.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +const AGENT_SCHEMA = { + type: "object", + properties: { + description: { type: "string" }, + isolation: { type: "string", enum: ["worktree", "remote"] }, + }, + required: ["description", "isolation"], +}; + +test("7023: injectOptionalEnumOmissionSentinel widens a no-default enum property not in required", () => { + const schema = { + type: "object", + properties: { + isolation: { type: "string", enum: ["worktree", "remote"] }, + }, + required: [], + }; + const result = injectOptionalEnumOmissionSentinel(schema); + assert.deepEqual(result.properties.isolation.enum, ["worktree", "remote", null]); + assert.deepEqual(result.properties.isolation.type, ["string", "null"]); + assert.match(result.properties.isolation.description, /null = omit this parameter/); +}); + +test("7023: injectOptionalEnumOmissionSentinel leaves a required enum property untouched", () => { + const schema = { + type: "object", + properties: { isolation: { type: "string", enum: ["worktree", "remote"] } }, + required: ["isolation"], + }; + const result = injectOptionalEnumOmissionSentinel(schema); + assert.deepEqual(result.properties.isolation.enum, ["worktree", "remote"]); + assert.equal(result.properties.isolation.type, "string"); +}); + +test("7023: injectOptionalEnumOmissionSentinel leaves an enum property with a default untouched", () => { + const schema = { + type: "object", + properties: { isolation: { type: "string", enum: ["worktree", "remote"], default: "worktree" } }, + required: [], + }; + const result = injectOptionalEnumOmissionSentinel(schema); + assert.deepEqual(result.properties.isolation.enum, ["worktree", "remote"]); +}); + +test("7023: injectOptionalEnumOmissionSentinel leaves a non-enum property untouched", () => { + const schema = { + type: "object", + properties: { note: { type: "string" } }, + required: [], + }; + const result = injectOptionalEnumOmissionSentinel(schema); + assert.deepEqual(result, schema); +}); + +test("7023: injectOptionalEnumOmissionForTools transforms Responses-API shaped tools", () => { + const tools = [ + { + type: "function", + name: "Agent", + parameters: { + type: "object", + properties: { isolation: { type: "string", enum: ["worktree", "remote"] } }, + required: [], + }, + }, + ]; + const result = injectOptionalEnumOmissionForTools(tools); + assert.deepEqual(result[0].parameters.properties.isolation.enum, ["worktree", "remote", null]); +}); + +test("7023: injectOptionalEnumOmissionForTools passes non-plain-object entries through unchanged", () => { + const tools = [null, "not-a-tool"]; + const result = injectOptionalEnumOmissionForTools(tools); + assert.deepEqual(result, tools); +}); + +test("7023: translateRequest applies the injection only for targetFormat OPENAI_RESPONSES", () => { + const body = { + model: "gpt-5.1-codex", + messages: [{ role: "user", content: "hi" }], + tools: [ + { + type: "function", + function: { + name: "Agent", + parameters: { + type: "object", + properties: { isolation: { type: "string", enum: ["worktree", "remote"] } }, + required: [], + }, + }, + }, + ], + }; + + const toResponses = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "gpt-5.1-codex", + JSON.parse(JSON.stringify(body)) + ); + const responsesTool = toResponses.tools.find((t) => t.name === "Agent" || t?.function?.name === "Agent"); + const responsesParams = responsesTool.parameters ?? responsesTool.function?.parameters; + assert.ok(responsesParams.properties.isolation.enum.includes(null)); + + const toClaude = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "claude-3-7-sonnet", + JSON.parse(JSON.stringify(body)) + ); + const claudeTool = toClaude.tools.find((t) => String(t.name).includes("Agent")); + const claudeSchema = claudeTool.input_schema ?? claudeTool.function?.parameters; + assert.equal(claudeSchema.properties.isolation.enum.includes(null), false); +}); + +const AGENT_SCHEMA_OPTIONAL = { + type: "object", + properties: { + description: { type: "string" }, + isolation: { type: ["string", "null"], enum: ["worktree", "remote", null] }, + }, + required: ["description"], +}; + +test("7023: stripEmptyOptionalToolArgs drops a null value for a non-required, schema-declared property", () => { + const raw = JSON.stringify({ description: "d", isolation: null }); + const cleaned = JSON.parse(stripEmptyOptionalToolArgs(raw, "Agent", AGENT_SCHEMA_OPTIONAL)); + assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "isolation"), false); + assert.equal(cleaned.description, "d"); +}); + +test("7023: stripEmptyOptionalToolArgs preserves null for a required property (never drop what the schema demands)", () => { + const schema = { + type: "object", + properties: { note: { type: ["string", "null"] } }, + required: ["note"], + }; + const raw = JSON.stringify({ note: null }); + const cleaned = JSON.parse(stripEmptyOptionalToolArgs(raw, "SomeTool", schema)); + assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "note"), true); +}); + +test("7023: acceptance — codex Agent call emits isolation:null (post-injection idiom) -> client-visible call has no isolation key", () => { + const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; + + openaiResponsesToOpenAIResponse( + { type: "response.output_item.added", item: { type: "function_call", call_id: "call_1", name: "Agent" } }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_1", + name: "Agent", + arguments: JSON.stringify({ description: "no isolation intended", isolation: null }), + }, + }, + state + ); + + const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments); + assert.equal(Object.prototype.hasOwnProperty.call(args, "isolation"), false); + assert.equal(args.description, "no isolation intended"); +}); + +test("7023: negative — a legitimate isolation:'worktree' value is preserved unchanged", () => { + const state = { toolSchemas: new Map([["Agent", AGENT_SCHEMA_OPTIONAL]]) }; + + openaiResponsesToOpenAIResponse( + { type: "response.output_item.added", item: { type: "function_call", call_id: "call_2", name: "Agent" } }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_2", + name: "Agent", + arguments: JSON.stringify({ description: "explicit worktree", isolation: "worktree" }), + }, + }, + state + ); + + const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments); + assert.equal(args.isolation, "worktree"); +}); diff --git a/tests/unit/schema-coercion.test.ts b/tests/unit/schema-coercion.test.ts index 0a775a05ab..1d04c5ef55 100644 --- a/tests/unit/schema-coercion.test.ts +++ b/tests/unit/schema-coercion.test.ts @@ -4,6 +4,8 @@ import { coerceSchemaNumericFields, coerceToolSchemas, injectEmptyReasoningContentForToolCalls, + injectOptionalEnumOmissionForTools, + injectOptionalEnumOmissionSentinel, sanitizeToolDescription, sanitizeToolDescriptions, } from "../../open-sse/translator/helpers/schemaCoercion.ts"; @@ -214,3 +216,56 @@ test("injectEmptyReasoningContentForToolCalls skips non-reasoning models", () => ); } }); + +// #7023 — optional-enum null-omission sentinel +test("injectOptionalEnumOmissionSentinel: no-op on non-object schema", () => { + assert.strictEqual(injectOptionalEnumOmissionSentinel(null), null); + assert.strictEqual(injectOptionalEnumOmissionSentinel("not-a-schema"), "not-a-schema"); +}); + +test("injectOptionalEnumOmissionSentinel: no-op when schema has no properties", () => { + const schema = { type: "object" }; + assert.deepStrictEqual(injectOptionalEnumOmissionSentinel(schema), schema); +}); + +test("injectOptionalEnumOmissionSentinel: preserves an already-present null in enum without duplicating", () => { + const schema = { + type: "object", + properties: { mode: { type: ["string", "null"], enum: ["a", "b", null] } }, + required: [], + }; + const result = injectOptionalEnumOmissionSentinel(schema) as { + properties: { mode: { enum: unknown[] } }; + }; + assert.deepStrictEqual(result.properties.mode.enum, ["a", "b", null]); +}); + +test("injectOptionalEnumOmissionForTools: non-array input passed through unchanged", () => { + assert.strictEqual(injectOptionalEnumOmissionForTools(null), null); + assert.strictEqual(injectOptionalEnumOmissionForTools(undefined), undefined); +}); + +test("injectOptionalEnumOmissionForTools: Chat Completions {function:{parameters}} shape is left untouched", () => { + const tools = [ + { + type: "function", + function: { + name: "Agent", + parameters: { + type: "object", + properties: { isolation: { type: "string", enum: ["worktree", "remote"] } }, + required: [], + }, + }, + }, + ]; + const result = injectOptionalEnumOmissionForTools(tools) as Array<{ + function: { parameters: { properties: { isolation: { enum: unknown[] } } } }; + }>; + // Chat Completions shape (nested under `.function`) is out of scope for this + // Responses-API-only injector — assert it is not mutated. + assert.deepStrictEqual( + result[0].function.parameters.properties.isolation.enum, + ["worktree", "remote"] + ); +});