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,364 @@
|
||||
/**
|
||||
* API profile 能力探测与 structured 投递选路。
|
||||
* 优先 response json_schema;不可用则 forced tool;再退 json_object。
|
||||
*/
|
||||
|
||||
import {
|
||||
applyRejectedGenerationField,
|
||||
parseRejectedGenerationField,
|
||||
} from "./generation-compat.js";
|
||||
|
||||
export type CapabilityStatus = "ok" | "fail" | "unknown";
|
||||
|
||||
export type StructuredDeliveryMode =
|
||||
| "json_schema"
|
||||
| "forced_tool"
|
||||
| "json_object"
|
||||
| "text";
|
||||
|
||||
export type ProfileCapabilities = {
|
||||
/** 普通聊天 /models 或极简 completion */
|
||||
chat: CapabilityStatus;
|
||||
/** response_format: json_object */
|
||||
jsonObject: CapabilityStatus;
|
||||
/** response_format: json_schema(真 schema 约束) */
|
||||
jsonSchema: CapabilityStatus;
|
||||
/** tools + 返回 tool_calls */
|
||||
tools: CapabilityStatus;
|
||||
/** tool_choice=required(或指名函数)能强制提交 */
|
||||
forcedTool: CapabilityStatus;
|
||||
/** tools[].function.strict(若探测时带了) */
|
||||
strictTool: CapabilityStatus;
|
||||
testedAt?: string;
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export const UNKNOWN_CAPABILITIES: ProfileCapabilities = {
|
||||
chat: "unknown",
|
||||
jsonObject: "unknown",
|
||||
jsonSchema: "unknown",
|
||||
tools: "unknown",
|
||||
forcedTool: "unknown",
|
||||
strictTool: "unknown",
|
||||
};
|
||||
|
||||
/** 探测固定 low,避免 always-on 思考把 max_tokens 吃光导致误判 */
|
||||
export const CAPABILITY_PROBE_REASONING_EFFORT = "low" as const;
|
||||
|
||||
const PROBE_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ping", "n"],
|
||||
properties: {
|
||||
ping: { type: "string" },
|
||||
n: { type: "number" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PROBE_TOOL = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "submit_probe",
|
||||
description: "Submit the probe result. Always call this tool.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["ping", "n"],
|
||||
properties: {
|
||||
ping: { type: "string" },
|
||||
n: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const PROBE_TOOL_STRICT = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
...PROBE_TOOL.function,
|
||||
strict: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function normalizeCapabilities(
|
||||
raw: unknown,
|
||||
): ProfileCapabilities | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const row = raw as Record<string, unknown>;
|
||||
const read = (k: string): CapabilityStatus => {
|
||||
const v = row[k];
|
||||
if (v === "ok" || v === "fail" || v === "unknown") return v;
|
||||
return "unknown";
|
||||
};
|
||||
return {
|
||||
chat: read("chat"),
|
||||
jsonObject: read("jsonObject"),
|
||||
jsonSchema: read("jsonSchema"),
|
||||
tools: read("tools"),
|
||||
forcedTool: read("forcedTool"),
|
||||
strictTool: read("strictTool"),
|
||||
testedAt: typeof row.testedAt === "string" ? row.testedAt : undefined,
|
||||
notes: Array.isArray(row.notes)
|
||||
? row.notes.filter((x): x is string => typeof x === "string")
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 选路:schema → forced tool → json_object → text */
|
||||
export function resolveStructuredDeliveryMode(
|
||||
caps: ProfileCapabilities | null | undefined,
|
||||
): StructuredDeliveryMode {
|
||||
if (!caps) return "json_object";
|
||||
if (caps.jsonSchema === "ok") return "json_schema";
|
||||
if (caps.forcedTool === "ok") return "forced_tool";
|
||||
if (caps.jsonObject === "ok") return "json_object";
|
||||
return "text";
|
||||
}
|
||||
|
||||
export function deliveryModeLabel(mode: StructuredDeliveryMode): string {
|
||||
switch (mode) {
|
||||
case "json_schema":
|
||||
return "JSON Schema";
|
||||
case "forced_tool":
|
||||
return "强制 Tool";
|
||||
case "json_object":
|
||||
return "JSON Object";
|
||||
default:
|
||||
return "纯文本";
|
||||
}
|
||||
}
|
||||
|
||||
type ProbeConfig = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
async function postOnce(
|
||||
config: ProbeConfig,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
|
||||
const url = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ model: config.model, ...body }),
|
||||
});
|
||||
const text = await res.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { ok: res.ok, status: res.status, json, text };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
json: null,
|
||||
text: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测请求始终带 reasoning_effort=low。
|
||||
* 若供应商拒收该字段,剥掉后再试一次。
|
||||
*/
|
||||
async function postChat(
|
||||
config: ProbeConfig,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ ok: boolean; status: number; json: unknown; text: string }> {
|
||||
const withEffort: Record<string, unknown> = {
|
||||
...body,
|
||||
reasoning_effort: CAPABILITY_PROBE_REASONING_EFFORT,
|
||||
};
|
||||
const first = await postOnce(config, withEffort);
|
||||
if (first.ok || first.status !== 400) return first;
|
||||
|
||||
const rejected = parseRejectedGenerationField(first.text);
|
||||
if (rejected !== "reasoning_effort") return first;
|
||||
|
||||
const retryBody = { ...withEffort };
|
||||
const tried = new Set<string>();
|
||||
if (!applyRejectedGenerationField(retryBody, "reasoning_effort", tried)) {
|
||||
return first;
|
||||
}
|
||||
return postOnce(config, retryBody);
|
||||
}
|
||||
|
||||
function assistantMessage(json: unknown): Record<string, unknown> | null {
|
||||
if (!json || typeof json !== "object") return null;
|
||||
const choices = (json as { choices?: unknown }).choices;
|
||||
if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== "object") {
|
||||
return null;
|
||||
}
|
||||
const msg = (choices[0] as { message?: unknown }).message;
|
||||
if (!msg || typeof msg !== "object") return null;
|
||||
return msg as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function hasToolCallNamed(json: unknown, name: string): boolean {
|
||||
const msg = assistantMessage(json);
|
||||
if (!msg) return false;
|
||||
const calls = msg.tool_calls;
|
||||
if (!Array.isArray(calls)) return false;
|
||||
return calls.some((c) => {
|
||||
if (!c || typeof c !== "object") return false;
|
||||
const fn = (c as { function?: unknown }).function;
|
||||
if (!fn || typeof fn !== "object") return false;
|
||||
return (fn as { name?: unknown }).name === name;
|
||||
});
|
||||
}
|
||||
|
||||
function contentLooksLikeJsonObject(json: unknown): boolean {
|
||||
const msg = assistantMessage(json);
|
||||
const content = msg?.content;
|
||||
if (typeof content !== "string" || !content.trim()) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return parsed != null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 对单个 profile 跑一轮能力探测(会真实打 API,费用很小)。
|
||||
*/
|
||||
export async function probeProfileCapabilities(
|
||||
config: ProbeConfig,
|
||||
): Promise<ProfileCapabilities> {
|
||||
const notes: string[] = [];
|
||||
const caps: ProfileCapabilities = {
|
||||
...UNKNOWN_CAPABILITIES,
|
||||
testedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const chat = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Reply with exactly: ok" }],
|
||||
max_tokens: 32,
|
||||
});
|
||||
caps.chat = chat.ok ? "ok" : "fail";
|
||||
if (!chat.ok) {
|
||||
notes.push(`chat: HTTP ${chat.status} ${chat.text.slice(0, 160)}`);
|
||||
caps.notes = notes;
|
||||
return caps;
|
||||
}
|
||||
|
||||
const jsonObject = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
'Return a JSON object with keys ping (string "ok") and n (number 1).',
|
||||
},
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 128,
|
||||
});
|
||||
caps.jsonObject =
|
||||
jsonObject.ok && contentLooksLikeJsonObject(jsonObject.json) ? "ok" : "fail";
|
||||
if (!jsonObject.ok) {
|
||||
notes.push(`json_object: HTTP ${jsonObject.status} ${jsonObject.text.slice(0, 120)}`);
|
||||
} else if (caps.jsonObject === "fail") {
|
||||
notes.push("json_object: 返回了但不是可解析对象");
|
||||
}
|
||||
|
||||
const jsonSchema = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Fill ping=ok and n=1.",
|
||||
},
|
||||
],
|
||||
response_format: {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: "probe_result",
|
||||
strict: true,
|
||||
schema: PROBE_SCHEMA,
|
||||
},
|
||||
},
|
||||
max_tokens: 128,
|
||||
});
|
||||
caps.jsonSchema =
|
||||
jsonSchema.ok && contentLooksLikeJsonObject(jsonSchema.json) ? "ok" : "fail";
|
||||
if (!jsonSchema.ok) {
|
||||
notes.push(`json_schema: HTTP ${jsonSchema.status} ${jsonSchema.text.slice(0, 120)}`);
|
||||
}
|
||||
|
||||
const tools = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Call submit_probe with ping=ok and n=1.",
|
||||
},
|
||||
],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: "auto",
|
||||
max_tokens: 256,
|
||||
});
|
||||
caps.tools = tools.ok && hasToolCallNamed(tools.json, "submit_probe") ? "ok" : "fail";
|
||||
if (!tools.ok) {
|
||||
notes.push(`tools: HTTP ${tools.status} ${tools.text.slice(0, 120)}`);
|
||||
} else if (caps.tools === "fail") {
|
||||
notes.push("tools: 请求成功但未返回 submit_probe");
|
||||
}
|
||||
|
||||
const forced = await postChat(config, {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Submit the probe.",
|
||||
},
|
||||
],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: "required",
|
||||
max_tokens: 256,
|
||||
});
|
||||
let forcedOk =
|
||||
forced.ok && hasToolCallNamed(forced.json, "submit_probe");
|
||||
if (!forcedOk && forced.ok) {
|
||||
const named = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Submit the probe." }],
|
||||
tools: [PROBE_TOOL],
|
||||
tool_choice: {
|
||||
type: "function",
|
||||
function: { name: "submit_probe" },
|
||||
},
|
||||
max_tokens: 256,
|
||||
});
|
||||
forcedOk = named.ok && hasToolCallNamed(named.json, "submit_probe");
|
||||
if (!named.ok) {
|
||||
notes.push(`forced_tool(named): HTTP ${named.status} ${named.text.slice(0, 120)}`);
|
||||
}
|
||||
} else if (!forced.ok) {
|
||||
notes.push(`forced_tool: HTTP ${forced.status} ${forced.text.slice(0, 120)}`);
|
||||
}
|
||||
caps.forcedTool = forcedOk ? "ok" : "fail";
|
||||
|
||||
if (caps.tools === "ok" || caps.forcedTool === "ok") {
|
||||
const strict = await postChat(config, {
|
||||
messages: [{ role: "user", content: "Submit the probe." }],
|
||||
tools: [PROBE_TOOL_STRICT],
|
||||
tool_choice: "required",
|
||||
max_tokens: 256,
|
||||
});
|
||||
caps.strictTool =
|
||||
strict.ok && hasToolCallNamed(strict.json, "submit_probe") ? "ok" : "fail";
|
||||
if (!strict.ok) {
|
||||
notes.push(`strict_tool: HTTP ${strict.status} ${strict.text.slice(0, 120)}`);
|
||||
}
|
||||
} else {
|
||||
caps.strictTool = "fail";
|
||||
}
|
||||
|
||||
caps.notes = notes.length ? notes : undefined;
|
||||
return caps;
|
||||
}
|
||||
+56
-37
@@ -1,11 +1,18 @@
|
||||
import type { LlmConfig } from "../config/env.js";
|
||||
import type { GenerationParameters } from "../types/preset.js";
|
||||
import {
|
||||
applyOpenAiGeneration,
|
||||
fetchWithGenerationCompat,
|
||||
sanitizeReasoningEffort,
|
||||
} from "./generation-compat.js";
|
||||
import { consumeOpenAiToolStream } from "./stream-complete.js";
|
||||
import {
|
||||
currentAbortSignal,
|
||||
throwIfAborted,
|
||||
} from "./run-abort.js";
|
||||
|
||||
export { sanitizeReasoningEffort };
|
||||
|
||||
export type ToolCallPayload = {
|
||||
id: string;
|
||||
type: "function";
|
||||
@@ -30,6 +37,8 @@ export type ToolDefinition = {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
/** 部分供应商支持的严格参数约束 */
|
||||
strict?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -57,8 +66,16 @@ export type CompleteResult = {
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export type JsonSchemaFormat = {
|
||||
name: string;
|
||||
strict?: boolean;
|
||||
schema: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type CompleteOptions = {
|
||||
responseFormat?: "json_object" | "text";
|
||||
responseFormat?: "json_object" | "json_schema" | "text";
|
||||
/** responseFormat=json_schema 时使用 */
|
||||
jsonSchema?: JsonSchemaFormat;
|
||||
generation?: GenerationParameters;
|
||||
/** 统计用途,如 main_agent / worker:write-rules */
|
||||
caller?: string;
|
||||
@@ -66,8 +83,15 @@ export type CompleteOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type ToolChoice =
|
||||
| "auto"
|
||||
| "required"
|
||||
| "none"
|
||||
| { type: "function"; function: { name: string } };
|
||||
|
||||
export type CompleteWithToolsOptions = CompleteOptions & {
|
||||
tools: ToolDefinition[];
|
||||
toolChoice?: ToolChoice;
|
||||
};
|
||||
|
||||
export type CompleteWithToolsResult = {
|
||||
@@ -104,12 +128,11 @@ export type LlmProvider = {
|
||||
): Promise<CompleteWithToolsResult>;
|
||||
};
|
||||
|
||||
function buildRequestBody(
|
||||
export function buildRequestBody(
|
||||
config: LlmConfig,
|
||||
messages: ChatMessage[],
|
||||
options?: CompleteOptions & { tools?: ToolDefinition[]; stream?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const gen = options?.generation ?? {};
|
||||
const body: Record<string, unknown> = {
|
||||
model: config.model,
|
||||
messages,
|
||||
@@ -117,34 +140,28 @@ function buildRequestBody(
|
||||
|
||||
if (options?.tools?.length) {
|
||||
body.tools = options.tools;
|
||||
body.tool_choice = "auto";
|
||||
body.tool_choice =
|
||||
(options as CompleteWithToolsOptions).toolChoice ?? "auto";
|
||||
}
|
||||
|
||||
if (gen.temperature !== undefined) body.temperature = gen.temperature;
|
||||
else body.temperature = 0.2;
|
||||
|
||||
if (gen.topP !== undefined) body.top_p = gen.topP;
|
||||
if (gen.topK !== undefined) body.top_k = gen.topK;
|
||||
if (gen.minP !== undefined) body.min_p = gen.minP;
|
||||
if (gen.frequencyPenalty !== undefined) {
|
||||
body.frequency_penalty = gen.frequencyPenalty;
|
||||
}
|
||||
if (gen.presencePenalty !== undefined) {
|
||||
body.presence_penalty = gen.presencePenalty;
|
||||
}
|
||||
if (gen.repetitionPenalty !== undefined) {
|
||||
body.repetition_penalty = gen.repetitionPenalty;
|
||||
}
|
||||
if (gen.maxOutputTokens !== undefined) {
|
||||
body.max_tokens = gen.maxOutputTokens;
|
||||
}
|
||||
if (gen.seed !== undefined) body.seed = gen.seed;
|
||||
if (gen.reasoningEffort !== undefined) {
|
||||
body.reasoning_effort = gen.reasoningEffort;
|
||||
}
|
||||
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
});
|
||||
|
||||
if (options?.responseFormat === "json_object") {
|
||||
body.response_format = { type: "json_object" };
|
||||
} else if (
|
||||
options?.responseFormat === "json_schema" &&
|
||||
options.jsonSchema
|
||||
) {
|
||||
body.response_format = {
|
||||
type: "json_schema",
|
||||
json_schema: {
|
||||
name: options.jsonSchema.name,
|
||||
strict: options.jsonSchema.strict ?? true,
|
||||
schema: options.jsonSchema.schema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (options?.stream) {
|
||||
@@ -246,19 +263,21 @@ function extractMessageParts(message: Record<string, unknown> | undefined): {
|
||||
async function llmFetch(
|
||||
url: string,
|
||||
apiKey: string,
|
||||
body: unknown,
|
||||
body: Record<string, unknown>,
|
||||
options?: CompleteOptions,
|
||||
): Promise<Response> {
|
||||
throwIfAborted(options?.signal);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal ?? currentAbortSignal(),
|
||||
});
|
||||
return fetchWithGenerationCompat(async (payload) => {
|
||||
throwIfAborted(options?.signal);
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal ?? currentAbortSignal(),
|
||||
});
|
||||
}, body);
|
||||
}
|
||||
|
||||
export class OpenAiCompatibleProvider implements LlmProvider {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 按 profile 能力选路的 structured 完成:json_schema → forced_tool → json_object。
|
||||
*/
|
||||
import {
|
||||
resolveStructuredDeliveryMode,
|
||||
type ProfileCapabilities,
|
||||
type StructuredDeliveryMode,
|
||||
} from "./capabilities.js";
|
||||
import type {
|
||||
ChatMessage,
|
||||
CompleteOptions,
|
||||
CompleteResult,
|
||||
LlmProvider,
|
||||
ToolDefinition,
|
||||
} from "./client.js";
|
||||
import { tryParseJsonDoc } from "../parse/json-doc.js";
|
||||
|
||||
export type StructuredCompleteOptions = CompleteOptions & {
|
||||
/** JSON Schema 对象(json_schema / tool parameters 共用) */
|
||||
schema: Record<string, unknown>;
|
||||
/** schema / tool 名称 */
|
||||
name: string;
|
||||
/** 覆盖自动选路 */
|
||||
mode?: StructuredDeliveryMode;
|
||||
capabilities?: ProfileCapabilities | null;
|
||||
/**
|
||||
* schema 含 additionalProperties:true 等、无法走 strict json_schema 时置 true。
|
||||
* 会自动降级:forced_tool → json_object。
|
||||
*/
|
||||
schemaIsLoose?: boolean;
|
||||
/** forced_tool 时是否尝试 strict(默认:capabilities.strictTool===ok 且非 loose) */
|
||||
preferStrictTool?: boolean;
|
||||
stream?: import("./client.js").StreamCallbacks;
|
||||
};
|
||||
|
||||
export type StructuredCompleteResult = CompleteResult & {
|
||||
parsed: unknown;
|
||||
mode: StructuredDeliveryMode;
|
||||
};
|
||||
|
||||
function parseObjectPayload(raw: string): unknown {
|
||||
const parsed = tryParseJsonDoc(raw);
|
||||
if (parsed != null) return parsed;
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
throw new Error("structured 输出无法解析为 JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function buildSubmitTool(
|
||||
name: string,
|
||||
schema: Record<string, unknown>,
|
||||
strict: boolean,
|
||||
): ToolDefinition {
|
||||
return {
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
description: `Submit the structured result as arguments to ${name}. Always call this tool.`,
|
||||
parameters: schema,
|
||||
...(strict ? { strict: true } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次 structured 完成:返回解析后的对象。
|
||||
* forced_tool 路径走 completeWithTools;其余走 complete / completeStream。
|
||||
*/
|
||||
export async function completeStructured(
|
||||
llm: LlmProvider,
|
||||
messages: ChatMessage[],
|
||||
options: StructuredCompleteOptions,
|
||||
): Promise<StructuredCompleteResult> {
|
||||
const requested =
|
||||
options.mode ?? resolveStructuredDeliveryMode(options.capabilities);
|
||||
let mode = requested;
|
||||
if (options.schemaIsLoose && mode === "json_schema") {
|
||||
mode =
|
||||
options.capabilities?.forcedTool === "ok"
|
||||
? "forced_tool"
|
||||
: options.capabilities?.jsonObject === "ok"
|
||||
? "json_object"
|
||||
: "text";
|
||||
}
|
||||
const schemaName = options.name || "submit_result";
|
||||
|
||||
if (mode === "forced_tool") {
|
||||
const strict =
|
||||
!options.schemaIsLoose &&
|
||||
(options.preferStrictTool ??
|
||||
options.capabilities?.strictTool === "ok");
|
||||
const tools = [buildSubmitTool(schemaName, options.schema, strict)];
|
||||
const result = await llm.completeWithTools(messages, {
|
||||
...options,
|
||||
tools,
|
||||
toolChoice: "required",
|
||||
responseFormat: undefined,
|
||||
jsonSchema: undefined,
|
||||
});
|
||||
const call =
|
||||
result.toolCalls.find((t) => t.name === schemaName) ??
|
||||
result.toolCalls[0];
|
||||
if (!call) {
|
||||
throw new Error("forced_tool 模式未返回 tool_calls");
|
||||
}
|
||||
const parsed = parseObjectPayload(call.arguments);
|
||||
return {
|
||||
content: call.arguments,
|
||||
reasoning: result.reasoning,
|
||||
usage: result.usage,
|
||||
model: result.model,
|
||||
parsed,
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
const completeOpts: CompleteOptions = {
|
||||
...options,
|
||||
responseFormat:
|
||||
mode === "json_schema"
|
||||
? "json_schema"
|
||||
: mode === "json_object"
|
||||
? "json_object"
|
||||
: "text",
|
||||
jsonSchema:
|
||||
mode === "json_schema"
|
||||
? { name: schemaName, strict: true, schema: options.schema }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
let result: CompleteResult;
|
||||
if (options.stream && llm.completeStream) {
|
||||
result = await llm.completeStream(messages, completeOpts, options.stream);
|
||||
} else {
|
||||
result = await llm.complete(messages, completeOpts);
|
||||
}
|
||||
|
||||
const parsed = parseObjectPayload(result.content);
|
||||
return { ...result, parsed, mode };
|
||||
}
|
||||
Reference in New Issue
Block a user