Add capability-probed structured outputs and a prototype/instance creation graph.
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>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user