UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

915 lines (914 loc) 31.6 kB
import { a as normalizeLowercaseStringOrEmpty, c as normalizeOptionalString } from "./string-coerce-mnp54Vah.js"; import { D as resolveIntegerOption } from "./number-coercion-CJQ8TR--.js"; import { n as defaultRuntime } from "./runtime-B4lgFmsS.js"; import { a as logWarn } from "./logger-lqqYRtFw.js"; import { i as emitAgentEvent, l as onAgentEvent } from "./agent-events-C1B8VhOg.js"; import { n as estimateBase64DecodedBytes } from "./base64-l6yrCyHc.js"; import { i as hasNonzeroUsage, o as normalizeUsage, s as toOpenAiChatCompletionsUsage } from "./usage-C67Kbb7n.js"; import "./compaction-successor-transcript-ONv2lneO.js"; import { i as isClientToolNameConflictError } from "./tool-split-jrKfShnK.js"; import { d as DEFAULT_INPUT_TIMEOUT_MS, l as DEFAULT_INPUT_IMAGE_MAX_BYTES, m as normalizeMimeList, p as extractImageContentFromSource, u as DEFAULT_INPUT_IMAGE_MIMES } from "./runner.entries-B23jIAju.js"; import { n as agentCommandFromIngress } from "./agent-command-DimMXeog.js"; import { t as createDefaultDeps } from "./deps-CWqGdIJS.js"; import "./agent-COt_BYfw.js"; import { a as sendJson, d as writeDone, l as setSseHeaders, u as watchClientDisconnect } from "./http-common-DunL6val.js"; import { l as resolveOpenAiCompatibleHttpOperatorScopes } from "./http-auth-utils-Du8mRZIw.js"; import { a as resolveGatewayRequestContext, o as resolveOpenAiCompatModelOverride } from "./http-utils-leZWdpma.js"; import { t as handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers-BkKm9tZp.js"; import { a as validateOpenAiSamplingParams, c as resolveAssistantStreamDeltaText, i as resolveOpenAiCompatError, n as resolveUnsatisfiedToolChoiceMessage, o as normalizeInputHostnameAllowlist, r as toolChoiceConstraintPrompt, s as buildAgentMessageFromConversationEntries, t as isToolChoiceConstraintSatisfied } from "./openai-tool-choice-CG8IZWcK.js"; import { randomUUID } from "node:crypto"; //#region src/gateway/openai-http.ts const DEFAULT_OPENAI_CHAT_COMPLETIONS_BODY_BYTES = 20 * 1024 * 1024; const IMAGE_ONLY_USER_MESSAGE = "User sent image(s) with no text."; const DEFAULT_OPENAI_MAX_IMAGE_PARTS = 8; const DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024; 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: config?.maxBodyBytes ?? DEFAULT_OPENAI_CHAT_COMPLETIONS_BODY_BYTES, maxImageParts: resolveIntegerOption(config?.maxImageParts, DEFAULT_OPENAI_MAX_IMAGE_PARTS, { min: 0 }), maxTotalImageBytes: resolveIntegerOption(config?.maxTotalImageBytes, DEFAULT_OPENAI_MAX_TOTAL_IMAGE_BYTES, { min: 1 }), 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, bestEffortDeliver: false, allowModelOverride: true, 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 writeAssistantRoleChunk(res, params) { writeSse(res, { id: params.runId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1e3), model: params.model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] }); } function writeAssistantContentChunk(res, params) { writeSse(res, { id: params.runId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1e3), model: params.model, choices: [{ index: 0, delta: { content: params.content }, finish_reason: params.finishReason }] }); } function writeAssistantFinishChunk(res, params) { writeSse(res, { id: params.runId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1e3), model: params.model, choices: [{ index: 0, delta: {}, finish_reason: params.finishReason }] }); } function splitArgumentsForStreaming(argumentsValue) { if (!argumentsValue) return [""]; const chunkSize = 256; const chunks = []; for (let i = 0; i < argumentsValue.length; i += chunkSize) chunks.push(argumentsValue.slice(i, i + chunkSize)); return chunks.length > 0 ? chunks : [""]; } function writeAssistantToolCallsIncrementalChunks(res, params) { for (const [index, call] of params.toolCalls.entries()) { writeSse(res, { id: params.runId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1e3), model: params.model, 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)) writeSse(res, { id: params.runId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1e3), model: params.model, choices: [{ index: 0, delta: { tool_calls: [{ index, function: { arguments: argsDelta } }] }, finish_reason: null }] }); } } function writeUsageChunk(res, params) { writeSse(res, { id: params.runId, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1e3), model: params.model, 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 renderAssistantToolCalls(calls) { return calls.map((call) => `tool_call id=${call.id} name=${call.name} arguments=${call.arguments}`).join("\n"); } 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) { if (!Array.isArray(content)) return []; const urls = []; for (const part of content) { if (!part || typeof part !== "object") continue; if (part.type !== "image_url") continue; const url = resolveImageUrlPart(part); if (url) urls.push(url); } return 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; return { activeTurnIndex: i, activeUserMessageIndex: normalizedRole === "user" ? i : -1, urls: normalizedRole === "user" ? extractImageUrls(msg.content) : [] }; } return { activeTurnIndex: -1, activeUserMessageIndex: -1, urls: [] }; } async function resolveImagesForRequest(activeTurnContext, limits) { const urls = activeTurnContext.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; } const testOnlyOpenAiHttp = { resolveImagesForRequest, resolveOpenAiChatCompletionsLimits, resolveChatCompletionUsage }; function buildAgentPrompt(messagesUnknown, activeUserMessageIndex) { const messages = asMessages(messagesUnknown); 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(); const hasImage = extractImageUrls(msg.content).length > 0; 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 assistantToolCalls = normalizedRole === "assistant" ? extractAssistantToolCalls(msg.tool_calls) : []; const assistantToolCallsSummary = assistantToolCalls.length > 0 ? renderAssistantToolCalls(assistantToolCalls) : ""; const messageContent = [normalizedRole === "user" && !content && hasImage && i === 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 coerceRequest(val) { if (!val || typeof val !== "object") return {}; return val; } 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 resolveAgentRunUsage(result) { const agentMeta = result?.meta?.agentMeta; const primary = normalizeUsage(agentMeta?.usage); if (hasNonzeroUsage(primary)) return primary; const fallback = normalizeUsage(agentMeta?.lastCallUsage); if (hasNonzeroUsage(fallback)) return fallback; return primary ?? fallback; } 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 resolveErrorMessage(err) { if (err instanceof Error) { const message = err.message.trim(); if (message) return message; } return String(err); } 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 payload = coerceRequest(handled.body); const stream = Boolean(payload.stream); const streamIncludeUsage = stream && resolveIncludeUsageForStreaming(payload); const model = typeof payload.model === "string" ? payload.model : "openclaw"; const user = typeof payload.user === "string" ? payload.user : void 0; const maxTokens = typeof payload.max_completion_tokens === "number" ? payload.max_completion_tokens : typeof payload.max_tokens === "number" ? payload.max_tokens : void 0; 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) { sendJson(res, 400, { error: { message: `Invalid response_format: ${resolveErrorMessage(err)}`, type: "invalid_request_error" } }); return true; } let stop; try { stop = resolveStopSequences(payload.stop); } catch (err) { sendJson(res, 400, { error: { message: `Invalid stop: ${resolveErrorMessage(err)}`, type: "invalid_request_error" } }); 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) { sendJson(res, 400, { error: { message: samplingError, type: "invalid_request_error" } }); 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; const { agentId, sessionKey, messageChannel } = resolveGatewayRequestContext({ req, model, user, sessionPrefix: "openai", defaultMessageChannel: "webchat", useMessageChannelHeader: true }); const { modelOverride, errorMessage: modelError } = await resolveOpenAiCompatModelOverride({ req, agentId, model }); if (modelError) { sendJson(res, 400, { error: { message: modelError, type: "invalid_request_error" } }); return true; } const activeTurnContext = resolveActiveTurnContext(payload.messages); const prompt = buildAgentPrompt(payload.messages, activeTurnContext.activeUserMessageIndex); 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) { sendJson(res, 400, { error: { message: `Invalid tools/tool_choice: ${resolveErrorMessage(err)}`, type: "invalid_request_error" } }); return true; } let images; try { images = await resolveImagesForRequest(activeTurnContext, limits); } catch (err) { logWarn(`openai-compat: invalid image_url content: ${String(err)}`); sendJson(res, 400, { error: { message: "Invalid image_url content in `messages`.", type: "invalid_request_error" } }); return true; } if (!prompt.message && images.length === 0) { sendJson(res, 400, { error: { message: "Missing user message in `messages`.", type: "invalid_request_error" } }); return true; } const runId = `chatcmpl_${randomUUID()}`; 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, abortSignal: abortController.signal, streamParams }); if (!stream) { const stopWatchingDisconnect = watchClientDisconnect(req, res, abortController); try { const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps); if (abortController.signal.aborted) return true; const usage = resolveChatCompletionUsage(result); const meta = result?.meta; 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: Math.floor(Date.now() / 1e3), 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: Math.floor(Date.now() / 1e3), 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)) { sendJson(res, 400, { error: { message: "invalid tool configuration", type: "invalid_request_error" } }); 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 wroteRole = false; let wroteStopChunk = false; let sawAssistantDelta = false; let bufferedAssistantContent = ""; let finalUsage; let finalizeRequested = false; let finalizeFinishReason = "stop"; let resultResolved = false; let closed = false; let stopWatchingDisconnect = () => {}; const maybeFinalize = () => { if (closed || !finalizeRequested) return; if (!resultResolved) return; if (streamIncludeUsage && !finalUsage) return; closed = true; stopWatchingDisconnect(); unsubscribe(); if (!wroteStopChunk) { writeAssistantFinishChunk(res, { runId, model, finishReason: finalizeFinishReason }); wroteStopChunk = true; } if (streamIncludeUsage && finalUsage) writeUsageChunk(res, { runId, model, usage: finalUsage }); writeDone(res); res.end(); }; const requestFinalize = (finishReason = "stop") => { finalizeFinishReason = finishReason; finalizeRequested = true; maybeFinalize(); }; const unsubscribe = onAgentEvent((evt) => { if (evt.runId !== runId) return; if (closed) return; if (evt.stream === "assistant") { const content = resolveAssistantStreamDeltaText(evt) ?? ""; if (!content) return; if (toolChoiceConstraint) { bufferedAssistantContent += content; return; } if (!wroteRole) { wroteRole = true; writeAssistantRoleChunk(res, { runId, model }); } sawAssistantDelta = true; writeAssistantContentChunk(res, { runId, model, content, finishReason: null }); return; } if (evt.stream === "lifecycle") { const phase = evt.data?.phase; if (phase === "end" || phase === "error") requestFinalize(); } }); stopWatchingDisconnect = watchClientDisconnect(req, res, abortController, () => { closed = true; unsubscribe(); }); wroteRole = true; writeAssistantRoleChunk(res, { runId, model }); (async () => { try { const result = await agentCommandFromIngress(commandInput, defaultRuntime, deps); resultResolved = true; if (closed) return; finalUsage = resolveChatCompletionUsage(result); const meta = result?.meta; const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta); if (toolChoiceConstraint && !isToolChoiceConstraintSatisfied({ constraint: toolChoiceConstraint, pendingToolCalls })) { closed = true; stopWatchingDisconnect(); unsubscribe(); writeSse(res, { error: { message: resolveUnsatisfiedToolChoiceMessage(toolChoiceConstraint), type: "api_error" } }); writeDone(res); res.end(); return; } if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) { if (!wroteRole) { wroteRole = true; writeAssistantRoleChunk(res, { runId, model }); } if (!sawAssistantDelta) { const commentary = bufferedAssistantContent || resolveAgentResponseCommentary(result); if (commentary) { sawAssistantDelta = true; writeAssistantContentChunk(res, { runId, model, content: commentary, finishReason: null }); } } writeAssistantToolCallsIncrementalChunks(res, { runId, model, toolCalls: pendingToolCalls }); requestFinalize("tool_calls"); return; } if (!sawAssistantDelta) { if (!wroteRole) { wroteRole = true; writeAssistantRoleChunk(res, { runId, model }); } const content = resolveAgentResponseText(result); sawAssistantDelta = true; writeAssistantContentChunk(res, { runId, model, content, finishReason: null }); } requestFinalize(); } catch (err) { resultResolved = true; if (closed || abortController.signal.aborted) return; logWarn(`openai-compat: streaming chat completion failed: ${String(err)}`); if (isClientToolNameConflictError(err)) { closed = true; stopWatchingDisconnect(); unsubscribe(); writeSse(res, { error: { message: "invalid tool configuration", type: "invalid_request_error" } }); writeDone(res); res.end(); return; } const mapped = resolveOpenAiCompatError(err); if (mapped) { closed = true; stopWatchingDisconnect(); unsubscribe(); writeSse(res, { error: mapped.error }); writeDone(res); res.end(); return; } writeAssistantContentChunk(res, { runId, model, content: "Error: internal error", finishReason: "stop" }); wroteStopChunk = true; finalUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }; emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "error" } }); requestFinalize(); } finally { if (!closed) emitAgentEvent({ runId, stream: "lifecycle", data: { phase: "end" } }); } })(); return true; } //#endregion export { testOnlyOpenAiHttp as __testOnlyOpenAiHttp, testOnlyOpenAiHttp, handleOpenAiHttpRequest };