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:
2026-08-31 01:57:22 +08:00
co-authored by Cursor
parent 872f742d3e
commit bd5d88abfa
65 changed files with 7625 additions and 942 deletions
+364
View File
@@ -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
View File
@@ -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 {
+277
View File
@@ -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 });
}
+142
View File
@@ -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 };
}