relmio 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +126 -36
- package/docs/architecture.md +57 -0
- package/docs/images/examples/gpt-56-model-selector.png +0 -0
- package/docs/images/examples/n8n-openai-credential-connected.png +0 -0
- package/docs/images/examples/telegram-model-results.png +0 -0
- package/docs/images/examples/telegram-n8n-workflow-execution.png +0 -0
- package/docs/images/setup/00-install-methods.png +0 -0
- package/docs/images/setup/01-local-sign-in-ready.png +0 -0
- package/docs/images/setup/02-vps-identity-confirmed.png +0 -0
- package/docs/images/setup/03-n8n-detected.png +0 -0
- package/docs/images/setup/04-install-plan.png +0 -0
- package/docs/images/setup/05-bridge-ready.png +0 -0
- package/docs/local-endpoints-spec.md +370 -0
- package/docs/local-endpoints.md +374 -0
- package/docs/npm-publish.md +121 -201
- package/docs/security.md +92 -3
- package/package.json +2 -2
- package/src/domain/local-endpoints.js +460 -0
- package/src/gateway/openai.js +834 -0
- package/src/infrastructure/local-process.js +375 -0
- package/src/services/codex-login.js +711 -0
- package/src/services/local-installer.js +1120 -0
- package/src/ui/app.js +4 -0
- package/src/ui/index.html +119 -108
- package/src/ui/local.css +262 -0
- package/src/ui/local.html +442 -0
- package/src/ui/local.js +550 -0
- package/src/ui/styles.css +577 -278
- package/src/web/server.js +247 -12
|
@@ -0,0 +1,834 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { readFile as readFileFromDisk } from "node:fs/promises";
|
|
5
|
+
import { request as httpRequest } from "node:http";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { isIP } from "node:net";
|
|
8
|
+
import { request as httpsRequest } from "node:https";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_UPSTREAM = "https://api.openai.com";
|
|
12
|
+
const DEFAULT_HOST = "0.0.0.0";
|
|
13
|
+
const DEFAULT_PORT = 10531;
|
|
14
|
+
const DEFAULT_LIMITS = Object.freeze({
|
|
15
|
+
maxHeaderBytes: 16 * 1024,
|
|
16
|
+
maxPathBytes: 8 * 1024,
|
|
17
|
+
maxBodyBytes: 8 * 1024 * 1024,
|
|
18
|
+
maxConcurrentRequests: 32,
|
|
19
|
+
upstreamResponseHeaderTimeoutMs: 5 * 60_000,
|
|
20
|
+
upstreamIdleTimeoutMs: 2 * 60_000,
|
|
21
|
+
downstreamStallTimeoutMs: 2 * 60_000,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
25
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
26
|
+
"connection",
|
|
27
|
+
"keep-alive",
|
|
28
|
+
"proxy-authenticate",
|
|
29
|
+
"proxy-authorization",
|
|
30
|
+
"te",
|
|
31
|
+
"trailer",
|
|
32
|
+
"transfer-encoding",
|
|
33
|
+
"upgrade",
|
|
34
|
+
]);
|
|
35
|
+
const REQUEST_HEADERS_TO_STRIP = new Set([
|
|
36
|
+
"authorization",
|
|
37
|
+
"cookie",
|
|
38
|
+
"cookie2",
|
|
39
|
+
"expect",
|
|
40
|
+
"forwarded",
|
|
41
|
+
"host",
|
|
42
|
+
"origin",
|
|
43
|
+
"openai-organization",
|
|
44
|
+
"openai-project",
|
|
45
|
+
"referer",
|
|
46
|
+
"via",
|
|
47
|
+
"x-real-ip",
|
|
48
|
+
]);
|
|
49
|
+
const PREFLIGHT_HEADER_NAMES = new Set([
|
|
50
|
+
"authorization",
|
|
51
|
+
"content-type",
|
|
52
|
+
"idempotency-key",
|
|
53
|
+
"openai-beta",
|
|
54
|
+
]);
|
|
55
|
+
const PREFLIGHT_METHODS = new Set(["DELETE", "GET", "HEAD", "PATCH", "POST", "PUT"]);
|
|
56
|
+
const ALLOWED_ROUTES = new Map([
|
|
57
|
+
["/v1/models", new Set(["GET"])],
|
|
58
|
+
["/v1/responses", new Set(["POST"])],
|
|
59
|
+
["/v1/chat/completions", new Set(["POST"])],
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
function byteLength(value) {
|
|
63
|
+
return Buffer.byteLength(value, "utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizePositiveInteger(value, name, { allowZero = false } = {}) {
|
|
67
|
+
if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1)) {
|
|
68
|
+
throw new TypeError(`${name} is invalid.`);
|
|
69
|
+
}
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeVerifier(value) {
|
|
74
|
+
if (typeof value !== "string" || !/^[a-f0-9]{64}$/iu.test(value)) {
|
|
75
|
+
throw new TypeError("The gateway token verifier is invalid.");
|
|
76
|
+
}
|
|
77
|
+
return value.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizePlatformApiKey(value) {
|
|
81
|
+
if (
|
|
82
|
+
typeof value !== "string" ||
|
|
83
|
+
value.length < 8 ||
|
|
84
|
+
value.length > 512 ||
|
|
85
|
+
/[\s\u0000-\u001f\u007f]/u.test(value)
|
|
86
|
+
) {
|
|
87
|
+
throw new TypeError("The Platform API key file is invalid.");
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeOrigin(value) {
|
|
93
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048) {
|
|
94
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let parsed;
|
|
98
|
+
try {
|
|
99
|
+
parsed = new URL(value);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
106
|
+
parsed.username !== "" ||
|
|
107
|
+
parsed.password !== "" ||
|
|
108
|
+
parsed.origin !== value
|
|
109
|
+
) {
|
|
110
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeAllowedOrigins(values) {
|
|
116
|
+
if (!Array.isArray(values) || values.length > 10) {
|
|
117
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
118
|
+
}
|
|
119
|
+
const normalized = values.map(normalizeOrigin);
|
|
120
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
121
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
122
|
+
}
|
|
123
|
+
return new Set(normalized);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeUpstream(value) {
|
|
127
|
+
const raw = value ?? DEFAULT_UPSTREAM;
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = raw instanceof URL ? new URL(raw.href) : new URL(raw);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new TypeError("The upstream configuration is invalid.");
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
136
|
+
parsed.username !== "" ||
|
|
137
|
+
parsed.password !== "" ||
|
|
138
|
+
parsed.pathname !== "/" ||
|
|
139
|
+
parsed.search !== "" ||
|
|
140
|
+
parsed.hash !== ""
|
|
141
|
+
) {
|
|
142
|
+
throw new TypeError("The upstream configuration is invalid.");
|
|
143
|
+
}
|
|
144
|
+
return parsed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function normalizeHost(value) {
|
|
148
|
+
if (typeof value !== "string" || (isIP(value) === 0 && value !== "localhost")) {
|
|
149
|
+
throw new TypeError("The gateway host is invalid.");
|
|
150
|
+
}
|
|
151
|
+
return value;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizePort(value, { allowZero = false } = {}) {
|
|
155
|
+
if (!Number.isSafeInteger(value) || value < (allowZero ? 0 : 1) || value > 65535) {
|
|
156
|
+
throw new TypeError("The gateway port is invalid.");
|
|
157
|
+
}
|
|
158
|
+
return value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function strictBase64Decode(value) {
|
|
162
|
+
if (
|
|
163
|
+
typeof value !== "string" ||
|
|
164
|
+
value.length === 0 ||
|
|
165
|
+
value.length % 4 !== 0 ||
|
|
166
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
|
|
167
|
+
value,
|
|
168
|
+
)
|
|
169
|
+
) {
|
|
170
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
171
|
+
}
|
|
172
|
+
const decoded = Buffer.from(value, "base64");
|
|
173
|
+
if (decoded.toString("base64") !== value) {
|
|
174
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
175
|
+
}
|
|
176
|
+
return decoded.toString("utf8");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseAllowedOrigins(value) {
|
|
180
|
+
let parsed;
|
|
181
|
+
try {
|
|
182
|
+
parsed = JSON.parse(strictBase64Decode(value));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error instanceof TypeError) {
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
throw new TypeError("The allowed origin configuration is invalid.");
|
|
188
|
+
}
|
|
189
|
+
return [...normalizeAllowedOrigins(parsed)];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parsePort(value) {
|
|
193
|
+
if (value === undefined || value === "") {
|
|
194
|
+
return DEFAULT_PORT;
|
|
195
|
+
}
|
|
196
|
+
if (!/^[0-9]{1,5}$/u.test(value)) {
|
|
197
|
+
throw new TypeError("The gateway port is invalid.");
|
|
198
|
+
}
|
|
199
|
+
return normalizePort(Number(value));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sendJson(response, status, payload, headers = {}) {
|
|
203
|
+
if (response.headersSent || response.destroyed) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const body = Buffer.from(JSON.stringify(payload));
|
|
207
|
+
response.writeHead(status, {
|
|
208
|
+
"Cache-Control": "no-store",
|
|
209
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
210
|
+
"Content-Length": body.length,
|
|
211
|
+
"X-Content-Type-Options": "nosniff",
|
|
212
|
+
...headers,
|
|
213
|
+
});
|
|
214
|
+
response.end(body);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function errorResponse(response, status, code, origin, extraHeaders = {}) {
|
|
218
|
+
const corsHeaders = origin
|
|
219
|
+
? {
|
|
220
|
+
"Access-Control-Allow-Origin": origin,
|
|
221
|
+
Vary: "Origin",
|
|
222
|
+
}
|
|
223
|
+
: {};
|
|
224
|
+
sendJson(
|
|
225
|
+
response,
|
|
226
|
+
status,
|
|
227
|
+
{ error: { code, message: "The request was rejected by the local gateway." } },
|
|
228
|
+
{ ...corsHeaders, ...extraHeaders },
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function headerOccurrences(request, targetName) {
|
|
233
|
+
let count = 0;
|
|
234
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
235
|
+
if (request.rawHeaders[index].toLowerCase() === targetName) {
|
|
236
|
+
count += 1;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return count;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function requestHeaderBytes(request) {
|
|
243
|
+
let total = 2;
|
|
244
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
245
|
+
total += byteLength(request.rawHeaders[index]);
|
|
246
|
+
total += byteLength(request.rawHeaders[index + 1]);
|
|
247
|
+
total += 4;
|
|
248
|
+
}
|
|
249
|
+
return total;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function connectionHeaderNames(headers) {
|
|
253
|
+
const value = headers.connection;
|
|
254
|
+
if (typeof value !== "string") {
|
|
255
|
+
return new Set();
|
|
256
|
+
}
|
|
257
|
+
return new Set(
|
|
258
|
+
value
|
|
259
|
+
.split(",")
|
|
260
|
+
.map((name) => name.trim().toLowerCase())
|
|
261
|
+
.filter(Boolean),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function sanitizeRequestHeaders(request, platformApiKey) {
|
|
266
|
+
const connectionNames = connectionHeaderNames(request.headers);
|
|
267
|
+
const headers = {};
|
|
268
|
+
for (const [rawName, rawValue] of Object.entries(request.headers)) {
|
|
269
|
+
const name = rawName.toLowerCase();
|
|
270
|
+
if (
|
|
271
|
+
rawValue === undefined ||
|
|
272
|
+
HOP_BY_HOP_HEADERS.has(name) ||
|
|
273
|
+
REQUEST_HEADERS_TO_STRIP.has(name) ||
|
|
274
|
+
connectionNames.has(name) ||
|
|
275
|
+
name.startsWith("proxy-") ||
|
|
276
|
+
name.startsWith("x-forwarded-")
|
|
277
|
+
) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
headers[name] = rawValue;
|
|
281
|
+
}
|
|
282
|
+
headers.authorization = `Bearer ${platformApiKey}`;
|
|
283
|
+
return headers;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function sanitizeResponseHeaders(upstreamResponse, origin) {
|
|
287
|
+
const connectionNames = connectionHeaderNames(upstreamResponse.headers);
|
|
288
|
+
const result = [];
|
|
289
|
+
const varyValues = [];
|
|
290
|
+
|
|
291
|
+
for (let index = 0; index < upstreamResponse.rawHeaders.length; index += 2) {
|
|
292
|
+
const rawName = upstreamResponse.rawHeaders[index];
|
|
293
|
+
const rawValue = upstreamResponse.rawHeaders[index + 1];
|
|
294
|
+
const name = rawName.toLowerCase();
|
|
295
|
+
if (
|
|
296
|
+
HOP_BY_HOP_HEADERS.has(name) ||
|
|
297
|
+
connectionNames.has(name) ||
|
|
298
|
+
name.startsWith("access-control-") ||
|
|
299
|
+
name === "location" ||
|
|
300
|
+
name === "set-cookie" ||
|
|
301
|
+
name === "set-cookie2"
|
|
302
|
+
) {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (name === "vary") {
|
|
306
|
+
varyValues.push(rawValue);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
result.push(rawName, rawValue);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const varyNames = new Set(
|
|
313
|
+
varyValues
|
|
314
|
+
.join(",")
|
|
315
|
+
.split(",")
|
|
316
|
+
.map((name) => name.trim())
|
|
317
|
+
.filter(Boolean),
|
|
318
|
+
);
|
|
319
|
+
if (origin) {
|
|
320
|
+
if (![...varyNames].some((name) => name.toLowerCase() === "origin")) {
|
|
321
|
+
varyNames.add("Origin");
|
|
322
|
+
}
|
|
323
|
+
result.push("Access-Control-Allow-Origin", origin);
|
|
324
|
+
}
|
|
325
|
+
if (varyNames.size > 0) {
|
|
326
|
+
result.push("Vary", [...varyNames].join(", "));
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function isExpectedHost(value, allowedHostnames) {
|
|
332
|
+
if (
|
|
333
|
+
typeof value !== "string" ||
|
|
334
|
+
value.length === 0 ||
|
|
335
|
+
value.length > 261 ||
|
|
336
|
+
/[\s,@/?#\\]/u.test(value)
|
|
337
|
+
) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
const authority = /^(\[[^\]]+\]|[^:[\]]+)(?::([0-9]{1,5}))?$/u.exec(value);
|
|
341
|
+
if (!authority) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (authority[2] !== undefined) {
|
|
345
|
+
const port = Number(authority[2]);
|
|
346
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return allowedHostnames.has(authority[1].toLowerCase());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function isSafeTarget(value, maxPathBytes) {
|
|
354
|
+
if (
|
|
355
|
+
typeof value !== "string" ||
|
|
356
|
+
value.length === 0 ||
|
|
357
|
+
byteLength(value) > maxPathBytes
|
|
358
|
+
) {
|
|
359
|
+
return { safe: false, status: 414 };
|
|
360
|
+
}
|
|
361
|
+
if (
|
|
362
|
+
value.startsWith("//") ||
|
|
363
|
+
/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value) ||
|
|
364
|
+
/[\u0000-\u001f\u007f\\#]/u.test(value)
|
|
365
|
+
) {
|
|
366
|
+
return { safe: false, status: 400 };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let parsed;
|
|
370
|
+
try {
|
|
371
|
+
parsed = new URL(value, "http://relmio.invalid");
|
|
372
|
+
} catch {
|
|
373
|
+
return { safe: false, status: 400 };
|
|
374
|
+
}
|
|
375
|
+
if (!ALLOWED_ROUTES.has(parsed.pathname)) {
|
|
376
|
+
return { safe: false, status: 404 };
|
|
377
|
+
}
|
|
378
|
+
return { safe: true, status: 0, pathname: parsed.pathname };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function hasValidBearer(request, verifierBuffer) {
|
|
382
|
+
if (headerOccurrences(request, "authorization") !== 1) {
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
const authorization = request.headers.authorization;
|
|
386
|
+
if (typeof authorization !== "string" || authorization.length > 2048) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
const match = /^Bearer ([^\s,]+)$/iu.exec(authorization);
|
|
390
|
+
if (!match) {
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
const candidate = createHash("sha256").update(match[1], "utf8").digest();
|
|
394
|
+
return timingSafeEqual(candidate, verifierBuffer);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function readOrigin(request, allowedOrigins) {
|
|
398
|
+
const count = headerOccurrences(request, "origin");
|
|
399
|
+
if (count === 0) {
|
|
400
|
+
return { present: false, valid: true, value: undefined };
|
|
401
|
+
}
|
|
402
|
+
const origin = request.headers.origin;
|
|
403
|
+
const valid =
|
|
404
|
+
count === 1 && typeof origin === "string" && allowedOrigins.has(origin);
|
|
405
|
+
return { present: true, valid, value: valid ? origin : undefined };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function isAllowedPreflightHeader(name) {
|
|
409
|
+
return PREFLIGHT_HEADER_NAMES.has(name) || name.startsWith("x-stainless-");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function handlePreflight(request, response, origin, pathname) {
|
|
413
|
+
const requestedMethod = request.headers["access-control-request-method"];
|
|
414
|
+
const requestedHeadersValue = request.headers["access-control-request-headers"] ?? "";
|
|
415
|
+
if (
|
|
416
|
+
typeof requestedMethod !== "string" ||
|
|
417
|
+
!PREFLIGHT_METHODS.has(requestedMethod) ||
|
|
418
|
+
!ALLOWED_ROUTES.get(pathname)?.has(requestedMethod) ||
|
|
419
|
+
typeof requestedHeadersValue !== "string"
|
|
420
|
+
) {
|
|
421
|
+
errorResponse(response, 403, "cors_preflight_rejected", origin);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const requestedHeaders = requestedHeadersValue
|
|
426
|
+
.split(",")
|
|
427
|
+
.map((name) => name.trim().toLowerCase())
|
|
428
|
+
.filter(Boolean);
|
|
429
|
+
if (
|
|
430
|
+
requestedHeaders.length > 32 ||
|
|
431
|
+
!requestedHeaders.includes("authorization") ||
|
|
432
|
+
new Set(requestedHeaders).size !== requestedHeaders.length ||
|
|
433
|
+
requestedHeaders.some((name) => !isAllowedPreflightHeader(name))
|
|
434
|
+
) {
|
|
435
|
+
errorResponse(response, 403, "cors_preflight_rejected", origin);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
response.writeHead(204, {
|
|
440
|
+
"Access-Control-Allow-Headers": requestedHeaders.join(", "),
|
|
441
|
+
"Access-Control-Allow-Methods": requestedMethod,
|
|
442
|
+
"Access-Control-Allow-Origin": origin,
|
|
443
|
+
"Access-Control-Max-Age": "600",
|
|
444
|
+
"Cache-Control": "no-store",
|
|
445
|
+
Vary: "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
|
446
|
+
});
|
|
447
|
+
response.end();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function proxyRequest(request, response, state, origin) {
|
|
451
|
+
state.activeRequests += 1;
|
|
452
|
+
let released = false;
|
|
453
|
+
let rejectedForSize = false;
|
|
454
|
+
let responseHeaderTimedOut = false;
|
|
455
|
+
let responseHeaderTimer;
|
|
456
|
+
let upstreamResponse;
|
|
457
|
+
let inboundDone = request.readableEnded;
|
|
458
|
+
let downstreamDone = response.writableEnded;
|
|
459
|
+
const releaseIfDone = () => {
|
|
460
|
+
if (!released && inboundDone && downstreamDone) {
|
|
461
|
+
released = true;
|
|
462
|
+
state.activeRequests -= 1;
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const markInboundDone = () => {
|
|
466
|
+
inboundDone = true;
|
|
467
|
+
releaseIfDone();
|
|
468
|
+
};
|
|
469
|
+
const markDownstreamDone = () => {
|
|
470
|
+
downstreamDone = true;
|
|
471
|
+
releaseIfDone();
|
|
472
|
+
};
|
|
473
|
+
response.once("finish", markDownstreamDone);
|
|
474
|
+
response.once("close", markDownstreamDone);
|
|
475
|
+
|
|
476
|
+
const headers = sanitizeRequestHeaders(request, state.platformApiKey);
|
|
477
|
+
const transport = state.upstream.protocol === "https:" ? httpsRequest : httpRequest;
|
|
478
|
+
const upstreamRequest = transport(
|
|
479
|
+
{
|
|
480
|
+
protocol: state.upstream.protocol,
|
|
481
|
+
hostname: state.upstream.hostname,
|
|
482
|
+
port: state.upstream.port,
|
|
483
|
+
method: request.method,
|
|
484
|
+
path: request.url,
|
|
485
|
+
headers,
|
|
486
|
+
},
|
|
487
|
+
(incoming) => {
|
|
488
|
+
clearTimeout(responseHeaderTimer);
|
|
489
|
+
upstreamResponse = incoming;
|
|
490
|
+
if (response.destroyed) {
|
|
491
|
+
incoming.destroy();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const responseHeaders = sanitizeResponseHeaders(incoming, origin);
|
|
495
|
+
response.writeHead(
|
|
496
|
+
incoming.statusCode ?? 502,
|
|
497
|
+
incoming.statusMessage,
|
|
498
|
+
responseHeaders,
|
|
499
|
+
);
|
|
500
|
+
const upstreamSocket = incoming.socket;
|
|
501
|
+
let downstreamStallTimer;
|
|
502
|
+
const disableIdleTimeout = () => upstreamSocket.setTimeout(0);
|
|
503
|
+
const armIdleTimeout = () =>
|
|
504
|
+
upstreamSocket.setTimeout(state.upstreamIdleTimeoutMs);
|
|
505
|
+
const clearDownstreamStallTimeout = () => {
|
|
506
|
+
clearTimeout(downstreamStallTimer);
|
|
507
|
+
downstreamStallTimer = undefined;
|
|
508
|
+
};
|
|
509
|
+
const abortStreaming = () => {
|
|
510
|
+
clearDownstreamStallTimeout();
|
|
511
|
+
disableIdleTimeout();
|
|
512
|
+
incoming.destroy();
|
|
513
|
+
upstreamRequest.destroy();
|
|
514
|
+
if (!response.destroyed) {
|
|
515
|
+
response.destroy();
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
const handlePause = () => {
|
|
519
|
+
disableIdleTimeout();
|
|
520
|
+
clearDownstreamStallTimeout();
|
|
521
|
+
downstreamStallTimer = setTimeout(
|
|
522
|
+
abortStreaming,
|
|
523
|
+
state.downstreamStallTimeoutMs,
|
|
524
|
+
);
|
|
525
|
+
downstreamStallTimer.unref();
|
|
526
|
+
};
|
|
527
|
+
const handleResume = () => {
|
|
528
|
+
clearDownstreamStallTimeout();
|
|
529
|
+
armIdleTimeout();
|
|
530
|
+
};
|
|
531
|
+
const clearStreamTimeouts = () => {
|
|
532
|
+
clearDownstreamStallTimeout();
|
|
533
|
+
disableIdleTimeout();
|
|
534
|
+
upstreamSocket.off("timeout", abortStreaming);
|
|
535
|
+
};
|
|
536
|
+
upstreamSocket.once("timeout", abortStreaming);
|
|
537
|
+
incoming.on("pause", handlePause);
|
|
538
|
+
incoming.on("resume", handleResume);
|
|
539
|
+
incoming.once("close", clearStreamTimeouts);
|
|
540
|
+
incoming.once("error", () => {
|
|
541
|
+
clearStreamTimeouts();
|
|
542
|
+
if (!response.destroyed) {
|
|
543
|
+
response.destroy();
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
armIdleTimeout();
|
|
547
|
+
incoming.pipe(response);
|
|
548
|
+
incoming.once("end", clearStreamTimeouts);
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
responseHeaderTimer = setTimeout(() => {
|
|
553
|
+
responseHeaderTimedOut = true;
|
|
554
|
+
upstreamRequest.destroy();
|
|
555
|
+
if (!response.headersSent) {
|
|
556
|
+
errorResponse(response, 504, "upstream_timeout", origin);
|
|
557
|
+
} else if (!response.destroyed) {
|
|
558
|
+
response.destroy();
|
|
559
|
+
}
|
|
560
|
+
}, state.upstreamResponseHeaderTimeoutMs);
|
|
561
|
+
responseHeaderTimer.unref();
|
|
562
|
+
|
|
563
|
+
upstreamRequest.once("error", () => {
|
|
564
|
+
clearTimeout(responseHeaderTimer);
|
|
565
|
+
if (rejectedForSize || responseHeaderTimedOut || response.destroyed) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (!response.headersSent) {
|
|
569
|
+
errorResponse(response, 502, "upstream_unavailable", origin);
|
|
570
|
+
} else {
|
|
571
|
+
response.destroy();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const cancelUpstream = () => {
|
|
576
|
+
clearTimeout(responseHeaderTimer);
|
|
577
|
+
upstreamRequest.destroy();
|
|
578
|
+
upstreamResponse?.destroy();
|
|
579
|
+
};
|
|
580
|
+
request.once("aborted", cancelUpstream);
|
|
581
|
+
request.once("aborted", markInboundDone);
|
|
582
|
+
response.once("close", () => {
|
|
583
|
+
if (!response.writableEnded) {
|
|
584
|
+
cancelUpstream();
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
let receivedBytes = 0;
|
|
589
|
+
request.on("data", (chunk) => {
|
|
590
|
+
if (rejectedForSize) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
receivedBytes += chunk.length;
|
|
594
|
+
if (receivedBytes > state.maxBodyBytes) {
|
|
595
|
+
rejectedForSize = true;
|
|
596
|
+
upstreamRequest.destroy();
|
|
597
|
+
errorResponse(response, 413, "request_body_too_large", origin);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (!upstreamRequest.write(chunk)) {
|
|
601
|
+
request.pause();
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
upstreamRequest.on("drain", () => request.resume());
|
|
605
|
+
request.once("end", () => {
|
|
606
|
+
markInboundDone();
|
|
607
|
+
if (!rejectedForSize) {
|
|
608
|
+
upstreamRequest.end();
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
request.once("error", cancelUpstream);
|
|
612
|
+
request.once("error", markInboundDone);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function createRequestHandler(state) {
|
|
616
|
+
return (request, response) => {
|
|
617
|
+
if (requestHeaderBytes(request) > state.maxHeaderBytes) {
|
|
618
|
+
errorResponse(response, 431, "request_headers_too_large");
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (
|
|
622
|
+
headerOccurrences(request, "host") !== 1 ||
|
|
623
|
+
!isExpectedHost(request.headers.host, state.allowedHostnames)
|
|
624
|
+
) {
|
|
625
|
+
errorResponse(response, 421, "unexpected_host");
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (request.method === "GET" && request.url === "/health") {
|
|
630
|
+
sendJson(response, 200, { status: "ok" });
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const target = isSafeTarget(request.url, state.maxPathBytes);
|
|
635
|
+
if (!target.safe) {
|
|
636
|
+
errorResponse(response, target.status, "invalid_target");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const originState = readOrigin(request, state.allowedOrigins);
|
|
641
|
+
const origin = originState.value;
|
|
642
|
+
if (request.method === "OPTIONS") {
|
|
643
|
+
if (!originState.present || !originState.valid) {
|
|
644
|
+
errorResponse(response, 403, "origin_rejected");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
handlePreflight(request, response, origin, target.pathname);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (!hasValidBearer(request, state.verifierBuffer)) {
|
|
652
|
+
errorResponse(response, 401, "authentication_required", origin, {
|
|
653
|
+
"WWW-Authenticate": "Bearer",
|
|
654
|
+
});
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (originState.present && !originState.valid) {
|
|
658
|
+
errorResponse(response, 403, "origin_rejected");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (request.method === "TRACE" || request.method === "CONNECT") {
|
|
662
|
+
errorResponse(response, 405, "method_not_allowed", origin);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
if (!ALLOWED_ROUTES.get(target.pathname)?.has(request.method)) {
|
|
666
|
+
errorResponse(response, 405, "method_not_allowed", origin);
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const rawContentLength = request.headers["content-length"];
|
|
671
|
+
if (
|
|
672
|
+
rawContentLength !== undefined &&
|
|
673
|
+
(typeof rawContentLength !== "string" ||
|
|
674
|
+
!/^(?:0|[1-9][0-9]*)$/u.test(rawContentLength) ||
|
|
675
|
+
Number(rawContentLength) > state.maxBodyBytes)
|
|
676
|
+
) {
|
|
677
|
+
errorResponse(response, 413, "request_body_too_large", origin);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
if (state.activeRequests >= state.maxConcurrentRequests) {
|
|
681
|
+
errorResponse(response, 429, "gateway_overloaded", origin, {
|
|
682
|
+
"Retry-After": "1",
|
|
683
|
+
});
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
proxyRequest(request, response, state, origin);
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function hashGatewayToken(value) {
|
|
691
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048) {
|
|
692
|
+
throw new TypeError("The gateway token is invalid.");
|
|
693
|
+
}
|
|
694
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export async function loadOpenAIGatewayConfig(
|
|
698
|
+
environment = process.env,
|
|
699
|
+
{ readFile = readFileFromDisk } = {},
|
|
700
|
+
) {
|
|
701
|
+
const keyFile = environment.OPENAI_API_KEY_FILE;
|
|
702
|
+
if (typeof keyFile !== "string" || keyFile.length === 0 || keyFile.length > 4096) {
|
|
703
|
+
throw new TypeError("The Platform API key file is invalid.");
|
|
704
|
+
}
|
|
705
|
+
const rawKey = await readFile(keyFile, "utf8");
|
|
706
|
+
if (typeof rawKey !== "string") {
|
|
707
|
+
throw new TypeError("The Platform API key file is invalid.");
|
|
708
|
+
}
|
|
709
|
+
const keyWithoutTerminator = rawKey.endsWith("\r\n")
|
|
710
|
+
? rawKey.slice(0, -2)
|
|
711
|
+
: rawKey.endsWith("\n")
|
|
712
|
+
? rawKey.slice(0, -1)
|
|
713
|
+
: rawKey;
|
|
714
|
+
const platformApiKey = normalizePlatformApiKey(keyWithoutTerminator);
|
|
715
|
+
const tokenVerifier = normalizeVerifier(
|
|
716
|
+
environment.RELMIO_GATEWAY_TOKEN_SHA256,
|
|
717
|
+
);
|
|
718
|
+
const allowedOrigins = parseAllowedOrigins(
|
|
719
|
+
environment.RELMIO_ALLOWED_ORIGINS_BASE64,
|
|
720
|
+
);
|
|
721
|
+
const host = normalizeHost(environment.RELMIO_GATEWAY_HOST ?? DEFAULT_HOST);
|
|
722
|
+
const port = parsePort(environment.RELMIO_GATEWAY_PORT);
|
|
723
|
+
return { platformApiKey, tokenVerifier, allowedOrigins, host, port };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function createOpenAIGatewayServer(options) {
|
|
727
|
+
if (!options || typeof options !== "object") {
|
|
728
|
+
throw new TypeError("The gateway configuration is invalid.");
|
|
729
|
+
}
|
|
730
|
+
const verifier = normalizeVerifier(options.tokenVerifier);
|
|
731
|
+
const allowedHostnames = new Set(
|
|
732
|
+
options.allowedHostnames ?? LOOPBACK_HOSTNAMES,
|
|
733
|
+
);
|
|
734
|
+
if (
|
|
735
|
+
allowedHostnames.size === 0 ||
|
|
736
|
+
[...allowedHostnames].some(
|
|
737
|
+
(hostname) => typeof hostname !== "string" || hostname !== hostname.toLowerCase(),
|
|
738
|
+
)
|
|
739
|
+
) {
|
|
740
|
+
throw new TypeError("The allowed Host configuration is invalid.");
|
|
741
|
+
}
|
|
742
|
+
const state = {
|
|
743
|
+
activeRequests: 0,
|
|
744
|
+
platformApiKey: normalizePlatformApiKey(options.platformApiKey),
|
|
745
|
+
verifierBuffer: Buffer.from(verifier, "hex"),
|
|
746
|
+
allowedOrigins: normalizeAllowedOrigins(options.allowedOrigins),
|
|
747
|
+
allowedHostnames,
|
|
748
|
+
upstream: normalizeUpstream(options.upstreamBaseUrl),
|
|
749
|
+
maxHeaderBytes: normalizePositiveInteger(
|
|
750
|
+
options.maxHeaderBytes ?? DEFAULT_LIMITS.maxHeaderBytes,
|
|
751
|
+
"The maximum header size",
|
|
752
|
+
),
|
|
753
|
+
maxPathBytes: normalizePositiveInteger(
|
|
754
|
+
options.maxPathBytes ?? DEFAULT_LIMITS.maxPathBytes,
|
|
755
|
+
"The maximum path size",
|
|
756
|
+
),
|
|
757
|
+
maxBodyBytes: normalizePositiveInteger(
|
|
758
|
+
options.maxBodyBytes ?? DEFAULT_LIMITS.maxBodyBytes,
|
|
759
|
+
"The maximum body size",
|
|
760
|
+
),
|
|
761
|
+
maxConcurrentRequests: normalizePositiveInteger(
|
|
762
|
+
options.maxConcurrentRequests ?? DEFAULT_LIMITS.maxConcurrentRequests,
|
|
763
|
+
"The maximum concurrent request count",
|
|
764
|
+
),
|
|
765
|
+
upstreamResponseHeaderTimeoutMs: normalizePositiveInteger(
|
|
766
|
+
options.upstreamResponseHeaderTimeoutMs ??
|
|
767
|
+
DEFAULT_LIMITS.upstreamResponseHeaderTimeoutMs,
|
|
768
|
+
"The upstream response-header timeout",
|
|
769
|
+
),
|
|
770
|
+
upstreamIdleTimeoutMs: normalizePositiveInteger(
|
|
771
|
+
options.upstreamIdleTimeoutMs ?? DEFAULT_LIMITS.upstreamIdleTimeoutMs,
|
|
772
|
+
"The upstream idle timeout",
|
|
773
|
+
),
|
|
774
|
+
downstreamStallTimeoutMs: normalizePositiveInteger(
|
|
775
|
+
options.downstreamStallTimeoutMs ?? DEFAULT_LIMITS.downstreamStallTimeoutMs,
|
|
776
|
+
"The downstream stall timeout",
|
|
777
|
+
),
|
|
778
|
+
};
|
|
779
|
+
const server = createServer(createRequestHandler(state));
|
|
780
|
+
server.on("connect", (_request, socket) => {
|
|
781
|
+
socket.end(
|
|
782
|
+
"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
|
783
|
+
);
|
|
784
|
+
});
|
|
785
|
+
server.on("clientError", (_error, socket) => {
|
|
786
|
+
if (socket.writable) {
|
|
787
|
+
socket.end(
|
|
788
|
+
"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
return server;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export async function startOpenAIGateway(options) {
|
|
796
|
+
const host = normalizeHost(options?.host ?? DEFAULT_HOST);
|
|
797
|
+
const port = normalizePort(options?.port ?? DEFAULT_PORT, { allowZero: true });
|
|
798
|
+
const server = createOpenAIGatewayServer(options);
|
|
799
|
+
await new Promise((resolve, reject) => {
|
|
800
|
+
server.once("error", reject);
|
|
801
|
+
server.listen(port, host, () => {
|
|
802
|
+
server.off("error", reject);
|
|
803
|
+
resolve();
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
const address = server.address();
|
|
807
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
808
|
+
const publicHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
809
|
+
const originHost = publicHost.includes(":") ? `[${publicHost}]` : publicHost;
|
|
810
|
+
return {
|
|
811
|
+
server,
|
|
812
|
+
origin: `http://${originHost}:${actualPort}`,
|
|
813
|
+
async close() {
|
|
814
|
+
await new Promise((resolve, reject) => {
|
|
815
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
816
|
+
});
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function runFromEnvironment() {
|
|
822
|
+
const config = await loadOpenAIGatewayConfig();
|
|
823
|
+
const gateway = await startOpenAIGateway(config);
|
|
824
|
+
const address = gateway.server.address();
|
|
825
|
+
const port = typeof address === "object" && address ? address.port : config.port;
|
|
826
|
+
process.stdout.write(`Relmio OpenAI API gateway listening on ${config.host}:${port}\n`);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
830
|
+
runFromEnvironment().catch(() => {
|
|
831
|
+
process.stderr.write("Relmio OpenAI API gateway failed to start.\n");
|
|
832
|
+
process.exitCode = 1;
|
|
833
|
+
});
|
|
834
|
+
}
|