UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

960 lines (959 loc) 36 kB
import { d as asPositiveSafeInteger } from "./number-coercion-CLj0HTDM.js"; import { l as normalizeOptionalString, o as normalizeLowercaseStringOrEmpty } from "./string-coerce-CIXf7egm.js"; import { t as avoidTrailingHighSurrogateBreak } from "./utf16-slice-D_ngcYKd.js"; import { r as defaultRuntime } from "./runtime-CF2WjnNZ.js"; import { At as boolean, Et as array, Rn as string, Tn as object, Xn as union, Zn as unknown, wn as number } from "./schemas-zxit8y5H.js"; import { n as getRuntimeConfig } from "./io.runtime-B9iJRs3w.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import "./io-bdCzpGWJ.js"; import { i as logWarn } from "./logger-DwECwNVZ.js"; import { f as onAgentEventForRun, i as emitAgentEvent } from "./agent-events-CoxiItUi.js"; import { t as bindGatewayContextResolver } from "./gateway-request-scope-BCMYlsDI.js"; import { g as retainGatewayRootWorkAdmissionContinuation } from "./gateway-work-admission-R1IpuDim.js"; import { l as toOpenAiChatCompletionsUsage } from "./usage-CtmV8Xxq.js"; import { n as estimateBase64DecodedBytes } from "./base64-Vw7DZYSc.js"; import "./stream-message-shared-Cyrn1UHN.js"; import { n as readAgentRunTerminalOutcome } from "./agent-run-terminal-outcome-BwslKylZ.js"; import { t as authorizeGatewaySessionCreation } from "./operator-role-policy-wsr1DeJv.js"; import { r as isClientToolNameConflictError } from "./agent-tool-definition-adapter-_QXAVYIM.js"; import { c as normalizeMimeList, n as DEFAULT_INPUT_IMAGE_MIMES, o as extractImageContentFromSource, r as DEFAULT_INPUT_TIMEOUT_MS, t as DEFAULT_INPUT_IMAGE_MAX_BYTES } from "./input-files-CWWFaJLH.js"; import { t as createDefaultDeps } from "./deps-CSNtqiEp.js"; import { n as agentCommandFromGatewayIngress } from "./agent-command-DK8Hh-Mq.js"; import "./agent-W7bgjwyp.js"; import { g as resolveOpenAiCompatibleHttpSenderIsOwner, h as resolveOpenAiCompatibleHttpOperatorScopes, o as authorizeOpenAiCompatibleHttpModelOverride } from "./http-auth-utils-C3lb4QXY.js"; import { h as writeDone, l as sendMissingScopeForbidden, m as watchClientDisconnect, o as sendInvalidRequest, p as setSseHeaders, r as parseGatewayJsonRequest, s as sendJson } from "./http-common-BaZaosnr.js"; import { a as isGatewaySessionKeyOverrideError, c as isUnknownGatewayAgentError, d as resolveGatewayRequestContext, f as resolveOpenAiCompatModelOverride, i as isAgentSelectionRequiredError, o as isInvalidGatewayModelError, r as authorizeOpenAiCompatibleHttpSession } from "./http-utils-BHgXp7Zb.js"; import { t as handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers-B6JLQadu.js"; import { a as validateOpenAiSamplingParams, c as IMAGE_ONLY_USER_MESSAGE, d as isReplaceableAssistantStreamEvent, f as resolveAssistantStreamDeltaText, i as resolveOpenAiCompatError, l as buildAgentMessageFromConversationEntries, n as resolveUnsatisfiedToolChoiceMessage, o as resolveAgentRunUsage, p as resolveAssistantStreamSnapshotText, r as toolChoiceConstraintPrompt, s as normalizeInputHostnameAllowlist, t as isToolChoiceConstraintSatisfied, u as renderConversationToolCall } from "./openai-tool-choice-Cml008Vd.js"; import { randomUUID } from "node:crypto"; //#region src/gateway/openai-http.ts const OpenAiChatCompletionRequestSchema = object({ model: string().optional(), stream: boolean().nullish(), stream_options: object({ include_usage: boolean().optional() }).passthrough().nullish(), tools: array(unknown()).optional(), tool_choice: unknown().optional(), messages: array(unknown()).optional(), user: string().optional(), max_tokens: number().int().positive().nullish(), max_completion_tokens: number().int().positive().nullish(), temperature: number().nullish(), top_p: number().nullish(), response_format: unknown().optional(), frequency_penalty: number().nullish(), presence_penalty: number().nullish(), seed: number().nullish(), stop: union([string(), array(string())]).nullish() }); const DEFAULT_OPENAI_CHAT_COMPLETIONS_BODY_BYTES = 20971520; const DEFAULT_OPENAI_MAX_IMAGE_PARTS = 8; const DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES = 20971520; const DEFAULT_OPENAI_IMAGE_LIMITS = { allowUrl: false, allowedMimes: new Set(DEFAULT_INPUT_IMAGE_MIMES), maxBytes: DEFAULT_INPUT_IMAGE_MAX_BYTES, maxRedirects: 3, timeoutMs: DEFAULT_INPUT_TIMEOUT_MS }; function resolveOpenAiChatCompletionsLimits(config) { const imageConfig = config?.images; return { maxBodyBytes: DEFAULT_OPENAI_CHAT_COMPLETIONS_BODY_BYTES, maxImageParts: DEFAULT_OPENAI_MAX_IMAGE_PARTS, maxTotalImageBytes: DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES, images: { allowUrl: imageConfig?.allowUrl ?? DEFAULT_OPENAI_IMAGE_LIMITS.allowUrl, urlAllowlist: normalizeInputHostnameAllowlist(imageConfig?.urlAllowlist), allowedMimes: normalizeMimeList(imageConfig?.allowedMimes, DEFAULT_INPUT_IMAGE_MIMES), maxBytes: imageConfig?.maxBytes ?? 10485760, maxRedirects: imageConfig?.maxRedirects ?? 3, timeoutMs: imageConfig?.timeoutMs ?? 1e4 } }; } function writeSse(res, data) { res.write(`data: ${JSON.stringify(data)}\n\n`); } function buildAgentCommandInput(params) { return { message: params.prompt.message, extraSystemPrompt: params.prompt.extraSystemPrompt, images: params.prompt.images, clientTools: params.clientTools, model: params.modelOverride, sessionKey: params.sessionKey, runId: params.runId, deliver: false, messageChannel: params.messageChannel, senderIsOwner: params.senderIsOwner, bestEffortDeliver: false, allowModelOverride: params.modelOverride !== void 0, abortSignal: params.abortSignal, streamParams: params.streamParams }; } function extractClientToolsFromChatRequest(tools) { if (tools == null) return []; if (!Array.isArray(tools)) throw new Error("tools must be an array"); const clientTools = []; for (const tool of tools) { if (!tool || typeof tool !== "object" || Array.isArray(tool)) throw new Error("each tool must be an object"); if (tool.type !== "function") throw new Error("only function tools are supported"); const functionValue = tool.function; if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) throw new Error("tool.function is required"); const rawName = functionValue.name; const name = typeof rawName === "string" ? rawName.trim() : ""; if (!name) throw new Error("tool.function.name is required"); const description = functionValue.description; const parameters = functionValue.parameters; const strict = functionValue.strict; clientTools.push({ type: "function", function: { name, ...typeof description === "string" ? { description } : {}, ...parameters && typeof parameters === "object" && !Array.isArray(parameters) ? { parameters } : {}, ...typeof strict === "boolean" ? { strict } : {} } }); } return clientTools; } function applyChatToolChoice(params) { const { tools, toolChoice } = params; if (toolChoice == null || toolChoice === "auto") return { tools }; if (toolChoice === "none") return { tools: [] }; if (toolChoice === "required") { if (tools.length === 0) throw new Error("tool_choice=required but no tools were provided"); const constraint = { type: "required" }; return { tools, extraSystemPrompt: toolChoiceConstraintPrompt(constraint), constraint }; } if (typeof toolChoice !== "object" || Array.isArray(toolChoice)) throw new Error("tool_choice must be a string or object"); const choiceType = toolChoice.type; if (choiceType === "function") { const targetName = normalizeOptionalString(toolChoice.function?.name); if (!targetName) throw new Error("tool_choice.function.name is required"); const matched = tools.filter((tool) => tool.function?.name === targetName); if (matched.length === 0) throw new Error(`tool_choice requested unknown tool: ${targetName}`); const constraint = { type: "function", name: targetName }; return { tools: matched, extraSystemPrompt: toolChoiceConstraintPrompt(constraint), constraint }; } if (typeof choiceType !== "string") throw new Error("unsupported tool_choice type"); throw new Error(`tool_choice ${choiceType} is not supported`); } function writeChatCompletionChunk(res, identity, chunk) { writeSse(res, { id: identity.runId, object: "chat.completion.chunk", created: identity.created, model: identity.model, ...chunk }); } function writeAssistantRoleChunk(res, params) { writeChatCompletionChunk(res, params, { choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }); } function writeAssistantContentChunk(res, params) { writeChatCompletionChunk(res, params, { choices: [{ index: 0, delta: { content: params.content }, finish_reason: null }] }); } function writeAssistantFinishChunk(res, params) { writeChatCompletionChunk(res, params, { choices: [{ index: 0, delta: {}, finish_reason: params.finishReason }] }); } function splitArgumentsForStreaming(argumentsValue) { if (!argumentsValue) return [""]; const chunkSize = 256; const chunks = []; for (let start = 0; start < argumentsValue.length;) { const end = avoidTrailingHighSurrogateBreak(argumentsValue, start, Math.min(start + chunkSize, argumentsValue.length)); chunks.push(argumentsValue.slice(start, end)); start = end; } return chunks.length > 0 ? chunks : [""]; } function writeAssistantToolCallsIncrementalChunks(res, params) { for (const [index, call] of params.toolCalls.entries()) { writeChatCompletionChunk(res, params, { choices: [{ index: 0, delta: { tool_calls: [{ index, id: call.id, type: "function", function: { name: call.name, arguments: "" } }] }, finish_reason: null }] }); for (const argsDelta of splitArgumentsForStreaming(call.arguments)) writeChatCompletionChunk(res, params, { choices: [{ index: 0, delta: { tool_calls: [{ index, function: { arguments: argsDelta } }] }, finish_reason: null }] }); } } function writeUsageChunk(res, params) { writeChatCompletionChunk(res, params, { choices: [], usage: params.usage }); } function asMessages(val) { return Array.isArray(val) ? val : []; } function extractTextContent(content) { if (typeof content === "string") return content; if (Array.isArray(content)) return content.map((part) => { if (!part || typeof part !== "object") return ""; const type = part.type; const text = part.text; const inputText = part.input_text; if (type === "text" && typeof text === "string") return text; if (type === "input_text" && typeof text === "string") return text; if (typeof inputText === "string") return inputText; return ""; }).filter(Boolean).join("\n"); return ""; } function stringifyToolCallArguments(value) { if (typeof value === "string") return value; if (value == null) return ""; try { const serialized = JSON.stringify(value); return typeof serialized === "string" ? serialized : ""; } catch { return ""; } } function extractAssistantToolCalls(value) { if (!Array.isArray(value)) return []; const calls = []; for (const rawCall of value) { if (!rawCall || typeof rawCall !== "object" || Array.isArray(rawCall)) continue; const id = normalizeOptionalString(rawCall.id) ?? ""; const functionValue = rawCall.function; if (!functionValue || typeof functionValue !== "object" || Array.isArray(functionValue)) continue; const name = normalizeOptionalString(functionValue.name) ?? ""; if (!id || !name) continue; const argumentsValue = stringifyToolCallArguments(functionValue.arguments); calls.push({ id, name, arguments: argumentsValue }); } return calls; } function resolveImageUrlPart(part) { if (!part || typeof part !== "object") return; const imageUrl = part.image_url; if (typeof imageUrl === "string") { const trimmed = imageUrl.trim(); return trimmed.length > 0 ? trimmed : void 0; } if (!imageUrl || typeof imageUrl !== "object") return; const rawUrl = imageUrl.url; if (typeof rawUrl !== "string") return; const trimmed = rawUrl.trim(); return trimmed.length > 0 ? trimmed : void 0; } function extractImageUrls(content) { const urls = []; if (!Array.isArray(content)) return { kind: "valid", urls }; for (const part of content) { if (!part || typeof part !== "object") continue; if (part.type !== "image_url") continue; const url = resolveImageUrlPart(part); if (!url) return { kind: "invalid" }; urls.push(url); } return { kind: "valid", urls }; } function parseImageUrlToSource(url) { const dataUriMatch = /^data:([^,]*?),(.*)$/is.exec(url); if (dataUriMatch) { const metadata = normalizeOptionalString(dataUriMatch[1]) ?? ""; const data = dataUriMatch[2] ?? ""; const metadataParts = metadata.split(";").map((part) => normalizeOptionalString(part) ?? "").filter(Boolean); if (!metadataParts.some((part) => normalizeLowercaseStringOrEmpty(part) === "base64")) throw new Error("image_url data URI must be base64 encoded"); if (!(normalizeOptionalString(data) ?? "")) throw new Error("image_url data URI is missing payload data"); return { type: "base64", mediaType: metadataParts.find((part) => part.includes("/")), data }; } return { type: "url", url }; } function resolveActiveTurnContext(messagesUnknown) { const messages = asMessages(messagesUnknown); for (let i = messages.length - 1; i >= 0; i -= 1) { const msg = messages[i]; if (!msg || typeof msg !== "object") continue; const role = normalizeOptionalString(msg.role) ?? ""; const normalizedRole = role === "function" ? "tool" : role; if (normalizedRole !== "user" && normalizedRole !== "tool") continue; const imageUrls = normalizedRole === "user" ? extractImageUrls(msg.content) : { kind: "valid", urls: [] }; return { activeTurnIndex: i, activeUserMessageIndex: normalizedRole === "user" ? i : -1, imageUrls }; } return { activeTurnIndex: -1, activeUserMessageIndex: -1, imageUrls: { kind: "valid", urls: [] } }; } async function resolveImagesForRequest(activeTurnContext, limits) { if (activeTurnContext.imageUrls.kind === "invalid") throw new Error("image_url part is missing a valid URL"); const urls = activeTurnContext.imageUrls.urls; if (urls.length === 0) return []; if (urls.length > limits.maxImageParts) throw new Error(`Too many image_url parts (${urls.length}; limit ${limits.maxImageParts})`); const images = []; let totalBytes = 0; for (const url of urls) { const source = parseImageUrlToSource(url); if (source.type === "base64") { const sourceBytes = estimateBase64DecodedBytes(source.data); if (totalBytes + sourceBytes > limits.maxTotalImageBytes) throw new Error(`Total image payload too large (${totalBytes + sourceBytes}; limit ${limits.maxTotalImageBytes})`); } const image = await extractImageContentFromSource(source, limits.images); totalBytes += estimateBase64DecodedBytes(image.data); if (totalBytes > limits.maxTotalImageBytes) throw new Error(`Total image payload too large (${totalBytes}; limit ${limits.maxTotalImageBytes})`); images.push(image); } return images; } function buildAgentPrompt(messagesUnknown, activeTurnContext) { const messages = asMessages(messagesUnknown); const hasActiveTurnImage = activeTurnContext.imageUrls.kind === "valid" && activeTurnContext.imageUrls.urls.length > 0; const systemParts = []; const conversationEntries = []; for (const [i, msg] of messages.entries()) { if (!msg || typeof msg !== "object") continue; const role = normalizeOptionalString(msg.role) ?? ""; const content = extractTextContent(msg.content).trim(); if (!role) continue; if (role === "system" || role === "developer") { if (content) systemParts.push(content); continue; } const normalizedRole = role === "function" ? "tool" : role; if (normalizedRole !== "user" && normalizedRole !== "assistant" && normalizedRole !== "tool") continue; const assistantToolCallsSummary = (normalizedRole === "assistant" ? extractAssistantToolCalls(msg.tool_calls) : []).map(renderConversationToolCall).join("\n"); const messageContent = [normalizedRole === "user" && !content && hasActiveTurnImage && i === activeTurnContext.activeUserMessageIndex ? IMAGE_ONLY_USER_MESSAGE : content, assistantToolCallsSummary].filter((part) => Boolean(part)).join("\n"); if (!messageContent) continue; const name = normalizeOptionalString(msg.name) ?? ""; const toolCallId = normalizeOptionalString(msg.tool_call_id) ?? ""; const sender = normalizedRole === "assistant" ? "Assistant" : normalizedRole === "user" ? "User" : toolCallId ? `Tool:${toolCallId}` : name ? `Tool:${name}` : "Tool"; conversationEntries.push({ role: normalizedRole, entry: { sender, body: messageContent }, internalStreamError: normalizedRole === "assistant" && normalizeOptionalString(msg.stopReason) === "error" && messageContent.trim() === "[assistant turn failed before producing content]" }); } return { message: buildAgentMessageFromConversationEntries(conversationEntries), extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : void 0 }; } function resolveAgentResponseText(result) { const payloads = result?.payloads; if (!Array.isArray(payloads) || payloads.length === 0) return "No response from OpenClaw."; return payloads.map((p) => typeof p.text === "string" ? p.text : "").filter(Boolean).join("\n\n") || "No response from OpenClaw."; } function resolveAgentResponseCommentary(result) { const payloads = result?.payloads; if (!Array.isArray(payloads) || payloads.length === 0) return ""; return payloads.map((p) => typeof p.text === "string" ? p.text : "").filter(Boolean).join("\n\n"); } function resolveStopReasonAndPendingToolCalls(meta) { if (!meta || typeof meta !== "object" || Array.isArray(meta)) return { stopReason: void 0, pendingToolCalls: void 0 }; const stopReasonRaw = meta.stopReason; const stopReason = typeof stopReasonRaw === "string" ? stopReasonRaw : void 0; const pendingRaw = meta.pendingToolCalls; if (!Array.isArray(pendingRaw)) return { stopReason, pendingToolCalls: void 0 }; const pendingToolCalls = []; for (const call of pendingRaw) { const id = typeof call?.id === "string" ? call.id.trim() : ""; const name = typeof call?.name === "string" ? call.name.trim() : ""; const argsValue = call?.arguments; const argumentsValue = typeof argsValue === "string" ? argsValue : argsValue == null ? "" : JSON.stringify(argsValue); if (!id || !name) continue; pendingToolCalls.push({ id, name, arguments: argumentsValue }); } return { stopReason, pendingToolCalls }; } function resolveChatCompletionUsage(result) { return toOpenAiChatCompletionsUsage(resolveAgentRunUsage(result)); } function resolveIncludeUsageForStreaming(payload) { const streamOptions = payload.stream_options; if (!streamOptions || typeof streamOptions !== "object" || Array.isArray(streamOptions)) return false; return streamOptions.include_usage === true; } function resolveResponseFormat(value) { if (value == null) return; if (typeof value !== "object" || Array.isArray(value)) throw new Error("response_format must be an object"); const obj = value; const type = obj.type; if (type !== "text" && type !== "json_object" && type !== "json_schema") throw new Error("response_format.type must be text, json_object, or json_schema"); return obj; } function resolveStopSequences(value) { if (value == null) return; const list = typeof value === "string" ? [value] : value; if (!Array.isArray(list)) throw new Error("stop must be a string or array of strings"); if (list.length > 4) throw new Error("stop supports at most 4 sequences"); const sequences = []; for (const item of list) { if (typeof item !== "string" || item.length === 0) throw new Error("stop entries must be non-empty strings"); sequences.push(item); } return sequences.length > 0 ? sequences : void 0; } function resolveChatCompletionTokenCap(value, field) { if (value == null) return; const maxTokens = asPositiveSafeInteger(value); if (maxTokens === void 0) throw new Error(`${field} must be a positive safe integer`); return maxTokens; } async function handleOpenAiHttpRequest(req, res, opts) { const limits = resolveOpenAiChatCompletionsLimits(opts.config); const handled = await handleGatewayPostJsonEndpoint(req, res, { pathname: "/v1/chat/completions", requiredOperatorMethod: "chat.send", resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes, auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter, maxBodyBytes: opts.maxBodyBytes ?? limits.maxBodyBytes }); if (handled === false) return false; if (!handled) return true; const modelOverrideAuth = authorizeOpenAiCompatibleHttpModelOverride(req, handled.requestAuth); if (!modelOverrideAuth.allowed) { sendMissingScopeForbidden(res, modelOverrideAuth.missingScope); return true; } const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, handled.requestAuth); const payload = parseGatewayJsonRequest(res, handled.body, OpenAiChatCompletionRequestSchema); if (!payload) return true; const stream = payload.stream === true; const streamIncludeUsage = stream && resolveIncludeUsageForStreaming(payload); const model = typeof payload.model === "string" ? payload.model : "openclaw"; const user = typeof payload.user === "string" ? payload.user : void 0; let maxTokens; try { const maxCompletionTokens = resolveChatCompletionTokenCap(payload.max_completion_tokens, "max_completion_tokens"); const legacyMaxTokens = resolveChatCompletionTokenCap(payload.max_tokens, "max_tokens"); maxTokens = maxCompletionTokens ?? legacyMaxTokens; } catch (err) { sendInvalidRequest(res, formatErrorMessage(err).trim()); return true; } const temperature = typeof payload.temperature === "number" ? payload.temperature : void 0; const topP = typeof payload.top_p === "number" ? payload.top_p : void 0; const frequencyPenalty = typeof payload.frequency_penalty === "number" ? payload.frequency_penalty : void 0; const presencePenalty = typeof payload.presence_penalty === "number" ? payload.presence_penalty : void 0; const seed = typeof payload.seed === "number" ? payload.seed : void 0; let responseFormat; try { responseFormat = resolveResponseFormat(payload.response_format); } catch (err) { sendInvalidRequest(res, `Invalid response_format: ${formatErrorMessage(err).trim()}`); return true; } let stop; try { stop = resolveStopSequences(payload.stop); } catch (err) { sendInvalidRequest(res, `Invalid stop: ${formatErrorMessage(err).trim()}`); return true; } const samplingError = validateOpenAiSamplingParams({ temperature: payload.temperature, topP: payload.top_p, frequencyPenalty: payload.frequency_penalty, presencePenalty: payload.presence_penalty, seed: payload.seed }); if (samplingError) { sendInvalidRequest(res, samplingError); return true; } const streamParams = maxTokens !== void 0 || temperature !== void 0 || topP !== void 0 || responseFormat !== void 0 || frequencyPenalty !== void 0 || presencePenalty !== void 0 || seed !== void 0 || stop !== void 0 ? { ...maxTokens !== void 0 ? { maxTokens } : {}, ...temperature !== void 0 ? { temperature } : {}, ...topP !== void 0 ? { topP } : {}, ...responseFormat !== void 0 ? { responseFormat } : {}, ...frequencyPenalty !== void 0 ? { frequencyPenalty } : {}, ...presencePenalty !== void 0 ? { presencePenalty } : {}, ...seed !== void 0 ? { seed } : {}, ...stop !== void 0 ? { stop } : {} } : void 0; let agentId; let sessionKey; let messageChannel; try { ({agentId, sessionKey, messageChannel} = resolveGatewayRequestContext({ req, model, user, sessionPrefix: "openai", defaultMessageChannel: "webchat", useMessageChannelHeader: true })); } catch (err) { if (isAgentSelectionRequiredError(err) || isUnknownGatewayAgentError(err) || isInvalidGatewayModelError(err) || isGatewaySessionKeyOverrideError(err)) { sendInvalidRequest(res, err.message); return true; } throw err; } const creationAuth = authorizeGatewaySessionCreation({ cfg: getRuntimeConfig(), ...handled.requestAuth.operatorRoleActor ? { actor: handled.requestAuth.operatorRoleActor } : { profileId: handled.requestAuth.authenticatedUserProfile?.profileId }, agentId }); if (creationAuth) { sendJson(res, 403, { error: { message: creationAuth.message, type: "forbidden" } }); return true; } const sessionAuth = authorizeOpenAiCompatibleHttpSession({ agentId, sessionKey, requestAuth: handled.requestAuth, senderIsOwner }); if (!sessionAuth.allowed) { sendJson(res, 403, { error: { message: sessionAuth.message, type: "forbidden" } }); return true; } const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({ req, agentId, model }); if (modelError) { sendInvalidRequest(res, modelError); return true; } const activeTurnContext = resolveActiveTurnContext(payload.messages); const prompt = buildAgentPrompt(payload.messages, activeTurnContext); let resolvedClientTools; let toolChoicePrompt; let toolChoiceConstraint; try { const toolChoiceResult = applyChatToolChoice({ tools: extractClientToolsFromChatRequest(payload.tools), toolChoice: payload.tool_choice }); resolvedClientTools = toolChoiceResult.tools; toolChoicePrompt = toolChoiceResult.extraSystemPrompt; toolChoiceConstraint = toolChoiceResult.constraint; } catch (err) { sendInvalidRequest(res, `Invalid tools/tool_choice: ${formatErrorMessage(err).trim()}`); return true; } let images; try { images = await resolveImagesForRequest(activeTurnContext, limits); } catch (err) { logWarn(`openai-compat: invalid image_url content: ${String(err)}`); sendInvalidRequest(res, "Invalid image_url content in `messages`."); return true; } if (!prompt.message && images.length === 0) { sendInvalidRequest(res, "Missing user message in `messages`."); return true; } const runId = `chatcmpl_${randomUUID()}`; const created = Math.floor(Date.now() / 1e3); const streamIdentity = { runId, model, created }; const deps = createDefaultDeps(); const abortController = new AbortController(); const mergedExtraSystemPrompt = [prompt.extraSystemPrompt, toolChoicePrompt].filter((part) => Boolean(part)).join("\n\n"); const commandInput = buildAgentCommandInput({ prompt: { message: prompt.message, extraSystemPrompt: mergedExtraSystemPrompt || void 0, images: images.length > 0 ? images : void 0 }, clientTools: resolvedClientTools.length > 0 ? resolvedClientTools : void 0, modelOverride, sessionKey, runId, messageChannel, senderIsOwner, abortSignal: abortController.signal, streamParams }); const gatewayCommandInput = opts.resolveGatewayContext ? { ...commandInput, onAdmittedRunContext: (context) => bindGatewayContextResolver(context, opts.resolveGatewayContext) } : commandInput; if (!stream) { const stopWatchingDisconnect = watchClientDisconnect(req, res, abortController); try { const result = await agentCommandFromGatewayIngress(gatewayCommandInput, defaultRuntime, deps, {}); if (abortController.signal.aborted) return true; const meta = result?.meta; if (readAgentRunTerminalOutcome(result) === "failed") throw new Error("agent run failed"); const usage = resolveChatCompletionUsage(result); const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta); if (toolChoiceConstraint && !isToolChoiceConstraintSatisfied({ constraint: toolChoiceConstraint, pendingToolCalls })) { sendJson(res, 502, { error: { message: resolveUnsatisfiedToolChoiceMessage(toolChoiceConstraint), type: "api_error" } }); return true; } if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { const commentary = resolveAgentResponseCommentary(result); sendJson(res, 200, { id: runId, object: "chat.completion", created, model, choices: [{ index: 0, message: { role: "assistant", content: commentary, tool_calls: pendingToolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: call.arguments } })) }, finish_reason: "tool_calls" }], usage }); return true; } const content = resolveAgentResponseText(result); sendJson(res, 200, { id: runId, object: "chat.completion", created, model, choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], usage }); } catch (err) { if (abortController.signal.aborted) return true; logWarn(`openai-compat: chat completion failed: ${String(err)}`); if (isClientToolNameConflictError(err)) { sendInvalidRequest(res, "invalid tool configuration"); return true; } const mapped = resolveOpenAiCompatError(err); if (mapped) { sendJson(res, mapped.status, { error: mapped.error }); return true; } sendJson(res, 500, { error: { message: "internal error", type: "api_error" } }); } finally { stopWatchingDisconnect(); } return true; } setSseHeaders(res); let wroteStopChunk = false; let sawAssistantDelta = false; let streamedAssistantText = ""; let bufferedReplaceableAssistantContent = ""; let finalUsage; let finalizeRequested = false; let finalizeScheduled = false; let finalizeFinishReason = "stop"; let resultResolved = false; let closed = false; let observedTerminalLifecycle = false; let terminalStreamError; let terminalLifecyclePhase = "end"; let stopWatchingDisconnect = () => {}; const maybeFinalize = () => { if (closed || finalizeScheduled || !finalizeRequested) return; if (!resultResolved) return; if (streamIncludeUsage && !finalUsage) return; finalizeScheduled = true; queueMicrotask(() => { if (closed) return; if (terminalStreamError) { finishStreamWithError(terminalStreamError); return; } closed = true; stopWatchingDisconnect(); unsubscribe(); if (!wroteStopChunk) { writeAssistantFinishChunk(res, { ...streamIdentity, finishReason: finalizeFinishReason }); wroteStopChunk = true; } if (streamIncludeUsage && finalUsage) writeUsageChunk(res, { ...streamIdentity, usage: finalUsage }); writeDone(res); res.end(); }); }; const requestFinalize = (finishReason = "stop") => { if (!finalizeRequested || finishReason === "tool_calls") finalizeFinishReason = finishReason; finalizeRequested = true; maybeFinalize(); }; const unsubscribe = onAgentEventForRun(runId, (evt) => { if (evt.runId !== runId) return; if (closed) return; if (evt.stream === "assistant") { const text = evt.data?.text; const replace = evt.data?.replace === true; if (replace && typeof text === "string") bufferedReplaceableAssistantContent = text; if (isReplaceableAssistantStreamEvent(evt)) { const snapshot = resolveAssistantStreamSnapshotText(evt); if (snapshot) bufferedReplaceableAssistantContent = snapshot; return; } if (replace && typeof text === "string" && !toolChoiceConstraint && !text.startsWith(streamedAssistantText)) { terminalStreamError ??= { message: "Assistant output cannot be represented as an append-only response stream.", type: "api_error" }; return; } const content = typeof text === "string" && text.startsWith(streamedAssistantText) ? text.slice(streamedAssistantText.length) : resolveAssistantStreamDeltaText(evt); if (!content) return; streamedAssistantText += content; if (toolChoiceConstraint) return; sawAssistantDelta = true; writeAssistantContentChunk(res, { ...streamIdentity, content }); return; } if (evt.stream === "lifecycle") { const phase = evt.data?.phase; if (phase === "start") observedTerminalLifecycle = false; if (phase === "end" || phase === "error") { observedTerminalLifecycle = true; if (phase === "error" && terminalLifecyclePhase !== "error") terminalStreamError ??= { message: normalizeOptionalString(evt.data?.error) ?? "Agent run failed", type: "api_error" }; requestFinalize(); } } }); const finishStreamWithError = (error) => { if (closed) return; closed = true; stopWatchingDisconnect(); unsubscribe(); writeSse(res, { error }); writeDone(res); res.end(); }; const releaseAgentRootWork = retainGatewayRootWorkAdmissionContinuation(); const releaseResponseRootWork = retainGatewayRootWorkAdmissionContinuation(); const releaseStreamRootWork = () => { res.off("finish", releaseStreamRootWork); res.off("close", releaseStreamRootWork); releaseResponseRootWork?.(); }; res.once("finish", releaseStreamRootWork); res.once("close", releaseStreamRootWork); stopWatchingDisconnect = watchClientDisconnect(req, res, abortController, () => { closed = true; unsubscribe(); releaseStreamRootWork(); }); writeAssistantRoleChunk(res, streamIdentity); (async () => { try { const result = await agentCommandFromGatewayIngress(gatewayCommandInput, defaultRuntime, deps, {}); resultResolved = true; if (closed) return; if (readAgentRunTerminalOutcome(result) === "failed") { terminalLifecyclePhase = "error"; finishStreamWithError({ message: "internal error", type: "api_error" }); return; } if (terminalStreamError) { finishStreamWithError(terminalStreamError); return; } finalUsage = resolveChatCompletionUsage(result); const meta = result?.meta; const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta); if (toolChoiceConstraint && !isToolChoiceConstraintSatisfied({ constraint: toolChoiceConstraint, pendingToolCalls })) { finishStreamWithError({ message: resolveUnsatisfiedToolChoiceMessage(toolChoiceConstraint), type: "api_error" }); return; } if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { if (!sawAssistantDelta) { const commentary = resolveAgentResponseCommentary(result) || streamedAssistantText || bufferedReplaceableAssistantContent; if (commentary) { sawAssistantDelta = true; writeAssistantContentChunk(res, { ...streamIdentity, content: commentary }); } } writeAssistantToolCallsIncrementalChunks(res, { ...streamIdentity, toolCalls: pendingToolCalls }); requestFinalize("tool_calls"); return; } if (!sawAssistantDelta) { const content = resolveAgentResponseCommentary(result) || bufferedReplaceableAssistantContent || resolveAgentResponseText(result) || "No response from OpenClaw."; sawAssistantDelta = true; writeAssistantContentChunk(res, { ...streamIdentity, content }); } requestFinalize(); } catch (err) { resultResolved = true; if (closed || abortController.signal.aborted) return; terminalLifecyclePhase = "error"; logWarn(`openai-compat: streaming chat completion failed: ${String(err)}`); if (isClientToolNameConflictError(err)) { finishStreamWithError({ message: "invalid tool configuration", type: "invalid_request_error" }); return; } const mapped = resolveOpenAiCompatError(err); if (mapped) { finishStreamWithError(mapped.error); return; } if (terminalStreamError) { finishStreamWithError(terminalStreamError); return; } finishStreamWithError({ message: "internal error", type: "api_error" }); } finally { releaseAgentRootWork?.(); if (!observedTerminalLifecycle && (terminalLifecyclePhase === "error" || !closed)) emitAgentEvent({ runId, stream: "lifecycle", data: { phase: terminalLifecyclePhase } }); } })(); return true; } //#endregion export { handleOpenAiHttpRequest };