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.
@@ -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
- `[gateway] requested_model=${params.requestedModel ?? "-"} used_model=${params.usedModel ?? "-"} route=${params.routeName ?? "-"}`,
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
- `[gateway] switch trigger_status=${params.triggerStatus} from_model=${params.fromModel ?? "-"} from_route=${params.fromRoute ?? "-"} to_model=${params.toModel ?? "-"} to_route=${params.toRoute ?? "-"}`,
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
- return rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
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 buildRoutedUpstreamUrl(
123
- request: IncomingMessage,
124
- selectedRoute: ModelRouteConfig | null,
125
- ): string {
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}${normalizeRequestPath(request)}`;
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
- request: IncomingMessage,
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(request, selectedRoute),
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 = selectedRoute.authHeader || "authorization";
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 fetch(url, { ...options, signal: controller.signal });
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 && parsedJsonBody && modelId) {
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 upstreamResponse = await fetchWithTimeout(
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 === "/v1" || pathname.startsWith("/v1/")) {
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/* or /health.",
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} -> ${config.upstreamBaseUrl}`);
42
+ console.log(`Gateway listening on http://${host}:${resolvedPort}`);
43
43
 
44
44
  return {
45
45
  close: async () => {
Binary file