openclaw-autoproxy 1.0.2 → 1.0.5
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 +66 -159
- package/README.zh-CN.md +127 -0
- package/dist/gateway/anthropic-compat.js +841 -0
- package/dist/gateway/config.js +16 -0
- package/dist/gateway/model-load-metrics.js +114 -0
- package/dist/gateway/proxy.js +324 -19
- package/dist/gateway/server-http.js +12 -2
- package/dist/gateway/server.impl.js +1 -1
- package/package.json +2 -1
- package/src/gateway/anthropic-compat.ts +1085 -0
- package/src/gateway/config.ts +29 -0
- package/src/gateway/model-load-metrics.ts +166 -0
- package/src/gateway/proxy.ts +443 -25
- package/src/gateway/server-http.ts +16 -2
- package/src/gateway/server.impl.ts +1 -1
- package/openclaw-autoproxy-1.0.1.tgz +0 -0
package/src/gateway/proxy.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
2
|
import { PassThrough, Readable } from "node:stream";
|
|
3
|
+
import { Agent } from "undici";
|
|
4
|
+
import {
|
|
5
|
+
createAnthropicMessagesEventStreamTransformer,
|
|
6
|
+
maybeTransformAnthropicMessagesRequest,
|
|
7
|
+
transformOpenAiChatCompletionToAnthropicMessage,
|
|
8
|
+
transformUpstreamErrorToAnthropicError,
|
|
9
|
+
} from "./anthropic-compat.js";
|
|
3
10
|
import { config, type ModelRouteConfig } from "./config.js";
|
|
11
|
+
import { recordModelLoadSample } from "./model-load-metrics.js";
|
|
4
12
|
|
|
5
13
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
6
14
|
"connection",
|
|
@@ -28,16 +36,66 @@ interface GatewaySwitchNotice {
|
|
|
28
36
|
to_route: string | null;
|
|
29
37
|
}
|
|
30
38
|
|
|
39
|
+
type GatewayProtocol = "openai" | "anthropic";
|
|
40
|
+
|
|
31
41
|
const AUTO_MODEL = "auto";
|
|
32
42
|
let autoModelCursor = 0;
|
|
33
43
|
|
|
44
|
+
const upstreamAgent = new Agent({
|
|
45
|
+
connections: config.upstreamMaxConnections,
|
|
46
|
+
pipelining: 1,
|
|
47
|
+
keepAliveTimeout: config.upstreamKeepAliveTimeoutMs,
|
|
48
|
+
keepAliveMaxTimeout: config.upstreamKeepAliveMaxTimeoutMs,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
interface RequestInitWithDispatcher extends RequestInit {
|
|
52
|
+
dispatcher?: Agent;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const fetchWithDispatcher = fetch as unknown as (
|
|
56
|
+
input: string,
|
|
57
|
+
init?: RequestInitWithDispatcher,
|
|
58
|
+
) => Promise<Response>;
|
|
59
|
+
|
|
60
|
+
function formatGatewayLogValue(value: string | number | null | undefined): string {
|
|
61
|
+
if (value === null || value === undefined || value === "") {
|
|
62
|
+
return "-";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const normalized = String(value);
|
|
66
|
+
return /\s|"/.test(normalized) ? JSON.stringify(normalized) : normalized;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildGatewayLogLine(
|
|
70
|
+
protocol: GatewayProtocol,
|
|
71
|
+
event: string,
|
|
72
|
+
fields: Record<string, string | number | null | undefined>,
|
|
73
|
+
): string {
|
|
74
|
+
const parts = [
|
|
75
|
+
"[gateway]",
|
|
76
|
+
`protocol=${formatGatewayLogValue(protocol)}`,
|
|
77
|
+
`event=${formatGatewayLogValue(event)}`,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
81
|
+
parts.push(`${key}=${formatGatewayLogValue(value)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return parts.join(" ");
|
|
85
|
+
}
|
|
86
|
+
|
|
34
87
|
function logProxyModelRoute(params: {
|
|
88
|
+
protocol: GatewayProtocol;
|
|
35
89
|
requestedModel: string | null;
|
|
36
90
|
usedModel: string | null;
|
|
37
91
|
routeName: string | null;
|
|
38
92
|
}): void {
|
|
39
93
|
console.log(
|
|
40
|
-
|
|
94
|
+
buildGatewayLogLine(params.protocol, "routed", {
|
|
95
|
+
requested_model: params.requestedModel,
|
|
96
|
+
used_model: params.usedModel,
|
|
97
|
+
route: params.routeName,
|
|
98
|
+
}),
|
|
41
99
|
);
|
|
42
100
|
}
|
|
43
101
|
|
|
@@ -50,6 +108,7 @@ function resolveRouteNameForModel(modelId: string | null): string | null {
|
|
|
50
108
|
}
|
|
51
109
|
|
|
52
110
|
function logProxyModelSwitch(params: {
|
|
111
|
+
protocol: GatewayProtocol;
|
|
53
112
|
triggerStatus: number;
|
|
54
113
|
fromModel: string | null;
|
|
55
114
|
toModel: string | null;
|
|
@@ -57,10 +116,36 @@ function logProxyModelSwitch(params: {
|
|
|
57
116
|
toRoute: string | null;
|
|
58
117
|
}): void {
|
|
59
118
|
console.log(
|
|
60
|
-
|
|
119
|
+
buildGatewayLogLine(params.protocol, "switch", {
|
|
120
|
+
trigger_status: params.triggerStatus,
|
|
121
|
+
from_model: params.fromModel,
|
|
122
|
+
from_route: params.fromRoute,
|
|
123
|
+
to_model: params.toModel,
|
|
124
|
+
to_route: params.toRoute,
|
|
125
|
+
}),
|
|
61
126
|
);
|
|
62
127
|
}
|
|
63
128
|
|
|
129
|
+
function resolveGatewayProtocolFromPath(requestPath: string): GatewayProtocol {
|
|
130
|
+
const { pathname } = parsePathnameAndSearch(requestPath);
|
|
131
|
+
|
|
132
|
+
if (
|
|
133
|
+
pathname === "/anthropic" ||
|
|
134
|
+
pathname.startsWith("/anthropic/") ||
|
|
135
|
+
isAnthropicApiPath(pathname)
|
|
136
|
+
) {
|
|
137
|
+
return "anthropic";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return "openai";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resolveGatewayProtocol(request: IncomingMessage): GatewayProtocol {
|
|
144
|
+
const rawUrl = request.url ?? "/";
|
|
145
|
+
const normalizedRawUrl = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
|
|
146
|
+
return resolveGatewayProtocolFromPath(normalizedRawUrl);
|
|
147
|
+
}
|
|
148
|
+
|
|
64
149
|
function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void {
|
|
65
150
|
if (response.writableEnded) {
|
|
66
151
|
return;
|
|
@@ -78,12 +163,31 @@ function normalizeRequestPath(request: IncomingMessage): string {
|
|
|
78
163
|
|
|
79
164
|
try {
|
|
80
165
|
const parsed = new URL(rawUrl, "http://localhost");
|
|
81
|
-
return `${parsed.pathname}${parsed.search}
|
|
166
|
+
return normalizeGatewayRequestPath(`${parsed.pathname}${parsed.search}`);
|
|
82
167
|
} catch {
|
|
83
|
-
|
|
168
|
+
const normalizedRawUrl = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
|
|
169
|
+
return normalizeGatewayRequestPath(normalizedRawUrl);
|
|
84
170
|
}
|
|
85
171
|
}
|
|
86
172
|
|
|
173
|
+
function normalizeGatewayRequestPath(requestPath: string): string {
|
|
174
|
+
const { pathname, search } = parsePathnameAndSearch(requestPath);
|
|
175
|
+
|
|
176
|
+
if (pathname === "/anthropic") {
|
|
177
|
+
return `/v1${search}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (pathname === "/anthropic/v1" || pathname.startsWith("/anthropic/v1/")) {
|
|
181
|
+
return `${pathname.slice("/anthropic".length)}${search}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (pathname.startsWith("/anthropic/")) {
|
|
185
|
+
return `/v1${pathname.slice("/anthropic".length)}${search}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return `${pathname}${search}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
87
191
|
function rotateCandidates(candidates: string[], startIndex: number): string[] {
|
|
88
192
|
if (candidates.length <= 1) {
|
|
89
193
|
return [...candidates];
|
|
@@ -119,20 +223,84 @@ function buildModelCandidates(requestedModel: string): string[] {
|
|
|
119
223
|
return [requestedModel];
|
|
120
224
|
}
|
|
121
225
|
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
226
|
+
function parsePathnameAndSearch(requestPath: string): { pathname: string; search: string } {
|
|
227
|
+
try {
|
|
228
|
+
const parsed = new URL(requestPath, "http://localhost");
|
|
229
|
+
return {
|
|
230
|
+
pathname: parsed.pathname,
|
|
231
|
+
search: parsed.search,
|
|
232
|
+
};
|
|
233
|
+
} catch {
|
|
234
|
+
const [pathnamePart, ...searchParts] = requestPath.split("?");
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
pathname: pathnamePart || "/",
|
|
238
|
+
search: searchParts.length > 0 ? `?${searchParts.join("?")}` : "",
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isAnthropicApiPath(pathname: string): boolean {
|
|
244
|
+
return (
|
|
245
|
+
pathname === "/v1/messages" ||
|
|
246
|
+
pathname.startsWith("/v1/messages/") ||
|
|
247
|
+
pathname === "/v1/models" ||
|
|
248
|
+
pathname === "/v1/complete"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function rewriteFixedChatCompletionsRouteUrlForAnthropic(
|
|
253
|
+
routeUrl: string,
|
|
254
|
+
requestPath: string,
|
|
255
|
+
): string | null {
|
|
256
|
+
const { pathname: requestPathname, search: requestSearch } = parsePathnameAndSearch(requestPath);
|
|
257
|
+
|
|
258
|
+
if (!isAnthropicApiPath(requestPathname)) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
let parsedRouteUrl: URL;
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
parsedRouteUrl = new URL(routeUrl);
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const normalizedRoutePath = parsedRouteUrl.pathname.replace(/\/+$/, "");
|
|
271
|
+
const fixedChatCompletionsSuffix = "/v1/chat/completions";
|
|
272
|
+
|
|
273
|
+
if (!normalizedRoutePath.endsWith(fixedChatCompletionsSuffix)) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const routePrefixPath = normalizedRoutePath.slice(0, -fixedChatCompletionsSuffix.length);
|
|
278
|
+
parsedRouteUrl.pathname = `${routePrefixPath}${requestPathname}`.replace(/\/{2,}/g, "/");
|
|
279
|
+
parsedRouteUrl.search = requestSearch;
|
|
280
|
+
|
|
281
|
+
return parsedRouteUrl.toString();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildRoutedUpstreamUrl(requestPath: string, selectedRoute: ModelRouteConfig | null): string {
|
|
126
285
|
if (!selectedRoute) {
|
|
127
|
-
return `${config.upstreamBaseUrl}${
|
|
286
|
+
return `${config.upstreamBaseUrl}${requestPath}`;
|
|
128
287
|
}
|
|
129
288
|
|
|
130
289
|
if (!selectedRoute.isBaseUrl) {
|
|
290
|
+
// Backward-compatible Anthropic support when route URL is fixed to /v1/chat/completions.
|
|
291
|
+
const anthropicCompatUrl = rewriteFixedChatCompletionsRouteUrlForAnthropic(
|
|
292
|
+
selectedRoute.url,
|
|
293
|
+
requestPath,
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
if (anthropicCompatUrl) {
|
|
297
|
+
return anthropicCompatUrl;
|
|
298
|
+
}
|
|
299
|
+
|
|
131
300
|
return selectedRoute.url;
|
|
132
301
|
}
|
|
133
302
|
|
|
134
303
|
const routeBase = selectedRoute.url.replace(/\/+$/, "");
|
|
135
|
-
const requestPath = normalizeRequestPath(request);
|
|
136
304
|
|
|
137
305
|
if (routeBase.endsWith("/v1") && requestPath.startsWith("/v1")) {
|
|
138
306
|
return `${routeBase}${requestPath.slice(3)}`;
|
|
@@ -142,7 +310,7 @@ function buildRoutedUpstreamUrl(
|
|
|
142
310
|
}
|
|
143
311
|
|
|
144
312
|
function resolveUpstreamTarget(
|
|
145
|
-
|
|
313
|
+
requestPath: string,
|
|
146
314
|
modelId: string | null,
|
|
147
315
|
): { upstreamUrl: string; selectedRoute: ModelRouteConfig | null } {
|
|
148
316
|
const modelRoute = modelId ? config.modelRouteMap[modelId] ?? null : null;
|
|
@@ -150,17 +318,52 @@ function resolveUpstreamTarget(
|
|
|
150
318
|
const selectedRoute = modelRoute ?? wildcardRoute;
|
|
151
319
|
|
|
152
320
|
return {
|
|
153
|
-
upstreamUrl: buildRoutedUpstreamUrl(
|
|
321
|
+
upstreamUrl: buildRoutedUpstreamUrl(requestPath, selectedRoute),
|
|
154
322
|
selectedRoute,
|
|
155
323
|
};
|
|
156
324
|
}
|
|
157
325
|
|
|
326
|
+
async function logUpstreamErrorResponse(params: {
|
|
327
|
+
protocol: GatewayProtocol;
|
|
328
|
+
requestPath: string;
|
|
329
|
+
upstreamUrl: string;
|
|
330
|
+
routeName: string | null;
|
|
331
|
+
modelId: string | null;
|
|
332
|
+
response: Response;
|
|
333
|
+
}): Promise<void> {
|
|
334
|
+
let detail = "-";
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const raw = await params.response.clone().text();
|
|
338
|
+
const normalized = raw.replace(/\s+/g, " ").trim();
|
|
339
|
+
|
|
340
|
+
if (normalized) {
|
|
341
|
+
detail = normalized.slice(0, 2000);
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
detail = "<unavailable>";
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
console.error(
|
|
348
|
+
buildGatewayLogLine(params.protocol, "upstream_error", {
|
|
349
|
+
status: params.response.status,
|
|
350
|
+
path: params.requestPath,
|
|
351
|
+
route: params.routeName,
|
|
352
|
+
model: params.modelId,
|
|
353
|
+
upstream: params.upstreamUrl,
|
|
354
|
+
detail,
|
|
355
|
+
}),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
158
359
|
function buildUpstreamHeaders(
|
|
159
360
|
reqHeaders: IncomingHttpHeaders,
|
|
160
361
|
bodyLength: number | undefined,
|
|
161
362
|
selectedRoute: ModelRouteConfig | null,
|
|
162
363
|
): Headers {
|
|
163
364
|
const headers = new Headers();
|
|
365
|
+
const selectedAuthHeader = selectedRoute?.authHeader || "authorization";
|
|
366
|
+
const conflictingAuthHeaders = ["authorization", "x-api-key", "api-key"];
|
|
164
367
|
|
|
165
368
|
for (const [key, value] of Object.entries(reqHeaders)) {
|
|
166
369
|
if (value === undefined) {
|
|
@@ -176,6 +379,14 @@ function buildUpstreamHeaders(
|
|
|
176
379
|
headers.set(key, Array.isArray(value) ? value.join(",") : String(value));
|
|
177
380
|
}
|
|
178
381
|
|
|
382
|
+
if (selectedRoute?.apiKey) {
|
|
383
|
+
for (const headerName of conflictingAuthHeaders) {
|
|
384
|
+
if (headerName !== selectedAuthHeader) {
|
|
385
|
+
headers.delete(headerName);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
179
390
|
if (selectedRoute?.headers) {
|
|
180
391
|
for (const [key, value] of Object.entries(selectedRoute.headers)) {
|
|
181
392
|
headers.set(key, value);
|
|
@@ -183,7 +394,7 @@ function buildUpstreamHeaders(
|
|
|
183
394
|
}
|
|
184
395
|
|
|
185
396
|
if (selectedRoute?.apiKey) {
|
|
186
|
-
const authHeader =
|
|
397
|
+
const authHeader = selectedAuthHeader;
|
|
187
398
|
const authPrefix = selectedRoute.authPrefix ?? "Bearer ";
|
|
188
399
|
|
|
189
400
|
if (!headers.has(authHeader)) {
|
|
@@ -328,9 +539,76 @@ async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: nu
|
|
|
328
539
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
329
540
|
|
|
330
541
|
try {
|
|
331
|
-
return await
|
|
542
|
+
return await fetchWithDispatcher(url, {
|
|
543
|
+
...(options as RequestInitWithDispatcher),
|
|
544
|
+
signal: controller.signal,
|
|
545
|
+
dispatcher: upstreamAgent,
|
|
546
|
+
});
|
|
547
|
+
} finally {
|
|
548
|
+
clearTimeout(timeoutId);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function createClientAbortSignal(
|
|
553
|
+
request: IncomingMessage,
|
|
554
|
+
response: ServerResponse,
|
|
555
|
+
): AbortSignal | null {
|
|
556
|
+
const controller = new AbortController();
|
|
557
|
+
let aborted = false;
|
|
558
|
+
|
|
559
|
+
const abort = () => {
|
|
560
|
+
if (aborted) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
aborted = true;
|
|
565
|
+
controller.abort();
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
request.once("aborted", abort);
|
|
569
|
+
response.once("close", () => {
|
|
570
|
+
if (!response.writableEnded) {
|
|
571
|
+
abort();
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
return controller.signal;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function fetchWithTimeoutAndClientSignal(
|
|
579
|
+
url: string,
|
|
580
|
+
options: RequestInit,
|
|
581
|
+
timeoutMs: number,
|
|
582
|
+
clientSignal: AbortSignal | null,
|
|
583
|
+
): Promise<Response> {
|
|
584
|
+
if (!clientSignal) {
|
|
585
|
+
return fetchWithTimeout(url, options, timeoutMs);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const controller = new AbortController();
|
|
589
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
590
|
+
|
|
591
|
+
const onClientAbort = () => {
|
|
592
|
+
if (!controller.signal.aborted) {
|
|
593
|
+
controller.abort();
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
if (clientSignal.aborted) {
|
|
598
|
+
onClientAbort();
|
|
599
|
+
} else {
|
|
600
|
+
clientSignal.addEventListener("abort", onClientAbort, { once: true });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
try {
|
|
604
|
+
return await fetchWithDispatcher(url, {
|
|
605
|
+
...(options as RequestInitWithDispatcher),
|
|
606
|
+
signal: controller.signal,
|
|
607
|
+
dispatcher: upstreamAgent,
|
|
608
|
+
});
|
|
332
609
|
} finally {
|
|
333
610
|
clearTimeout(timeoutId);
|
|
611
|
+
clientSignal.removeEventListener("abort", onClientAbort);
|
|
334
612
|
}
|
|
335
613
|
}
|
|
336
614
|
|
|
@@ -414,6 +692,9 @@ async function readRequestBody(request: IncomingMessage): Promise<Buffer> {
|
|
|
414
692
|
export async function proxyRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
415
693
|
const method = (request.method ?? "GET").toUpperCase();
|
|
416
694
|
const supportsBody = method !== "GET" && method !== "HEAD";
|
|
695
|
+
const clientSignal = createClientAbortSignal(request, response);
|
|
696
|
+
const normalizedRequestPath = normalizeRequestPath(request);
|
|
697
|
+
const requestProtocol = resolveGatewayProtocol(request);
|
|
417
698
|
let incomingBody: Buffer = Buffer.alloc(0);
|
|
418
699
|
|
|
419
700
|
if (supportsBody) {
|
|
@@ -473,20 +754,59 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
473
754
|
|
|
474
755
|
for (let attemptIndex = 0; attemptIndex < modelCandidates.length; attemptIndex += 1) {
|
|
475
756
|
const modelId = modelCandidates[attemptIndex];
|
|
757
|
+
let requestPath = normalizedRequestPath;
|
|
758
|
+
let responseFormat: "anthropic-messages" | null = null;
|
|
759
|
+
let requestJsonPayload: Record<string, unknown> | null = null;
|
|
760
|
+
|
|
761
|
+
if (supportsBody && parsedJsonBody) {
|
|
762
|
+
requestJsonPayload = {
|
|
763
|
+
...parsedJsonBody,
|
|
764
|
+
...(modelId ? { model: modelId } : {}),
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
let { upstreamUrl, selectedRoute } = resolveUpstreamTarget(requestPath, modelId);
|
|
769
|
+
lastAttemptRouteName = selectedRoute?.routeName ?? null;
|
|
770
|
+
|
|
771
|
+
if (requestJsonPayload) {
|
|
772
|
+
const compatRequest = maybeTransformAnthropicMessagesRequest({
|
|
773
|
+
requestPath,
|
|
774
|
+
upstreamUrl,
|
|
775
|
+
body: requestJsonPayload,
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
if (compatRequest.error) {
|
|
779
|
+
console.error(
|
|
780
|
+
buildGatewayLogLine(requestProtocol, "compat_error", {
|
|
781
|
+
path: requestPath,
|
|
782
|
+
route: selectedRoute?.routeName ?? null,
|
|
783
|
+
model: modelId,
|
|
784
|
+
detail: compatRequest.error,
|
|
785
|
+
}),
|
|
786
|
+
);
|
|
787
|
+
sendJson(response, 400, {
|
|
788
|
+
error: {
|
|
789
|
+
message: compatRequest.error,
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
requestPath = compatRequest.requestPath;
|
|
796
|
+
requestJsonPayload = compatRequest.body;
|
|
797
|
+
responseFormat = compatRequest.responseFormat;
|
|
798
|
+
|
|
799
|
+
if (responseFormat) {
|
|
800
|
+
upstreamUrl = buildRoutedUpstreamUrl(requestPath, selectedRoute);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
476
804
|
let bodyBuffer = supportsBody && incomingBody.length > 0 ? incomingBody : undefined;
|
|
477
805
|
|
|
478
|
-
if (supportsBody &&
|
|
479
|
-
bodyBuffer = Buffer.from(
|
|
480
|
-
JSON.stringify({
|
|
481
|
-
...parsedJsonBody,
|
|
482
|
-
model: modelId,
|
|
483
|
-
}),
|
|
484
|
-
"utf8",
|
|
485
|
-
);
|
|
806
|
+
if (supportsBody && requestJsonPayload) {
|
|
807
|
+
bodyBuffer = Buffer.from(JSON.stringify(requestJsonPayload), "utf8");
|
|
486
808
|
}
|
|
487
809
|
|
|
488
|
-
const { upstreamUrl, selectedRoute } = resolveUpstreamTarget(request, modelId);
|
|
489
|
-
lastAttemptRouteName = selectedRoute?.routeName ?? null;
|
|
490
810
|
const requestBody = bodyBuffer ? new Uint8Array(bodyBuffer) : undefined;
|
|
491
811
|
const headers = buildUpstreamHeaders(
|
|
492
812
|
request.headers,
|
|
@@ -495,7 +815,8 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
495
815
|
);
|
|
496
816
|
|
|
497
817
|
try {
|
|
498
|
-
const
|
|
818
|
+
const attemptStartedAt = Date.now();
|
|
819
|
+
const upstreamResponse = await fetchWithTimeoutAndClientSignal(
|
|
499
820
|
upstreamUrl,
|
|
500
821
|
{
|
|
501
822
|
method,
|
|
@@ -503,7 +824,14 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
503
824
|
body: requestBody,
|
|
504
825
|
},
|
|
505
826
|
config.timeoutMs,
|
|
827
|
+
clientSignal,
|
|
506
828
|
);
|
|
829
|
+
const headerLoadMs = Date.now() - attemptStartedAt;
|
|
830
|
+
const modelForMetric = modelId ?? requestedModel;
|
|
831
|
+
|
|
832
|
+
if (upstreamResponse.ok) {
|
|
833
|
+
recordModelLoadSample(modelForMetric, headerLoadMs);
|
|
834
|
+
}
|
|
507
835
|
|
|
508
836
|
const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
|
|
509
837
|
const isEventStream = contentType.includes("text/event-stream");
|
|
@@ -525,6 +853,7 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
525
853
|
const nextRouteName = resolveRouteNameForModel(nextModel);
|
|
526
854
|
|
|
527
855
|
logProxyModelSwitch({
|
|
856
|
+
protocol: requestProtocol,
|
|
528
857
|
triggerStatus,
|
|
529
858
|
fromModel: modelId,
|
|
530
859
|
toModel: nextModel,
|
|
@@ -546,6 +875,17 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
546
875
|
continue;
|
|
547
876
|
}
|
|
548
877
|
|
|
878
|
+
if (!upstreamResponse.ok) {
|
|
879
|
+
await logUpstreamErrorResponse({
|
|
880
|
+
protocol: requestProtocol,
|
|
881
|
+
requestPath,
|
|
882
|
+
upstreamUrl,
|
|
883
|
+
routeName: selectedRoute?.routeName ?? null,
|
|
884
|
+
modelId,
|
|
885
|
+
response: upstreamResponse,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
549
889
|
const attemptCount = attemptIndex + 1;
|
|
550
890
|
const effectiveSwitchNotice: GatewaySwitchNotice | null = switchNotice;
|
|
551
891
|
|
|
@@ -561,6 +901,7 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
561
901
|
}
|
|
562
902
|
|
|
563
903
|
logProxyModelRoute({
|
|
904
|
+
protocol: requestProtocol,
|
|
564
905
|
requestedModel,
|
|
565
906
|
usedModel: modelId,
|
|
566
907
|
routeName: selectedRoute?.routeName ?? null,
|
|
@@ -573,6 +914,82 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
573
914
|
return;
|
|
574
915
|
}
|
|
575
916
|
|
|
917
|
+
if (responseFormat === "anthropic-messages" && isEventStream) {
|
|
918
|
+
const nodeStream = Readable.fromWeb(upstreamResponse.body as any);
|
|
919
|
+
const anthropicStream = nodeStream.pipe(
|
|
920
|
+
createAnthropicMessagesEventStreamTransformer(modelId),
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
response.removeHeader("content-length");
|
|
924
|
+
response.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
925
|
+
|
|
926
|
+
if (effectiveSwitchNotice) {
|
|
927
|
+
createSsePrefixedStream(anthropicStream, effectiveSwitchNotice).pipe(response);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
anthropicStream.on("error", () => {
|
|
932
|
+
if (!response.writableEnded) {
|
|
933
|
+
response.destroy();
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
anthropicStream.pipe(response);
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (responseFormat === "anthropic-messages" && isJsonResponse && !isEventStream) {
|
|
942
|
+
const rawText = await upstreamResponse.text();
|
|
943
|
+
response.removeHeader("content-length");
|
|
944
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
const parsed = JSON.parse(rawText);
|
|
948
|
+
|
|
949
|
+
if (!upstreamResponse.ok) {
|
|
950
|
+
response.end(JSON.stringify(transformUpstreamErrorToAnthropicError(parsed, upstreamResponse.status)));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
const transformed = transformOpenAiChatCompletionToAnthropicMessage(parsed, modelId);
|
|
955
|
+
|
|
956
|
+
if (transformed.value) {
|
|
957
|
+
response.end(JSON.stringify(transformed.value));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
console.error(
|
|
962
|
+
buildGatewayLogLine(requestProtocol, "compat_error", {
|
|
963
|
+
path: requestPath,
|
|
964
|
+
route: selectedRoute?.routeName ?? null,
|
|
965
|
+
model: modelId,
|
|
966
|
+
detail: transformed.error ?? "Unknown transform error",
|
|
967
|
+
}),
|
|
968
|
+
);
|
|
969
|
+
sendJson(response, 502, {
|
|
970
|
+
error: {
|
|
971
|
+
message: "Gateway failed to translate the OpenAI-compatible response to Anthropic format.",
|
|
972
|
+
detail: transformed.error ?? "Unknown transform error",
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
return;
|
|
976
|
+
} catch {
|
|
977
|
+
if (!upstreamResponse.ok) {
|
|
978
|
+
response.end(
|
|
979
|
+
JSON.stringify(
|
|
980
|
+
transformUpstreamErrorToAnthropicError(
|
|
981
|
+
{
|
|
982
|
+
message: rawText,
|
|
983
|
+
},
|
|
984
|
+
upstreamResponse.status,
|
|
985
|
+
),
|
|
986
|
+
),
|
|
987
|
+
);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
576
993
|
if (effectiveSwitchNotice && isJsonResponse && !isEventStream) {
|
|
577
994
|
const rawText = await upstreamResponse.text();
|
|
578
995
|
response.removeHeader("content-length");
|
|
@@ -627,6 +1044,7 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
627
1044
|
const lastTriedModel = modelCandidates[modelCandidates.length - 1] ?? null;
|
|
628
1045
|
|
|
629
1046
|
logProxyModelRoute({
|
|
1047
|
+
protocol: requestProtocol,
|
|
630
1048
|
requestedModel,
|
|
631
1049
|
usedModel: lastTriedModel,
|
|
632
1050
|
routeName: lastAttemptRouteName,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
2
|
import { config } from "./config.js";
|
|
3
|
+
import { getModelLoadRankingHealth } from "./model-load-metrics.js";
|
|
3
4
|
import { proxyRequest } from "./proxy.js";
|
|
4
5
|
|
|
5
6
|
function sendJson(response: ServerResponse, statusCode: number, payload: unknown): void {
|
|
@@ -24,27 +25,40 @@ function resolvePathname(request: IncomingMessage): string {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
function isGatewayApiPath(pathname: string): boolean {
|
|
29
|
+
return (
|
|
30
|
+
pathname === "/v1" ||
|
|
31
|
+
pathname.startsWith("/v1/") ||
|
|
32
|
+
pathname === "/anthropic" ||
|
|
33
|
+
pathname.startsWith("/anthropic/")
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
async function handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
28
38
|
const method = (request.method ?? "GET").toUpperCase();
|
|
29
39
|
const pathname = resolvePathname(request);
|
|
30
40
|
|
|
31
41
|
if ((method === "GET" || method === "HEAD") && pathname === "/health") {
|
|
42
|
+
const modelLoadHealth = getModelLoadRankingHealth(12 * 60 * 60 * 1000);
|
|
43
|
+
|
|
32
44
|
sendJson(response, 200, {
|
|
33
45
|
status: "ok",
|
|
34
46
|
retryStatusCodes: Array.from(config.retryStatusCodes),
|
|
35
47
|
enabledRouteCount: Object.keys(config.modelRouteMap).length,
|
|
48
|
+
modelLoadWindowHours: modelLoadHealth.windowHours,
|
|
49
|
+
modelLoadRanking: modelLoadHealth.rankedModels,
|
|
36
50
|
});
|
|
37
51
|
return;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
|
-
if (pathname
|
|
54
|
+
if (isGatewayApiPath(pathname)) {
|
|
41
55
|
await proxyRequest(request, response);
|
|
42
56
|
return;
|
|
43
57
|
}
|
|
44
58
|
|
|
45
59
|
sendJson(response, 404, {
|
|
46
60
|
error: {
|
|
47
|
-
message: "Route not found. Use /v1
|
|
61
|
+
message: "Route not found. Use /v1/*, /anthropic/*, or /health.",
|
|
48
62
|
},
|
|
49
63
|
});
|
|
50
64
|
}
|
|
@@ -39,7 +39,7 @@ export async function startGatewayServer(
|
|
|
39
39
|
const address = server.address();
|
|
40
40
|
const resolvedPort = typeof address === "object" && address ? (address as AddressInfo).port : port;
|
|
41
41
|
|
|
42
|
-
console.log(`Gateway listening on http://${host}:${resolvedPort}
|
|
42
|
+
console.log(`Gateway listening on http://${host}:${resolvedPort}`);
|
|
43
43
|
|
|
44
44
|
return {
|
|
45
45
|
close: async () => {
|
|
Binary file
|