fix(ai): declare tool schema roots as objects (#51153)

This commit is contained in:
Aiden Cline
2026-09-24 11:22:38 -05:00
committed by GitHub
parent e22c1622e0
commit d932cad09c
7 changed files with 125 additions and 21 deletions
+10 -3
View File
@@ -13,6 +13,7 @@ import {
UnknownProviderError,
Usage,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ToolCallPart,
@@ -22,6 +23,7 @@ import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
@@ -366,9 +368,9 @@ const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request:
return messages
})
const lowerTool = (tool: ToolDefinition): MistralTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): MistralTool => ({
type: "function",
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
function: { name: tool.name, description: tool.description, parameters: inputSchema, strict: false },
})
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
@@ -394,7 +396,12 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
return {
model: request.model.id,
messages: yield* lowerMessages(flattened.request),
tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined,
tools:
flattened.tools.length > 0
? flattened.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
)
: undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
+21 -6
View File
@@ -53,22 +53,37 @@ const MODEL_NAMES = [
[/kimi/i, "moonshot"],
] as const
// An explicit `sanitizer` wins, and `none` opts out. Otherwise the protocol's own default
// applies (the Gemini API always uses Gemini's rules), then the model name selects the family's rules
// so models reached through gateways and OpenAI-compatible endpoints get the same handling.
// Tool arguments are always a JSON object, and most providers reject a tool schema whose root does not
// declare `type: "object"`, such as `{}` or a bare `properties` map. Effect encodes an empty struct as
// `anyOf` object or array; every object matches its bare object branch, so that `anyOf` is dropped.
const objectRoot = (schema: JsonSchema): JsonSchema => {
if (schema.type !== undefined) return schema
if (
Array.isArray(schema.anyOf) &&
schema.anyOf.some((branch) => isRecord(branch) && branch.type === "object" && Object.keys(branch).length === 1)
)
return { type: "object", ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")) }
return { type: "object", ...schema }
}
// Every tool schema gets an object root. Then an explicit `sanitizer` wins, and `none` opts out.
// Otherwise the protocol's own default applies (the Gemini API always uses Gemini's rules), then the
// model name selects the family's rules so models reached through gateways and OpenAI-compatible
// endpoints get the same handling.
const modelCompatibility = (
schema: JsonSchema,
model: LanguageModel,
protocolDefault?: LanguageModelSanitizerCompatibility,
): JsonSchema => {
const root = objectRoot(schema)
switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) {
case "gemini":
return gemini(schema)
return gemini(root)
case "moonshot":
return moonshot(schema)
return moonshot(root)
case "none":
case undefined:
return schema
return root
}
}
+6 -4
View File
@@ -144,7 +144,7 @@ describe("request option precedence", () => {
type: "function",
name: "crm",
description: "Top-level CRM tool",
parameters: {},
parameters: { type: "object" },
strict: false,
},
{
@@ -152,15 +152,17 @@ describe("request option precedence", () => {
name: "crm",
description: "CRM tools",
tools: [
{ type: "function", name: "lookup", description: "new", parameters: {}, strict: false },
{ type: "function", name: "search", description: "search", parameters: {}, strict: false },
{ type: "function", name: "lookup", description: "new", parameters: { type: "object" }, strict: false },
{ type: "function", name: "search", description: "search", parameters: { type: "object" }, strict: false },
],
},
{
type: "namespace",
name: "support",
description: "Support tools",
tools: [{ type: "function", name: "lookup", description: "support", parameters: {}, strict: false }],
tools: [
{ type: "function", name: "lookup", description: "support", parameters: { type: "object" }, strict: false },
],
},
])
}),
@@ -59,7 +59,7 @@ describe("Mistral Chat", () => {
],
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up a city", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: { type: "object" } }),
ToolDefinition.make({ name: "other", description: "Other operation", inputSchema: {} }),
],
toolChoice: "lookup",
promptCacheKey: "session-1",
@@ -84,7 +84,10 @@ describe("Mistral Chat", () => {
expect(prepared.body).toMatchObject({
model: "mistral-large-latest",
tools: [{ function: { name: "lookup", strict: false } }, { function: { name: "other", strict: false } }],
tools: [
{ function: { name: "lookup", strict: false } },
{ function: { name: "other", strict: false, parameters: { type: "object" } } },
],
tool_choice: { type: "function", function: { name: "lookup" } },
stream: true,
max_tokens: 64,
@@ -202,10 +202,16 @@ describe("Open Responses-compatible route", () => {
type: "function",
name: "acme_billing_lookup",
description: "Lookup billing",
parameters: {},
parameters: { type: "object" },
strict: false,
},
{
type: "function",
name: "acme_users",
description: "Lookup users",
parameters: { type: "object" },
strict: false,
},
{ type: "function", name: "acme_users", description: "Lookup users", parameters: {}, strict: false },
])
}),
)
@@ -196,8 +196,20 @@ describe("OpenAI Responses route", () => {
name: "crm",
description: "Customer management",
tools: [
{ type: "function", name: "lookup", description: "Look up a customer", parameters: {}, strict: false },
{ type: "function", name: "orders", description: "List customer orders", parameters: {}, strict: false },
{
type: "function",
name: "lookup",
description: "Look up a customer",
parameters: { type: "object" },
strict: false,
},
{
type: "function",
name: "orders",
description: "List customer orders",
parameters: { type: "object" },
strict: false,
},
],
},
])
@@ -232,7 +244,15 @@ describe("OpenAI Responses route", () => {
type: "namespace",
name: "crm",
description: "Customer management",
tools: [{ type: "function", name: "orders_list", description: "List orders", parameters: {}, strict: false }],
tools: [
{
type: "function",
name: "orders_list",
description: "List orders",
parameters: { type: "object" },
strict: false,
},
],
},
])
}),
@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { LLM } from "../src/index.js"
import { OpenAIChat } from "../src/protocols.js"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema.js"
import { Tool, toDefinitions } from "../src/tool.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { it } from "./lib/effect.js"
@@ -60,6 +61,56 @@ describe("tool schema projections", () => {
})
})
it.effect("declares every tool schema root as an object", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({
endpoint: { baseURL: "https://api.openai.test/v1/" },
auth: Auth.bearer("test"),
})
const parameters = (inputSchema: Record<string, unknown>, model = route.model({ id: "gpt-6-luna" })) =>
compileRequest(
LLM.request({
model,
prompt: "Use the tool.",
tools: [{ name: "lookup", description: "Lookup data.", inputSchema }],
}),
).pipe(Effect.map((prepared) => prepared.body.tools?.[0]?.function.parameters))
const parameterless = toDefinitions({
lookup: Tool.make({
description: "Lookup data.",
parameters: Schema.Struct({}).annotate({ description: "No input." }),
success: Schema.String,
}),
})[0].inputSchema
const union = {
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
}
const exclusive = { oneOf: [{ type: "object" }, { type: "object", required: ["a"] }] }
const object = { type: "object", properties: {} }
expect(yield* parameters(parameterless)).toEqual({ type: "object", description: "No input." })
expect(yield* parameters({})).toEqual({ type: "object" })
expect(yield* parameters({ description: "Query", properties: { q: { type: "string" } } })).toEqual({
type: "object",
description: "Query",
properties: { q: { type: "string" } },
})
expect(yield* parameters(union)).toEqual({ type: "object", ...union })
expect(yield* parameters(exclusive)).toEqual({ type: "object", ...exclusive })
expect(yield* parameters(object)).toEqual(object)
expect(yield* parameters({}, route.model({ id: "gpt-6-luna", compatibility: { sanitizer: "none" } }))).toEqual({
type: "object",
})
expect(yield* parameters({ properties: { mode: { enum: ["fast"] } } }, route.model({ id: "kimi-k3" }))).toEqual({
type: "object",
properties: { mode: { type: "string", enum: ["fast"] } },
})
}),
)
it.effect("selects tool schema handling from the model name unless compatibility is explicit", () =>
Effect.gen(function* () {
const route = OpenAIChat.route.with({