Ctrl K
ComunidadGuías…

packages/ai/test/llm.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, Stream } from "effect"
import { ai, CacheHint, GENERATION_KEYS, LLMEvent, LLMResponse, ToolEntry, ToolNamespace } from "../src/effect.js"
import { OpenAI } from "../src/providers.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
import {
  Message,
  LanguageModel,
  SystemMessage,
  ToolCallPart,
  ToolChoice,
  ToolDefinition,
  ToolResultPart,
  UserMessage,
} from "../src/schema/index.js"
import { fixedResponse } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"

const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route

describe("llm constructors", () => {
  test("normalizes recursive tool namespaces", () => {
    const request = ai.request({
      model: LanguageModel.make({ id: "fake-model", provider: "fake", route: responsesRoute }),
      prompt: "Hello",
      tools: [
        {
          type: "namespace",
          name: "crm",
          description: "Customer management",
          tools: [
            { name: "lookup", description: "Look up a customer", inputSchema: { type: "object" } },
            {
              type: "namespace",
              name: "orders",
              tools: [{ name: "list", description: "List orders", inputSchema: { type: "object" } }],
            },
          ],
        },
      ],
    })

    expect(request.tools[0]).toEqual({
      type: "namespace",
      name: "crm",
      description: "Customer management",
      tools: [
        expect.objectContaining({ type: "tool", name: "lookup" }),
        {
          type: "namespace",
          name: "orders",
          description: undefined,
          tools: [expect.objectContaining({ type: "tool", name: "list" })],
        },
      ],
    })
    expect(request.tools[0]).toEqual(
      ToolNamespace.make({
        name: "crm",
        description: "Customer management",
        tools: request.tools[0]!.type === "namespace" ? request.tools[0].tools : [],
      }),
    )
    expect(Schema.decodeUnknownSync(ToolEntry)(Schema.encodeUnknownSync(ToolEntry)(request.tools[0]))).toEqual(
      request.tools[0],
    )
  })

  test("normalizes ergonomic input into a plain request", () => {
    const request = ai.request({
      id: "req_1",
      model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
      system: "You are concise.",
      prompt: "Say hello.",
    })

    expect(Object.getPrototypeOf(request)).toBe(Object.prototype)
    expect(request.model).toBeInstanceOf(LanguageModel)
    expect(request.messages[0]).toBeInstanceOf(UserMessage)
    expect(request.system).toEqual([{ type: "text", text: "You are concise." }])
    expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }])
    expect(request.maxTokens).toBeUndefined()
    expect(request.tools).toEqual([])
  })

  test("edits requests with a spread or by passing them back to ai.request", () => {
    const base = ai.request({
      id: "req_1",
      model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
      prompt: "Say hello.",
    })
    const updated = {
      ...base,
      maxTokens: 20,
      messages: [...base.messages, Message.assistant("Hi.")],
    }

    expect(updated.id).toBe("req_1")
    expect(updated.model).toBe(base.model)
    expect(updated.maxTokens).toBe(20)
    expect(updated.messages.map((message) => message.role)).toEqual(["user", "assistant"])
    expect(ai.request(updated)).toEqual(updated)
    expect(ai.request(updated).messages[1]).toBe(updated.messages[1])

    const prompted = ai.request({
      ...updated,
      system: "Be brief.",
      messages: [...updated.messages, Message.user("Again.")],
    })
    expect(prompted.system).toEqual([{ type: "text", text: "Be brief." }])
    expect(prompted.messages.map((message) => message.role)).toEqual(["user", "assistant", "user"])
  })

  test("lists the portable knobs", () => {
    expect([...GENERATION_KEYS]).toEqual([
      "maxTokens",
      "temperature",
      "topP",
      "topK",
      "frequencyPenalty",
      "presencePenalty",
      "seed",
      "stop",
    ])
  })

  test("keeps request options separate from route defaults", () => {
    const request = ai.request({
      model: LanguageModel.make({
        id: "fake-model",
        provider: "fake",
        route: chatRoute.with({
          maxTokens: 100,
          temperature: 1,
          providerOptions: { store: false, metadata: { model: true } },
          http: { body: { metadata: { model: true } }, headers: { "x-shared": "model" }, query: { model: "1" } },
        }),
      }),
      prompt: "Say hello.",
      temperature: 0,
      providerOptions: { store: true, metadata: { request: true } },
      headers: { "x-shared": "request" },
      http: { body: { metadata: { request: true } }, query: { request: "1" } },
    })

    expect(request.temperature).toBe(0)
    expect(request.maxTokens).toBeUndefined()
    expect(request.providerOptions).toEqual({ store: true, metadata: { request: true } })
    expect(request.headers).toEqual({ "x-shared": "request" })
    expect(request.http).toEqual({ body: { metadata: { request: true } }, query: { request: "1" } })
  })

  test("updates canonical models from the model datatype", () => {
    const base = LanguageModel.make({
      id: "fake-model",
      provider: "fake",
      route: chatRoute,
    })
    const updated = LanguageModel.update(base, {
      route: responsesRoute,
      options: { maxTokens: 20 },
      compatibility: { sanitizer: "gemini", requireFinishReason: false },
    })
    const updatedInput = LanguageModel.input(updated)

    expect(updated).toBeInstanceOf(LanguageModel)
    expect(String(updated.id)).toBe("fake-model")
    expect(updated.route).toBe(responsesRoute)
    expect(updated.options?.maxTokens).toBe(20)
    expect(updated.compatibility).toEqual({ sanitizer: "gemini", requireFinishReason: false })
    expect(updatedInput.options).toBe(updated.options)
    expect(updatedInput.compatibility).toBe(updated.compatibility)
    expect(String(updatedInput.provider)).toBe("fake")
    expect(LanguageModel.update(updated, {})).toBe(updated)
  })

  test("carries model defaults and compatibility through route model selection", () => {
    const model = chatRoute.model({
      id: "kimi-k2",
      options: {
        maxTokens: 1_024,
        stop: ["END"],
        providerOptions: { parallelToolCalls: false },
        http: { body: { extra_body: true } },
      },
      compatibility: { sanitizer: "moonshot" },
    })
    const request = ai.request({ model, prompt: "Say hello." })

    expect(request.model.options).toMatchObject({ maxTokens: 1_024, stop: ["END"] })
    expect(request.model.options?.providerOptions).toEqual({ parallelToolCalls: false })
    expect(request.model.options?.http).toEqual({ body: { extra_body: true } })
    expect(request.model.compatibility).toEqual({ sanitizer: "moonshot" })
    expect(request.maxTokens).toBeUndefined()
    expect(request.providerOptions).toBeUndefined()
    expect(request.http).toBeUndefined()
  })

  test("builds tool choices from names and tools", () => {
    const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })

    expect(tool).toBeInstanceOf(ToolDefinition)
    expect(ToolChoice.make("lookup")).toEqual(new ToolChoice({ type: "tool", name: "lookup" }))
    expect(ToolChoice.named("required")).toEqual(new ToolChoice({ type: "tool", name: "required" }))
    expect(ToolChoice.make(tool)).toEqual(new ToolChoice({ type: "tool", name: "lookup" }))
  })

  test("builds tool choice modes from reserved strings", () => {
    expect(ToolChoice.make("auto")).toEqual(new ToolChoice({ type: "auto" }))
    expect(ToolChoice.make("none")).toEqual(new ToolChoice({ type: "none" }))
    expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" }))
    expect(
      ai.request({
        model: LanguageModel.make({
          id: "fake-model",
          provider: "fake",
          route: chatRoute,
        }),
        prompt: "Use tools if needed.",
        toolChoice: "required",
      }).toolChoice,
    ).toEqual(new ToolChoice({ type: "required" }))
  })

  test("builds assistant tool calls and tool result messages", () => {
    const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })
    const result = ToolResultPart.make({ id: "call_1", name: "lookup", result: { temperature: 72 } })

    expect(Message.assistant([call]).content).toEqual([call])
    expect(Message.tool(result).content).toEqual([
      { type: "tool-result", id: "call_1", name: "lookup", result: { type: "json", value: { temperature: 72 } } },
    ])
  })

  test("builds chronological text-only system updates separately from the initial system prompt", () => {
    const update = Message.system([
      { type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) },
    ])
    const request = ai.request({
      model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
      system: "Initial operator prompt.",
      messages: [Message.user("Review this."), update],
    })

    expect(update).toBeInstanceOf(SystemMessage)
    expect(update).toEqual({
      role: "system",
      content: [{ type: "text", text: "Use parameterized SQL.", cache: { type: "ephemeral" } }],
    })
    expect(request.system).toEqual([{ type: "text", text: "Initial operator prompt." }])
    expect(request.messages.map((message) => message.role)).toEqual(["user", "system"])
  })

  test("generates and streams from input or a prebuilt request", async () => {
    const model = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).chat("gpt-4o-mini")
    const layer = fixedResponse(
      sseEvents({ choices: [{ delta: { content: "Hello" } }] }, { choices: [{ delta: {}, finish_reason: "stop" }] }),
    )
    const input = { model, prompt: "Say hello." }
    const request = ai.request(input)
    const generated = await Effect.runPromise(ai.generate(input).pipe(Effect.provide(layer)))
    const generatedFromRequest = await Effect.runPromise(ai.generate(request).pipe(Effect.provide(layer)))
    expect(generated.text).toBe("Hello")
    expect(generatedFromRequest.text).toBe(generated.text)

    const streamed = await Effect.runPromise(ai.stream(input).pipe(Stream.runCollect, Effect.provide(layer)))
    const streamedFromRequest = await Effect.runPromise(
      ai.stream(request).pipe(Stream.runCollect, Effect.provide(layer)),
    )
    expect(Array.from(streamed).some(LLMEvent.is.textDelta)).toBe(true)
    expect(streamedFromRequest).toEqual(streamed)
  })

  test("extracts output text from response events", () => {
    expect(
      LLMResponse.text({
        events: [
          { type: "text-delta", id: "text-0", text: "hi" },
          { type: "finish", reason: { normalized: "stop" } },
        ],
      }),
    ).toBe("hi")
  })
})