fix(llm): normalize OpenAI function tool schemas

This commit is contained in:
Kit Langton 2026-06-04 19:56:31 -04:00
commit f011d77128
4 changed files with 89 additions and 4 deletions

View file

@ -165,7 +165,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
},
})

View file

@ -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<LLMRequest["toolChoice"]>) =>

View file

@ -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 = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(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

View file

@ -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<OpenAIResponses.OpenAIResponsesBody>(
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<OpenAIResponses.OpenAIResponsesBody>(