mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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).
This commit is contained in:
committed by
GitHub
parent
ef777d77e1
commit
046dad5bee
1
changelog.d/features/7023-optional-enum-null-sentinel.md
Normal file
1
changelog.d/features/7023-optional-enum-null-sentinel.md
Normal file
@@ -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))
|
||||
@@ -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<string>): 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));
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
216
tests/unit/repro-7023.test.ts
Normal file
216
tests/unit/repro-7023.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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"]
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user