Ordo commited on
Commit
182a5f2
·
0 Parent(s):

Initial public release

Browse files
Files changed (8) hide show
  1. .env.example +4 -0
  2. .gitignore +6 -0
  3. LICENSE +21 -0
  4. README.md +23 -0
  5. SECURITY.md +5 -0
  6. dockerfile +12 -0
  7. package.json +13 -0
  8. server.js +315 -0
.env.example ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ PORT=3080
2
+ OPENCLAW_BASE_URL=http://gateway:18789
3
+ OPENCLAW_BEARER_TOKEN=
4
+ OPENCLAW_MODELS=openclaw-main
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .env
2
+ .env.*
3
+ !.env.example
4
+ node_modules/
5
+ npm-debug.log*
6
+ *.log
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # OpenClaw LibreChat Bridge
2
+
3
+ Small Express bridge that exposes OpenAI-compatible chat completion endpoints for LibreChat while forwarding requests to an OpenClaw gateway.
4
+
5
+ ## Endpoints
6
+
7
+ - `GET /health`
8
+ - `GET /v1/models`
9
+ - `POST /v1/chat/completions`
10
+
11
+ ## Configuration
12
+
13
+ Copy `.env.example` to `.env` for local runs. `OPENCLAW_BEARER_TOKEN` is optional and must never be committed.
14
+
15
+ ## Local Development
16
+
17
+ ```bash
18
+ npm install
19
+ npm test
20
+ npm start
21
+ ```
22
+
23
+ The bridge maps models like `openclaw-main`, `openclaw:main`, or `agent:main` to OpenClaw agent model IDs.
SECURITY.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # Security
2
+
3
+ Do not commit gateway bearer tokens, LibreChat credentials, logs, or private request transcripts.
4
+
5
+ This bridge forwards user prompts to an OpenClaw gateway. Treat request bodies as sensitive operational data.
dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+
5
+ COPY package.json ./
6
+ RUN npm install --omit=dev
7
+
8
+ COPY server.js ./
9
+
10
+ EXPOSE 3080
11
+
12
+ CMD ["node", "server.js"]
package.json ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "openclaw-librechat-bridge",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "scripts": {
7
+ "test": "node --check server.js",
8
+ "start": "node server.js"
9
+ },
10
+ "dependencies": {
11
+ "express": "^4.21.2"
12
+ }
13
+ }
server.js ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from "express";
2
+ import crypto from "node:crypto";
3
+
4
+ const app = express();
5
+ app.use(express.json({ limit: "10mb" }));
6
+
7
+ const PORT = Number(process.env.PORT || 3080);
8
+ const OPENCLAW_BASE_URL = process.env.OPENCLAW_BASE_URL || "http://gateway:18789";
9
+ const OPENCLAW_BEARER_TOKEN = process.env.OPENCLAW_BEARER_TOKEN || "";
10
+ const OPENCLAW_MODELS = (process.env.OPENCLAW_MODELS || "openclaw-main")
11
+ .split(",")
12
+ .map((s) => s.trim())
13
+ .filter(Boolean);
14
+
15
+ function nowSeconds() {
16
+ return Math.floor(Date.now() / 1000);
17
+ }
18
+
19
+ function makeId(prefix) {
20
+ return `${prefix}-${crypto.randomUUID()}`;
21
+ }
22
+
23
+ function pseudoModelToAgent(model) {
24
+ if (!model) return "main";
25
+
26
+ if (model.startsWith("openclaw-")) {
27
+ return model.slice("openclaw-".length);
28
+ }
29
+
30
+ if (model.startsWith("openclaw:")) {
31
+ return model.slice("openclaw:".length);
32
+ }
33
+
34
+ if (model.startsWith("agent:")) {
35
+ return model.slice("agent:".length);
36
+ }
37
+
38
+ return model;
39
+ }
40
+
41
+ function agentToOpenClawModel(agent) {
42
+ return `openclaw:${agent}`;
43
+ }
44
+
45
+ function flattenContent(content) {
46
+ if (typeof content === "string") return content;
47
+
48
+ if (Array.isArray(content)) {
49
+ return content
50
+ .map((part) => {
51
+ if (!part) return "";
52
+ if (typeof part === "string") return part;
53
+ if (part?.type === "text") return part.text || "";
54
+ if (part?.type === "input_text") return part.text || "";
55
+ if (typeof part?.text === "string") return part.text;
56
+ return "";
57
+ })
58
+ .join("\n");
59
+ }
60
+
61
+ if (content == null) return "";
62
+
63
+ if (typeof content === "object") {
64
+ if (typeof content.text === "string") return content.text;
65
+ return JSON.stringify(content);
66
+ }
67
+
68
+ return String(content);
69
+ }
70
+
71
+ function normalizeMessages(messages = []) {
72
+ if (!Array.isArray(messages)) return [];
73
+
74
+ return messages
75
+ .filter((m) => m && typeof m === "object")
76
+ .map((m) => ({
77
+ role:
78
+ typeof m.role === "string" && m.role.length
79
+ ? m.role
80
+ : "user",
81
+ content: flattenContent(m.content),
82
+ }))
83
+ .filter((m) => m.content !== undefined);
84
+ }
85
+
86
+ function extractAssistantTextFromResponsesApi(payload) {
87
+ if (!payload) return "";
88
+
89
+ if (typeof payload.output_text === "string" && payload.output_text.length) {
90
+ return payload.output_text;
91
+ }
92
+
93
+ const output = Array.isArray(payload.output) ? payload.output : [];
94
+ const texts = [];
95
+
96
+ for (const item of output) {
97
+ if (item?.type === "message" && Array.isArray(item.content)) {
98
+ for (const part of item.content) {
99
+ if (part?.type === "output_text" && typeof part.text === "string") {
100
+ texts.push(part.text);
101
+ }
102
+ if (part?.type === "text" && typeof part.text === "string") {
103
+ texts.push(part.text);
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ return texts.join("\n").trim();
110
+ }
111
+
112
+ function buildOpenClawInput(messages = []) {
113
+ if (!Array.isArray(messages)) return [];
114
+
115
+ return messages
116
+ .filter((m) => m && typeof m === "object")
117
+ .map((m) => ({
118
+ role:
119
+ typeof m.role === "string" && m.role.length
120
+ ? m.role
121
+ : "user",
122
+ content: [
123
+ {
124
+ type: "input_text",
125
+ text: flattenContent(m.content) || "",
126
+ },
127
+ ],
128
+ }));
129
+ }
130
+
131
+ function buildChatCompletionResponse({ model, content, finishReason = "stop" }) {
132
+ return {
133
+ id: makeId("chatcmpl"),
134
+ object: "chat.completion",
135
+ created: nowSeconds(),
136
+ model,
137
+ choices: [
138
+ {
139
+ index: 0,
140
+ message: {
141
+ role: "assistant",
142
+ content,
143
+ },
144
+ finish_reason: finishReason,
145
+ },
146
+ ],
147
+ usage: {
148
+ prompt_tokens: 0,
149
+ completion_tokens: 0,
150
+ total_tokens: 0,
151
+ },
152
+ };
153
+ }
154
+
155
+ function sseWrite(res, obj) {
156
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
157
+ }
158
+
159
+ app.get("/health", (_req, res) => {
160
+ res.json({ ok: true });
161
+ });
162
+
163
+ app.get("/v1/models", (_req, res) => {
164
+ res.json({
165
+ object: "list",
166
+ data: OPENCLAW_MODELS.map((modelId) => ({
167
+ id: modelId,
168
+ object: "model",
169
+ created: 0,
170
+ owned_by: "openclaw",
171
+ })),
172
+ });
173
+ });
174
+
175
+ app.post("/v1/chat/completions", async (req, res) => {
176
+ try {
177
+ const {
178
+ model,
179
+ messages = [],
180
+ stream = false,
181
+ user,
182
+ temperature,
183
+ max_tokens,
184
+ } = req.body || {};
185
+ console.log("Incoming model:", model);
186
+ console.log("Incoming messages preview:", JSON.stringify(messages?.slice?.(0, 3), null, 2));
187
+ const agent = pseudoModelToAgent(model || OPENCLAW_MODELS[0] || "openclaw-hermes");
188
+ const openclawModel = agentToOpenClawModel(agent);
189
+
190
+ const normalizedMessages = normalizeMessages(messages);
191
+ if (!normalizedMessages.length) {
192
+ return res.status(400).json({
193
+ error: {
194
+ message: "No valid messages were provided",
195
+ type: "invalid_request_error",
196
+ },
197
+ });
198
+ }
199
+ const derivedUser =
200
+ user ||
201
+ req.header("x-conversation-id") ||
202
+ req.header("x-session-id") ||
203
+ `librechat-${crypto.randomUUID()}`;
204
+
205
+ const body = {
206
+ model: openclawModel,
207
+ input: buildOpenClawInput(normalizedMessages),
208
+ user: derivedUser,
209
+ temperature,
210
+ max_output_tokens: max_tokens,
211
+ stream: false
212
+ };
213
+
214
+ const headers = {
215
+ "content-type": "application/json",
216
+ };
217
+
218
+ if (OPENCLAW_BEARER_TOKEN) {
219
+ headers.authorization = `Bearer ${OPENCLAW_BEARER_TOKEN}`;
220
+ }
221
+
222
+ const upstream = await fetch(`${OPENCLAW_BASE_URL}/v1/responses`, {
223
+ method: "POST",
224
+ headers,
225
+ body: JSON.stringify(body),
226
+ });
227
+
228
+ const text = await upstream.text();
229
+
230
+ if (!upstream.ok) {
231
+ res.status(upstream.status).json({
232
+ error: {
233
+ message: text || "OpenClaw upstream error",
234
+ type: "upstream_error",
235
+ },
236
+ });
237
+ return;
238
+ }
239
+
240
+ const payload = JSON.parse(text);
241
+ const assistantText = extractAssistantTextFromResponsesApi(payload);
242
+
243
+ if (!stream) {
244
+ res.json(
245
+ buildChatCompletionResponse({
246
+ model: model || OPENCLAW_MODELS[0] || "openclaw-main",
247
+ content: assistantText,
248
+ })
249
+ );
250
+ return;
251
+ }
252
+
253
+ res.setHeader("Content-Type", "text/event-stream");
254
+ res.setHeader("Cache-Control", "no-cache, no-transform");
255
+ res.setHeader("Connection", "keep-alive");
256
+
257
+ sseWrite(res, {
258
+ id: makeId("chatcmpl"),
259
+ object: "chat.completion.chunk",
260
+ created: nowSeconds(),
261
+ model: model || OPENCLAW_MODELS[0] || "openclaw-main",
262
+ choices: [
263
+ {
264
+ index: 0,
265
+ delta: { role: "assistant" },
266
+ finish_reason: null,
267
+ },
268
+ ],
269
+ });
270
+
271
+ if (assistantText) {
272
+ sseWrite(res, {
273
+ id: makeId("chatcmpl"),
274
+ object: "chat.completion.chunk",
275
+ created: nowSeconds(),
276
+ model: model || OPENCLAW_MODELS[0] || "openclaw-main",
277
+ choices: [
278
+ {
279
+ index: 0,
280
+ delta: { content: assistantText },
281
+ finish_reason: null,
282
+ },
283
+ ],
284
+ });
285
+ }
286
+
287
+ sseWrite(res, {
288
+ id: makeId("chatcmpl"),
289
+ object: "chat.completion.chunk",
290
+ created: nowSeconds(),
291
+ model: model || OPENCLAW_MODELS[0] || "openclaw-main",
292
+ choices: [
293
+ {
294
+ index: 0,
295
+ delta: {},
296
+ finish_reason: "stop",
297
+ },
298
+ ],
299
+ });
300
+
301
+ res.write("data: [DONE]\n\n");
302
+ res.end();
303
+ } catch (err) {
304
+ res.status(500).json({
305
+ error: {
306
+ message: err instanceof Error ? err.message : "Internal server error",
307
+ type: "server_error",
308
+ },
309
+ });
310
+ }
311
+ });
312
+
313
+ app.listen(PORT, () => {
314
+ console.log(`openclaw-librechat-bridge listening on ${PORT}`);
315
+ });