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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user