Ctrl K
ComunidadGuías…

packages/ai/test/tool-schema-projection.test.ts

Rama dev · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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" },
            },
          },
        ],
      })
    }),
  )
})