From 9f9184b0029b026b989ef7227cd5126cc072a7b9 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:05:06 -0500 Subject: [PATCH] fix(ai): preserve OpenAI tool schemas (#44546) --- packages/ai/src/protocols/open-responses.ts | 2 +- packages/ai/src/protocols/openai-chat.ts | 2 +- .../ai/src/protocols/utils/tool-schema.ts | 33 +-------------- .../ai/test/provider/openai-responses.test.ts | 25 +++++++---- .../ai/test/tool-schema-projection.test.ts | 41 +++++-------------- 5 files changed, 30 insertions(+), 73 deletions(-) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 64328c9b00c..d5c5d39f8ca 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -362,7 +362,7 @@ export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* ( type: "function" as const, name: tool.name, description: tool.description, - parameters: ToolSchemaProjection.responses(inputSchema), + parameters: inputSchema, // The common tool definition does not currently express Responses strict-schema policy. strict: false, } diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index de7554bc55a..795ec921996 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -269,7 +269,7 @@ const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: Lower function: { name: tool.name, description: tool.description, - parameters: ToolSchemaProjection.openAI(inputSchema), + parameters: inputSchema, }, cache_control: options.cacheControl?.(tool.cache), }) diff --git a/packages/ai/src/protocols/utils/tool-schema.ts b/packages/ai/src/protocols/utils/tool-schema.ts index 3abf9b0d5c0..2c6ab38c1ed 100644 --- a/packages/ai/src/protocols/utils/tool-schema.ts +++ b/packages/ai/src/protocols/utils/tool-schema.ts @@ -2,20 +2,6 @@ import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../sch import { isRecord } from "../../utils/record.js" import { GeminiToolSchema } from "./gemini-tool-schema.js" -const removeNullSchemas = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(removeNullSchemas) - if (!isRecord(value)) return value - const fields = Object.fromEntries( - Object.entries(value) - .filter(([key]) => key !== "anyOf") - .map(([key, field]) => [key, removeNullSchemas(field)]), - ) - if (!Array.isArray(value.anyOf)) return fields - const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) - if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } - return { ...fields, anyOf: variants } -} - const tupleItemsSchema = (items: ReadonlyArray) => { const projected = items.map(moonshotNode) if (projected.length === 0) return {} @@ -45,24 +31,7 @@ const moonshot = (schema: JsonSchema): JsonSchema => { return isRecord(projected) ? projected : {} } -const openAI = (schema: JsonSchema): JsonSchema => { - const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] - const flattened = - variants.length === 0 - ? { ...schema, type: "object" } - : { - ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), - type: "object", - properties: variants.reduce( - (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), - {}, - ), - additionalProperties: false, - } - const normalized = removeNullSchemas(flattened) - return isRecord(normalized) ? normalized : { type: "object" } -} - +const openAI = (schema: JsonSchema): JsonSchema => schema const responses = openAI const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index e73125119e5..b3995b51395 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -196,7 +196,7 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("flattens top-level object unions in function schemas", () => + it.effect("preserves function schemas", () => Effect.gen(function* () { const prepared = yield* compileRequest( LLMRequest.update(request, { @@ -236,13 +236,22 @@ describe("OpenAI Responses route", () => { strict: false, parameters: { type: "object", - properties: { - path: { type: "string" }, - reference: { type: "string" }, - limit: { type: "integer", maximum: 2000 }, - resource: { type: "string" }, - }, - additionalProperties: false, + anyOf: [ + { + type: "object", + properties: { + path: { type: "string" }, + reference: { anyOf: [{ type: "string" }, { type: "null" }] }, + limit: { type: "integer", maximum: 2000 }, + }, + required: ["path"], + }, + { + type: "object", + properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } }, + required: ["resource"], + }, + ], }, }, ]) diff --git a/packages/ai/test/tool-schema-projection.test.ts b/packages/ai/test/tool-schema-projection.test.ts index 551d231da38..fdc86b2d3ce 100644 --- a/packages/ai/test/tool-schema-projection.test.ts +++ b/packages/ai/test/tool-schema-projection.test.ts @@ -50,32 +50,7 @@ describe("tool schema projections", () => { }) }) - test("openai keeps one flat object top-level schema", () => { - expect( - ToolSchemaProjection.openAI({ - anyOf: [ - { - type: "object", - properties: { - path: { type: "string" }, - maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, - }, - }, - { type: "object", properties: { resource: { type: "string" } } }, - ], - }), - ).toEqual({ - type: "object", - properties: { - path: { type: "string" }, - maybe: { type: "string" }, - resource: { type: "string" }, - }, - additionalProperties: false, - }) - }) - - it.effect("applies model compatibility before protocol projection", () => + it.effect("applies model compatibility without changing schema semantics", () => Effect.gen(function* () { const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) @@ -107,11 +82,15 @@ describe("tool schema projections", () => { expect(prepared.body.tools?.[0]?.function.parameters).toEqual({ type: "object", - properties: { - tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, - linked: { $ref: "#/$defs/Linked" }, - }, - additionalProperties: false, + anyOf: [ + { + type: "object", + properties: { + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + linked: { $ref: "#/$defs/Linked" }, + }, + }, + ], }) }), )