diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index b9bf1fc7a3..c0769bf1f6 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -165,7 +165,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ function: { name: tool.name, description: tool.description, - parameters: tool.inputSchema, + parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), }, }) diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 2f354427fe..06d07f8ccb 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -255,7 +255,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ type: "function", name: tool.name, description: tool.description, - parameters: tool.inputSchema, + parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), }) const lowerToolChoice = (toolChoice: NonNullable) => diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index ca325ff823..46e5509250 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer" -import { Effect, Schema, Stream } from "effect" +import { Effect, JsonSchema, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { Headers, HttpClientRequest } from "effect/unstable/http" import { @@ -12,7 +12,8 @@ import { type TextPart, type ToolResultPart, } from "../schema" -export { isRecord } from "../utils/record" +import { isRecord } from "../utils/record" +export { isRecord } export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) @@ -21,6 +22,38 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) +/** OpenAI function schemas require one flat object at the top level. */ +export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): 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 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 } +} + /** * Streaming tool-call accumulator. Adapters that build a tool call across * multiple `tool-input-delta` chunks store the partial JSON input string here diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 72de93ed11..aee6454fd6 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -57,6 +57,58 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("flattens top-level object unions in function schemas", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(request, { + tools: [ + { + name: "read", + description: "Read a path or resource.", + inputSchema: { + type: "object", + 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"], + }, + ], + }, + }, + ], + }), + ) + + expect(prepared.body.tools).toEqual([ + { + type: "function", + name: "read", + description: "Read a path or resource.", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + reference: { type: "string" }, + limit: { type: "integer", maximum: 2000 }, + resource: { type: "string" }, + }, + additionalProperties: false, + }, + }, + ]) + }), + ) + it.effect("lowers chronological system updates to escaped user wrappers in order", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare(