mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-29 17:09:40 +08:00
fix(ai): declare tool schema roots as objects (#51153)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user