fix(llm): normalize OpenAI function tool schemas
This commit is contained in:
parent
ab5a12d916
commit
f011d77128
4 changed files with 89 additions and 4 deletions
|
|
@ -165,7 +165,7 @@ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
|
|||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]>) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue