| import express from "express"; | |
| import crypto from "node:crypto"; | |
| const app = express(); | |
| app.use(express.json({ limit: "10mb" })); | |
| const PORT = Number(process.env.PORT || 3080); | |
| const OPENCLAW_BASE_URL = process.env.OPENCLAW_BASE_URL || "http://gateway:18789"; | |
| const OPENCLAW_BEARER_TOKEN = process.env.OPENCLAW_BEARER_TOKEN || ""; | |
| const OPENCLAW_MODELS = (process.env.OPENCLAW_MODELS || "openclaw-main") | |
| .split(",") | |
| .map((s) => s.trim()) | |
| .filter(Boolean); | |
| function nowSeconds() { | |
| return Math.floor(Date.now() / 1000); | |
| } | |
| function makeId(prefix) { | |
| return `${prefix}-${crypto.randomUUID()}`; | |
| } | |
| function pseudoModelToAgent(model) { | |
| if (!model) return "main"; | |
| if (model.startsWith("openclaw-")) { | |
| return model.slice("openclaw-".length); | |
| } | |
| if (model.startsWith("openclaw:")) { | |
| return model.slice("openclaw:".length); | |
| } | |
| if (model.startsWith("agent:")) { | |
| return model.slice("agent:".length); | |
| } | |
| return model; | |
| } | |
| function agentToOpenClawModel(agent) { | |
| return `openclaw:${agent}`; | |
| } | |
| function flattenContent(content) { | |
| if (typeof content === "string") return content; | |
| if (Array.isArray(content)) { | |
| return content | |
| .map((part) => { | |
| if (!part) return ""; | |
| if (typeof part === "string") return part; | |
| if (part?.type === "text") return part.text || ""; | |
| if (part?.type === "input_text") return part.text || ""; | |
| if (typeof part?.text === "string") return part.text; | |
| return ""; | |
| }) | |
| .join("\n"); | |
| } | |
| if (content == null) return ""; | |
| if (typeof content === "object") { | |
| if (typeof content.text === "string") return content.text; | |
| return JSON.stringify(content); | |
| } | |
| return String(content); | |
| } | |
| function normalizeMessages(messages = []) { | |
| if (!Array.isArray(messages)) return []; | |
| return messages | |
| .filter((m) => m && typeof m === "object") | |
| .map((m) => ({ | |
| role: | |
| typeof m.role === "string" && m.role.length | |
| ? m.role | |
| : "user", | |
| content: flattenContent(m.content), | |
| })) | |
| .filter((m) => m.content !== undefined); | |
| } | |
| function extractAssistantTextFromResponsesApi(payload) { | |
| if (!payload) return ""; | |
| if (typeof payload.output_text === "string" && payload.output_text.length) { | |
| return payload.output_text; | |
| } | |
| const output = Array.isArray(payload.output) ? payload.output : []; | |
| const texts = []; | |
| for (const item of output) { | |
| if (item?.type === "message" && Array.isArray(item.content)) { | |
| for (const part of item.content) { | |
| if (part?.type === "output_text" && typeof part.text === "string") { | |
| texts.push(part.text); | |
| } | |
| if (part?.type === "text" && typeof part.text === "string") { | |
| texts.push(part.text); | |
| } | |
| } | |
| } | |
| } | |
| return texts.join("\n").trim(); | |
| } | |
| function buildOpenClawInput(messages = []) { | |
| if (!Array.isArray(messages)) return []; | |
| return messages | |
| .filter((m) => m && typeof m === "object") | |
| .map((m) => ({ | |
| role: | |
| typeof m.role === "string" && m.role.length | |
| ? m.role | |
| : "user", | |
| content: [ | |
| { | |
| type: "input_text", | |
| text: flattenContent(m.content) || "", | |
| }, | |
| ], | |
| })); | |
| } | |
| function buildChatCompletionResponse({ model, content, finishReason = "stop" }) { | |
| return { | |
| id: makeId("chatcmpl"), | |
| object: "chat.completion", | |
| created: nowSeconds(), | |
| model, | |
| choices: [ | |
| { | |
| index: 0, | |
| message: { | |
| role: "assistant", | |
| content, | |
| }, | |
| finish_reason: finishReason, | |
| }, | |
| ], | |
| usage: { | |
| prompt_tokens: 0, | |
| completion_tokens: 0, | |
| total_tokens: 0, | |
| }, | |
| }; | |
| } | |
| function sseWrite(res, obj) { | |
| res.write(`data: ${JSON.stringify(obj)}\n\n`); | |
| } | |
| app.get("/health", (_req, res) => { | |
| res.json({ ok: true }); | |
| }); | |
| app.get("/v1/models", (_req, res) => { | |
| res.json({ | |
| object: "list", | |
| data: OPENCLAW_MODELS.map((modelId) => ({ | |
| id: modelId, | |
| object: "model", | |
| created: 0, | |
| owned_by: "openclaw", | |
| })), | |
| }); | |
| }); | |
| app.post("/v1/chat/completions", async (req, res) => { | |
| try { | |
| const { | |
| model, | |
| messages = [], | |
| stream = false, | |
| user, | |
| temperature, | |
| max_tokens, | |
| } = req.body || {}; | |
| console.log("Incoming model:", model); | |
| console.log("Incoming messages preview:", JSON.stringify(messages?.slice?.(0, 3), null, 2)); | |
| const agent = pseudoModelToAgent(model || OPENCLAW_MODELS[0] || "openclaw-hermes"); | |
| const openclawModel = agentToOpenClawModel(agent); | |
| const normalizedMessages = normalizeMessages(messages); | |
| if (!normalizedMessages.length) { | |
| return res.status(400).json({ | |
| error: { | |
| message: "No valid messages were provided", | |
| type: "invalid_request_error", | |
| }, | |
| }); | |
| } | |
| const derivedUser = | |
| user || | |
| req.header("x-conversation-id") || | |
| req.header("x-session-id") || | |
| `librechat-${crypto.randomUUID()}`; | |
| const body = { | |
| model: openclawModel, | |
| input: buildOpenClawInput(normalizedMessages), | |
| user: derivedUser, | |
| temperature, | |
| max_output_tokens: max_tokens, | |
| stream: false | |
| }; | |
| const headers = { | |
| "content-type": "application/json", | |
| }; | |
| if (OPENCLAW_BEARER_TOKEN) { | |
| headers.authorization = `Bearer ${OPENCLAW_BEARER_TOKEN}`; | |
| } | |
| const upstream = await fetch(`${OPENCLAW_BASE_URL}/v1/responses`, { | |
| method: "POST", | |
| headers, | |
| body: JSON.stringify(body), | |
| }); | |
| const text = await upstream.text(); | |
| if (!upstream.ok) { | |
| res.status(upstream.status).json({ | |
| error: { | |
| message: text || "OpenClaw upstream error", | |
| type: "upstream_error", | |
| }, | |
| }); | |
| return; | |
| } | |
| const payload = JSON.parse(text); | |
| const assistantText = extractAssistantTextFromResponsesApi(payload); | |
| if (!stream) { | |
| res.json( | |
| buildChatCompletionResponse({ | |
| model: model || OPENCLAW_MODELS[0] || "openclaw-main", | |
| content: assistantText, | |
| }) | |
| ); | |
| return; | |
| } | |
| res.setHeader("Content-Type", "text/event-stream"); | |
| res.setHeader("Cache-Control", "no-cache, no-transform"); | |
| res.setHeader("Connection", "keep-alive"); | |
| sseWrite(res, { | |
| id: makeId("chatcmpl"), | |
| object: "chat.completion.chunk", | |
| created: nowSeconds(), | |
| model: model || OPENCLAW_MODELS[0] || "openclaw-main", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: { role: "assistant" }, | |
| finish_reason: null, | |
| }, | |
| ], | |
| }); | |
| if (assistantText) { | |
| sseWrite(res, { | |
| id: makeId("chatcmpl"), | |
| object: "chat.completion.chunk", | |
| created: nowSeconds(), | |
| model: model || OPENCLAW_MODELS[0] || "openclaw-main", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: { content: assistantText }, | |
| finish_reason: null, | |
| }, | |
| ], | |
| }); | |
| } | |
| sseWrite(res, { | |
| id: makeId("chatcmpl"), | |
| object: "chat.completion.chunk", | |
| created: nowSeconds(), | |
| model: model || OPENCLAW_MODELS[0] || "openclaw-main", | |
| choices: [ | |
| { | |
| index: 0, | |
| delta: {}, | |
| finish_reason: "stop", | |
| }, | |
| ], | |
| }); | |
| res.write("data: [DONE]\n\n"); | |
| res.end(); | |
| } catch (err) { | |
| res.status(500).json({ | |
| error: { | |
| message: err instanceof Error ? err.message : "Internal server error", | |
| type: "server_error", | |
| }, | |
| }); | |
| } | |
| }); | |
| app.listen(PORT, () => { | |
| console.log(`openclaw-librechat-bridge listening on ${PORT}`); | |
| }); |