diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 5e28b495c..a66602437 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -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 diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 1553db3be..572e4a237 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -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", diff --git a/packages/ai/src/utils/schema/normalize.ts b/packages/ai/src/utils/schema/normalize.ts index 40446cbb7..689c5ab3b 100644 --- a/packages/ai/src/utils/schema/normalize.ts +++ b/packages/ai/src/utils/schema/normalize.ts @@ -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): 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; let changed = false; - const output: Record = {}; - 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): 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 { + 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 // --------------------------------------------------------------------------- diff --git a/packages/ai/test/openai-codex.test.ts b/packages/ai/test/openai-codex.test.ts index b9ba6d3ee..4dbc616a7 100644 --- a/packages/ai/test/openai-codex.test.ts +++ b/packages/ai/test/openai-codex.test.ts @@ -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 = { diff --git a/packages/ai/test/schema-normalization.test.ts b/packages/ai/test/schema-normalization.test.ts index baf413ec7..c1a0a6657 100644 --- a/packages/ai/test/schema-normalization.test.ts +++ b/packages/ai/test/schema-normalization.test.ts @@ -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 // ---------------------------------------------------------------------------