Ctrl K
ComunidadGuías…

lib/ai-chat.test.ts

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

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from "vitest";

import {
  alternateOutputTokenBody,
  aiChatProviderHeaders,
  repairRejectedAiChatBody,
  translateAiChatRequest,
  translateLegacyAiChatBody,
} from "./ai-chat";
import {
  anthropicModelReasoning,
  catalogModelReasoning,
  nativeModelReasoning,
} from "./model-reasoning-rules";
import { SITE_URL } from "./site";

const base = {
  model: "model-x",
  messages: [{ role: "user", content: "Hello" }],
  maxOutputTokens: 1234,
  reasoning: { effort: "high" as const },
};

describe("translateAiChatRequest", () => {
  it("translates OpenAI without retaining the legacy max_tokens field", () => {
    const body = translateAiChatRequest({ ...base, model: "gpt-5.7", stream: true }, "openai");
    expect(body).toMatchObject({
      max_completion_tokens: 1234,
      reasoning_effort: "high",
      stream_options: { include_usage: true },
    });
    expect(body).not.toHaveProperty("max_tokens");
    expect(body).not.toHaveProperty("usage");
    expect(body).not.toHaveProperty("reasoning");
  });

  it("explicitly disables GPT-5.6 reasoning with function tools", () => {
    const withTools = translateAiChatRequest(
      {
        ...base,
        model: "gpt-5.6-sol",
        tools: [{ type: "function", function: { name: "search" } }],
      },
      "openai",
    );
    expect(withTools.reasoning_effort).toBe("none");

    const withoutTools = translateAiChatRequest(
      { ...base, model: "gpt-5.6-sol" },
      "openai",
    );
    expect(withoutTools.reasoning_effort).toBe("high");
  });

  it("applies the no-reasoning-with-tools contract to GPT-6 Sol and Luna", () => {
    const tools = [{ type: "function", function: { name: "search" } }];
    for (const model of ["gpt-6-sol", "gpt-6-luna"]) {
      expect(
        translateAiChatRequest({ ...base, model, tools }, "openai").reasoning_effort,
      ).toBe("none");
    }
    // Without tools the contract does not apply.
    expect(
      translateAiChatRequest({ ...base, model: "gpt-6-sol" }, "openai").reasoning_effort,
    ).not.toBe("none");
    // GPT-6 Astra and GPT-6.1 Sol reject `none` and need Responses for tools:
    // no effort value fixes them, so the adapter never sends `none` to them.
    for (const model of ["gpt-6-astra", "gpt-6.1-sol"]) {
      expect(
        translateAiChatRequest({ ...base, model, tools }, "openai").reasoning_effort,
      ).not.toBe("none");
    }
  });

  it("translates OpenRouter with its counting and reasoning extensions", () => {
    const body = translateAiChatRequest({ ...base, stream: true }, "openrouter");
    expect(body).toMatchObject({
      max_completion_tokens: 1234,
      reasoning: { effort: "high", exclude: false },
      usage: { include: true },
      stream_options: { include_usage: true },
    });
    expect(body).not.toHaveProperty("max_tokens");
    expect(body).not.toHaveProperty("reasoning_effort");
  });

  it("sends Claude's adaptive thinking and effort natively (MIN-826)", () => {
    // Native Messages API fields, carried as they are by the Anthropic
    // transport; the effort really changes the depth there.
    expect(
      translateAiChatRequest({ ...base, model: "claude-opus-5-5", reasoning: { effort: "max" } }, "anthropic"),
    ).toMatchObject({
      thinking: { type: "adaptive", display: "summarized" },
      output_config: { effort: "max" },
      // Thinking shares the ceiling: lifted so it cannot consume the answer.
      max_tokens: 16_000,
    });
    for (const [model, effort, sent] of [
      ["claude-opus-4-7", "minimal", "low"],
      ["claude-sonnet-5", "xhigh", "xhigh"],
      ["claude-haiku-5-5", "medium", "medium"],
      // Claude 4.6 predates `xhigh`: the level folds down, never up.
      ["claude-opus-4-6", "xhigh", "high"],
    ] as const) {
      expect(translateAiChatRequest({ ...base, model, reasoning: { effort } }, "anthropic"))
        .toMatchObject({ thinking: { type: "adaptive" }, output_config: { effort: sent } });
    }
    // No requested reasoning: the model keeps its own default.
    const plain = translateAiChatRequest({ model: "claude-sonnet-5", messages: [], maxOutputTokens: 1234 }, "anthropic");
    expect(plain).toMatchObject({ max_tokens: 1234 });
    expect(plain).not.toHaveProperty("thinking");
    expect(plain).not.toHaveProperty("output_config");
    expect(plain).not.toHaveProperty("max_completion_tokens");
  });

  it("keeps a manual budget on the Claude 4.5 families", () => {
    // Haiku 4.5 rejects adaptive thinking and Sonnet 4.5 rejects `effort`.
    expect(
      translateAiChatRequest({ ...base, model: "claude-haiku-4-5", reasoning: { effort: "low" } }, "anthropic"),
    ).toMatchObject({ thinking: { type: "enabled", budget_tokens: 1024 } });
    expect(
      translateAiChatRequest({ ...base, model: "claude-sonnet-4-5", reasoning: { effort: "medium" } }, "anthropic"),
    ).not.toHaveProperty("output_config");
    expect(
      translateAiChatRequest({ ...base, model: "claude-unknown", reasoning: { effort: "high" } }, "anthropic"),
    ).not.toHaveProperty("thinking");
  });

  it("turns Claude reasoning off where the family allows it", () => {
    for (const model of ["claude-sonnet-5", "claude-opus-5", "claude-opus-4-7", "claude-haiku-4-5"]) {
      expect(translateAiChatRequest({ ...base, model, reasoning: { effort: "off" } }, "anthropic"))
        .toMatchObject({ thinking: { type: "disabled" } });
    }
    // Families that reject `disabled` (400) run at their cheapest effort.
    for (const model of ["claude-opus-5-5", "claude-sonnet-5-5", "claude-fable-5", "claude-mythos-preview"]) {
      const body = translateAiChatRequest({ ...base, model, reasoning: { effort: "off" } }, "anthropic");
      expect(body).not.toHaveProperty("thinking");
      expect(body).toMatchObject({ output_config: { effort: "low" } });
    }
  });

  it("drops a manual budget when the tool choice is forced", () => {
    // "Thinking may not be enabled when tool_choice forces tool use" (400).
    const tools = [{ type: "function", function: { name: "fill" } }];
    const body = translateAiChatRequest({
      ...base, model: "claude-opus-4-6", tools, reasoning: { maxTokens: 2048 },
      toolChoice: { type: "function", function: { name: "fill" } },
    }, "anthropic");
    expect(body).not.toHaveProperty("thinking");
    expect(body.tool_choice).toEqual({ type: "function", function: { name: "fill" } });
  });

  it("publishes each Claude family's real levels to the picker", () => {
    expect(anthropicModelReasoning("claude-opus-5-5"))
      .toEqual({ efforts: ["low", "medium", "high", "xhigh", "max"], mandatory: true });
    expect(anthropicModelReasoning("claude-sonnet-5"))
      .toEqual({ efforts: ["low", "medium", "high", "xhigh", "max"], mandatory: false });
    expect(anthropicModelReasoning("claude-opus-4-6"))
      .toEqual({ efforts: ["low", "medium", "high", "max"], mandatory: false });
    // Manual-budget and unknown families keep the generic levels.
    expect(anthropicModelReasoning("claude-haiku-4-5-20251001")).toBeUndefined();
    expect(anthropicModelReasoning("claude-unknown")).toBeUndefined();
  });

  it("sends OpenAI reasoning only to reasoning models, at levels they take", () => {
    // `reasoning_effort` on a chat model (gpt-4o, gpt-4.1) is a 400.
    for (const model of ["gpt-4o", "gpt-4.1-mini", "chatgpt-4o-latest"]) {
      expect(translateAiChatRequest({ ...base, model }, "openai")).not.toHaveProperty("reasoning_effort");
    }
    // GPT-5.1+ really turns reasoning off with "none"; GPT-5 and o-series cannot.
    expect(translateAiChatRequest({ ...base, model: "gpt-5.1", reasoning: { effort: "off" } }, "openai"))
      .toMatchObject({ reasoning_effort: "none" });
    for (const model of ["gpt-5", "o3"]) {
      expect(translateAiChatRequest({ ...base, model, reasoning: { effort: "off" } }, "openai"))
        .not.toHaveProperty("reasoning_effort");
    }
    expect(translateAiChatRequest({ ...base, model: "gpt-5-mini", reasoning: { effort: "minimal" } }, "openai"))
      .toMatchObject({ reasoning_effort: "minimal" });
    expect(translateAiChatRequest({ ...base, model: "gpt-5.2", reasoning: { effort: "max" } }, "openai"))
      .toMatchObject({ reasoning_effort: "high" });
  });

  it("follows Gemini's per-model thinking rules", () => {
    expect(translateAiChatRequest({ ...base, model: "gemini-2.5-flash", reasoning: { effort: "off" } }, "google"))
      .toMatchObject({ reasoning_effort: "none" });
    // 2.5 Pro and Gemini 3 cannot stop thinking; 3 Pro only takes low / high.
    for (const model of ["gemini-2.5-pro", "gemini-3-pro-preview"]) {
      expect(translateAiChatRequest({ ...base, model, reasoning: { effort: "off" } }, "google"))
        .not.toHaveProperty("reasoning_effort");
    }
    expect(translateAiChatRequest({ ...base, model: "gemini-3-pro-preview", reasoning: { effort: "medium" } }, "google"))
      .toMatchObject({ reasoning_effort: "low" });
    expect(translateAiChatRequest({ ...base, model: "gemini-2.0-flash" }, "google"))
      .not.toHaveProperty("reasoning_effort");
  });

  it("describes which direct providers honor reasoning levels", () => {
    expect(nativeModelReasoning("openai", "gpt-4o")).toBeNull();
    expect(nativeModelReasoning("openai", "gpt-5.1")).toEqual({ efforts: ["low", "medium", "high"], mandatory: false });
    expect(nativeModelReasoning("google", "gemini-2.5-pro")).toMatchObject({ mandatory: true });
    // Endpoints without a reasoning field: no level would reach the model.
    for (const provider of ["generic", "local_openai", "ollama", "opencode-go", "opencode-zen"] as const) {
      expect(nativeModelReasoning(provider, "any-model")).toBeNull();
    }
    // OpenRouter publishes its own levels per model.
    expect(nativeModelReasoning("openrouter", "openai/gpt-5.1")).toBeUndefined();
    // A catalog entry wins; a model without one falls back to the rule.
    expect(catalogModelReasoning("openai", "gpt-4o", { reasoning: { efforts: ["low"], mandatory: false } }))
      .toEqual({ efforts: ["low"], mandatory: false });
    expect(catalogModelReasoning("ollama", "llama3", undefined)).toBeNull();
    expect(catalogModelReasoning("openrouter", null, undefined)).toBeUndefined();
  });

  it("keeps Gemini's tool-call extras for Gemini only", () => {
    const messages = [{
      role: "assistant",
      content: null,
      tool_calls: [{
        id: "call_1", type: "function", function: { name: "list", arguments: "{}" },
        extra_content: { google: { thought_signature: "sig" } },
      }],
    }];
    const sent = (provider: "google" | "openai" | "anthropic") =>
      (translateAiChatRequest({ model: "m", messages }, provider).messages as Array<{ tool_calls: unknown[] }>)[0]!.tool_calls[0];
    expect(sent("google")).toHaveProperty("extra_content");
    expect(sent("openai")).not.toHaveProperty("extra_content");
    expect(sent("anthropic")).not.toHaveProperty("extra_content");
  });

  it("drops the sampling temperature that Claude 4.7 and later reject", () => {
    const request = { ...base, temperature: 0 };
    for (const model of ["claude-opus-5-5", "claude-sonnet-5", "claude-opus-4-7"]) {
      expect(translateAiChatRequest({ ...request, model }, "anthropic"))
        .not.toHaveProperty("temperature");
    }
    expect(translateAiChatRequest({ ...request, model: "claude-sonnet-4-6" }, "anthropic"))
      .toMatchObject({ temperature: 0 });
    expect(translateAiChatRequest({ ...request, model: "claude-opus-5-5" }, "openrouter"))
      .toMatchObject({ temperature: 0 });
  });

  it("downgrades forced tool choice on Anthropic families that reject it", () => {
    const tools = [{ type: "function", function: { name: "write_tasks" } }];
    const forced = { type: "function", function: { name: "write_tasks" } };
    // Claude Opus 5.5 / Sonnet 5.5 / Fable 5.1 / Mythos 5.1 return a 400 for
    // forced tool choice on every request; the documented path is `auto`.
    for (const model of [
      "claude-opus-5-5",
      "claude-sonnet-5-5",
      "claude-fable-5-1",
      "claude-mythos-5-1",
    ]) {
      expect(
        translateAiChatRequest(
          { ...base, model, tools, toolChoice: forced },
          "anthropic",
        ).tool_choice,
      ).toBe("auto");
      expect(
        translateAiChatRequest(
          { ...base, model, tools, toolChoice: "required" },
          "anthropic",
        ).tool_choice,
      ).toBe("auto");
    }
    // Families that still accept forced tool use keep it untouched.
    expect(
      translateAiChatRequest(
        { ...base, model: "claude-fable-5", tools, toolChoice: forced },
        "anthropic",
      ).tool_choice,
    ).toEqual(forced);
    // "none" is never forced and must survive untouched.
    expect(
      translateAiChatRequest(
        { ...base, model: "claude-opus-5-5", tools, toolChoice: "none" },
        "anthropic",
      ).tool_choice,
    ).toBe("none");
  });

  it("translates Gemini to reasoning_effort and its supported stream usage", () => {
    const body = translateAiChatRequest({ ...base, model: "gemini-2.5-flash", stream: true }, "google");
    expect(body).toMatchObject({
      max_completion_tokens: 1234,
      reasoning_effort: "high",
      stream_options: { include_usage: true },
    });
    expect(body).not.toHaveProperty("usage");
  });

  it("preserves non-forced choices and other providers' tool restrictions", () => {
    const tools = [{ type: "function", function: { name: "write_tasks" } }];
    const request = { ...base, model: "claude-opus-5-5", tools };
    for (const toolChoice of [
      "auto",
      "none",
      { type: "allowed_tools", allowed_tools: { mode: "auto", tools } },
    ]) {
      expect(translateAiChatRequest({ ...request, toolChoice }, "anthropic").tool_choice)
        .toEqual(toolChoice);
    }
    const required = { type: "allowed_tools", allowed_tools: { mode: "required", tools } };
    expect(translateAiChatRequest({ ...request, toolChoice: required }, "anthropic").tool_choice)
      .toBe("auto");
    for (const provider of ["openai", "openrouter", "generic"] as const) {
      expect(translateAiChatRequest({ ...request, toolChoice: required }, provider).tool_choice)
        .toEqual(required);
    }
  });

  it("stays conservative for a generic endpoint", () => {
    const body = translateAiChatRequest({ ...base, stream: true }, "generic");
    expect(body).toMatchObject({ max_tokens: 1234, stream: true });
    expect(body).not.toHaveProperty("max_completion_tokens");
    expect(body).not.toHaveProperty("reasoning");
    expect(body).not.toHaveProperty("reasoning_effort");
    expect(body).not.toHaveProperty("stream_options");
    expect(body).not.toHaveProperty("usage");
  });

  it("expresses a fixed budget only where one exists", () => {
    const request = { ...base, reasoning: { maxTokens: 2048 } };
    expect(translateAiChatRequest(request, "openrouter")).toMatchObject({
      reasoning: { max_tokens: 2048, exclude: false },
    });
    expect(
      translateAiChatRequest(
        { ...request, model: "claude-sonnet-4-6" },
        "anthropic",
      ),
    ).toMatchObject({
      // The manual budget must remain strictly under the output ceiling.
      thinking: { type: "enabled", budget_tokens: 1233 },
    });
    expect(translateAiChatRequest(request, "openai")).not.toHaveProperty("reasoning");
  });

  it("really disables optional OpenRouter reasoning at off", () => {
    expect(translateAiChatRequest({ ...base, reasoning: { enabled: false, effort: "low" } }, "openrouter"))
      .toMatchObject({ reasoning: { enabled: false } });
    // Omitting the field ran the model at its default effort (MIN-826).
    expect(translateAiChatRequest({ ...base, reasoning: { effort: "off" } }, "openrouter"))
      .toMatchObject({ reasoning: { enabled: false } });
    for (const provider of ["generic", "openai", "google", "anthropic"] as const) {
      expect(translateAiChatRequest({ ...base, reasoning: { enabled: false } }, provider))
        .not.toHaveProperty("reasoning");
    }
  });
});

describe("translateLegacyAiChatBody", () => {
  it("normalizes opencode aliases before translating them", () => {
    const body = translateLegacyAiChatBody(
      {
        model: "gpt-5.7",
        messages: [],
        stream: true,
        max_tokens: 900,
        reasoning: { effort: "low" },
        reasoning_effort: "low",
        usage: { include: true },
      },
      "openai",
      "medium",
    );
    expect(body).toMatchObject({
      max_completion_tokens: 900,
      reasoning_effort: "medium",
      stream_options: { include_usage: true },
    });
    expect(body).not.toHaveProperty("max_tokens");
    expect(body).not.toHaveProperty("reasoning");
    expect(body).not.toHaveProperty("usage");
  });

  it("avoids rejected forced tool choices in Anthropic code-worker requests", () => {
    const tools = [{ type: "function", function: { name: "write_tasks" } }];
    for (const model of [
      "claude-opus-5-5",
      "claude-sonnet-5-5",
      "claude-fable-5-1",
      "claude-mythos-5-1",
    ]) {
      for (const tool_choice of ["required", tools[0]]) {
        const body = translateLegacyAiChatBody(
          { model, messages: [], tools, tool_choice }, "anthropic", "off",
        );
        expect(body.tool_choice).toBe("auto");
        expect(body).not.toHaveProperty("thinking");
        expect(body.tools).toEqual(tools);
      }
    }
    expect(translateLegacyAiChatBody(
      { model: "claude-fable-5", messages: [], tools, tool_choice: "required" },
      "anthropic", "off",
    ).tool_choice).toBe("required");
    expect(translateLegacyAiChatBody(
      { model: "claude-opus-5-5", messages: [], tools, tool_choice: "none" },
      "anthropic",
    ).tool_choice).toBe("none");
  });
});

describe("alternateOutputTokenBody", () => {
  it("changes only the explicitly rejected alias", () => {
    expect(
      JSON.parse(
        alternateOutputTokenBody(
          JSON.stringify({ max_completion_tokens: 42, model: "x" }),
          "Unsupported parameter: max_completion_tokens",
        )!,
      ),
    ).toEqual({ max_tokens: 42, model: "x" });
    expect(
      alternateOutputTokenBody(
        JSON.stringify({ max_completion_tokens: 42 }),
        "invalid tool schema",
      ),
    ).toBeNull();
  });
});

describe("repairRejectedAiChatBody", () => {
  it("fixes only the explicit tools + reasoning rejection from Chat Completions", () => {
    const repaired = repairRejectedAiChatBody(
      JSON.stringify({
        model: "gpt-5.6-sol",
        tools: [{ type: "function", function: { name: "search" } }],
        reasoning_effort: "medium",
      }),
      "Function tools with reasoning_effort are not supported for gpt-5.6-sol",
    );
    expect(JSON.parse(repaired!)).toMatchObject({ reasoning_effort: "none" });
    expect(
      repairRejectedAiChatBody(
        JSON.stringify({ model: "gpt-5.6-sol", reasoning_effort: "medium" }),
        "Function tools with reasoning_effort are not supported for gpt-5.6-sol",
      ),
    ).toBeNull();
  });
});

describe("repairRejectedAiChatBody — mandatory reasoning", () => {
  it("replays a refused disable at the model's own default", () => {
    const openRouter = JSON.stringify({ model: "m", reasoning: { enabled: false } });
    expect(JSON.parse(repairRejectedAiChatBody(openRouter, "Reasoning is mandatory for this endpoint and cannot be disabled.")!))
      .toEqual({ model: "m" });
    const gemini = JSON.stringify({ model: "gemini-3-pro", reasoning_effort: "none" });
    expect(JSON.parse(repairRejectedAiChatBody(gemini, "Reasoning cannot be turned off for this model")!))
      .toEqual({ model: "gemini-3-pro" });
    // An active effort is never stripped by this rule.
    expect(repairRejectedAiChatBody(JSON.stringify({ model: "m", reasoning: { effort: "low" } }),
      "Reasoning is mandatory")).toBeNull();
  });
});

describe("aiChatProviderHeaders", () => {
  it("adds only the headers documented by the provider profile", () => {
    expect(aiChatProviderHeaders("openai", "x")).toEqual({});
    expect(aiChatProviderHeaders("anthropic", "x")).toEqual({});
    expect(aiChatProviderHeaders("openrouter", "Minddy")).toMatchObject({
      "HTTP-Referer": SITE_URL,
      "X-Title": "Minddy",
    });
  });
});