/** * OpenAI 兼容 API 格式构建工具 * * 将 MetonaRequest 转换为 OpenAI /chat/completions 兼容的原生请求格式。 * DeepSeek、Agnes AI 和 MiMo 共享此工具,各自 Adapter 只需处理 Provider 特有的差异参数。 * * @see electron/harness/types/metona-request.ts — MetonaRequest 定义 * @see apis/deepseek-api-docs-20260518.html * @see apis/agnes-ai-api-docs-20260625.html * @see apis/mimo-api-docs-20260715.html */ import log from 'electron-log'; import type { MetonaMessage, MetonaRequest, MetonaToolDef } from '../../types'; /** * 构建 OpenAI 兼容的 messages 数组 * * 处理: * - System Prompt 拼接(静态区 + 动态区 + 安全准则) * - 工具调用历史保留(reasoning_content + tool_calls) * - 工具结果注入(tool_call_id + content) * - 孤立 tool 消息过滤(纵深防御,见函数内注释) * - 多模态图片(includeImages=true 时转换为 image_url content parts) * * @param includeImages true 时将消息的 images 转为 OpenAI image_url content parts * (Agnes/MiMo/OpenAI 全系、DeepSeek 仅 vision 模型传 true) */ export function buildOpenAICompatibleMessages( request: MetonaRequest, includeImages = false, ): Array> { const systemContent = [ request.systemPrompt.roleDefinition, request.systemPrompt.outputConstraints, request.systemPrompt.safetyGuidelines, request.systemPrompt.dynamicReminders, ] .filter(Boolean) .join('\n\n'); // 崩溃修复(纵深防御): 过滤孤立 tool 消息 — 其 tool_call_id 不属于任何前置 // assistant(tool_calls) 消息。OpenAI/DeepSeek 协议要求 tool 消息必须紧跟带 // tool_calls 的 assistant,违反直接 400 且不可重试(会话死锁)。正常链路由 // engine 保证配对;此处兜底任何来源的历史污染(旧版本数据/导入/边界场景)。 const nonSystem = request.messages.filter((m) => m.role !== 'system'); const sanitized: MetonaMessage[] = []; /** 已出现且尚未被 tool 结果回应的 tool_call id 集合 */ const pendingToolCallIds = new Set(); let droppedOrphans = 0; for (const m of nonSystem) { if (m.role === 'assistant' && m.toolCalls?.length) { for (const tc of m.toolCalls) pendingToolCallIds.add(tc.id); sanitized.push(m); continue; } if (m.role === 'tool' && m.toolResult) { if (pendingToolCallIds.has(m.toolResult.toolCallId)) { pendingToolCallIds.delete(m.toolResult.toolCallId); sanitized.push(m); } else { droppedOrphans++; } continue; } sanitized.push(m); } if (droppedOrphans > 0) { log.warn( `[OpenAIFormat] Dropped ${droppedOrphans} orphan tool message(s) without matching assistant tool_calls`, ); } const messages = sanitized.map((m) => { // v0.3.0 修复: assistant 消息有 tool_calls 但 content 为空时,content 设为 null // DeepSeek/OpenAI API 要求有 tool_calls 的 assistant 消息 content 必须为 null 而非空字符串 const msg: Record = { role: m.role, content: m.content, }; // === 多模态图片(includeImages=true 时) === // v0.6.2 收敛: 原先 4 家 adapter 各自按 nonSystemMsgs[i-1] 索引对齐处理 images, // 孤立 tool 过滤引入后索引错位。统一收进共享函数,基于 sanitized 原位转换。 // DeepSeek 非 vision 模型传 false(images 静默丢弃是正确行为,见 #27 审查撤销记录)。 if (includeImages && m.images?.length) { const contentParts: Array> = []; if (m.content) contentParts.push({ type: 'text', text: m.content }); for (const img of m.images) { contentParts.push({ type: 'image_url', image_url: { url: img.url } }); } msg.content = contentParts; } // === Assistant 消息 === if (m.role === 'assistant') { // 工具调用历史 if (m.toolCalls?.length) { msg.tool_calls = m.toolCalls.map((tc) => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: JSON.stringify(tc.args) }, })); // 有 tool_calls 时 content 必须为 null(API 规范) if (!m.content) msg.content = null; } // 推理内容(无论是否有工具调用,都保留 reasoning_content) if (m.reasoningContent) { msg.reasoning_content = m.reasoningContent; } } // === 工具执行结果 === if (m.role === 'tool' && m.toolResult) { msg.tool_call_id = m.toolResult.toolCallId; // CE-2 修复: 工具失败时 result 为 null,优先用 error 字段作为 content // 否则 LLM 看到 "null" 不知道失败原因,可能重复调用导致死循环 msg.content = m.toolResult.error ? m.toolResult.error : typeof m.toolResult.result === 'string' ? m.toolResult.result : JSON.stringify(m.toolResult.result); // #26 修复: 确保 tool 消息 content 不为 undefined // JSON.stringify(undefined) 返回 undefined(非字符串),会导致 content 字段在序列化后消失 // OpenAI/DeepSeek/Agnes API 严格要求 tool 消息必须有 content 字段,缺失会返回 400 if (msg.content === undefined) msg.content = ''; } return msg; }); return [{ role: 'system', content: systemContent }, ...messages]; } /** * 构建 OpenAI 兼容的 tools 数组 */ export function buildOpenAICompatibleTools( tools?: MetonaToolDef[], ): Array> | undefined { if (!tools?.length) return undefined; return tools.map((t) => ({ type: 'function', function: { name: t.name, description: t.description, parameters: t.parameters, }, })); }