fix(ai): normalized openai object tool schemas

Normalized OpenAI Responses schema sanitization so object schema nodes always include properties while only traversing schema-valued positions. Wired the same sanitizer into the OpenAI Codex Responses tool conversion path and added regression coverage for no-argument MCP-style schemas and literal payload preservation.

Fixes #1147
This commit is contained in:
roboomp
2026-05-17 09:28:59 +00:00
parent 8f204539b8
commit 03623bdf5e
5 changed files with 148 additions and 30 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed OpenAI Responses and Codex tool schema normalization to emit `properties: {}` for no-argument object schemas without rewriting literal payloads. ([#1147](https://github.com/can1357/oh-my-pi/issues/1147))
## [15.1.3] - 2026-05-17
### Breaking Changes
@@ -40,7 +40,7 @@ import { AssistantMessageEventStream } from "../utils/event-stream";
import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
import { parseStreamingJson } from "../utils/json-parse";
import { adaptSchemaForStrict, NO_STRICT, toolWireSchema } from "../utils/schema";
import { adaptSchemaForStrict, NO_STRICT, sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { compactGrammarDefinition } from "./grammar";
import { CODEX_BASE_URL, getCodexAccountId, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "./openai-codex/constants";
import {
@@ -2485,7 +2485,7 @@ export function convertOpenAICodexResponsesTools(
};
}
const strict = !!(!NO_STRICT && tool.strict);
const baseParameters = toolWireSchema(tool);
const baseParameters = sanitizeSchemaForOpenAIResponses(toolWireSchema(tool));
const { schema: parameters, strict: effectiveStrict } = adaptSchemaForStrict(baseParameters, strict);
return {
type: "function",
+79 -28
View File
@@ -850,13 +850,36 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
}
// ---------------------------------------------------------------------------
// OpenAI Responses — `oneOf` → `anyOf` rewrite
// OpenAI Responses — schema-valued normalization
// ---------------------------------------------------------------------------
const OPENAI_RESPONSES_SCHEMA_ARRAY_KEYS = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
const OPENAI_RESPONSES_SCHEMA_MAP_KEYS = new Set([
"properties",
"patternProperties",
"dependentSchemas",
"$defs",
"definitions",
]);
const OPENAI_RESPONSES_SCHEMA_VALUE_KEYS = new Set([
"items",
"additionalItems",
"contains",
"propertyNames",
"if",
"then",
"else",
"not",
"additionalProperties",
"unevaluatedItems",
"unevaluatedProperties",
]);
/**
* OpenAI Responses rejects `oneOf` in tool schemas even when strict mode is
* disabled. Non-strict schemas can still use `anyOf`, so preserve the union
* shape by recursively rewriting `oneOf` branches to `anyOf`.
* disabled, and rejects every schema node with `type: "object"` unless it has
* a `properties` member. Normalize only schema-valued positions so literal
* payloads under `enum`, `const`, `default`, and `examples` remain unchanged.
*
* Identity-preserving: returns the input reference unchanged when no rewrite
* occurred so callers can dedupe via reference equality (and the strict-mode
@@ -865,7 +888,7 @@ export function normalizeSchemaForMCP(value: unknown): unknown {
* would not survive).
*/
export function sanitizeSchemaForOpenAIResponses(schema: JsonObject): JsonObject {
return rewriteOneOfToAnyOf(schema) as JsonObject;
return normalizeOpenAIResponsesSchemaNode(schema, new WeakMap()) as JsonObject;
}
/**
@@ -874,50 +897,78 @@ export function sanitizeSchemaForOpenAIResponses(schema: JsonObject): JsonObject
*/
export const normalizeSchemaForOpenAIResponses: (schema: JsonObject) => JsonObject = sanitizeSchemaForOpenAIResponses;
function rewriteOneOfToAnyOf(value: unknown): unknown {
if (Array.isArray(value)) {
let changed = false;
const rewritten = value.map(item => {
const next = rewriteOneOfToAnyOf(item);
if (next !== item) changed = true;
return next;
});
return changed ? rewritten : value;
}
function normalizeOpenAIResponsesSchemaNode(value: unknown, cache: WeakMap<JsonObject, JsonObject>): unknown {
if (!isJsonObject(value)) return value;
if (!value || typeof value !== "object") {
return value;
}
const cached = cache.get(value);
if (cached) return cached;
const output: JsonObject = {};
cache.set(value, output);
const input = value as Record<string, unknown>;
let changed = false;
const output: Record<string, unknown> = {};
for (const key in input) {
const child = input[key];
// Skip `oneOf` here; it is re-emitted as `anyOf` after the loop so
// neighboring `anyOf` entries can be folded in.
for (const key in value) {
if (!Object.hasOwn(value, key)) continue;
if (key === "oneOf") {
changed = true;
continue;
}
const next = rewriteOneOfToAnyOf(child);
const child = value[key];
let next: unknown = child;
if (OPENAI_RESPONSES_SCHEMA_MAP_KEYS.has(key) && isJsonObject(child)) {
next = normalizeOpenAIResponsesSchemaMap(child, cache);
} else if (OPENAI_RESPONSES_SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(child)) {
next = normalizeOpenAIResponsesSchemaArray(child, cache);
} else if (OPENAI_RESPONSES_SCHEMA_VALUE_KEYS.has(key) && isJsonObject(child)) {
next = normalizeOpenAIResponsesSchemaNode(child, cache);
}
if (next !== child) changed = true;
output[key] = next;
}
// Re-emit `oneOf` content under `anyOf`, concatenating with any existing
// `anyOf` branches in the original node.
if (Array.isArray(input.oneOf)) {
const rewrittenOneOf = rewriteOneOfToAnyOf(input.oneOf);
if (Array.isArray(value.oneOf)) {
const rewrittenOneOf = normalizeOpenAIResponsesSchemaArray(value.oneOf, cache);
const existingAnyOf = output.anyOf;
output.anyOf = Array.isArray(existingAnyOf)
? [...existingAnyOf, ...(rewrittenOneOf as unknown[])]
: rewrittenOneOf;
}
if (value.type === "object" && !Object.hasOwn(value, "properties")) {
output.properties = {};
changed = true;
}
const result = changed ? output : value;
cache.set(value, result);
return result;
}
function normalizeOpenAIResponsesSchemaArray(value: unknown[], cache: WeakMap<JsonObject, JsonObject>): unknown[] {
let changed = false;
const output = value.map(item => {
const next = normalizeOpenAIResponsesSchemaNode(item, cache);
if (next !== item) changed = true;
return next;
});
return changed ? output : value;
}
function normalizeOpenAIResponsesSchemaMap(schemaMap: JsonObject, cache: WeakMap<JsonObject, JsonObject>): JsonObject {
let changed = false;
const output: JsonObject = {};
for (const key in schemaMap) {
if (!Object.hasOwn(schemaMap, key)) continue;
const child = schemaMap[key];
const next = normalizeOpenAIResponsesSchemaNode(child, cache);
if (next !== child) changed = true;
output[key] = next;
}
return changed ? output : schemaMap;
}
// ---------------------------------------------------------------------------
// OpenAI strict mode — sanitize + enforce
// ---------------------------------------------------------------------------
+23
View File
@@ -1,11 +1,34 @@
import { describe, expect, it } from "bun:test";
import { type RequestBody, transformRequestBody } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
import { parseCodexError } from "@oh-my-pi/pi-ai/providers/openai-codex/response-handler";
import { convertOpenAICodexResponsesTools } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
import type { Tool } from "@oh-my-pi/pi-ai/types";
import { createCodexModel } from "./helpers";
const DEFAULT_PROMPT_PREFIX =
"You are an expert coding assistant. You help users with coding tasks by reading files, executing commands";
describe("openai-codex tool schemas", () => {
it("adds empty properties to no-argument object parameter schemas", () => {
const tools: Tool[] = [
{
name: "list_outgoing_messages",
description: "List outgoing messages",
parameters: { type: "object" },
},
];
const converted = convertOpenAICodexResponsesTools(tools, createCodexModel("gpt-5.1-codex"));
expect(converted[0]).toEqual({
type: "function",
name: "list_outgoing_messages",
description: "List outgoing messages",
parameters: { type: "object", properties: {} },
});
});
});
describe("openai-codex request transformer", () => {
it("filters item_reference and strips ids", async () => {
const body: RequestBody = {
@@ -8,6 +8,7 @@ import {
normalizeSchemaForCCA,
normalizeSchemaForGoogle,
normalizeSchemaForMCP,
sanitizeSchemaForOpenAIResponses,
sanitizeSchemaForStrictMode,
schemaNeedsDraft202012Upgrade,
stripResidualCombiners,
@@ -349,6 +350,45 @@ describe("normalizeSchemaForMCP", () => {
});
});
// ---------------------------------------------------------------------------
// sanitizeSchemaForOpenAIResponses
// ---------------------------------------------------------------------------
describe("sanitizeSchemaForOpenAIResponses", () => {
it("adds empty properties to object schemas without rewriting literal payloads", () => {
const literal = { type: "object", oneOf: [{ const: "literal" }] };
const schema = {
type: "object",
properties: {
nested: { type: "object" },
union: {
oneOf: [{ type: "object" }],
},
},
oneOf: [{ type: "object" }],
enum: [literal],
const: literal,
default: literal,
examples: [literal],
};
expect(sanitizeSchemaForOpenAIResponses(schema)).toEqual({
type: "object",
properties: {
nested: { type: "object", properties: {} },
union: {
anyOf: [{ type: "object", properties: {} }],
},
},
enum: [literal],
const: literal,
default: literal,
examples: [literal],
anyOf: [{ type: "object", properties: {} }],
});
});
});
// ---------------------------------------------------------------------------
// enforceStrictSchema and tryEnforceStrictSchema
// ---------------------------------------------------------------------------