openclaw-autoproxy 1.0.3 → 1.0.6
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 +125 -0
- package/dist/gateway/proxy.js +331 -19
- package/dist/gateway/server-http.js +83 -6
- 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 +192 -0
- package/src/gateway/proxy.ts +452 -25
- package/src/gateway/server-http.ts +104 -6
- 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 { recordModelRequestSample } 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,10 +163,29 @@ 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);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function normalizeGatewayRequestPath(requestPath: string): string {
|
|
174
|
+
const { pathname, search } = parsePathnameAndSearch(requestPath);
|
|
175
|
+
|
|
176
|
+
if (pathname === "/anthropic") {
|
|
177
|
+
return `/v1${search}`;
|
|
84
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}`;
|
|
85
189
|
}
|
|
86
190
|
|
|
87
191
|
function rotateCandidates(candidates: string[], startIndex: number): string[] {
|
|
@@ -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,12 +539,79 @@ 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
|
+
});
|
|
332
547
|
} finally {
|
|
333
548
|
clearTimeout(timeoutId);
|
|
334
549
|
}
|
|
335
550
|
}
|
|
336
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
|
+
});
|
|
609
|
+
} finally {
|
|
610
|
+
clearTimeout(timeoutId);
|
|
611
|
+
clientSignal.removeEventListener("abort", onClientAbort);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
337
615
|
async function disposeBody(response: Response): Promise<void> {
|
|
338
616
|
if (!response.body) {
|
|
339
617
|
return;
|
|
@@ -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,
|
|
@@ -494,8 +814,10 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
494
814
|
selectedRoute,
|
|
495
815
|
);
|
|
496
816
|
|
|
817
|
+
const attemptStartedAt = Date.now();
|
|
818
|
+
|
|
497
819
|
try {
|
|
498
|
-
const upstreamResponse = await
|
|
820
|
+
const upstreamResponse = await fetchWithTimeoutAndClientSignal(
|
|
499
821
|
upstreamUrl,
|
|
500
822
|
{
|
|
501
823
|
method,
|
|
@@ -503,7 +825,16 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
503
825
|
body: requestBody,
|
|
504
826
|
},
|
|
505
827
|
config.timeoutMs,
|
|
828
|
+
clientSignal,
|
|
506
829
|
);
|
|
830
|
+
const headerLoadMs = Date.now() - attemptStartedAt;
|
|
831
|
+
const modelForMetric = modelId ?? requestedModel;
|
|
832
|
+
|
|
833
|
+
recordModelRequestSample(modelForMetric, {
|
|
834
|
+
ok: upstreamResponse.ok,
|
|
835
|
+
responseMs: headerLoadMs,
|
|
836
|
+
statusCode: upstreamResponse.status,
|
|
837
|
+
});
|
|
507
838
|
|
|
508
839
|
const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
|
|
509
840
|
const isEventStream = contentType.includes("text/event-stream");
|
|
@@ -525,6 +856,7 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
525
856
|
const nextRouteName = resolveRouteNameForModel(nextModel);
|
|
526
857
|
|
|
527
858
|
logProxyModelSwitch({
|
|
859
|
+
protocol: requestProtocol,
|
|
528
860
|
triggerStatus,
|
|
529
861
|
fromModel: modelId,
|
|
530
862
|
toModel: nextModel,
|
|
@@ -546,6 +878,17 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
546
878
|
continue;
|
|
547
879
|
}
|
|
548
880
|
|
|
881
|
+
if (!upstreamResponse.ok) {
|
|
882
|
+
await logUpstreamErrorResponse({
|
|
883
|
+
protocol: requestProtocol,
|
|
884
|
+
requestPath,
|
|
885
|
+
upstreamUrl,
|
|
886
|
+
routeName: selectedRoute?.routeName ?? null,
|
|
887
|
+
modelId,
|
|
888
|
+
response: upstreamResponse,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
549
892
|
const attemptCount = attemptIndex + 1;
|
|
550
893
|
const effectiveSwitchNotice: GatewaySwitchNotice | null = switchNotice;
|
|
551
894
|
|
|
@@ -561,6 +904,7 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
561
904
|
}
|
|
562
905
|
|
|
563
906
|
logProxyModelRoute({
|
|
907
|
+
protocol: requestProtocol,
|
|
564
908
|
requestedModel,
|
|
565
909
|
usedModel: modelId,
|
|
566
910
|
routeName: selectedRoute?.routeName ?? null,
|
|
@@ -573,6 +917,82 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
573
917
|
return;
|
|
574
918
|
}
|
|
575
919
|
|
|
920
|
+
if (responseFormat === "anthropic-messages" && isEventStream) {
|
|
921
|
+
const nodeStream = Readable.fromWeb(upstreamResponse.body as any);
|
|
922
|
+
const anthropicStream = nodeStream.pipe(
|
|
923
|
+
createAnthropicMessagesEventStreamTransformer(modelId),
|
|
924
|
+
);
|
|
925
|
+
|
|
926
|
+
response.removeHeader("content-length");
|
|
927
|
+
response.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
928
|
+
|
|
929
|
+
if (effectiveSwitchNotice) {
|
|
930
|
+
createSsePrefixedStream(anthropicStream, effectiveSwitchNotice).pipe(response);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
anthropicStream.on("error", () => {
|
|
935
|
+
if (!response.writableEnded) {
|
|
936
|
+
response.destroy();
|
|
937
|
+
}
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
anthropicStream.pipe(response);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
if (responseFormat === "anthropic-messages" && isJsonResponse && !isEventStream) {
|
|
945
|
+
const rawText = await upstreamResponse.text();
|
|
946
|
+
response.removeHeader("content-length");
|
|
947
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
948
|
+
|
|
949
|
+
try {
|
|
950
|
+
const parsed = JSON.parse(rawText);
|
|
951
|
+
|
|
952
|
+
if (!upstreamResponse.ok) {
|
|
953
|
+
response.end(JSON.stringify(transformUpstreamErrorToAnthropicError(parsed, upstreamResponse.status)));
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const transformed = transformOpenAiChatCompletionToAnthropicMessage(parsed, modelId);
|
|
958
|
+
|
|
959
|
+
if (transformed.value) {
|
|
960
|
+
response.end(JSON.stringify(transformed.value));
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
console.error(
|
|
965
|
+
buildGatewayLogLine(requestProtocol, "compat_error", {
|
|
966
|
+
path: requestPath,
|
|
967
|
+
route: selectedRoute?.routeName ?? null,
|
|
968
|
+
model: modelId,
|
|
969
|
+
detail: transformed.error ?? "Unknown transform error",
|
|
970
|
+
}),
|
|
971
|
+
);
|
|
972
|
+
sendJson(response, 502, {
|
|
973
|
+
error: {
|
|
974
|
+
message: "Gateway failed to translate the OpenAI-compatible response to Anthropic format.",
|
|
975
|
+
detail: transformed.error ?? "Unknown transform error",
|
|
976
|
+
},
|
|
977
|
+
});
|
|
978
|
+
return;
|
|
979
|
+
} catch {
|
|
980
|
+
if (!upstreamResponse.ok) {
|
|
981
|
+
response.end(
|
|
982
|
+
JSON.stringify(
|
|
983
|
+
transformUpstreamErrorToAnthropicError(
|
|
984
|
+
{
|
|
985
|
+
message: rawText,
|
|
986
|
+
},
|
|
987
|
+
upstreamResponse.status,
|
|
988
|
+
),
|
|
989
|
+
),
|
|
990
|
+
);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
576
996
|
if (effectiveSwitchNotice && isJsonResponse && !isEventStream) {
|
|
577
997
|
const rawText = await upstreamResponse.text();
|
|
578
998
|
response.removeHeader("content-length");
|
|
@@ -612,6 +1032,12 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
612
1032
|
} catch (error) {
|
|
613
1033
|
lastError = error;
|
|
614
1034
|
|
|
1035
|
+
recordModelRequestSample(modelId ?? requestedModel, {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
responseMs: Date.now() - attemptStartedAt,
|
|
1038
|
+
statusCode: null,
|
|
1039
|
+
});
|
|
1040
|
+
|
|
615
1041
|
if (attemptIndex < modelCandidates.length - 1) {
|
|
616
1042
|
continue;
|
|
617
1043
|
}
|
|
@@ -627,6 +1053,7 @@ export async function proxyRequest(request: IncomingMessage, response: ServerRes
|
|
|
627
1053
|
const lastTriedModel = modelCandidates[modelCandidates.length - 1] ?? null;
|
|
628
1054
|
|
|
629
1055
|
logProxyModelRoute({
|
|
1056
|
+
protocol: requestProtocol,
|
|
630
1057
|
requestedModel,
|
|
631
1058
|
usedModel: lastTriedModel,
|
|
632
1059
|
routeName: lastAttemptRouteName,
|