Probe API profiles for json_schema/tools, flatten worker artifacts onto a strict schema, and let users spawn or revise graph nodes instead of a one-shot DAG. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
8.0 KiB
TypeScript
278 lines
8.0 KiB
TypeScript
import type { GenerationParameters } from "../types/preset.js";
|
|
|
|
/**
|
|
* OpenAI-compatible Chat Completions 的生成参数兼容层,对齐 SillyTavern Custom 源:
|
|
* - `auto` / 关闭值不发送
|
|
* - top_k、min_p、repetition_penalty 不是官方 Chat Completions 字段,默认不发
|
|
* - 推理模型去掉 temperature / top_p / penalty
|
|
* - o 系列与 gpt-5 用 max_completion_tokens
|
|
* - 仍 400 时按错误信息剥掉不支持字段再试
|
|
*/
|
|
|
|
const REASONING_EFFORT_VALUES = [
|
|
"none",
|
|
"minimal",
|
|
"low",
|
|
"medium",
|
|
"high",
|
|
"xhigh",
|
|
"max",
|
|
] as const;
|
|
|
|
type ReasoningEffort = (typeof REASONING_EFFORT_VALUES)[number];
|
|
|
|
const REASONING_EFFORT_ALIASES: Record<string, ReasoningEffort> = {
|
|
min: "minimal",
|
|
};
|
|
|
|
const VERBOSITY_VALUES = ["low", "medium", "high"] as const;
|
|
type Verbosity = (typeof VERBOSITY_VALUES)[number];
|
|
|
|
export const STRIPPABLE_GENERATION_FIELDS = new Set([
|
|
"temperature",
|
|
"top_p",
|
|
"top_k",
|
|
"min_p",
|
|
"frequency_penalty",
|
|
"presence_penalty",
|
|
"repetition_penalty",
|
|
"seed",
|
|
"n",
|
|
"reasoning_effort",
|
|
"verbosity",
|
|
"max_tokens",
|
|
"max_completion_tokens",
|
|
"logit_bias",
|
|
"stop",
|
|
"response_format",
|
|
"stream_options",
|
|
]);
|
|
|
|
export type ChatModelCompat = {
|
|
omitSampling: boolean;
|
|
useMaxCompletionTokens: boolean;
|
|
supportsVerbosity: boolean;
|
|
};
|
|
|
|
function modelLeaf(model: string): string {
|
|
const trimmed = model.trim().toLowerCase();
|
|
const slash = trimmed.lastIndexOf("/");
|
|
return slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
|
|
}
|
|
|
|
export function classifyChatModel(model: string): ChatModelCompat {
|
|
const id = modelLeaf(model);
|
|
const isOSeries = /^o[134]($|[-.\d])/.test(id);
|
|
const isGpt5 = id.includes("gpt-5");
|
|
const isGpt5Chat = isGpt5 && id.includes("chat");
|
|
const isKimi25 = id.includes("kimi-k2.5");
|
|
const isDeepseekReasoner = id.includes("deepseek-reasoner");
|
|
const isGrok3Mini = id.includes("grok-3-mini");
|
|
|
|
return {
|
|
omitSampling:
|
|
isOSeries ||
|
|
(isGpt5 && !isGpt5Chat) ||
|
|
isKimi25 ||
|
|
isDeepseekReasoner ||
|
|
isGrok3Mini,
|
|
useMaxCompletionTokens: isOSeries || isGpt5,
|
|
supportsVerbosity: isGpt5,
|
|
};
|
|
}
|
|
|
|
export function sanitizeReasoningEffort(
|
|
value: unknown,
|
|
): ReasoningEffort | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const key = value.trim().toLowerCase();
|
|
if (!key || key === "auto") return undefined;
|
|
const mapped = REASONING_EFFORT_ALIASES[key] ?? key;
|
|
return (REASONING_EFFORT_VALUES as readonly string[]).includes(mapped)
|
|
? (mapped as ReasoningEffort)
|
|
: undefined;
|
|
}
|
|
|
|
export function sanitizeVerbosity(value: unknown): Verbosity | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const key = value.trim().toLowerCase();
|
|
if (!key || key === "auto") return undefined;
|
|
return (VERBOSITY_VALUES as readonly string[]).includes(key)
|
|
? (key as Verbosity)
|
|
: undefined;
|
|
}
|
|
|
|
function finiteNumber(value: unknown): number | undefined {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
return value;
|
|
}
|
|
|
|
export type GenerationDefaults = {
|
|
/** API profile 等来源的默认思考强度;仅当 gen 未指定时生效 */
|
|
reasoningEffort?: string;
|
|
};
|
|
|
|
export function applyOpenAiGeneration(
|
|
body: Record<string, unknown>,
|
|
gen: GenerationParameters,
|
|
model: string,
|
|
defaults?: GenerationDefaults,
|
|
): void {
|
|
const { omitSampling, useMaxCompletionTokens, supportsVerbosity } =
|
|
classifyChatModel(model);
|
|
|
|
if (!omitSampling) {
|
|
body.temperature = gen.temperature !== undefined ? gen.temperature : 0.2;
|
|
const topP = finiteNumber(gen.topP);
|
|
if (topP !== undefined && topP > 0) body.top_p = topP;
|
|
const frequencyPenalty = finiteNumber(gen.frequencyPenalty);
|
|
if (frequencyPenalty !== undefined) {
|
|
body.frequency_penalty = frequencyPenalty;
|
|
}
|
|
const presencePenalty = finiteNumber(gen.presencePenalty);
|
|
if (presencePenalty !== undefined) {
|
|
body.presence_penalty = presencePenalty;
|
|
}
|
|
}
|
|
|
|
const maxOutput = finiteNumber(gen.maxOutputTokens);
|
|
if (maxOutput !== undefined && maxOutput > 0) {
|
|
if (useMaxCompletionTokens) body.max_completion_tokens = maxOutput;
|
|
else body.max_tokens = maxOutput;
|
|
}
|
|
|
|
const seed = finiteNumber(gen.seed);
|
|
if (seed !== undefined && seed >= 0) body.seed = seed;
|
|
|
|
const reasoningEffort =
|
|
sanitizeReasoningEffort(gen.reasoningEffort) ??
|
|
sanitizeReasoningEffort(defaults?.reasoningEffort);
|
|
if (reasoningEffort !== undefined) {
|
|
body.reasoning_effort = reasoningEffort;
|
|
}
|
|
|
|
const verbosity = sanitizeVerbosity(gen.verbosity);
|
|
if (verbosity !== undefined && supportsVerbosity) {
|
|
body.verbosity = verbosity;
|
|
}
|
|
}
|
|
|
|
function readErrorMessage(raw: unknown): string {
|
|
if (!raw || typeof raw !== "object") return "";
|
|
const err = (raw as { error?: unknown }).error;
|
|
if (typeof err === "string") return err;
|
|
if (err && typeof err === "object") {
|
|
const message = (err as { message?: unknown }).message;
|
|
if (typeof message === "string") return message;
|
|
}
|
|
const message = (raw as { message?: unknown }).message;
|
|
return typeof message === "string" ? message : "";
|
|
}
|
|
|
|
function readErrorParam(raw: unknown): string | undefined {
|
|
if (!raw || typeof raw !== "object") return undefined;
|
|
const err = (raw as { error?: unknown }).error;
|
|
if (err && typeof err === "object") {
|
|
const param = (err as { param?: unknown }).param;
|
|
if (typeof param === "string" && STRIPPABLE_GENERATION_FIELDS.has(param)) {
|
|
return param;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function parseRejectedGenerationField(
|
|
errorText: string,
|
|
): string | undefined {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(errorText);
|
|
} catch {
|
|
parsed = undefined;
|
|
}
|
|
const fromParam = readErrorParam(parsed);
|
|
if (fromParam) return fromParam;
|
|
|
|
const message = `${readErrorMessage(parsed)}\n${errorText}`;
|
|
if (/temperature and top_p cannot both/i.test(message)) return "top_p";
|
|
|
|
const patterns = [
|
|
/unsupported parameter:?\s*['"`]?([a-z_]+)/i,
|
|
/unknown field [`'"]([a-z_]+)[`'"]/i,
|
|
/([a-z_]+):\s*unknown variant/i,
|
|
/invalid value[^\n]*for[^\n]*['"`]([a-z_]+)['"`]/i,
|
|
];
|
|
for (const pattern of patterns) {
|
|
const match = message.match(pattern);
|
|
const field = match?.[1]?.toLowerCase();
|
|
if (field && STRIPPABLE_GENERATION_FIELDS.has(field)) return field;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function applyRejectedGenerationField(
|
|
body: Record<string, unknown>,
|
|
field: string,
|
|
tried: Set<string>,
|
|
): boolean {
|
|
if (!STRIPPABLE_GENERATION_FIELDS.has(field)) return false;
|
|
|
|
if (
|
|
field === "max_completion_tokens" &&
|
|
body.max_completion_tokens != null &&
|
|
!tried.has("max_tokens")
|
|
) {
|
|
body.max_tokens = body.max_completion_tokens;
|
|
delete body.max_completion_tokens;
|
|
tried.add("max_completion_tokens");
|
|
return true;
|
|
}
|
|
if (
|
|
field === "max_tokens" &&
|
|
body.max_tokens != null &&
|
|
!tried.has("max_completion_tokens")
|
|
) {
|
|
body.max_completion_tokens = body.max_tokens;
|
|
delete body.max_tokens;
|
|
tried.add("max_tokens");
|
|
return true;
|
|
}
|
|
|
|
if (!(field in body)) return false;
|
|
delete body[field];
|
|
tried.add(field);
|
|
return true;
|
|
}
|
|
|
|
function replayResponse(source: Response, text: string): Response {
|
|
return new Response(text, {
|
|
status: source.status,
|
|
statusText: source.statusText,
|
|
headers: source.headers,
|
|
});
|
|
}
|
|
|
|
export async function fetchWithGenerationCompat(
|
|
send: (body: Record<string, unknown>) => Promise<Response>,
|
|
initialBody: Record<string, unknown>,
|
|
): Promise<Response> {
|
|
let body = initialBody;
|
|
const tried = new Set<string>();
|
|
let last: Response | undefined;
|
|
|
|
for (let attempt = 0; attempt < 8; attempt++) {
|
|
last = await send(body);
|
|
if (last.ok || last.status !== 400) return last;
|
|
const text = await last.text();
|
|
const field = parseRejectedGenerationField(text);
|
|
if (!field) return replayResponse(last, text);
|
|
const next = { ...body };
|
|
if (!applyRejectedGenerationField(next, field, tried)) {
|
|
return replayResponse(last, text);
|
|
}
|
|
body = next;
|
|
}
|
|
|
|
return last ?? new Response("LLM request failed", { status: 400 });
|
|
}
|