Skip to content

Commit f011d77

Browse files
committed
fix(llm): normalize OpenAI function tool schemas
1 parent ab5a12d commit f011d77

4 files changed

Lines changed: 89 additions & 4 deletions

File tree

packages/llm/src/protocols/openai-chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
165165
function: {
166166
name: tool.name,
167167
description: tool.description,
168-
parameters: tool.inputSchema,
168+
parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
169169
},
170170
})
171171

packages/llm/src/protocols/openai-responses.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
255255
type: "function",
256256
name: tool.name,
257257
description: tool.description,
258-
parameters: tool.inputSchema,
258+
parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
259259
})
260260

261261
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>

packages/llm/src/protocols/shared.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Buffer } from "node:buffer"
2-
import { Effect, Schema, Stream } from "effect"
2+
import { Effect, JsonSchema, Schema, Stream } from "effect"
33
import * as Sse from "effect/unstable/encoding/Sse"
44
import { Headers, HttpClientRequest } from "effect/unstable/http"
55
import {
@@ -12,7 +12,8 @@ import {
1212
type TextPart,
1313
type ToolResultPart,
1414
} from "../schema"
15-
export { isRecord } from "../utils/record"
15+
import { isRecord } from "../utils/record"
16+
export { isRecord }
1617

1718
export const Json = Schema.fromJsonString(Schema.Unknown)
1819
export const decodeJson = Schema.decodeUnknownSync(Json)
@@ -21,6 +22,38 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
2122
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
2223
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
2324

25+
/** OpenAI function schemas require one flat object at the top level. */
26+
export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => {
27+
const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : []
28+
const flattened = variants.length === 0
29+
? { ...schema, type: "object" }
30+
: {
31+
...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")),
32+
type: "object",
33+
properties: variants.reduce(
34+
(properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }),
35+
{},
36+
),
37+
additionalProperties: false,
38+
}
39+
const normalized = removeNullSchemas(flattened)
40+
return isRecord(normalized) ? normalized : { type: "object" }
41+
}
42+
43+
const removeNullSchemas = (value: unknown): unknown => {
44+
if (Array.isArray(value)) return value.map(removeNullSchemas)
45+
if (!isRecord(value)) return value
46+
const fields = Object.fromEntries(
47+
Object.entries(value)
48+
.filter(([key]) => key !== "anyOf")
49+
.map(([key, field]) => [key, removeNullSchemas(field)]),
50+
)
51+
if (!Array.isArray(value.anyOf)) return fields
52+
const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas)
53+
if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] }
54+
return { ...fields, anyOf: variants }
55+
}
56+
2457
/**
2558
* Streaming tool-call accumulator. Adapters that build a tool call across
2659
* multiple `tool-input-delta` chunks store the partial JSON input string here

packages/llm/test/provider/openai-responses.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,58 @@ describe("OpenAI Responses route", () => {
5757
}),
5858
)
5959

60+
it.effect("flattens top-level object unions in function schemas", () =>
61+
Effect.gen(function* () {
62+
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
63+
LLM.updateRequest(request, {
64+
tools: [
65+
{
66+
name: "read",
67+
description: "Read a path or resource.",
68+
inputSchema: {
69+
type: "object",
70+
anyOf: [
71+
{
72+
type: "object",
73+
properties: {
74+
path: { type: "string" },
75+
reference: { anyOf: [{ type: "string" }, { type: "null" }] },
76+
limit: { type: "integer", maximum: 2000 },
77+
},
78+
required: ["path"],
79+
},
80+
{
81+
type: "object",
82+
properties: { resource: { type: "string" }, limit: { type: "integer", maximum: 51200 } },
83+
required: ["resource"],
84+
},
85+
],
86+
},
87+
},
88+
],
89+
}),
90+
)
91+
92+
expect(prepared.body.tools).toEqual([
93+
{
94+
type: "function",
95+
name: "read",
96+
description: "Read a path or resource.",
97+
parameters: {
98+
type: "object",
99+
properties: {
100+
path: { type: "string" },
101+
reference: { type: "string" },
102+
limit: { type: "integer", maximum: 2000 },
103+
resource: { type: "string" },
104+
},
105+
additionalProperties: false,
106+
},
107+
},
108+
])
109+
}),
110+
)
111+
60112
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
61113
Effect.gen(function* () {
62114
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(

0 commit comments

Comments
 (0)