把思考强度收到接口配置里,并支持分组、嗅探和切换同一接口下的模型。
预设不再保存思考强度。设置页会在首屏套用主题。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
filterUsedApiProfiles,
|
||||
forgetApiProfileUsage,
|
||||
loadApiProfileUsage,
|
||||
recordApiProfileUsage,
|
||||
} from "../web/api-profile-usage.js";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("forgetApiProfileUsage", () => {
|
||||
it("drops one used record and leaves the profile out of the shortcut list", () => {
|
||||
recordApiProfileUsage("a");
|
||||
recordApiProfileUsage("b");
|
||||
|
||||
expect(forgetApiProfileUsage("a")).toBe(true);
|
||||
expect(loadApiProfileUsage().a).toBeUndefined();
|
||||
expect(loadApiProfileUsage().b).toBeTruthy();
|
||||
|
||||
const visible = filterUsedApiProfiles(
|
||||
[{ id: "a" }, { id: "b" }, { id: "c" }],
|
||||
loadApiProfileUsage(),
|
||||
"c",
|
||||
);
|
||||
expect(visible.map((item) => item.id)).toEqual(["b", "c"]);
|
||||
});
|
||||
|
||||
it("does nothing when there is no record", () => {
|
||||
expect(forgetApiProfileUsage("missing")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
apiKeyHash,
|
||||
createApiProfile,
|
||||
listApiGroups,
|
||||
listApiProfiles,
|
||||
renameApiProfileGroup,
|
||||
updateApiProfile,
|
||||
} from "../src/config/api-profiles.js";
|
||||
import { modelListUrls, parseModelsPayload } from "../src/llm/list-models.js";
|
||||
|
||||
describe("api profile groups", () => {
|
||||
let dir = "";
|
||||
let prevData: string | undefined;
|
||||
let prevKey: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), "wa-profiles-"));
|
||||
prevData = process.env.WRITING_AGENT_DATA_DIR;
|
||||
prevKey = process.env.OPENAI_API_KEY;
|
||||
process.env.WRITING_AGENT_DATA_DIR = dir;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevData === undefined) delete process.env.WRITING_AGENT_DATA_DIR;
|
||||
else process.env.WRITING_AGENT_DATA_DIR = prevData;
|
||||
if (prevKey === undefined) delete process.env.OPENAI_API_KEY;
|
||||
else process.env.OPENAI_API_KEY = prevKey;
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps same-url profiles separate and folds them into one named group", () => {
|
||||
const first = createApiProfile({
|
||||
name: "ds",
|
||||
baseUrl: "https://openrouter.ai/api/v1/",
|
||||
apiKey: "sk-same",
|
||||
model: "deepseek/deepseek-v4-pro",
|
||||
});
|
||||
const second = createApiProfile({
|
||||
name: "ds flash",
|
||||
baseUrl: "https://OpenRouter.ai/api/v1",
|
||||
apiKey: "sk-same",
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
});
|
||||
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.groupId).toBe(first.groupId);
|
||||
expect(listApiProfiles()).toHaveLength(2);
|
||||
const groups = listApiGroups();
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]?.name).toBe("openrouter.ai");
|
||||
expect(groups[0]?.id).toBe(first.groupId);
|
||||
expect(JSON.stringify(groups)).not.toContain("sk-same");
|
||||
expect(JSON.stringify(groups)).not.toContain(apiKeyHash("sk-same"));
|
||||
});
|
||||
|
||||
it("uses a different group when the key differs", () => {
|
||||
createApiProfile({
|
||||
name: "a",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-one",
|
||||
model: "alpha",
|
||||
});
|
||||
createApiProfile({
|
||||
name: "b",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "sk-two",
|
||||
model: "beta",
|
||||
});
|
||||
const groups = listApiGroups();
|
||||
expect(groups).toHaveLength(2);
|
||||
const names = groups.map((group) => group.name).sort();
|
||||
expect(names[0]).toBe("openrouter.ai");
|
||||
expect(names[1]?.startsWith("openrouter.ai · ")).toBe(true);
|
||||
});
|
||||
|
||||
it("splits a legacy multi-model profile back into separate rows", () => {
|
||||
const now = "2026-01-01T00:00:00.000Z";
|
||||
writeFileSync(
|
||||
path.join(dir, "profiles.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
profiles: [
|
||||
{
|
||||
id: "ds",
|
||||
name: "ds",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
apiKey: "sk-1",
|
||||
model: "deepseek-v4-pro",
|
||||
models: ["deepseek-v4-pro", "deepseek-v4-flash"],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const profiles = listApiProfiles();
|
||||
expect(profiles.map((profile) => profile.model).sort()).toEqual([
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
]);
|
||||
expect(new Set(profiles.map((profile) => profile.groupId)).size).toBe(1);
|
||||
expect(profiles.every((profile) => profile.models === undefined)).toBe(true);
|
||||
const stored = JSON.parse(readFileSync(path.join(dir, "profiles.json"), "utf8")) as {
|
||||
profiles: Array<{ model: string }>;
|
||||
groups: Array<{ name: string }>;
|
||||
};
|
||||
expect(stored.profiles).toHaveLength(2);
|
||||
expect(stored.groups[0]?.name).toBe("api.deepseek.com");
|
||||
});
|
||||
|
||||
it("renames a group without moving its profiles", () => {
|
||||
const profile = createApiProfile({
|
||||
name: "ds",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
apiKey: "sk-1",
|
||||
model: "deepseek-v4-pro",
|
||||
});
|
||||
const renamed = renameApiProfileGroup(profile.groupId ?? "", "深度求索");
|
||||
expect(renamed.name).toBe("深度求索");
|
||||
expect(listApiProfiles()[0]?.groupId).toBe(profile.groupId);
|
||||
expect(listApiGroups()[0]?.name).toBe("深度求索");
|
||||
});
|
||||
|
||||
it("clears capability results when the model of one profile changes", () => {
|
||||
const profile = createApiProfile({
|
||||
name: "ds",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
apiKey: "sk-1",
|
||||
model: "deepseek-v4-pro",
|
||||
});
|
||||
const file = path.join(dir, "profiles.json");
|
||||
const raw = JSON.parse(readFileSync(file, "utf8")) as {
|
||||
profiles: Array<{ capabilities?: { chat: string } }>;
|
||||
};
|
||||
raw.profiles[0]!.capabilities = { chat: "ok" };
|
||||
writeFileSync(file, JSON.stringify(raw));
|
||||
|
||||
const switched = updateApiProfile(profile.id, { model: "deepseek-v4-flash" });
|
||||
expect(switched.id).toBe(profile.id);
|
||||
expect(switched.model).toBe("deepseek-v4-flash");
|
||||
expect(switched.capabilities).toBeUndefined();
|
||||
expect(listApiProfiles()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("models payload", () => {
|
||||
it("parses OpenAI and plain lists", () => {
|
||||
expect(
|
||||
parseModelsPayload({
|
||||
data: [{ id: "a" }, { id: "a" }, { name: "b" }, { id: "" }],
|
||||
}),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(parseModelsPayload(["z", "z", " y "])).toEqual(["z", "y"]);
|
||||
expect(parseModelsPayload({ error: { message: "nope" } })).toBeNull();
|
||||
expect(parseModelsPayload({ data: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("tries versioned models urls when base has no v1", () => {
|
||||
expect(modelListUrls("https://openrouter.ai")).toEqual([
|
||||
"https://openrouter.ai/models",
|
||||
"https://openrouter.ai/v1/models",
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
]);
|
||||
expect(modelListUrls("https://openrouter.ai/api/v1/")).toEqual([
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
classifyGlmThinking,
|
||||
fetchWithGenerationCompat,
|
||||
forceReasoningEffortNoneWhenTools,
|
||||
isReasoningMandatoryError,
|
||||
isToolsReasoningEffortConflictError,
|
||||
modelRejectsDisabledReasoning,
|
||||
parseRejectedGenerationField,
|
||||
sanitizeReasoningEffort,
|
||||
sanitizeVerbosity,
|
||||
@@ -178,6 +180,32 @@ describe("forceReasoningEffortNoneWhenTools", () => {
|
||||
expect(body.thinking).toEqual({ type: "disabled" });
|
||||
});
|
||||
|
||||
it("keeps gemini 3.8 on low when tools would otherwise disable reasoning", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: "google/gemini-3.8-flash",
|
||||
tools: [{ type: "function", function: { name: "x" } }],
|
||||
};
|
||||
forceReasoningEffortNoneWhenTools(body, "google/gemini-3.8-flash");
|
||||
expect(body.reasoning_effort).toBe("low");
|
||||
expect(body.reasoning).toEqual({ effort: "low" });
|
||||
});
|
||||
|
||||
it("maps gemini 3.8 max down to high and keeps medium", () => {
|
||||
const maxBody: Record<string, unknown> = {
|
||||
model: "google/gemini-3.8-flash",
|
||||
tools: [{ type: "function", function: { name: "x" } }],
|
||||
reasoning_effort: "max",
|
||||
};
|
||||
forceReasoningEffortNoneWhenTools(maxBody, "google/gemini-3.8-flash");
|
||||
expect(maxBody.reasoning_effort).toBe("high");
|
||||
expect(maxBody.reasoning).toEqual({ effort: "high" });
|
||||
|
||||
const midBody: Record<string, unknown> = { model: "google/gemini-3.8-flash" };
|
||||
applyOpenAiGeneration(midBody, { reasoningEffort: "medium" }, "google/gemini-3.8-flash");
|
||||
expect(midBody.reasoning_effort).toBe("medium");
|
||||
expect(midBody.reasoning).toEqual({ effort: "medium" });
|
||||
});
|
||||
|
||||
it("leaves body alone without tools", () => {
|
||||
const body: Record<string, unknown> = { reasoning_effort: "high" };
|
||||
forceReasoningEffortNoneWhenTools(body);
|
||||
@@ -185,6 +213,26 @@ describe("forceReasoningEffortNoneWhenTools", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isReasoningMandatoryError", () => {
|
||||
it("detects the OpenRouter mandatory-reasoning payload", () => {
|
||||
expect(modelRejectsDisabledReasoning("google/gemini-3.8-flash")).toBe(true);
|
||||
expect(modelRejectsDisabledReasoning("gemini-2.5-flash")).toBe(false);
|
||||
expect(
|
||||
isReasoningMandatoryError(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Reasoning is mandatory for this endpoint and cannot be disabled.",
|
||||
code: 400,
|
||||
metadata: { provider_name: null },
|
||||
},
|
||||
}),
|
||||
400,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isToolsReasoningEffortConflictError", () => {
|
||||
it("detects gpt-5.6-luna style conflict", () => {
|
||||
const text = JSON.stringify({
|
||||
@@ -329,6 +377,37 @@ describe("fetchWithGenerationCompat", () => {
|
||||
expect(sent[0].reasoning_effort).toBeUndefined();
|
||||
expect(sent[1].reasoning_effort).toBe("none");
|
||||
});
|
||||
|
||||
it("retries a mandatory-reasoning 400 by lifting none to low", async () => {
|
||||
const sent: Array<Record<string, unknown>> = [];
|
||||
const response = await fetchWithGenerationCompat(async (body) => {
|
||||
sent.push({ ...body });
|
||||
if (body.reasoning_effort === "none") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message:
|
||||
"Reasoning is mandatory for this endpoint and cannot be disabled.",
|
||||
code: 400,
|
||||
metadata: { provider_name: null },
|
||||
},
|
||||
}),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}, {
|
||||
model: "google/gemini-3.8-flash",
|
||||
tools: [{ type: "function", function: { name: "run" } }],
|
||||
reasoning_effort: "none",
|
||||
messages: [],
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(sent).toHaveLength(2);
|
||||
expect(sent[1].reasoning_effort).toBe("low");
|
||||
expect(sent[1].reasoning).toEqual({ effort: "low" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRequestBody", () => {
|
||||
@@ -387,6 +466,43 @@ describe("buildRequestBody", () => {
|
||||
expect(body.reasoning_effort).toBe("low");
|
||||
});
|
||||
|
||||
it("maps gemini 3.8 none and minimal to low", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "k",
|
||||
model: "google/gemini-3.8-flash",
|
||||
reasoningEffort: "none",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {});
|
||||
expect(body.reasoning_effort).toBe("low");
|
||||
expect(body.reasoning).toEqual({ effort: "low" });
|
||||
|
||||
const minimal = buildRequestBody(
|
||||
{ ...config, reasoningEffort: "minimal" },
|
||||
messages,
|
||||
{},
|
||||
);
|
||||
expect(minimal.reasoning_effort).toBe("low");
|
||||
});
|
||||
|
||||
it("uses low for gemini 3.8 tool calls when effort is omitted", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
apiKey: "k",
|
||||
model: "google/gemini-3.8-flash",
|
||||
};
|
||||
const body = buildRequestBody(config, messages, {
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: { name: "run_worker", parameters: { type: "object" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(body.reasoning_effort).toBe("low");
|
||||
expect(body.reasoning).toEqual({ effort: "low" });
|
||||
});
|
||||
|
||||
it("forces reasoning_effort none when tools are attached on non-glm", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
@@ -425,6 +541,62 @@ describe("buildRequestBody", () => {
|
||||
expect(body.thinking).toEqual({ type: "enabled" });
|
||||
});
|
||||
|
||||
it("adds the word json when json_object prompts omit it", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "gpt-4o",
|
||||
};
|
||||
const body = buildRequestBody(
|
||||
config,
|
||||
[{ role: "user", content: "只输出对象,列出候选。" }],
|
||||
{ responseFormat: "json_object" },
|
||||
);
|
||||
expect(body.response_format).toEqual({ type: "json_object" });
|
||||
const sent = body.messages as Array<{ role: string; content: string }>;
|
||||
expect(sent[0]).toEqual({
|
||||
role: "system",
|
||||
content: "请用 JSON 对象回复。",
|
||||
});
|
||||
expect(sent.some((message) => /json/i.test(message.content))).toBe(true);
|
||||
});
|
||||
|
||||
it("appends the json hint to an existing system message", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "gpt-4o",
|
||||
};
|
||||
const body = buildRequestBody(
|
||||
config,
|
||||
[
|
||||
{ role: "system", content: "你正在提取文风包。" },
|
||||
{ role: "user", content: "写得冷一点。" },
|
||||
],
|
||||
{ responseFormat: "json_object" },
|
||||
);
|
||||
const sent = body.messages as Array<{ role: string; content: string }>;
|
||||
expect(sent[0]?.content).toContain("你正在提取文风包。");
|
||||
expect(sent[0]?.content).toContain("请用 JSON 对象回复。");
|
||||
expect(sent[1]?.content).toBe("写得冷一点。");
|
||||
});
|
||||
|
||||
it("leaves a json_object prompt unchanged when it already says json", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "k",
|
||||
model: "gpt-4o",
|
||||
};
|
||||
const messages = [
|
||||
{ role: "system" as const, content: "输出一个 JSON 对象。" },
|
||||
{ role: "user" as const, content: "继续。" },
|
||||
];
|
||||
const body = buildRequestBody(config, messages, {
|
||||
responseFormat: "json_object",
|
||||
});
|
||||
expect(body.messages).toBe(messages);
|
||||
});
|
||||
|
||||
it("does not forward top_k from an imported ST preset", () => {
|
||||
const config: LlmConfig = {
|
||||
baseUrl: "https://example.test/v1",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { readSniffModels, writeSniffModels } from "../web/model-sniff-cache.js";
|
||||
|
||||
const store = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
store.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("model sniff cache", () => {
|
||||
it("keeps models for the current boot and drops them after restart", () => {
|
||||
writeSniffModels("boot-a", "https://openrouter.ai/api/v1\nkey:abc", [
|
||||
"google/gemini-3.8-flash",
|
||||
"google/gemini-3.7-flash",
|
||||
]);
|
||||
|
||||
expect(
|
||||
readSniffModels("boot-a", "https://openrouter.ai/api/v1\nkey:abc"),
|
||||
).toEqual(["google/gemini-3.8-flash", "google/gemini-3.7-flash"]);
|
||||
|
||||
expect(
|
||||
readSniffModels("boot-b", "https://openrouter.ai/api/v1\nkey:abc"),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not keep another boot's entries when writing again", () => {
|
||||
writeSniffModels("boot-a", "slot", ["old-model"]);
|
||||
writeSniffModels("boot-b", "slot", ["new-model"]);
|
||||
|
||||
expect(readSniffModels("boot-a", "slot")).toEqual([]);
|
||||
expect(readSniffModels("boot-b", "slot")).toEqual(["new-model"]);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ const samplePreset: PresetPackage = {
|
||||
},
|
||||
],
|
||||
promptOrder: [{ promptId: "sys", enabled: true, orderIndex: 0 }],
|
||||
generation: { temperature: 0.91, maxOutputTokens: 1234 },
|
||||
generation: { temperature: 0.91, maxOutputTokens: 1234, reasoningEffort: "max" },
|
||||
unsupported: [],
|
||||
importedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
@@ -72,6 +72,7 @@ describe("wrapLlmForSession", () => {
|
||||
temperature: 0.91,
|
||||
maxOutputTokens: 1234,
|
||||
});
|
||||
expect(received[0].generation).not.toHaveProperty("reasoningEffort");
|
||||
expect(ctx.pendingContextTrace?.messages).toEqual(received[0].messages);
|
||||
expect(ctx.pendingContextTrace?.generation).toMatchObject({
|
||||
temperature: 0.91,
|
||||
|
||||
@@ -190,7 +190,6 @@ describe("normalizeGeneration / applyPresetGeneration", () => {
|
||||
).toEqual({
|
||||
temperature: 0.9,
|
||||
maxOutputTokens: 4096,
|
||||
reasoningEffort: "high",
|
||||
stream: true,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user