openclaw-autoproxy 1.0.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/README.md +210 -0
- package/bin/openclaw-autoproxy.js +209 -0
- package/package.json +35 -0
- package/src/gateway/config.ts +431 -0
- package/src/gateway/proxy.ts +641 -0
- package/src/gateway/server-http.ts +63 -0
- package/src/gateway/server.impl.ts +58 -0
- package/src/gateway/server.ts +35 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import { type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { PassThrough, Readable } from "node:stream";
|
|
3
|
+
import { config, type ModelRouteConfig } from "./config.js";
|
|
4
|
+
|
|
5
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
6
|
+
"connection",
|
|
7
|
+
"keep-alive",
|
|
8
|
+
"proxy-authenticate",
|
|
9
|
+
"proxy-authorization",
|
|
10
|
+
"te",
|
|
11
|
+
"trailer",
|
|
12
|
+
"transfer-encoding",
|
|
13
|
+
"upgrade",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
interface ParsedJsonBody {
|
|
19
|
+
model?: string;
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface GatewaySwitchNotice {
|
|
24
|
+
trigger_status: number;
|
|
25
|
+
from_model: string;
|
|
26
|
+
from_route: string | null;
|
|
27
|
+
to_model: string;
|
|
28
|
+
to_route: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const AUTO_MODEL = "auto";
|
|
32
|
+
let autoModelCursor = 0;
|
|
33
|
+
|
|
34
|
+
function logProxyModelRoute(params: {
|
|
35
|
+
requestedModel: string | null;
|
|
36
|
+
usedModel: string | null;
|
|
37
|
+
routeName: string | null;
|
|
38
|
+
}): void {
|
|
39
|
+
console.log(
|
|
40
|
+
`[gateway] requested_model=${params.requestedModel ?? "-"} used_model=${params.usedModel ?? "-"} route=${params.routeName ?? "-"}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveRouteNameForModel(modelId: string | null): string | null {
|
|
45
|
+
if (modelId && config.modelRouteMap[modelId]) {
|
|
46
|
+
return config.modelRouteMap[modelId].routeName;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return config.modelRouteMap["*"]?.routeName ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function logProxyModelSwitch(params: {
|
|
53
|
+
triggerStatus: number;
|
|
54
|
+
fromModel: string | null;
|
|
55
|
+
toModel: string | null;
|
|
56
|
+
fromRoute: string | null;
|
|
57
|
+
toRoute: string | null;
|
|
58
|
+
}): void {
|
|
59
|
+
console.log(
|
|
60
|
+
`[gateway] switch trigger_status=${params.triggerStatus} from_model=${params.fromModel ?? "-"} from_route=${params.fromRoute ?? "-"} to_model=${params.toModel ?? "-"} to_route=${params.toRoute ?? "-"}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void {
|
|
65
|
+
if (response.writableEnded) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const body = JSON.stringify(payload);
|
|
70
|
+
response.statusCode = statusCode;
|
|
71
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
72
|
+
response.setHeader("content-length", Buffer.byteLength(body));
|
|
73
|
+
response.end(body);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeRequestPath(request: IncomingMessage): string {
|
|
77
|
+
const rawUrl = request.url ?? "/";
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const parsed = new URL(rawUrl, "http://localhost");
|
|
81
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
82
|
+
} catch {
|
|
83
|
+
return rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function rotateCandidates(candidates: string[], startIndex: number): string[] {
|
|
88
|
+
if (candidates.length <= 1) {
|
|
89
|
+
return [...candidates];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const normalizedStart = startIndex % candidates.length;
|
|
93
|
+
return [
|
|
94
|
+
...candidates.slice(normalizedStart),
|
|
95
|
+
...candidates.slice(0, normalizedStart),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildAutoModelCandidates(): string[] {
|
|
100
|
+
const routableModels = Object.keys(config.modelRouteMap).filter(
|
|
101
|
+
(modelName) => modelName !== "*" && modelName.toLowerCase() !== AUTO_MODEL,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (routableModels.length === 0) {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const rotated = rotateCandidates(routableModels, autoModelCursor);
|
|
109
|
+
autoModelCursor = (autoModelCursor + 1) % routableModels.length;
|
|
110
|
+
return rotated;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildModelCandidates(requestedModel: string): string[] {
|
|
114
|
+
if (requestedModel.toLowerCase() === AUTO_MODEL) {
|
|
115
|
+
return buildAutoModelCandidates();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Non-auto requests are pinned to the exact model specified by client.
|
|
119
|
+
return [requestedModel];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildRoutedUpstreamUrl(
|
|
123
|
+
request: IncomingMessage,
|
|
124
|
+
selectedRoute: ModelRouteConfig | null,
|
|
125
|
+
): string {
|
|
126
|
+
if (!selectedRoute) {
|
|
127
|
+
return `${config.upstreamBaseUrl}${normalizeRequestPath(request)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (!selectedRoute.isBaseUrl) {
|
|
131
|
+
return selectedRoute.url;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const routeBase = selectedRoute.url.replace(/\/+$/, "");
|
|
135
|
+
const requestPath = normalizeRequestPath(request);
|
|
136
|
+
|
|
137
|
+
if (routeBase.endsWith("/v1") && requestPath.startsWith("/v1")) {
|
|
138
|
+
return `${routeBase}${requestPath.slice(3)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `${routeBase}${requestPath}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function resolveUpstreamTarget(
|
|
145
|
+
request: IncomingMessage,
|
|
146
|
+
modelId: string | null,
|
|
147
|
+
): { upstreamUrl: string; selectedRoute: ModelRouteConfig | null } {
|
|
148
|
+
const modelRoute = modelId ? config.modelRouteMap[modelId] ?? null : null;
|
|
149
|
+
const wildcardRoute = config.modelRouteMap["*"] ?? null;
|
|
150
|
+
const selectedRoute = modelRoute ?? wildcardRoute;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
upstreamUrl: buildRoutedUpstreamUrl(request, selectedRoute),
|
|
154
|
+
selectedRoute,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function buildUpstreamHeaders(
|
|
159
|
+
reqHeaders: IncomingHttpHeaders,
|
|
160
|
+
bodyLength: number | undefined,
|
|
161
|
+
selectedRoute: ModelRouteConfig | null,
|
|
162
|
+
): Headers {
|
|
163
|
+
const headers = new Headers();
|
|
164
|
+
|
|
165
|
+
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
166
|
+
if (value === undefined) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const lowerKey = key.toLowerCase();
|
|
171
|
+
|
|
172
|
+
if (HOP_BY_HOP_HEADERS.has(lowerKey) || lowerKey === "host" || lowerKey === "content-length") {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
headers.set(key, Array.isArray(value) ? value.join(",") : String(value));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (selectedRoute?.headers) {
|
|
180
|
+
for (const [key, value] of Object.entries(selectedRoute.headers)) {
|
|
181
|
+
headers.set(key, value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (selectedRoute?.apiKey) {
|
|
186
|
+
const authHeader = selectedRoute.authHeader || "authorization";
|
|
187
|
+
const authPrefix = selectedRoute.authPrefix ?? "Bearer ";
|
|
188
|
+
|
|
189
|
+
if (!headers.has(authHeader)) {
|
|
190
|
+
headers.set(authHeader, `${authPrefix}${selectedRoute.apiKey}`);
|
|
191
|
+
}
|
|
192
|
+
} else if (!headers.has("authorization") && config.upstreamApiKey) {
|
|
193
|
+
headers.set("authorization", `Bearer ${config.upstreamApiKey}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (typeof bodyLength === "number") {
|
|
197
|
+
headers.set("content-length", String(bodyLength));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return headers;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isJsonRequest(headers: IncomingHttpHeaders): boolean {
|
|
204
|
+
const contentTypeHeader = headers["content-type"];
|
|
205
|
+
const contentType = Array.isArray(contentTypeHeader)
|
|
206
|
+
? contentTypeHeader.join(";").toLowerCase()
|
|
207
|
+
: String(contentTypeHeader ?? "").toLowerCase();
|
|
208
|
+
|
|
209
|
+
return contentType.includes("application/json");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseJsonBody(buffer: Buffer, shouldParse: boolean): { parsed: ParsedJsonBody | null; error: string | null } {
|
|
213
|
+
if (!shouldParse || buffer.length === 0) {
|
|
214
|
+
return { parsed: null, error: null };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(buffer.toString("utf8"));
|
|
219
|
+
|
|
220
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
221
|
+
return { parsed: parsed as ParsedJsonBody, error: null };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { parsed: null, error: null };
|
|
225
|
+
} catch {
|
|
226
|
+
return { parsed: null, error: "Invalid JSON body." };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseStatusLikeCode(value: unknown): number | null {
|
|
231
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
232
|
+
return value >= 100 && value <= 9999 ? value : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (typeof value === "string") {
|
|
236
|
+
const trimmed = value.trim();
|
|
237
|
+
|
|
238
|
+
if (!trimmed) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
243
|
+
return Number.isInteger(parsed) && parsed >= 100 && parsed <= 9999 ? parsed : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function extractRetryStatusFromJsonPayload(payload: unknown): number | null {
|
|
250
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const record = payload as Record<string, unknown>;
|
|
255
|
+
const candidates: unknown[] = [record.status, record.code, record.errorCode];
|
|
256
|
+
|
|
257
|
+
const nestedError = record.error;
|
|
258
|
+
if (nestedError && typeof nestedError === "object" && !Array.isArray(nestedError)) {
|
|
259
|
+
const nested = nestedError as Record<string, unknown>;
|
|
260
|
+
candidates.push(nested.status, nested.code, nested.errorCode);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const nestedErrors = record.errors;
|
|
264
|
+
if (Array.isArray(nestedErrors)) {
|
|
265
|
+
for (const item of nestedErrors) {
|
|
266
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const nested = item as Record<string, unknown>;
|
|
271
|
+
candidates.push(nested.status, nested.code, nested.errorCode);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const candidate of candidates) {
|
|
276
|
+
const parsed = parseStatusLikeCode(candidate);
|
|
277
|
+
|
|
278
|
+
if (parsed !== null) {
|
|
279
|
+
return parsed;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function detectRetryStatusFromBody(
|
|
287
|
+
upstreamResponse: Response,
|
|
288
|
+
retryStatusCodes: Set<number>,
|
|
289
|
+
): Promise<number | null> {
|
|
290
|
+
const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
|
|
291
|
+
|
|
292
|
+
if (!contentType.includes("application/json") || contentType.includes("text/event-stream")) {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const raw = await upstreamResponse.clone().text();
|
|
298
|
+
|
|
299
|
+
if (!raw.trim()) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const parsed = JSON.parse(raw);
|
|
304
|
+
const statusFromPayload = extractRetryStatusFromJsonPayload(parsed);
|
|
305
|
+
|
|
306
|
+
if (statusFromPayload !== null && retryStatusCodes.has(statusFromPayload)) {
|
|
307
|
+
return statusFromPayload;
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Ignore probe errors and continue with normal response handling.
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function copyResponseHeaders(upstreamResponse: Response, response: ServerResponse): void {
|
|
317
|
+
for (const [key, value] of upstreamResponse.headers.entries()) {
|
|
318
|
+
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
response.setHeader(key, value);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise<Response> {
|
|
327
|
+
const controller = new AbortController();
|
|
328
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
332
|
+
} finally {
|
|
333
|
+
clearTimeout(timeoutId);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function disposeBody(response: Response): Promise<void> {
|
|
338
|
+
if (!response.body) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await response.body.cancel();
|
|
344
|
+
} catch {
|
|
345
|
+
// Body cancellation is best effort.
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function createSsePrefixedStream(source: Readable, notice: GatewaySwitchNotice): PassThrough {
|
|
350
|
+
const passthrough = new PassThrough();
|
|
351
|
+
passthrough.write(`event: gateway_notice\ndata: ${JSON.stringify(notice)}\n\n`);
|
|
352
|
+
|
|
353
|
+
source.on("error", () => {
|
|
354
|
+
passthrough.end();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
source.pipe(passthrough);
|
|
358
|
+
return passthrough;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function readRequestBody(request: IncomingMessage): Promise<Buffer> {
|
|
362
|
+
return await new Promise<Buffer>((resolve, reject) => {
|
|
363
|
+
const chunks: Buffer[] = [];
|
|
364
|
+
let totalSize = 0;
|
|
365
|
+
let settled = false;
|
|
366
|
+
|
|
367
|
+
request.on("data", (chunk: Buffer | string) => {
|
|
368
|
+
if (settled) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const normalizedChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
373
|
+
totalSize += normalizedChunk.length;
|
|
374
|
+
|
|
375
|
+
if (totalSize > MAX_REQUEST_BODY_BYTES) {
|
|
376
|
+
settled = true;
|
|
377
|
+
request.destroy();
|
|
378
|
+
reject(new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`));
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
chunks.push(normalizedChunk);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
request.on("end", () => {
|
|
386
|
+
if (settled) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
settled = true;
|
|
391
|
+
resolve(Buffer.concat(chunks));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
request.on("aborted", () => {
|
|
395
|
+
if (settled) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
settled = true;
|
|
400
|
+
reject(new Error("Request was aborted by client."));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
request.on("error", (error) => {
|
|
404
|
+
if (settled) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
settled = true;
|
|
409
|
+
reject(error);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function proxyRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
415
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
416
|
+
const supportsBody = method !== "GET" && method !== "HEAD";
|
|
417
|
+
let incomingBody: Buffer = Buffer.alloc(0);
|
|
418
|
+
|
|
419
|
+
if (supportsBody) {
|
|
420
|
+
try {
|
|
421
|
+
incomingBody = await readRequestBody(request);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
const isBodyTooLarge =
|
|
424
|
+
error instanceof Error &&
|
|
425
|
+
error.message.includes("exceeds") &&
|
|
426
|
+
error.message.includes("bytes");
|
|
427
|
+
sendJson(response, isBodyTooLarge ? 413 : 400, {
|
|
428
|
+
error: {
|
|
429
|
+
message: "Failed to read request body.",
|
|
430
|
+
detail: error instanceof Error ? error.message : "Unknown error",
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const wantsJson = isJsonRequest(request.headers);
|
|
438
|
+
const { parsed: parsedJsonBody, error: parseError } = parseJsonBody(incomingBody, wantsJson);
|
|
439
|
+
|
|
440
|
+
if (parseError) {
|
|
441
|
+
sendJson(response, 400, {
|
|
442
|
+
error: {
|
|
443
|
+
message: parseError,
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const requestedModel =
|
|
450
|
+
parsedJsonBody &&
|
|
451
|
+
typeof parsedJsonBody.model === "string" &&
|
|
452
|
+
parsedJsonBody.model.trim()
|
|
453
|
+
? parsedJsonBody.model.trim()
|
|
454
|
+
: null;
|
|
455
|
+
|
|
456
|
+
const modelCandidates: Array<string | null> = requestedModel
|
|
457
|
+
? buildModelCandidates(requestedModel)
|
|
458
|
+
: [null];
|
|
459
|
+
|
|
460
|
+
if (requestedModel?.toLowerCase() === AUTO_MODEL && modelCandidates.length === 0) {
|
|
461
|
+
sendJson(response, 400, {
|
|
462
|
+
error: {
|
|
463
|
+
message:
|
|
464
|
+
'No auto model candidates configured. Set routes, GLOBAL_FALLBACK_MODELS, or MODEL_FALLBACK_MAP["auto"].',
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let lastError: unknown = null;
|
|
471
|
+
let lastAttemptRouteName: string | null = null;
|
|
472
|
+
let switchNotice: GatewaySwitchNotice | null = null;
|
|
473
|
+
|
|
474
|
+
for (let attemptIndex = 0; attemptIndex < modelCandidates.length; attemptIndex += 1) {
|
|
475
|
+
const modelId = modelCandidates[attemptIndex];
|
|
476
|
+
let bodyBuffer = supportsBody && incomingBody.length > 0 ? incomingBody : undefined;
|
|
477
|
+
|
|
478
|
+
if (supportsBody && parsedJsonBody && modelId) {
|
|
479
|
+
bodyBuffer = Buffer.from(
|
|
480
|
+
JSON.stringify({
|
|
481
|
+
...parsedJsonBody,
|
|
482
|
+
model: modelId,
|
|
483
|
+
}),
|
|
484
|
+
"utf8",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const { upstreamUrl, selectedRoute } = resolveUpstreamTarget(request, modelId);
|
|
489
|
+
lastAttemptRouteName = selectedRoute?.routeName ?? null;
|
|
490
|
+
const requestBody = bodyBuffer ? new Uint8Array(bodyBuffer) : undefined;
|
|
491
|
+
const headers = buildUpstreamHeaders(
|
|
492
|
+
request.headers,
|
|
493
|
+
bodyBuffer ? bodyBuffer.length : undefined,
|
|
494
|
+
selectedRoute,
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
try {
|
|
498
|
+
const upstreamResponse = await fetchWithTimeout(
|
|
499
|
+
upstreamUrl,
|
|
500
|
+
{
|
|
501
|
+
method,
|
|
502
|
+
headers,
|
|
503
|
+
body: requestBody,
|
|
504
|
+
},
|
|
505
|
+
config.timeoutMs,
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
|
|
509
|
+
const isEventStream = contentType.includes("text/event-stream");
|
|
510
|
+
const isJsonResponse = contentType.includes("application/json");
|
|
511
|
+
const hasNextCandidate = attemptIndex < modelCandidates.length - 1;
|
|
512
|
+
const httpRetryStatus = config.retryStatusCodes.has(upstreamResponse.status)
|
|
513
|
+
? upstreamResponse.status
|
|
514
|
+
: null;
|
|
515
|
+
const bodyRetryStatus = !httpRetryStatus && hasNextCandidate
|
|
516
|
+
? await detectRetryStatusFromBody(upstreamResponse, config.retryStatusCodes)
|
|
517
|
+
: null;
|
|
518
|
+
const retryTriggerStatus = httpRetryStatus ?? bodyRetryStatus;
|
|
519
|
+
|
|
520
|
+
const canRetry = retryTriggerStatus !== null && hasNextCandidate;
|
|
521
|
+
|
|
522
|
+
if (canRetry) {
|
|
523
|
+
const nextModel = modelCandidates[attemptIndex + 1];
|
|
524
|
+
const triggerStatus = retryTriggerStatus ?? upstreamResponse.status;
|
|
525
|
+
const nextRouteName = resolveRouteNameForModel(nextModel);
|
|
526
|
+
|
|
527
|
+
logProxyModelSwitch({
|
|
528
|
+
triggerStatus,
|
|
529
|
+
fromModel: modelId,
|
|
530
|
+
toModel: nextModel,
|
|
531
|
+
fromRoute: selectedRoute?.routeName ?? null,
|
|
532
|
+
toRoute: nextRouteName,
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
if (modelId && nextModel && nextModel !== modelId) {
|
|
536
|
+
switchNotice = {
|
|
537
|
+
trigger_status: triggerStatus,
|
|
538
|
+
from_model: modelId,
|
|
539
|
+
from_route: selectedRoute?.routeName ?? null,
|
|
540
|
+
to_model: nextModel,
|
|
541
|
+
to_route: nextRouteName,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
await disposeBody(upstreamResponse);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const attemptCount = attemptIndex + 1;
|
|
550
|
+
const effectiveSwitchNotice: GatewaySwitchNotice | null = switchNotice;
|
|
551
|
+
|
|
552
|
+
copyResponseHeaders(upstreamResponse, response);
|
|
553
|
+
response.setHeader("x-gateway-attempt-count", String(attemptCount));
|
|
554
|
+
|
|
555
|
+
if (modelId) {
|
|
556
|
+
response.setHeader("x-gateway-model-used", modelId);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (effectiveSwitchNotice) {
|
|
560
|
+
response.setHeader("x-gateway-switched", "1");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
logProxyModelRoute({
|
|
564
|
+
requestedModel,
|
|
565
|
+
usedModel: modelId,
|
|
566
|
+
routeName: selectedRoute?.routeName ?? null,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
response.statusCode = upstreamResponse.status;
|
|
570
|
+
|
|
571
|
+
if (!upstreamResponse.body) {
|
|
572
|
+
response.end();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (effectiveSwitchNotice && isJsonResponse && !isEventStream) {
|
|
577
|
+
const rawText = await upstreamResponse.text();
|
|
578
|
+
response.removeHeader("content-length");
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
const parsed = JSON.parse(rawText);
|
|
582
|
+
|
|
583
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
584
|
+
(parsed as Record<string, unknown>).gateway_notice = effectiveSwitchNotice;
|
|
585
|
+
response.end(JSON.stringify(parsed));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
// Keep original response body when JSON mutation is not possible.
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
response.end(rawText);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const nodeStream = Readable.fromWeb(upstreamResponse.body as any);
|
|
597
|
+
|
|
598
|
+
if (effectiveSwitchNotice && isEventStream) {
|
|
599
|
+
response.removeHeader("content-length");
|
|
600
|
+
createSsePrefixedStream(nodeStream, effectiveSwitchNotice).pipe(response);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
nodeStream.on("error", () => {
|
|
605
|
+
if (!response.writableEnded) {
|
|
606
|
+
response.destroy();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
nodeStream.pipe(response);
|
|
611
|
+
return;
|
|
612
|
+
} catch (error) {
|
|
613
|
+
lastError = error;
|
|
614
|
+
|
|
615
|
+
if (attemptIndex < modelCandidates.length - 1) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const timeoutLike =
|
|
622
|
+
lastError &&
|
|
623
|
+
typeof lastError === "object" &&
|
|
624
|
+
"name" in lastError &&
|
|
625
|
+
(lastError as { name?: unknown }).name === "AbortError";
|
|
626
|
+
const errorStatusCode = timeoutLike ? 504 : 502;
|
|
627
|
+
const lastTriedModel = modelCandidates[modelCandidates.length - 1] ?? null;
|
|
628
|
+
|
|
629
|
+
logProxyModelRoute({
|
|
630
|
+
requestedModel,
|
|
631
|
+
usedModel: lastTriedModel,
|
|
632
|
+
routeName: lastAttemptRouteName,
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
sendJson(response, errorStatusCode, {
|
|
636
|
+
error: {
|
|
637
|
+
message: "Gateway failed to reach upstream provider.",
|
|
638
|
+
detail: lastError instanceof Error ? lastError.message : "Unknown error",
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
import { proxyRequest } from "./proxy.js";
|
|
4
|
+
|
|
5
|
+
function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void {
|
|
6
|
+
if (response.writableEnded) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const body = JSON.stringify(payload);
|
|
11
|
+
response.statusCode = statusCode;
|
|
12
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
13
|
+
response.setHeader("content-length", Buffer.byteLength(body));
|
|
14
|
+
response.end(body);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolvePathname(request: IncomingMessage): string {
|
|
18
|
+
const rawUrl = request.url ?? "/";
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return new URL(rawUrl, "http://localhost").pathname;
|
|
22
|
+
} catch {
|
|
23
|
+
return rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
28
|
+
const method = (request.method ?? "GET").toUpperCase();
|
|
29
|
+
const pathname = resolvePathname(request);
|
|
30
|
+
|
|
31
|
+
if ((method === "GET" || method === "HEAD") && pathname === "/health") {
|
|
32
|
+
sendJson(response, 200, {
|
|
33
|
+
status: "ok",
|
|
34
|
+
retryStatusCodes: Array.from(config.retryStatusCodes),
|
|
35
|
+
enabledRouteCount: Object.keys(config.modelRouteMap).length,
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (pathname === "/v1" || pathname.startsWith("/v1/")) {
|
|
41
|
+
await proxyRequest(request, response);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
sendJson(response, 404, {
|
|
46
|
+
error: {
|
|
47
|
+
message: "Route not found. Use /v1/* or /health.",
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createGatewayHttpServer(): Server {
|
|
53
|
+
return createServer((request, response) => {
|
|
54
|
+
void handleRequest(request, response).catch((error) => {
|
|
55
|
+
sendJson(response, 500, {
|
|
56
|
+
error: {
|
|
57
|
+
message: "Unexpected gateway error.",
|
|
58
|
+
detail: error instanceof Error ? error.message : "Unknown error",
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|