把思考强度收到接口配置里,并支持分组、嗅探和切换同一接口下的模型。

预设不再保存思考强度。设置页会在首屏套用主题。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 12:00:27 +08:00
co-authored by Cursor
parent defd6739b7
commit 39fe2732fb
24 changed files with 3047 additions and 157 deletions
+236 -37
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import {
@@ -7,6 +7,7 @@ import {
type ProfileCapabilities,
} from "../llm/capabilities.js";
import { sanitizeReasoningEffort } from "../llm/generation-compat.js";
import { normalizeBaseUrl } from "../llm/list-models.js";
import type { LlmConfig } from "./env.js";
import { loadLlmConfigOptional } from "./env.js";
import { ensureUserDataDirs, getUserDataDir } from "./user-data-dir.js";
@@ -16,12 +17,21 @@ export type ApiProfile = {
name: string;
baseUrl: string;
apiKey: string;
/** 这一条配置对应的模型 */
model: string;
/**
* 该配置默认思考强度(按型号写入 reasoning_effort / thinking)。
* 省略 / auto = 不发送;none = 关闭或映射为最轻档;带 tools 时非 GLM 强制 none,GLM-5.3 保留合法档。
* 探测永远用 low,不受此项影响。
*/
* 旧数据里曾把同端点的多个模型塞进一条配置。
* 读入时会拆回独立配置,不再写回。
*/
models?: string[];
/** 同 baseUrl + 同 Key 哈希的收纳组 */
groupId?: string;
/**
* 该配置的思考强度(按型号写入 reasoning_effort / thinking)。预设不带这项。
* 省略 / auto = 不发送;none = 关闭或映射为最轻档。
* 带 tools 时:多数型号强制 none;GLM-5.3 与 Gemini 3 保留合法档(Gemini 3.8 为 low/medium/high)。
* 探测永远用 low,不受此项影响。
*/
reasoningEffort?: string;
createdAt: string;
updatedAt: string;
@@ -29,9 +39,24 @@ export type ApiProfile = {
capabilities?: ProfileCapabilities;
};
/** 前厅里折叠显示。分类用 Key 哈希,不比较、不展示原文。 */
export type ApiProfileGroup = {
id: string;
name: string;
baseUrl: string;
keyHash: string;
};
export type ApiProfileGroupView = {
id: string;
name: string;
baseUrl: string;
};
type ApiProfilesFile = {
version: 1;
profiles: ApiProfile[];
groups?: ApiProfileGroup[];
};
const FILE_NAME = "profiles.json";
@@ -69,6 +94,65 @@ export function normalizeProfileReasoningEffort(
return sanitizeReasoningEffort(trimmed);
}
/** 读旧的 models 字段时用:当前模型放最前,去重。 */
export function normalizeModelList(
model: string,
models: unknown,
): { model: string; models: string[] } {
const current = model.trim();
const extra = Array.isArray(models)
? models
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean)
: [];
const seen = new Set<string>();
const list: string[] = [];
const push = (id: string) => {
if (!id || seen.has(id)) return;
seen.add(id);
list.push(id);
};
push(current);
for (const id of extra) push(id);
const active = current || list[0] || "deepseek-v4-pro";
if (!seen.has(active)) list.unshift(active);
return { model: list[0] ?? active, models: list };
}
export function apiKeyHash(apiKey: string): string {
return createHash("sha256").update(apiKey.trim()).digest("hex");
}
function groupSlot(baseUrl: string, apiKey: string): string {
return `${normalizeBaseUrl(baseUrl)}\n${apiKeyHash(apiKey)}`;
}
function hostLabel(baseUrl: string): string {
const trimmed = baseUrl.trim();
try {
return new URL(trimmed).host || trimmed || "未命名组";
} catch {
return trimmed.replace(/\/+$/, "") || "未命名组";
}
}
function uniqueGroupName(host: string, keyHash: string, used: Set<string>): string {
if (!used.has(host)) return host;
const alt = `${host} · ${keyHash.slice(0, 4)}`;
if (!used.has(alt)) return alt;
let n = 2;
while (used.has(`${host} · ${n}`)) n += 1;
return `${host} · ${n}`;
}
function withoutLegacyModels(profile: ApiProfile): ApiProfile {
if (profile.models === undefined) return profile;
const next = { ...profile };
delete next.models;
return next;
}
function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
if (data.profiles.length > 0) return data;
const env = loadLlmConfigOptional();
@@ -79,23 +163,129 @@ function seedFromEnvIfEmpty(data: ApiProfilesFile): ApiProfilesFile {
name: "环境变量 (.env)",
baseUrl: env.baseUrl,
apiKey: env.apiKey,
model: env.model,
model: env.model.trim() || "deepseek-v4-pro",
createdAt: now,
updatedAt: now,
};
return { version: 1, profiles: [profile] };
}
export function listApiProfiles(): ApiProfile[] {
const data = seedFromEnvIfEmpty(readFile());
if (data.profiles.length !== readFile().profiles.length) {
writeFile(data);
function splitLegacyModelLists(profiles: ApiProfile[]): {
profiles: ApiProfile[];
changed: boolean;
} {
const out: ApiProfile[] = [];
let changed = false;
const now = new Date().toISOString();
for (const profile of profiles) {
const current = profile.model.trim();
const extras = normalizeModelList(current, profile.models).models.filter(
(model) => model !== current,
);
if (profile.models !== undefined) changed = true;
out.push(withoutLegacyModels({ ...profile, model: current || profile.model }));
for (const model of extras) {
changed = true;
const sibling: ApiProfile = {
id: randomUUID(),
name: model,
baseUrl: profile.baseUrl,
apiKey: profile.apiKey,
model,
createdAt: now,
updatedAt: now,
};
if (profile.reasoningEffort) sibling.reasoningEffort = profile.reasoningEffort;
out.push(sibling);
}
}
return data.profiles;
return { profiles: out, changed };
}
/** 同 baseUrl + 同 Key 哈希共用一个组。组名可改,分类键不变。 */
function assignGroups(data: ApiProfilesFile): { data: ApiProfilesFile; changed: boolean } {
const groups = [...(data.groups ?? [])];
let changed = !Array.isArray(data.groups);
const bySlot = new Map<string, ApiProfileGroup>();
for (const group of groups) {
bySlot.set(`${normalizeBaseUrl(group.baseUrl)}\n${group.keyHash}`, group);
}
const usedNames = new Set(groups.map((group) => group.name));
const profiles = data.profiles.map((profile) => {
const baseUrl = profile.baseUrl.trim().replace(/\/+$/, "") || profile.baseUrl;
const slot = groupSlot(baseUrl, profile.apiKey);
let group = bySlot.get(slot);
if (!group) {
const keyHash = apiKeyHash(profile.apiKey);
const name = uniqueGroupName(hostLabel(baseUrl), keyHash, usedNames);
usedNames.add(name);
group = {
id: randomUUID(),
name,
baseUrl: normalizeBaseUrl(baseUrl) || baseUrl,
keyHash,
};
groups.push(group);
bySlot.set(slot, group);
changed = true;
}
if (profile.groupId !== group.id || profile.baseUrl !== baseUrl || profile.models) {
changed = true;
return withoutLegacyModels({ ...profile, baseUrl, groupId: group.id });
}
return profile;
});
const used = new Set(profiles.map((profile) => profile.groupId).filter(Boolean));
const kept = groups.filter((group) => used.has(group.id));
if (kept.length !== groups.length) changed = true;
return { data: { version: 1, profiles, groups: kept }, changed };
}
function loadProfiles(): ApiProfilesFile {
const raw = readFile();
const seeded = seedFromEnvIfEmpty(raw);
const split = splitLegacyModelLists(seeded.profiles);
const assigned = assignGroups({
version: 1,
profiles: split.profiles,
groups: seeded.groups,
});
if (seeded !== raw || split.changed || assigned.changed) {
writeFile(assigned.data);
}
return assigned.data;
}
export function listApiProfiles(): ApiProfile[] {
return loadProfiles().profiles;
}
export function getApiProfile(id: string): ApiProfile | null {
return listApiProfiles().find((p) => p.id === id) ?? null;
return listApiProfiles().find((profile) => profile.id === id) ?? null;
}
export function listApiGroups(): ApiProfileGroupView[] {
return (loadProfiles().groups ?? []).map((group) => ({
id: group.id,
name: group.name,
baseUrl: group.baseUrl,
}));
}
export function renameApiProfileGroup(id: string, name: string): ApiProfileGroupView {
const trimmed = name.trim();
if (!trimmed) throw new Error("组名不能为空");
const data = loadProfiles();
const index = (data.groups ?? []).findIndex((group) => group.id === id);
if (index < 0) throw new Error("配置组不存在");
const groups = [...(data.groups ?? [])];
const current = groups[index]!;
if (current.name === trimmed) {
return { id: current.id, name: current.name, baseUrl: current.baseUrl };
}
groups[index] = { ...current, name: trimmed };
writeFile({ ...data, groups });
return { id, name: trimmed, baseUrl: current.baseUrl };
}
export function createApiProfile(input: {
@@ -105,7 +295,7 @@ export function createApiProfile(input: {
model: string;
reasoningEffort?: string;
}): ApiProfile {
const data = readFile();
const data = loadProfiles();
const now = new Date().toISOString();
const reasoningEffort = normalizeProfileReasoningEffort(input.reasoningEffort);
const profile: ApiProfile = {
@@ -119,8 +309,9 @@ export function createApiProfile(input: {
updatedAt: now,
};
data.profiles.push(profile);
writeFile(data);
return profile;
const assigned = assignGroups(data);
writeFile(assigned.data);
return assigned.data.profiles.find((item) => item.id === profile.id) ?? profile;
}
export function updateApiProfile(
@@ -129,43 +320,50 @@ export function updateApiProfile(
Pick<ApiProfile, "name" | "baseUrl" | "apiKey" | "model" | "reasoningEffort">
>,
): ApiProfile {
const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id);
const data = loadProfiles();
const index = data.profiles.findIndex((profile) => profile.id === id);
if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index];
const nextBase = input.baseUrl?.trim() || current.baseUrl;
const current = data.profiles[index]!;
const nextBase =
input.baseUrl !== undefined
? input.baseUrl.trim() || current.baseUrl
: current.baseUrl;
const nextModel = input.model?.trim() || current.model;
const nextKey =
input.apiKey !== undefined && input.apiKey.trim() !== ""
? input.apiKey.trim()
: current.apiKey;
const endpointChanged =
nextBase !== current.baseUrl || nextModel !== current.model;
normalizeBaseUrl(nextBase) !== normalizeBaseUrl(current.baseUrl) ||
nextModel !== current.model;
const reasoningEffort =
input.reasoningEffort !== undefined
? normalizeProfileReasoningEffort(input.reasoningEffort)
: current.reasoningEffort;
const updated: ApiProfile = {
const draft: ApiProfile = {
...current,
name: input.name?.trim() || current.name,
name: input.name !== undefined ? input.name.trim() || current.name : current.name,
baseUrl: nextBase,
apiKey: input.apiKey !== undefined && input.apiKey.trim() !== ""
? input.apiKey.trim()
: current.apiKey,
apiKey: nextKey,
model: nextModel,
updatedAt: new Date().toISOString(),
// 端点或模型变了,旧探测作废
...(endpointChanged ? { capabilities: undefined } : {}),
};
if (reasoningEffort) updated.reasoningEffort = reasoningEffort;
else delete updated.reasoningEffort;
data.profiles[index] = updated;
writeFile(data);
return updated;
delete draft.models;
if (reasoningEffort) draft.reasoningEffort = reasoningEffort;
else delete draft.reasoningEffort;
if (endpointChanged) delete draft.capabilities;
data.profiles[index] = draft;
const assigned = assignGroups(data);
writeFile(assigned.data);
return assigned.data.profiles.find((profile) => profile.id === id) ?? draft;
}
export function saveProfileCapabilities(
id: string,
capabilities: ProfileCapabilities,
): ApiProfile {
const data = readFile();
const index = data.profiles.findIndex((p) => p.id === id);
const data = loadProfiles();
const index = data.profiles.findIndex((profile) => profile.id === id);
if (index < 0) throw new Error("API 配置不存在");
const current = data.profiles[index]!;
const updated: ApiProfile = {
@@ -193,9 +391,10 @@ export async function probeAndSaveProfileCapabilities(
}
export function deleteApiProfile(id: string): void {
const data = readFile();
data.profiles = data.profiles.filter((p) => p.id !== id);
writeFile(data);
const data = loadProfiles();
data.profiles = data.profiles.filter((profile) => profile.id !== id);
const assigned = assignGroups(data);
writeFile(assigned.data);
}
export function profileToLlmConfig(profile: ApiProfile): LlmConfig {
+1
View File
@@ -113,6 +113,7 @@ export function resolveActiveProfile() {
baseUrl: env.baseUrl,
apiKey: env.apiKey,
model: env.model,
models: [env.model],
createdAt: "",
updatedAt: "",
};
+15 -1
View File
@@ -4,9 +4,12 @@
*/
import {
applyMandatoryReasoningFloor,
applyRejectedGenerationField,
forceReasoningEffortNoneWhenTools,
isReasoningMandatoryError,
isToolsReasoningEffortConflictError,
modelRejectsDisabledReasoning,
parseRejectedGenerationField,
} from "./generation-compat.js";
@@ -170,7 +173,8 @@ async function postOnce(
/**
* 探测请求始终带 reasoning_effort=low。
* 若供应商拒收该字段,剥掉后再试;tools 冲突则改 none 再试。
* Gemini 3 同时写 reasoning.effort。拒收字段则剥掉再试;
* 必须开思考则抬到 low;tools 冲突则改 none 再试。
*/
async function postChat(
config: ProbeConfig,
@@ -180,12 +184,22 @@ async function postChat(
...body,
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
};
if (modelRejectsDisabledReasoning(config.model)) {
withEffort.reasoning = { effort: CAPABILITY_PROBE_REASONING_EFFORT };
}
const first = await postOnce(config, withEffort);
if (first.ok || (first.status !== 400 && first.status !== 422)) return first;
const retryBody = { ...withEffort };
const tried = new Set<string>();
if (isReasoningMandatoryError(first.text, first.status)) {
if (applyMandatoryReasoningFloor(retryBody, tried)) {
return postOnce(config, retryBody);
}
return first;
}
if (isToolsReasoningEffortConflictError(first.text, first.status)) {
forceReasoningEffortNoneWhenTools(retryBody, config.model);
tried.add("reasoning_effort_none");
+40 -8
View File
@@ -131,14 +131,50 @@ export type LlmProvider = {
): Promise<CompleteWithToolsResult>;
};
const JSON_OBJECT_HINT = "请用 JSON 对象回复。";
function messagesMentionJson(messages: ChatMessage[]): boolean {
for (const message of messages) {
if (typeof message.content === "string" && /json/i.test(message.content)) {
return true;
}
}
return false;
}
/**
* OpenAI `response_format: json_object` 要求 messages 里出现 json,否则 400。
* 偏好/文风等提示不写这个词;预设夹心也可能丢掉带 JSON 的任务契约。
*/
export function ensureJsonObjectPrompt(messages: ChatMessage[]): ChatMessage[] {
if (messagesMentionJson(messages)) return messages;
const systemIndex = messages.findIndex((message) => message.role === "system");
if (systemIndex >= 0) {
const current = messages[systemIndex];
if (current?.role === "system") {
const next = messages.slice();
next[systemIndex] = {
role: "system",
content: `${current.content}\n\n${JSON_OBJECT_HINT}`,
};
return next;
}
}
return [{ role: "system", content: JSON_OBJECT_HINT }, ...messages];
}
export function buildRequestBody(
config: LlmConfig,
messages: ChatMessage[],
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
): Record<string, unknown> {
const requestMessages =
options?.responseFormat === "json_object"
? ensureJsonObjectPrompt(messages)
: messages;
const body: Record<string, unknown> = {
model: config.model,
messages,
messages: requestMessages,
};
if (options?.tools?.length) {
@@ -150,7 +186,7 @@ export function buildRequestBody(
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
reasoningEffort: config.reasoningEffort,
});
// 对齐 imyai:带 tools 时按型号改写思考参数(非 GLM 强制 none)
// 带 tools 时按型号改写思考参数(Gemini 3 / GLM-5.3 保留合法档,其余强制 none)
forceReasoningEffortNoneWhenTools(body, config.model);
if (options?.responseFormat === "json_object") {
@@ -313,10 +349,8 @@ export class OpenAiCompatibleProvider implements LlmProvider {
usage?: unknown;
choices?: Array<{ message?: Record<string, unknown> }>;
};
// 空正文不抛错:游玩/Worker 会落「本轮场面未写完」等占位,仍可重 roll
const parts = extractMessageParts(data.choices?.[0]?.message);
if (!parts.content && parts.toolCalls.length === 0) {
throw new Error("LLM returned empty content");
}
return {
content: parts.content ?? "",
reasoning: parts.reasoning,
@@ -347,10 +381,8 @@ export class OpenAiCompatibleProvider implements LlmProvider {
throw new Error("LLM stream response has no body");
}
// 空流不抛错:下游会落占位正文,消息正常渲染以便重 roll
const parts = await consumeOpenAiToolStream(response.body, callbacks);
if (!parts.content && !parts.reasoning) {
throw new Error("LLM stream returned empty content");
}
return {
content: parts.content ?? parts.reasoning ?? "",
reasoning: parts.reasoning || undefined,
+135 -3
View File
@@ -7,8 +7,10 @@ import type { GenerationParameters } from "../types/preset.js";
* - 推理模型去掉 temperature / top_p / penalty
* - o 系列与 gpt-5 用 max_completion_tokens
* - GLM:按型号映射 thinking / reasoning_effort(5.3 拒收 none;旧版拒收 effort)
* - 带 tools 时:非 GLM 强制 reasoning_effort=none(gpt-5.6-luna 等);GLM-5.3 保留合法档
* - 仍 400 时按错误信息剥掉不支持字段再试;tools+reasoning 冲突则按型号改写再试
* - Gemini 3(如 gemini-3.8-flash):思考不能关,none/minimal 抬到 low
* - 带 tools 时:非上述型号强制 reasoning_effort=none(gpt-5.6-luna 等);始终开启的型号保留合法档
* - 仍 400 时按错误信息剥掉不支持字段再试;tools 冲突改 none,
* “Reasoning is mandatory … cannot be disabled” 则抬到 low 再试
*/
const REASONING_EFFORT_VALUES = [
@@ -60,6 +62,7 @@ export const STRIPPABLE_GENERATION_FIELDS = new Set([
"stop",
"response_format",
"stream_options",
"reasoning",
]);
export type ChatModelCompat = {
@@ -109,6 +112,42 @@ export function classifyGlmThinking(model: string): GlmThinkingKind {
return "thinking_only";
}
/**
* Gemini 3:OpenRouter 拒绝关闭思考。
* 3.8 合法档只有 low / medium / high(minimal 也会 400)。
*/
export function modelRejectsDisabledReasoning(model: string): boolean {
const id = modelLeaf(model);
return /^gemini-3(?:[.\-]|$)/.test(id);
}
function isGemini38(model: string): boolean {
return /gemini-3\.8/.test(modelLeaf(model));
}
/** 落到 Gemini 思考档。未设置或 none 视为最低合法档 low。 */
function mapGeminiEffort(
model: string,
effort: ReasoningEffort | undefined,
): "minimal" | "low" | "medium" | "high" {
if (!effort || effort === "none") return "low";
if (effort === "minimal") return isGemini38(model) ? "low" : "minimal";
if (effort === "low") return "low";
if (effort === "medium") return "medium";
return "high";
}
function writeGeminiReasoning(
body: Record<string, unknown>,
model: string,
effort: ReasoningEffort | undefined,
): void {
const mapped = mapGeminiEffort(model, effort);
body.reasoning_effort = mapped;
body.reasoning = { effort: mapped };
if (isThinkingDisabled(body.thinking)) delete body.thinking;
}
function mapGlm53Effort(
effort: ReasoningEffort,
): "low" | "high" | "max" {
@@ -136,6 +175,10 @@ function applyReasoningToBody(
const glm = classifyGlmThinking(model);
if (glm === "none") {
if (modelRejectsDisabledReasoning(model)) {
writeGeminiReasoning(body, model, effort);
return;
}
body.reasoning_effort = effort;
return;
}
@@ -240,7 +283,8 @@ export function applyOpenAiGeneration(
/**
* 带 function tools 时改写思考参数。
* - 非 GLM(如 gpt-5.6-luna):须显式 reasoning_effort=none
* - 非 GLM、且允许关闭思考(如 gpt-5.6-luna):须显式 reasoning_effort=none
* - Gemini 3:不能 none/minimal,缺省或关闭档抬到 low
* - GLM-5.3:不能 none,保留/压到合法档(默认 low)
* - GLM-5.2 / 旧版:thinking disabled,去掉 effort
*/
@@ -262,6 +306,14 @@ export function forceReasoningEffortNoneWhenTools(
body.reasoning_effort = mapGlm53Effort(current);
return;
}
if (modelRejectsDisabledReasoning(modelId)) {
writeGeminiReasoning(
body,
modelId,
sanitizeReasoningEffort(body.reasoning_effort),
);
return;
}
if (glm === "effort_full" || glm === "thinking_only") {
body.thinking = { type: "disabled" };
delete body.reasoning_effort;
@@ -302,6 +354,79 @@ export function isToolsReasoningEffortConflictError(
return mentionsTools && mentionsReasoningEffort && conflict;
}
/** OpenRouter 等:该端点必须开思考,显式 none / disabled 会被 400。 */
export function isReasoningMandatoryError(
errorText: string,
status?: number,
): boolean {
if (status != null && status !== 400 && status !== 422) return false;
let parsed: unknown;
try {
parsed = JSON.parse(errorText);
} catch {
parsed = undefined;
}
const message = `${readErrorMessage(parsed)}\n${errorText}`.toLowerCase();
return (
/reasoning is mandatory/.test(message) && /cannot be disabled/.test(message)
);
}
function isThinkingDisabled(thinking: unknown): boolean {
return (
!!thinking &&
typeof thinking === "object" &&
(thinking as { type?: unknown }).type === "disabled"
);
}
/**
* 把关闭思考的字段抬到合法最低档 low。
* 只改 none / minimal / disabled;已经是 low 及以上则不动,避免空转重试。
*/
export function applyMandatoryReasoningFloor(
body: Record<string, unknown>,
tried: Set<string>,
): boolean {
if (tried.has("reasoning_mandatory_floor")) return false;
let changed = false;
if (isThinkingDisabled(body.thinking)) {
body.thinking = { type: "enabled" };
changed = true;
}
const effort = sanitizeReasoningEffort(body.reasoning_effort);
if (effort === "none" || effort === "minimal") {
body.reasoning_effort = "low";
body.reasoning = { effort: "low" };
changed = true;
}
if (body.reasoning && typeof body.reasoning === "object") {
const reasoning = { ...(body.reasoning as Record<string, unknown>) };
const effortValue =
typeof reasoning.effort === "string"
? reasoning.effort.trim().toLowerCase()
: "";
if (
reasoning.enabled === false ||
effortValue === "none" ||
effortValue === "minimal"
) {
delete reasoning.enabled;
reasoning.effort = "low";
body.reasoning = reasoning;
changed = true;
}
}
if (!changed) return false;
tried.add("reasoning_mandatory_floor");
return true;
}
function readErrorMessage(raw: unknown): string {
if (!raw || typeof raw !== "object") return "";
const err = (raw as { error?: unknown }).error;
@@ -450,6 +575,13 @@ export async function fetchWithGenerationCompat(
const text = await last.text();
const next = { ...body };
if (isReasoningMandatoryError(text, last.status)) {
if (applyMandatoryReasoningFloor(next, tried)) {
body = next;
continue;
}
}
if (isToolsReasoningEffortConflictError(text, last.status)) {
if (applyToolsReasoningEffortNone(next, tried)) {
body = next;
+153
View File
@@ -0,0 +1,153 @@
/**
* 拉取 OpenAI 兼容的 GET {base}/models,供同一 API 配置下快捷切换模型。
*/
const MAX_BODY_CHARS = 2_000_000;
const MAX_MODELS = 4000;
export function normalizeBaseUrl(url: string): string {
return url.trim().replace(/\/+$/, "").toLowerCase();
}
/** 按常见兼容网关尝试 models 地址。base 已含 /v1 时只打一次。 */
export function modelListUrls(baseUrl: string): string[] {
const base = baseUrl.trim().replace(/\/+$/, "");
if (!base) return [];
const urls: string[] = [];
const push = (url: string) => {
if (!urls.includes(url)) urls.push(url);
};
push(`${base}/models`);
const lower = base.toLowerCase();
if (!/\/v1(?:beta)?(?:\/|$)/.test(lower)) {
push(`${base}/v1/models`);
push(`${base}/api/v1/models`);
}
return urls;
}
function modelIdOf(item: unknown): string | null {
if (typeof item === "string") {
const id = item.trim();
return id && id.length <= 200 ? id : null;
}
if (!item || typeof item !== "object") return null;
const rec = item as { id?: unknown; name?: unknown };
const raw = typeof rec.id === "string" ? rec.id : rec.name;
if (typeof raw !== "string") return null;
const id = raw.trim();
return id && id.length <= 200 ? id : null;
}
/** 识别 { data: [{id}] }、{ models } 或纯数组。无法识别返回 null。 */
export function parseModelsPayload(data: unknown): string[] | null {
let list: unknown[] | null = null;
if (Array.isArray(data)) list = data;
else if (data && typeof data === "object") {
const rec = data as { data?: unknown; models?: unknown; error?: unknown };
if (rec.error && !Array.isArray(rec.data) && !Array.isArray(rec.models)) {
return null;
}
if (Array.isArray(rec.data)) list = rec.data;
else if (Array.isArray(rec.models)) list = rec.models;
}
if (!list) return null;
const seen = new Set<string>();
const ids: string[] = [];
for (const item of list) {
const id = modelIdOf(item);
if (!id || seen.has(id)) continue;
seen.add(id);
ids.push(id);
if (ids.length >= MAX_MODELS) break;
}
return ids;
}
type FetchOutcome =
| { ok: true; models: string[] }
| { ok: false; stop: boolean; message: string };
async function fetchModelList(url: string, apiKey: string): Promise<FetchOutcome> {
let response: Response;
try {
response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${apiKey}`,
},
signal: AbortSignal.timeout(20_000),
});
} catch (err) {
const message = err instanceof Error ? err.message : "请求失败";
return { ok: false, stop: false, message: `${url} ${message}` };
}
if (response.status === 401 || response.status === 403) {
return {
ok: false,
stop: true,
message: `API Key 被拒绝(${response.status})`,
};
}
if (!response.ok) {
return {
ok: false,
stop: false,
message: `${url} → ${response.status}`,
};
}
let text: string;
try {
text = await response.text();
} catch (err) {
const message = err instanceof Error ? err.message : "读取失败";
return { ok: false, stop: false, message: `${url} ${message}` };
}
if (text.length > MAX_BODY_CHARS) {
return { ok: false, stop: false, message: `${url} 响应过大` };
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return { ok: false, stop: false, message: `${url} 不是 JSON` };
}
const models = parseModelsPayload(parsed);
if (!models) {
return { ok: false, stop: false, message: `${url} 没有模型列表` };
}
return { ok: true, models };
}
export async function discoverRemoteModels(
baseUrl: string,
apiKey: string,
): Promise<{ models: string[]; url: string }> {
const key = apiKey.trim();
if (!key) throw new Error("未设置 API Key");
const urls = modelListUrls(baseUrl);
if (!urls.length) throw new Error("缺少 Base URL");
const errors: string[] = [];
for (let i = 0; i < urls.length; i += 1) {
const url = urls[i]!;
const result = await fetchModelList(url, key);
if (!result.ok) {
if (result.stop) throw new Error(result.message);
errors.push(result.message);
continue;
}
const last = i === urls.length - 1;
if (result.models.length === 0 && !last) {
errors.push(`${url} 返回空列表`);
continue;
}
return { models: result.models, url };
}
throw new Error(
errors.length ? `未能嗅探模型。${errors.join(";")}` : "未能嗅探模型",
);
}
+16 -3
View File
@@ -2,7 +2,7 @@ import { loadAppSettings } from "../config/settings.js";
import type { PersonaDirective } from "../persona/store.js";
import { frameMessagesWithPreset } from "../preset/play-frame.js";
import { resolveActivePreset } from "../preset/store.js";
import type { PresetPackage } from "../types/preset.js";
import type { GenerationParameters, PresetPackage } from "../types/preset.js";
import type {
ChatMessage,
CompleteOptions,
@@ -23,9 +23,20 @@ function readActivePreset(): PresetPackage | null {
return resolveActivePreset(loadAppSettings().activePresetId);
}
/** 思考强度只跟 API 配置,不跟预设。 */
function generationWithoutReasoningEffort(
generation: GenerationParameters | undefined,
): GenerationParameters | undefined {
if (!generation || generation.reasoningEffort === undefined) return generation;
const next = { ...generation };
delete next.reasoningEffort;
return next;
}
/**
* 统计在内、预设在外:痕迹里的 messages / generation 就是发给模型的那一份。
* 有选用预设时:在此按 prompt_order 夹心包裹,并合并 generation。
* 有选用预设时:在此按 prompt_order 夹心包裹,并合并采样参数。
* 预设上的思考强度不带入,改由 API 配置决定。
*/
export function wrapLlmForSession(
inner: LlmProvider,
@@ -58,7 +69,9 @@ export class PresetLlmProvider implements LlmProvider {
messages: frameMessagesWithPreset(preset, messages, persona),
options: {
...options,
generation: options?.generation ?? preset.generation,
generation: generationWithoutReasoningEffort(
options?.generation ?? preset.generation,
),
},
};
}
-3
View File
@@ -57,9 +57,6 @@ export function normalizeGeneration(
if (typeof raw.stream === "boolean") out.stream = raw.stream;
const reasoningEffort = optionalString(raw.reasoningEffort);
if (reasoningEffort !== undefined) out.reasoningEffort = reasoningEffort;
const verbosity = optionalString(raw.verbosity);
if (verbosity !== undefined) out.verbosity = verbosity;
-1
View File
@@ -77,7 +77,6 @@ function readGeneration(raw: SillyTavernPreset): GenerationParameters {
maxContextTokens: raw.openai_max_context,
maxOutputTokens: raw.openai_max_tokens,
stream: raw.stream_openai,
reasoningEffort: raw.reasoning_effort,
verbosity: raw.verbosity,
seed: raw.seed !== undefined && raw.seed >= 0 ? raw.seed : undefined,
variants: raw.n,
+16 -4
View File
@@ -4,6 +4,16 @@ import type { PresetPackage } from "../types/preset.js";
import { ensureUserDataDirs, getPresetsDir } from "../config/user-data-dir.js";
import { importSillyTavernPreset } from "./importer.js";
/** 旧预设里的思考强度不再使用;读到就从文件里删掉。 */
function withoutReasoningEffort(preset: PresetPackage, filePath: string): PresetPackage {
if (preset.generation?.reasoningEffort === undefined) return preset;
const generation = { ...preset.generation };
delete generation.reasoningEffort;
const next = { ...preset, generation };
writeFileSync(filePath, JSON.stringify(next, null, 2), "utf8");
return next;
}
function presetPath(id: string): string {
return path.join(getPresetsDir(), `${id}.json`);
}
@@ -20,8 +30,9 @@ export function listPresets(): PresetPackage[] {
const presets: PresetPackage[] = [];
for (const file of files) {
try {
const raw = readFileSync(path.join(getPresetsDir(), file), "utf8");
presets.push(JSON.parse(raw) as PresetPackage);
const filePath = path.join(getPresetsDir(), file);
const raw = readFileSync(filePath, "utf8");
presets.push(withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath));
} catch {
/* skip corrupt files */
}
@@ -31,8 +42,9 @@ export function listPresets(): PresetPackage[] {
export function getPreset(id: string): PresetPackage | null {
try {
const raw = readFileSync(presetPath(id), "utf8");
return JSON.parse(raw) as PresetPackage;
const filePath = presetPath(id);
const raw = readFileSync(filePath, "utf8");
return withoutReasoningEffort(JSON.parse(raw) as PresetPackage, filePath);
} catch {
return null;
}
+119 -5
View File
@@ -1,12 +1,16 @@
import { randomUUID } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import {
createApiProfile,
deleteApiProfile,
getApiProfile,
listApiGroups,
listApiProfiles,
probeAndSaveProfileCapabilities,
renameApiProfileGroup,
updateApiProfile,
} from "../config/api-profiles.js";
import { discoverRemoteModels } from "../llm/list-models.js";
import {
deliveryModeLabel,
resolveStructuredDeliveryMode,
@@ -94,6 +98,9 @@ function personasPayload(extra: Record<string, unknown> = {}) {
};
}
/** 本次进程启动标记。重启后变化,浏览器据此丢掉嗅探缓存。 */
const processBootId = randomUUID();
export async function handleSettingsApi(
req: IncomingMessage,
res: ServerResponse,
@@ -115,7 +122,13 @@ export async function handleSettingsApi(
importedAt: p.importedAt,
};
});
json(res, 200, { settings, profiles, presets });
json(res, 200, {
settings,
profiles,
groups: listApiGroups(),
presets,
bootId: processBootId,
});
return true;
}
@@ -164,6 +177,7 @@ export async function handleSettingsApi(
ensureActiveProfileDefault();
json(res, 200, {
profiles: listApiProfiles(),
groups: listApiGroups(),
activeProfileId: loadAppSettings().activeProfileId,
});
return true;
@@ -175,6 +189,7 @@ export async function handleSettingsApi(
baseUrl?: string;
apiKey?: string;
model?: string;
models?: string[];
reasoningEffort?: string;
};
const profile = createApiProfile({
@@ -189,17 +204,104 @@ export async function handleSettingsApi(
setActiveProfileId(profile.id);
}
const reloadedSessions = activate ? sessionManager.reloadAllLlms() : 0;
json(res, 201, { profile, activeProfileId: activate ? profile.id : null, reloadedSessions });
json(res, 201, {
profile,
groups: listApiGroups(),
activeProfileId: activate ? profile.id : null,
reloadedSessions,
});
return true;
}
if (pathname === "/api/profiles/discover-models" && req.method === "POST") {
const body = JSON.parse(await readBody(req)) as {
baseUrl?: string;
apiKey?: string;
profileId?: string;
};
let baseUrl = body.baseUrl?.trim() ?? "";
let apiKey = body.apiKey?.trim() ?? "";
if (body.profileId?.trim()) {
const profile = getApiProfile(body.profileId.trim());
if (!profile) {
json(res, 404, { error: "配置不存在" });
return true;
}
if (!baseUrl) baseUrl = profile.baseUrl;
if (!apiKey) apiKey = profile.apiKey;
}
if (!baseUrl) {
json(res, 400, { error: "缺少 Base URL" });
return true;
}
if (!apiKey) {
json(res, 400, { error: "缺少 API Key" });
return true;
}
try {
const found = await discoverRemoteModels(baseUrl, apiKey);
json(res, 200, { models: found.models, url: found.url });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "嗅探失败",
});
}
return true;
}
const groupMatch = pathname.match(/^\/api\/profile-groups\/([^/]+)$/);
if (groupMatch && req.method === "PUT") {
const id = decodeURIComponent(groupMatch[1] ?? "");
const body = JSON.parse(await readBody(req)) as { name?: string };
try {
const group = renameApiProfileGroup(id, body.name ?? "");
json(res, 200, { group, groups: listApiGroups() });
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "改组名失败",
});
}
return true;
}
const profileMatch = pathname.match(
/^\/api\/profiles\/([^/]+)(\/probe-capabilities)?$/,
/^\/api\/profiles\/([^/]+)(\/(?:probe-capabilities|select-model))?$/,
);
if (profileMatch) {
const id = decodeURIComponent(profileMatch[1]);
const suffix = profileMatch[2];
if (suffix === "/select-model" && req.method === "POST") {
const body = JSON.parse(await readBody(req)) as {
model?: string;
activate?: boolean;
};
const model = body.model?.trim() ?? "";
if (!model) {
json(res, 400, { error: "缺少模型" });
return true;
}
try {
const profile = updateApiProfile(id, { model });
if (body.activate) setActiveProfileId(profile.id);
const settings = loadAppSettings();
const reloadedSessions =
settings.activeProfileId === profile.id
? sessionManager.reloadAllLlms()
: 0;
json(res, 200, {
profile,
activeProfileId: loadAppSettings().activeProfileId,
reloadedSessions,
});
} catch (err) {
json(res, 400, {
error: err instanceof Error ? err.message : "切换模型失败",
});
}
return true;
}
if (suffix === "/probe-capabilities" && req.method === "POST") {
try {
const profile = await probeAndSaveProfileCapabilities(id);
@@ -240,16 +342,21 @@ export async function handleSettingsApi(
baseUrl?: string;
apiKey?: string;
model?: string;
models?: string[];
reasoningEffort?: string;
};
try {
const profile = updateApiProfile(id, body);
const settings = loadAppSettings();
const reloadedSessions =
settings.activeProfileId === id
settings.activeProfileId === profile.id
? sessionManager.reloadAllLlms()
: 0;
json(res, 200, { profile, reloadedSessions });
json(res, 200, {
profile,
groups: listApiGroups(),
reloadedSessions,
});
} catch (err) {
json(res, 404, {
error: err instanceof Error ? err.message : "更新失败",
@@ -426,6 +533,13 @@ export async function handleSettingsApi(
}
}
if (pathname === "/api/presets/deactivate" && req.method === "POST") {
setActivePresetId(null);
const reloadedSessions = sessionManager.reloadAllLlms();
json(res, 200, { activePresetId: null, reloadedSessions });
return true;
}
const presetMatch = pathname.match(/^\/api\/presets\/([^/]+)(\/activate)?$/);
if (presetMatch) {
const id = decodeURIComponent(presetMatch[1]);
+1
View File
@@ -71,6 +71,7 @@ const server = createServer(async (req, res) => {
"GET /api/settings",
"GET /api/profiles",
"POST /api/profiles",
"POST /api/profiles/discover-models",
"GET /api/presets",
"GET /api/books",
"GET /api/skills",