import type { IncomingMessage, ServerResponse } from "node:http"; import type { DifyPayload } from "../dify/types"; import { HEADER_AUTHORIZATION, HEADER_CONTENT_TYPE } from "../constants"; import { uploadToDify } from "../dify/client"; import { toolCache } from "../tools/cache"; import { executeToolCalls } from "../tools/executor"; import { cacheSummary, popSummary } from "../tools/summary-cache"; import { resolveDifyToolCalls, stringifyToolOutput, resolveToolResultOutput, isToolRole, resolveToolResultPrefix, normalizeToolDefinitions, normalizeToolChoice, extractToolOutput, } from "../tools/utils"; import { setConversationId, getConversationId, pruneConversationMap, deleteConversation, } from "../utils/conversation"; import { DifyLogger } from "../utils/logger"; import { transformEvent } from "./stream"; export async function handleChatCompletionProxyRequest( req: IncomingMessage, res: ServerResponse, params: { apiKey: string; baseUrl: string; appType: "chat" | "agent"; body: unknown; wasBusy?: boolean; }, ) { const requestId = `req-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const logger = new DifyLogger(requestId); logger.log("Incoming Request Body", params.body); if (typeof params.body !== "object" || params.body === null) { res.statusCode = 400; res.end("Invalid JSON"); return; } const chatBody = params.body as { model?: string; user?: string; tool_call_mode?: string; tools?: Array<{ type?: string; function?: { name?: string; description?: string; parameters?: unknown }; }>; tool_choice?: unknown; tool_results?: Array<{ tool_call_id?: string; output?: string; is_error?: boolean }>; messages?: Array<{ role?: string; name?: string; toolName?: string; toolCallId?: string; toolUseId?: string; tool_call_id?: string; content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>; }>; }; const messages = chatBody.messages || []; const toolResults: Array<{ tool_call_id: string; output: string; is_error?: boolean }> = []; if (Array.isArray(chatBody.tool_results)) { for (const result of chatBody.tool_results) { if (!result || typeof result !== "object") { continue; } const toolCallId = typeof result.tool_call_id === "string" ? result.tool_call_id.trim() : ""; const output = stringifyToolOutput(result.output); if (!toolCallId) { continue; } toolResults.push({ tool_call_id: toolCallId, output, is_error: result.is_error }); } } const hasExplicitToolResults = toolResults.length > 0; for (let i = 0; i < messages.length; i++) { const message = messages[i]; if (!message || typeof message !== "object") { continue; } if (!isToolRole(message.role)) { // If we encounter a non-tool message (like 'user' or 'assistant'), // it means any previous tool results in the history belong to a completed turn. // Dify is stateful, so we should not resend old tool results. // We clear the accumulated tool results to ensure we only send the latest pending ones. if (!hasExplicitToolResults && toolResults.length > 0) { toolResults.length = 0; } continue; } let toolCallId = (typeof message.tool_call_id === "string" && message.tool_call_id.trim()) || (typeof message.toolCallId === "string" && message.toolCallId.trim()) || (typeof message.toolUseId === "string" && message.toolUseId.trim()) || ""; // Fix for legacy function messages without tool_call_id if ( !toolCallId && message.role === "function" && typeof message.name === "string" && message.name ) { // Look backwards for a matching tool call for (let j = i - 1; j >= 0; j--) { const prev = messages[j] as any; // Cast to any to access tool_calls if (prev.role === "assistant" && Array.isArray(prev.tool_calls)) { const match = prev.tool_calls.find((tc: any) => tc.function?.name === message.name); if (match && match.id) { toolCallId = match.id; break; } } } } if (!toolCallId) { continue; } const output = resolveToolResultOutput(message.content); toolResults.push({ tool_call_id: toolCallId, output }); } const lastMessageEntry = messages[messages.length - 1]; const lastMessage = lastMessageEntry?.content ?? ""; const toolResultPrefix = toolResults.length > 0 || !lastMessageEntry ? "" : resolveToolResultPrefix(lastMessageEntry); let systemMessage = ""; for (const message of messages) { if ( typeof message === "object" && message !== null && "role" in message && message.role === "system" ) { if (typeof message.content === "string") { systemMessage = message.content; } break; } } const userId = chatBody.user || "openclaw-user"; const sessionKey = `${params.apiKey}:${userId}`; const now = Date.now(); pruneConversationMap(now); let conversationId = getConversationId(sessionKey, now); logger.log("request start", { sessionKey, conversationId: conversationId || "(empty)", hasToolResults: toolResults.length > 0, toolResultCount: toolResults.length, toolResultIds: toolResults.map((r) => r.tool_call_id), messageCount: messages.length, messageRoles: messages.map((m: any) => m?.role), lastMessageRole: lastMessageEntry?.role, }); const isReset = typeof lastMessage === "string" && lastMessage.includes("A new session was started") && toolResults.length === 0; if (isReset) { conversationId = ""; deleteConversation(sessionKey); toolCache.delete(sessionKey); } const difyPayload: DifyPayload = { inputs: {}, query: "", response_mode: "streaming", conversation_id: conversationId, user: userId, files: [], }; // Always set structured mode to enable pause/resume for multi-node ChatFlow workflows. difyPayload.tool_call_mode = "structured"; const normalizedTools = normalizeToolDefinitions(chatBody.tools); if (normalizedTools) { difyPayload.tools = normalizedTools; } const normalizedToolChoice = normalizeToolChoice(chatBody.tool_choice); if (typeof normalizedToolChoice !== "undefined") { difyPayload.tool_choice = normalizedToolChoice; } if (!difyPayload.tools && toolResults.length > 0) { const cached = toolCache.get(sessionKey); if (cached?.tools) { difyPayload.tools = cached.tools; } } if (typeof difyPayload.tool_choice === "undefined" && toolResults.length > 0) { const cached = toolCache.get(sessionKey); if (typeof cached?.tool_choice !== "undefined") { difyPayload.tool_choice = cached.tool_choice; } } // Fix: If we have pending tool results, we should NOT use the last message as the query. // The last message might be the original user query (resend) or just the tool output text. // If we send both tool_results and a user query, Dify might treat it as a new turn and error out (400). // We should let the subsequent logic auto-fill the query with tool outputs if needed. if (toolResults.length === 0) { if (Array.isArray(lastMessage)) { const textPart = lastMessage.find((p) => p.type === "text"); if (textPart?.text) { difyPayload.query = toolResultPrefix ? `${toolResultPrefix}\n${textPart.text}` : textPart.text; } const imageParts = lastMessage.filter((p) => p.type === "image_url"); for (const img of imageParts) { const url = img.image_url?.url; if (!url) { continue; } if (url.startsWith("http")) { difyPayload.files.push({ type: "image", transfer_method: "remote_url", url: url, }); } else { try { const fileId = await uploadToDify(url, params.apiKey, params.baseUrl, logger); difyPayload.files.push({ type: "image", transfer_method: "local_file", upload_file_id: fileId, }); } catch { // Ignore upload failures for now } } } } else { const textValue = String(lastMessage); // Only set query from lastMessage if it's not empty, or if query is currently empty // This prevents overwriting a query that might have been set by other logic (though currently none before this) if (textValue || !difyPayload.query) { difyPayload.query = toolResultPrefix ? `${toolResultPrefix}\n${textValue}` : textValue; } } } // When tool_results are present, Dify's LLM node sets query=None internally, // but the query is still saved to Message.query in conversation history. // Use a meaningful summary so future turns can see what the tools returned. if (toolResults.length > 0) { difyPayload.tool_results = toolResults; difyPayload.query = toolResults.map((r) => `[${r.tool_call_id}] ${r.output}`).join("\n"); } else if (!difyPayload.query) { difyPayload.query = "."; } if (difyPayload.tools || typeof difyPayload.tool_choice !== "undefined") { const cached = toolCache.get(sessionKey); toolCache.set(sessionKey, { tools: difyPayload.tools ?? cached?.tools, tool_choice: typeof difyPayload.tool_choice !== "undefined" ? difyPayload.tool_choice : cached?.tool_choice, }); } if (!conversationId && systemMessage && typeof difyPayload.query === "string") { difyPayload.query = `${systemMessage}\n\n${difyPayload.query}`; } logger.log("Dify Payload", { conversation_id: difyPayload.conversation_id || "(empty)", tool_call_mode: difyPayload.tool_call_mode, tool_results_count: difyPayload.tool_results?.length ?? 0, tools_count: difyPayload.tools?.length ?? 0, query_length: difyPayload.query?.length ?? 0, }); // Intercept openclaw compaction/summary requests — these should never be // forwarded to Dify because they can arrive while the last assistant message // still has pending tool_calls, causing "tool_calls must be followed by tool // messages" errors. Return an empty successful response instead. const isSummaryRequest = typeof difyPayload.query === "string" && (difyPayload.query.includes("") || difyPayload.query.includes("Create a structured context checkpoint summary")); if (isSummaryRequest) { logger.log("Deferred summary/compaction request", { query: difyPayload.query.slice(0, 120) }); // Cache the summary request to be sent after the tool-call loop completes cacheSummary(conversationId, difyPayload.query); res.setHeader(HEADER_CONTENT_TYPE, "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); const responseId = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); res.write( `data: ${JSON.stringify({ id: responseId, object: "chat.completion.chunk", created, model: "dify-app", choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: "stop" }], })}\n\n`, ); res.write("data: [DONE]\n\n"); res.end(); return; } try { const endpoint = "/chat-messages"; const responseId = `chatcmpl-${Date.now()}`; const created = Math.floor(Date.now() / 1000); const autoToolLoop = false; let loopCount = 0; let roleSent = false; let currentPayload = difyPayload; let lastHadToolCalls = false; res.setHeader(HEADER_CONTENT_TYPE, "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); if (params.wasBusy) { res.write( `data: ${JSON.stringify({ id: responseId, object: "chat.completion.chunk", created, model: "dify-app", choices: [ { index: 0, delta: { role: "assistant", content: "⏳ Processing...\n\n" }, finish_reason: null, }, ], })}\n\n`, ); roleSent = true; } while (true) { loopCount += 1; const requestOptions = { method: "POST", headers: { [HEADER_AUTHORIZATION]: `Bearer ${params.apiKey}`, [HEADER_CONTENT_TYPE]: "application/json", }, body: JSON.stringify(currentPayload), }; logger.log(`Dify Request (Loop ${loopCount})`, { url: `${params.baseUrl}${endpoint}`, conversation_id: currentPayload.conversation_id || "(empty)", tool_results_count: currentPayload.tool_results?.length ?? 0, }); const difyRes = await fetch(`${params.baseUrl}${endpoint}`, requestOptions); logger.log(`Dify Response (Loop ${loopCount})`, { status: difyRes.status, }); if (!difyRes.ok) { const errorText = await difyRes.text(); logger.log("Dify Error Response", errorText); res.statusCode = difyRes.status; res.end(errorText); return; } const reader = difyRes.body?.getReader(); const decoder = new TextDecoder(); if (!reader) { // console.error("[dify-auth] No response body reader available"); res.end(); return; } let buffer = ""; const toolCallMap = new Map< string, { callId: string; toolName: string; argsString: string } >(); while (true) { const { done, value } = await reader.read(); if (done) { break; } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.trim() === "") { continue; } // Log SSE event type only (reduce noise) if (line.startsWith("data: ")) { try { const data = JSON.parse(line.slice(6)); const event = typeof data.event === "string" ? data.event : ""; logger.log("SSE", { event, conversation_id: data.conversation_id || null }); if (data.conversation_id) { setConversationId(sessionKey, data.conversation_id, Date.now()); conversationId = data.conversation_id; logger.log("captured conversation_id", { event: data.event, conversation_id: data.conversation_id, }); } // Handle workflow_paused event: extract tool_calls and emit as OpenAI format if (event === "workflow_paused") { const reasons = data.data?.reasons || []; const pausedToolCalls = reasons .filter((r: Record) => r.TYPE === "tool_call_pending") .flatMap( (r: Record) => (r.tool_calls as Array>) || [], ); for (let idx = 0; idx < pausedToolCalls.length; idx++) { const tc = pausedToolCalls[idx]; if (!tc || !tc.id) continue; toolCallMap.set(tc.id, { callId: tc.id, toolName: tc.function?.name || "", argsString: tc.function?.arguments || "{}", }); const chunk = { id: responseId, object: "chat.completion.chunk", created, model: "dify-app", choices: [ { index: 0, delta: { ...(roleSent ? {} : { role: "assistant" }), tool_calls: [ { index: idx, id: tc.id, type: "function", function: { name: tc.function?.name || "", arguments: tc.function?.arguments || "{}", }, }, ], }, finish_reason: null, }, ], }; roleSent = true; res.write(`data: ${JSON.stringify(chunk)}\n\n`); } continue; } if (event === "message" || event === "agent_message") { // Keep track of answer content if needed, but we don't parse text tools anymore // const content = typeof data.answer === "string" ? data.answer : ""; // if (content) { // accumulatedText += content; // } } const resolvedToolCalls = resolveDifyToolCalls( data as Record, params.appType, ); if (resolvedToolCalls.length > 0) { for (const toolCall of resolvedToolCalls) { const existing = toolCallMap.get(toolCall.callId); let shouldEmit = false; let argsToEmit = ""; if (toolCall.isDelta) { // Delta: Append to existing args const currentArgs = existing ? existing.argsString : ""; argsToEmit = toolCall.argsString; toolCallMap.set(toolCall.callId, { ...toolCall, argsString: currentArgs + toolCall.argsString, }); shouldEmit = true; } else { // Full state: Replace existing args // Safety check: Don't overwrite existing valid args with empty/invalid ones const isNewEmpty = toolCall.argsString === "{}" || toolCall.argsString.trim() === ""; const isExistingValid = existing && existing.argsString.length > 2 && existing.argsString !== "{}"; if (isNewEmpty && isExistingValid) { continue; } toolCallMap.set(toolCall.callId, toolCall); // Only emit if we haven't seen this tool call or it has no args yet // This avoids duplicating args if we receive full state after deltas if (!existing || !existing.argsString || existing.argsString === "{}") { shouldEmit = true; argsToEmit = toolCall.argsString; } } if (shouldEmit && !autoToolLoop) { // Calculate index based on all unique calls so far const allCalls = Array.from(toolCallMap.values()); const toolCallIndex = allCalls.findIndex((c) => c.callId === toolCall.callId); const chunk = { id: responseId, object: "chat.completion.chunk", created, model: "dify-app", choices: [ { index: 0, delta: { ...(roleSent ? {} : { role: "assistant" }), tool_calls: [ { index: toolCallIndex, id: toolCall.callId, type: "function", function: { name: toolCall.toolName, arguments: argsToEmit, }, }, ], }, finish_reason: null, }, ], }; roleSent = true; res.write(`data: ${JSON.stringify(chunk)}\n\n`); } } } const openaiChunk = transformEvent(data); if (openaiChunk) { if (!roleSent) { roleSent = true; res.write( `data: ${JSON.stringify({ id: responseId, object: "chat.completion.chunk", created, model: "dify-app", choices: [{ index: 0, delta: { role: "assistant" } }], })}\n\n`, ); } res.write(`data: ${JSON.stringify(openaiChunk)}\n\n`); } } catch (e) { console.warn("[dify-auth] Parse error:", e, "Line:", line); } } } } const pendingToolCalls = Array.from(toolCallMap.values()); lastHadToolCalls = pendingToolCalls.length > 0; if (autoToolLoop && pendingToolCalls.length > 0) { const toolResults = await executeToolCalls({ req, model: chatBody.model, user: userId, calls: pendingToolCalls, }); const cached = toolCache.get(sessionKey); currentPayload = { inputs: {}, query: "", response_mode: "streaming", conversation_id: conversationId, user: userId, files: [], tool_results: toolResults, tools: cached?.tools, tool_choice: cached?.tool_choice, }; continue; } if (!autoToolLoop && pendingToolCalls.length > 0) { const doneChunk = { id: responseId, object: "chat.completion.chunk", created, model: "dify-app", choices: [ { index: 0, delta: {}, finish_reason: "tool_calls", }, ], }; res.write(`data: ${JSON.stringify(doneChunk)}\n\n`); } break; } // After tool-call loop completes (no pending tool_calls), flush any cached // summary/compaction request so it gets stored in Dify conversation history. if (!lastHadToolCalls && conversationId) { const deferredSummary = popSummary(conversationId); if (deferredSummary) { logger.log("Flushing deferred summary", { conversationId, query: deferredSummary.slice(0, 120), }); try { await fetch(`${params.baseUrl}/chat-messages`, { method: "POST", headers: { [HEADER_AUTHORIZATION]: `Bearer ${params.apiKey}`, [HEADER_CONTENT_TYPE]: "application/json", }, body: JSON.stringify({ inputs: {}, query: deferredSummary, response_mode: "blocking", conversation_id: conversationId, user: userId, files: [], }), }); } catch (err) { logger.log("Deferred summary flush error", err); } } } res.write("data: [DONE]\n\n"); res.end(); } catch (err) { logger.log("Proxy Error", err); // console.error("[dify-auth] Proxy error:", err); res.statusCode = 500; res.end(String(err)); } }