ソースを参照

fix(llm): normalize OpenAI function tool schemas

Kit Langton 3 ヶ月 前
コミット
f011d77128

+ 1 - 1
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),
   },
 })
 

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

+ 35 - 2
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 = <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

+ 52 - 0
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<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>(