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.
@@ -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
- `[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,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
- return rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
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 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,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 fetch(url, { ...options, signal: controller.signal });
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 && 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,
@@ -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 fetchWithTimeout(
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,