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; /** 部分供应商支持的严格参数约束 */ 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; }; 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; completeStream?( messages: ChatMessage[], options?: CompleteOptions, callbacks?: StreamCallbacks, ): Promise; completeWithTools( messages: ChatMessage[], options: CompleteWithToolsOptions, ): Promise; completeWithToolsStream?( messages: ChatMessage[], options: CompleteWithToolsOptions, callbacks: StreamCallbacks, ): Promise; }; 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 { const requestMessages = options?.responseFormat === "json_object" ? ensureJsonObjectPrompt(messages) : messages; const body: Record = { 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): number | undefined { const details = u.prompt_tokens_details ?? u.promptTokensDetails; if (details && typeof details === "object") { const d = details as Record; 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): number | undefined { return readFiniteNumber(u.prompt_cache_miss_tokens ?? u.promptCacheMissTokens); } function readCacheWriteTokens(u: Record): number | undefined { const details = u.prompt_tokens_details ?? u.promptTokensDetails; if (details && typeof details === "object") { const row = details as Record; 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; 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, index: number): ParsedToolCall | null { const fn = row.function; const nested = fn && typeof fn === "object" ? (fn as Record) : 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; 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) : 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 | 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, 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, options?: CompleteOptions, ): Promise { 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 { 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 }>; }; // 空正文不抛错:游玩/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 { 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 { 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; 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 { 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; 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 { 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 { 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 { 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 { 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 { return JSON.stringify({ action: "ask_user", reason: "请告诉我你想创作什么类型的作品、目标篇幅和风格偏好。", workerId: null, requiresApproval: false, ...overrides, }); } export function createMockToolCall( name: string, args: Record, id?: string, ): MockLlmStep { return { toolCalls: [{ name, arguments: args, id }] }; }