packages/ai/test/tool-schema-projection.test.ts
Rama dev · réplica de lectura del repositorio original.
import { describe, expect, test } from "bun:test"
import { Effect, Schema, SchemaGetter } from "effect"
import { ai } from "../src/effect.js"
import { AnthropicMessages, Gemini, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema.js"
import { toDefinitions, tool } from "../src/tool.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { it } from "./lib/effect.js"
const Token = Schema.Struct({ token: Schema.String })
const token = {
type: "object",
properties: { token: { type: "string" } },
required: ["token"],
additionalProperties: false,
}
const fromEmpty = {
decode: SchemaGetter.transform(() => ({ token: "default" })),
encode: SchemaGetter.transform(() => ({})),
}
// Callers send `{}`; the handler receives a token.
const defaulted = Schema.Struct({}).pipe(Schema.decodeTo(Token, fromEmpty))
// Callers send a token; the handler receives an empty struct.
const reversed = Token.pipe(
Schema.decodeTo(Schema.Struct({}), {
decode: SchemaGetter.transform(() => ({})),
encode: SchemaGetter.transform(() => ({ token: "default" })),
}),
)
describe("tool schema projections", () => {
test("only normalizes typed empty input structs, preserving raw schemas and output schemas", () => {
const schema = { not: { type: "null" } }
const definitions = toDefinitions([
tool({
name: "typed",
description: "Typed",
input: Schema.Struct({}),
success: Schema.Struct({}),
execute: () => Effect.succeed({}),
}),
tool({ name: "raw", description: "Raw", input: schema, execute: () => Effect.succeed({}) }),
])
expect(definitions[0]?.inputSchema).toEqual({ type: "object", properties: {}, additionalProperties: false })
expect(definitions[0]?.outputSchema).toEqual(schema)
expect(definitions[1]?.inputSchema).toEqual(schema)
})
test("retains empty input descriptions and preserves named outputs and explicit raw schemas", () => {
const input = Schema.Struct({}).annotate({ identifier: "Ping", description: "No arguments" })
const raw = { $ref: "#/$defs/Raw", $defs: { Raw: { not: { type: "null" }, description: "Raw schema" } } }
const definitions = toDefinitions([
tool({ name: "typed", description: "Ping", input, success: input, execute: () => Effect.succeed({}) }),
tool({ name: "raw", description: "Raw", input: raw, execute: () => Effect.succeed({}) }),
])
expect(definitions[0]?.inputSchema).toEqual({
type: "object",
properties: {},
additionalProperties: false,
description: "No arguments",
})
expect(definitions[0]?.outputSchema).toEqual({
$ref: "#/$defs/Ping",
$defs: { Ping: { not: { type: "null" }, description: "No arguments" } },
})
expect(definitions[1]?.inputSchema).toEqual(raw)
})
test("normalizes inputs whose encoded side is an empty struct", () => {
const definitions = toDefinitions([
tool({ name: "defaulted", description: "Defaulted", input: defaulted, success: defaulted }),
tool({
name: "named",
description: "Named",
input: defaulted.annotate({ identifier: "Defaulted" }),
success: Schema.String,
}),
tool({
name: "encodedMetadata",
description: "Encoded metadata",
input: Schema.Struct({})
.annotate({ identifier: "Ping", title: "Ping", description: "No arguments" })
.pipe(Schema.decodeTo(Token, fromEmpty)),
success: Schema.String,
}),
tool({
name: "decodedCheck",
description: "Decoded check",
input: defaulted.check(Schema.makeFilter(() => true, { toJsonSchema: () => ({ minProperties: 1 }) })),
success: Schema.String,
}),
tool({ name: "reversed", description: "Reversed", input: reversed, success: Schema.String }),
tool({
name: "encodedCheck",
description: "Encoded check",
input: Schema.Struct({}).check(Schema.isMinProperties(1)),
success: Schema.String,
}),
// A filter without a JSON Schema form emits nothing, so the input still describes an empty object.
tool({
name: "encodedFilter",
description: "Encoded filter",
input: Schema.Struct({}).check(Schema.makeFilter(() => true)),
success: Schema.String,
}),
])
expect(definitions[0]?.inputSchema).toEqual(empty)
expect(definitions[0]?.outputSchema).toEqual({ not: { type: "null" } })
expect(definitions[1]?.inputSchema).toEqual(empty)
expect(definitions[2]?.inputSchema).toEqual({ ...empty, title: "Ping", description: "No arguments" })
expect(definitions[3]?.inputSchema).toEqual(empty)
expect(definitions[4]?.inputSchema).toEqual(token)
expect(definitions[5]?.inputSchema).toEqual({ not: { type: "null" }, minProperties: 1 })
expect(definitions[6]?.inputSchema).toEqual(empty)
})
const empty = { type: "object", properties: {}, additionalProperties: false }
const nonempty = {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
additionalProperties: false,
}
const raw = { type: "object", properties: { value: { type: "number" } }, additionalProperties: true }
const names = ["ping", "lookup", "raw", "reversed", "checked"]
const schemas = [empty, nonempty, raw, token, { type: "object", minProperties: 1 }]
for (const scenario of [
{
route: OpenAIChat.route,
tools: schemas.map((parameters, index) => ({
type: "function",
function: { name: names[index], parameters },
})),
},
{
route: OpenAIResponses.route,
tools: schemas.map((parameters, index) => ({
type: "function",
name: names[index],
parameters,
})),
},
{
route: AnthropicMessages.route,
tools: schemas.map((input_schema, index) => ({
name: names[index],
input_schema,
})),
},
{
route: Gemini.route,
tools: [
{
functionDeclarations: schemas.map((parametersJsonSchema, index) => ({
name: names[index],
parametersJsonSchema,
})),
},
],
},
]) {
it.effect(`${scenario.route.id} prepares empty Effect Struct tools alongside nonempty and raw schemas`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
ai.request({
model: scenario.route.with({ auth: Auth.bearer("test") }).model({ id: "test-model" }),
prompt: "Use a tool.",
tools: [
tool({
name: "ping",
description: "Ping",
input: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.succeed("pong"),
}),
tool({
name: "lookup",
description: "Lookup",
input: Schema.Struct({ query: Schema.String }),
success: Schema.String,
execute: (input) => Effect.succeed(input.query),
}),
tool({ name: "raw", description: "Raw", input: raw, execute: () => Effect.succeed("raw") }),
tool({
name: "reversed",
description: "Reversed",
input: reversed,
success: Schema.String,
execute: () => Effect.succeed("reversed"),
}),
tool({
name: "checked",
description: "Checked",
input: Schema.Struct({}).check(Schema.isMinProperties(1)),
success: Schema.String,
execute: () => Effect.succeed("checked"),
}),
],
}),
)
expect(prepared.body.tools).toMatchObject(scenario.tools)
expect(JSON.stringify(prepared.body.tools)).not.toContain('"not"')
}),
)
}
test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => {
expect(
ToolSchemaProjection.moonshot({
type: "object",
properties: {
linked: { $ref: "#/$defs/Linked", description: "drop me" },
tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] },
prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }] },
closedTuple: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }], items: false },
restTuple: { type: "array", prefixItems: [{ type: "string" }], items: { type: "number" } },
prefixItems: { type: "string" },
unevaluatedItems: { type: "boolean" },
items: { type: "array", items: { type: "string" } },
cart: {
type: "object",
default: { items: ["a", "b"], $ref: "#/literal", count: 1 },
const: { enum: ["literal"] },
examples: [{ items: ["x", "y"] }],
},
},
dependentRequired: { items: ["cart"] },
dependencies: { items: ["cart"], prefixItems: { required: ["items"] } },
}),
).toEqual({
type: "object",
properties: {
linked: { $ref: "#/$defs/Linked" },
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } },
closedTuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
restTuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
prefixItems: { type: "string" },
unevaluatedItems: { type: "boolean" },
items: { type: "array", items: { type: "string" } },
cart: {
type: "object",
default: { items: ["a", "b"], $ref: "#/literal", count: 1 },
const: { enum: ["literal"] },
examples: [{ items: ["x", "y"] }],
},
},
dependentRequired: { items: ["cart"] },
dependencies: { items: ["cart"], prefixItems: { required: ["items"] } },
})
})
test("moonshot derives a type for untyped enums", () => {
expect(
ToolSchemaProjection.moonshot({
type: "object",
properties: {
kind: { description: "The kind of flag", enum: ["boolean", "string"] },
level: { enum: [1, 2.5] },
optional: { enum: [null, "a"] },
choice: { anyOf: [{ enum: [true, false] }, { type: "null" }] },
list: { type: "array", items: { enum: ["x"] } },
map: { type: "object", additionalProperties: { enum: ["y"] } },
typed: { type: "string", enum: ["a", null] },
mixed: { enum: ["a", 1] },
},
$defs: { Mode: { enum: ["fast"] } },
}),
).toEqual({
type: "object",
properties: {
kind: { type: "string", description: "The kind of flag", enum: ["boolean", "string"] },
level: { type: "number", enum: [1, 2.5] },
optional: { type: ["string", "null"], enum: [null, "a"] },
choice: { anyOf: [{ type: "boolean", enum: [true, false] }, { type: "null" }] },
list: { type: "array", items: { type: "string", enum: ["x"] } },
map: { type: "object", additionalProperties: { type: "string", enum: ["y"] } },
typed: { type: "string", enum: ["a", null] },
mixed: { enum: ["a", 1] },
},
$defs: { Mode: { type: "string", enum: ["fast"] } },
})
})
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(
ai.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([
tool({
name: "lookup",
description: "Lookup data.",
input: 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",
properties: {},
additionalProperties: false,
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({
endpoint: { baseURL: "https://api.openai.test/v1/" },
auth: Auth.bearer("test"),
})
const original = {
type: "object",
required: ["mode", "missing"],
properties: { mode: { enum: ["fast", "safe"] } },
}
const parameters = (model: ReturnType<typeof route.model>) =>
compileRequest(
ai.request({
model,
prompt: "Use the tool.",
tools: [{ name: "lookup", description: "Lookup data.", inputSchema: original }],
}),
).pipe(Effect.map((prepared) => prepared.body.tools?.[0]?.function.parameters))
const gemini = { ...original, required: ["mode"] }
const moonshot = { ...original, properties: { mode: { type: "string", enum: ["fast", "safe"] } } }
expect(yield* parameters(route.model({ id: "google/Gemini-3.8-Flash" }))).toEqual(gemini)
expect(
yield* parameters(route.model({ id: "my-tuned-endpoint", compatibility: { sanitizer: "gemini" } })),
).toEqual(gemini)
expect(
yield* parameters(route.model({ id: "google/gemini-3.8-flash", compatibility: { sanitizer: "moonshot" } })),
).toEqual(moonshot)
expect(yield* parameters(route.model({ id: "moonshotai/Kimi-K3" }))).toEqual(moonshot)
expect(
yield* parameters(route.model({ id: "google/gemini-3.8-flash", compatibility: { sanitizer: "none" } })),
).toEqual(original)
expect(
yield* parameters(route.model({ id: "moonshotai/Kimi-K3", compatibility: { sanitizer: "none" } })),
).toEqual(original)
expect(yield* parameters(route.model({ id: "gpt-6-luna" }))).toEqual(original)
}),
)
it.effect("applies model compatibility without changing schema semantics", () =>
Effect.gen(function* () {
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "kimi-k2", compatibility: { sanitizer: "moonshot" } })
const prepared = yield* compileRequest(
ai.request({
model,
prompt: "Use the tool.",
tools: [
{
name: "lookup",
description: "Lookup data.",
inputSchema: {
type: "object",
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] },
linked: { $ref: "#/$defs/Linked", description: "drop me" },
},
},
],
},
},
],
}),
)
expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
type: "object",
anyOf: [
{
type: "object",
properties: {
tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
linked: { $ref: "#/$defs/Linked" },
},
},
],
})
}),
)
})