Files
writing-agent/src/llm/client.ts
T
chenmoranzhi 9dc927282f 预设兼容:按真实抓包对齐装配与包装层,补上声明式工具与预设正则。
- 装配只认 prompt_order.enabled;squash_system_messages 只合并相邻 system(新增 system 档,连接符按酒馆用单换行)
- 包装层:new_chat_prompt、示例对话(new_example_chat_prompt + <START> 切段 + name)、角色卡三块与 personality_format/scenario_format、{{user}}/{{char}} 取值、任务契约并进预设条目
- 工具协议:按 ToolBindings / 伪工具协议数据声明真 tools(presetToolPolicy=preset),工具描述用到的条目不再重复作为消息发出
- 正则:promptOnly 作用于对话历史(含 depth 过滤)、写入时脚本改写回复;{{//}} 注释按配平扫描
- tools/st-request-diff:真假上游抓包对比工具、三份预设的机制结论与实测报告
2026-10-23 00:13:08 +08:00

790 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { LlmConfig } from "../config/env.js";
import { ensureNetworkProxy } from "../config/network.js";
import type { GenerationParameters } from "../types/preset.js";
import type { WorldInfoPack as PresetPackForFrame } from "../preset/world-info-pack.js";
import {
applyOpenAiGeneration,
fetchWithGenerationCompat,
forceReasoningEffortNoneWhenTools,
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";
function: {
name: string;
arguments: string;
};
};
export type ChatMessage =
| { role: "system" | "user"; content: string; name?: string }
| {
role: "assistant";
content: string | null;
tool_calls?: ToolCallPayload[];
name?: string;
}
| { role: "tool"; content: string; tool_call_id: string; name?: string };
export type ToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
/** 部分供应商支持的严格参数约束 */
strict?: boolean;
};
};
export type ParsedToolCall = {
id: string;
name: string;
arguments: string;
};
export type TokenUsage = {
promptTokens: number;
completionTokens: number;
totalTokens: number;
/** Prompt tokens served from provider cache (OpenAI cached_tokens, DeepSeek prompt_cache_hit_tokens) */
cachedTokens?: number;
/** Prompt tokens not served from cache (DeepSeek prompt_cache_miss_tokens) */
cacheMissTokens?: number;
/** 上游若单独返回缓存写入 token(如 cache_creation_input_tokens) */
cacheWriteTokens?: number;
};
/** 用量行上的调度信息。不包含提示词或产物正文。 */
export type DictateUsageStep = "select" | "fill" | "fill-retry" | "reply" | "play";
export type UsageDetail = {
turnId?: string;
step?: DictateUsageStep;
retryIndex?: number;
segmentChars?: {
rules: number;
history: number;
products: number;
injected: number;
tail: number;
};
plannedTags?: string[];
succeededTags?: string[];
failedTags?: string[];
finish?: string;
};
/** complete 返回后由统计层写入 recordId,供补记成功或失败的 tag。 */
export type UsageSink = { recordId?: string };
export type CompleteResult = {
content: string;
/** 推理模型思维链(如 DeepSeek reasoner 的 reasoning_content) */
reasoning?: string;
usage?: TokenUsage;
model?: string;
};
export type JsonSchemaFormat = {
name: string;
strict?: boolean;
schema: Record<string, unknown>;
};
export type CompleteOptions = {
responseFormat?: "json_object" | "json_schema" | "text";
/** responseFormat=json_schema 时使用 */
jsonSchema?: JsonSchemaFormat;
generation?: GenerationParameters;
/** 统计用途,如 main_agent / worker:write-rules */
caller?: string;
/** 日志短名,如「对话落盘 第2轮」;缺则用 caller */
label?: string;
/** 取消进行中的 LLM 请求(停止并重试) */
signal?: AbortSignal;
/**
* 本局会话 id。仅 OpenRouter 会写成请求体 `session_id`,用来粘住同一上游,
* 避免开头 message 一变就换缓存节点。其它渠道不发送。
*/
sessionId?: string;
/** 统计补充。不进上游请求体。 */
usageDetail?: UsageDetail;
usageSink?: UsageSink;
/**
* 内部:play 真多轮装配的分袋上下文(由 worker 从 contextSegments 切成四袋 + 历史轮次)。
* 不进上游请求体;由 PresetLlmProvider 取用后丢弃。
*/
presetPack?: PresetPackForFrame;
};
export type ToolChoice =
| "auto"
| "required"
| "none"
| { type: "function"; function: { name: string } };
export type CompleteWithToolsOptions = CompleteOptions & {
tools: ToolDefinition[];
toolChoice?: ToolChoice;
};
export type CompleteWithToolsResult = {
content: string | null;
toolCalls: ParsedToolCall[];
reasoning?: string;
usage?: TokenUsage;
model?: string;
};
export type StreamCallbacks = {
onReasoningDelta?: (delta: string) => void;
onContentDelta?: (delta: string) => void;
};
export type LlmProvider = {
complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult>;
completeStream?(
messages: ChatMessage[],
options?: CompleteOptions,
callbacks?: StreamCallbacks,
): Promise<CompleteResult>;
completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult>;
completeWithToolsStream?(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult>;
};
const JSON_OBJECT_HINT = "请用 JSON 对象回复。";
const OPENROUTER_SESSION_ID_MAX = 256;
/** OpenRouter 用 `session_id` 做粘滞路由;其它 OpenAI 兼容渠道不认识这个字段。 */
export function isOpenRouterBaseUrl(baseUrl: string): boolean {
const trimmed = baseUrl.trim();
if (!trimmed) return false;
try {
const withProtocol = /^https?:\/\//i.test(trimmed)
? trimmed
: `https://${trimmed}`;
const host = new URL(withProtocol).hostname.toLowerCase();
return host === "openrouter.ai" || host.endsWith(".openrouter.ai");
} catch {
return false;
}
}
/** 合法则返回要写入请求体的 session_id;非 OpenRouter 或空 id 则不写。 */
export function openRouterSessionId(
baseUrl: string,
sessionId: string | undefined,
): string | undefined {
if (!isOpenRouterBaseUrl(baseUrl)) return undefined;
const id = sessionId?.trim();
if (!id) return undefined;
return id.slice(0, OPENROUTER_SESSION_ID_MAX);
}
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];
}
/** 空正文时报得能定位:是限额吃光了,还是模型真没给。 */
function emptyToolResponseMessage(params: {
stream: boolean;
finishReason?: string;
reasoningChars?: number;
usage?: TokenUsage;
}): string {
const bits: string[] = [];
if (params.finishReason) bits.push(`finish_reason=${params.finishReason}`);
if (params.reasoningChars) bits.push(`思维链=${params.reasoningChars}字`);
if (params.usage) {
bits.push(
`prompt=${params.usage.promptTokens}/completion=${params.usage.completionTokens}`,
);
}
const hint =
params.finishReason === "length"
? "(输出上限被思考/正文吃光,调大 maxOutputTokens)"
: params.reasoningChars
? "(只产出思维链没有正文,多半是输出上限不足)"
: "";
const head = params.stream
? "LLM 流式返回空正文且没有工具调用"
: "LLM 返回空正文且没有工具调用";
return `${head}${bits.length ? `:${bits.join(" ")}` : ""}${hint}`;
}
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: requestMessages,
};
if (options?.tools?.length) {
body.tools = options.tools;
body.tool_choice =
(options as CompleteWithToolsOptions).toolChoice ?? "auto";
}
applyOpenAiGeneration(body, options?.generation ?? {}, config.model, {
reasoningEffort: config.reasoningEffort,
});
// 带 tools 时按型号改写思考参数(Gemini 3 / GLM-5.3 保留合法档,其余强制 none)
forceReasoningEffortNoneWhenTools(body, config.model);
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) {
body.stream = true;
body.stream_options = { include_usage: true };
}
const sessionId = openRouterSessionId(config.baseUrl, options?.sessionId);
if (sessionId) body.session_id = sessionId;
return body;
}
function readFiniteNumber(value: unknown): number | undefined {
const n = Number(value);
return Number.isFinite(n) ? n : undefined;
}
function readCachedTokens(u: Record<string, unknown>): number | undefined {
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
if (details && typeof details === "object") {
const d = details as Record<string, unknown>;
const cached = readFiniteNumber(d.cached_tokens ?? d.cachedTokens);
if (cached != null) return cached;
}
return readFiniteNumber(u.prompt_cache_hit_tokens ?? u.promptCacheHitTokens);
}
function readCacheMissTokens(u: Record<string, unknown>): number | undefined {
return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens);
}
function readCacheWriteTokens(u: Record<string, unknown>): number | undefined {
const details = u.prompt_tokens_details ?? u.promptTokensDetails;
if (details && typeof details === "object") {
const row = details as Record<string, unknown>;
const nested = readFiniteNumber(row.cache_write_tokens ?? row.cacheWriteTokens);
if (nested != null) return nested;
}
return readFiniteNumber(
u.cache_creation_input_tokens ??
u.cache_write_input_tokens ??
u.cacheWriteTokens,
);
}
/** Parse OpenAI-compatible usage object, including provider-specific cache fields. */
export function parseUsage(raw: unknown): TokenUsage | undefined {
if (!raw || typeof raw !== "object") return undefined;
const u = raw as Record<string, unknown>;
const prompt = Number(u.prompt_tokens ?? u.promptTokens);
const completion = Number(u.completion_tokens ?? u.completionTokens);
const total = Number(u.total_tokens ?? u.totalTokens);
if (!Number.isFinite(total) && !Number.isFinite(prompt)) return undefined;
const cachedTokens = readCachedTokens(u);
const cacheMissTokens = readCacheMissTokens(u);
const cacheWriteTokens = readCacheWriteTokens(u);
return {
promptTokens: Number.isFinite(prompt) ? prompt : 0,
completionTokens: Number.isFinite(completion) ? completion : 0,
totalTokens: Number.isFinite(total)
? total
: (Number.isFinite(prompt) ? prompt : 0) +
(Number.isFinite(completion) ? completion : 0),
...(cachedTokens != null ? { cachedTokens } : {}),
...(cacheMissTokens != null ? { cacheMissTokens } : {}),
...(cacheWriteTokens != null ? { cacheWriteTokens } : {}),
};
}
function stringifyToolArguments(value: unknown): string {
if (typeof value === "string" && value.trim()) return value;
if (value && typeof value === "object") return JSON.stringify(value);
return "{}";
}
function toolCallFromRow(row: Record<string, unknown>, index: number): ParsedToolCall | null {
const fn = row.function;
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : null;
const source = nested ?? row;
const name = typeof source.name === "string" ? source.name.trim() : "";
if (!name) return null;
const rawId = typeof row.id === "string" ? row.id.trim() : "";
return {
id: rawId || `call-${index}`,
name,
arguments: stringifyToolArguments(
source.arguments ?? source.args ?? row.arguments ?? row.args ?? row.input,
),
};
}
function embeddedContent(raw: unknown): { text: string | null; calls: ParsedToolCall[] } {
if (typeof raw === "string") return { text: raw.trim() || null, calls: [] };
if (!Array.isArray(raw)) return { text: raw == null ? null : "", calls: [] };
const texts: string[] = [];
const calls: ParsedToolCall[] = [];
raw.forEach((part, index) => {
if (!part || typeof part !== "object") return;
const row = part as Record<string, unknown>;
if (typeof row.text === "string" && row.text.trim()) texts.push(row.text.trim());
const fn = row.functionCall ?? row.function_call;
if (row.type === "tool_use" || (fn && typeof fn === "object")) {
const nested = fn && typeof fn === "object" ? (fn as Record<string, unknown>) : row;
const parsed = toolCallFromRow(
{
id: row.id,
name: nested.name ?? row.name,
arguments: nested.args ?? nested.arguments ?? row.input ?? row.arguments,
},
index,
);
if (parsed) calls.push(parsed);
}
});
return { text: texts.join("\n").trim() || null, calls };
}
/** 从一条 assistant message 取出正文和工具调用。对象参数、缺 id、content 片段里的调用都收。 */
export function extractMessageParts(message: Record<string, unknown> | undefined): {
content: string | null;
reasoning?: string;
toolCalls: ParsedToolCall[];
} {
if (!message) return { content: "", toolCalls: [] };
const embedded = embeddedContent(message.content);
const reasoningRaw =
(typeof message.reasoning_content === "string" &&
message.reasoning_content.trim()) ||
(typeof message.reasoning === "string" && message.reasoning.trim()) ||
(typeof message.thinking === "string" && message.thinking.trim()) ||
"";
const reasoning = reasoningRaw || undefined;
const toolCalls: ParsedToolCall[] = [];
const rawCalls = message.tool_calls;
if (Array.isArray(rawCalls)) {
rawCalls.forEach((call, index) => {
if (!call || typeof call !== "object") return;
const parsed = toolCallFromRow(call as Record<string, unknown>, index);
if (parsed) toolCalls.push(parsed);
});
}
for (const call of embedded.calls) {
if (!toolCalls.some((item) => item.id === call.id && item.name === call.name)) {
toolCalls.push(call);
}
}
const content = embedded.text;
if (toolCalls.length > 0) {
return { content, reasoning: reasoning || undefined, toolCalls };
}
if (content) return { content, reasoning: reasoning || undefined, toolCalls };
if (reasoning) return { content: reasoning, reasoning, toolCalls };
return { content: "", toolCalls };
}
async function llmFetch(
url: string,
apiKey: string,
body: Record<string, unknown>,
options?: CompleteOptions,
): Promise<Response> {
return fetchWithGenerationCompat(async (payload) => {
throwIfAborted(options?.signal);
await ensureNetworkProxy();
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 {
constructor(private readonly config: LlmConfig) {}
async complete(
messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, options),
options,
);
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
const data = (await response.json()) as {
model?: string;
usage?: unknown;
choices?: Array<{ message?: Record<string, unknown> }>;
};
// 空正文不抛错:游玩/Worker 会落「本轮场面未写完」等占位,仍可重 roll
const parts = extractMessageParts(data.choices?.[0]?.message);
return {
content: parts.content ?? "",
reasoning: parts.reasoning,
usage: parseUsage(data.usage),
model: data.model ?? this.config.model,
};
}
async completeStream(
messages: ChatMessage[],
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, { ...options, stream: true }),
options,
);
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
if (!response.body) {
throw new Error("LLM stream response has no body");
}
// 空流不抛错:下游会落占位正文,消息正常渲染以便重 roll
const parts = await consumeOpenAiToolStream(response.body, callbacks);
return {
content: parts.content ?? parts.reasoning ?? "",
reasoning: parts.reasoning || undefined,
usage: parts.usage,
model: parts.model ?? this.config.model,
};
}
async completeWithTools(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
}),
options,
);
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
const data = (await response.json()) as {
model?: string;
usage?: unknown;
choices?: Array<{
message?: Record<string, unknown>;
finish_reason?: string;
}>;
};
const choice = data.choices?.[0];
const parts = extractMessageParts(choice?.message);
const usage = parseUsage(data.usage);
if (!parts.content && parts.toolCalls.length === 0) {
throw new Error(
emptyToolResponseMessage({
stream: false,
finishReason: choice?.finish_reason,
reasoningChars: parts.reasoning?.length ?? 0,
usage,
}),
);
}
return {
content: parts.content,
toolCalls: parts.toolCalls,
reasoning: parts.reasoning,
usage,
model: data.model ?? this.config.model,
};
}
async completeWithToolsStream(
messages: ChatMessage[],
options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
const url = `${this.config.baseUrl.replace(/\/$/, "")}/chat/completions`;
const response = await llmFetch(
url,
this.config.apiKey,
buildRequestBody(this.config, messages, {
...options,
tools: options.tools,
stream: true,
}),
options,
);
if (!response.ok) {
const body = await response.text();
throw new Error(`LLM request failed (${response.status}): ${body}`);
}
if (!response.body) {
throw new Error("LLM stream response has no body");
}
const parts = await consumeOpenAiToolStream(response.body, callbacks);
if (!parts.content && parts.toolCalls.length === 0 && !parts.reasoning) {
throw new Error(
emptyToolResponseMessage({
stream: true,
usage: parts.usage,
}),
);
}
const content =
parts.content ??
(parts.toolCalls.length === 0 ? parts.reasoning || null : null);
return {
content,
toolCalls: parts.toolCalls,
reasoning: parts.reasoning || undefined,
usage: parts.usage,
model: parts.model ?? this.config.model,
};
}
}
export type MockLlmStep =
| string
| {
toolCalls: Array<{ name: string; arguments: Record<string, unknown>; id?: string }>;
content?: string | null;
};
export class MockLlmProvider implements LlmProvider {
private readonly responses: MockLlmStep[];
private index = 0;
constructor(responses: MockLlmStep[]) {
this.responses = responses;
}
private nextStep(): MockLlmStep {
const step = this.responses[this.index] ?? this.responses.at(-1)!;
this.index += 1;
return step;
}
async complete(
_messages: ChatMessage[],
options?: CompleteOptions,
): Promise<CompleteResult> {
throwIfAborted(options?.signal);
const step = this.nextStep();
const response =
typeof step === "string"
? step
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
const approx = Math.max(1, Math.ceil(response.length / 4));
return {
content: response,
usage: {
promptTokens: approx,
completionTokens: approx,
totalTokens: approx * 2,
},
model: "mock",
...(options?.caller ? {} : {}),
};
}
async completeStream(
_messages: ChatMessage[],
options?: CompleteOptions,
callbacks: StreamCallbacks = {},
): Promise<CompleteResult> {
throwIfAborted(options?.signal);
const step = this.nextStep();
const response =
typeof step === "string"
? step
: (step.content ?? JSON.stringify({ action: "ask_user", reason: "mock" }));
const reasoning = "用户需要明确分工 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
throwIfAborted(options?.signal);
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
for (const ch of response) {
throwIfAborted(options?.signal);
callbacks.onContentDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
const approx = Math.max(1, Math.ceil(response.length / 4));
return {
content: response,
reasoning,
usage: {
promptTokens: approx,
completionTokens: approx,
totalTokens: approx * 2,
},
model: "mock",
...(options?.caller ? {} : {}),
};
}
async completeWithTools(
_messages: ChatMessage[],
_options: CompleteWithToolsOptions,
): Promise<CompleteWithToolsResult> {
throwIfAborted(_options?.signal);
const step = this.nextStep();
if (typeof step === "string") {
return {
content: step,
toolCalls: [],
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
model: "mock",
};
}
const toolCalls: ParsedToolCall[] = step.toolCalls.map((tc, i) => ({
id: tc.id ?? `mock_call_${this.index}_${i}`,
name: tc.name,
arguments: JSON.stringify(tc.arguments),
}));
return {
content: step.content ?? null,
toolCalls,
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
model: "mock",
};
}
async completeWithToolsStream(
_messages: ChatMessage[],
_options: CompleteWithToolsOptions,
callbacks: StreamCallbacks,
): Promise<CompleteWithToolsResult> {
throwIfAborted(_options?.signal);
const reasoning =
"用户需要明确 Worker 分工 → 先读取黑板与 worker 列表 → 调用 design-intake 产出 worker 集。";
for (const ch of reasoning) {
throwIfAborted(_options?.signal);
callbacks.onReasoningDelta?.(ch);
await new Promise((r) => setTimeout(r, 0));
}
return this.completeWithTools(_messages, _options);
}
}
export function createMockMainAgentResponse(
overrides: Record<string, unknown> = {},
): string {
return JSON.stringify({
action: "ask_user",
reason: "请告诉我你想创作什么类型的作品、目标篇幅和风格偏好。",
workerId: null,
requiresApproval: false,
...overrides,
});
}
export function createMockToolCall(
name: string,
args: Record<string, unknown>,
id?: string,
): MockLlmStep {
return { toolCalls: [{ name, arguments: args, id }] };
}