lib/ai-chat.test.ts
Rama main · réplica de lectura del repositorio original.
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",
});
});
});