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, 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) => 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" }, }, }, ], }) }), ) })