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.
@@ -12,6 +12,16 @@ function parseCsvList(value) {
12
12
  .map((item) => item.trim())
13
13
  .filter(Boolean);
14
14
  }
15
+ function parsePositiveInteger(value, fallback) {
16
+ if (!value) {
17
+ return fallback;
18
+ }
19
+ const parsed = Number.parseInt(value, 10);
20
+ if (!Number.isInteger(parsed) || parsed <= 0) {
21
+ return fallback;
22
+ }
23
+ return parsed;
24
+ }
15
25
  function parseRetryCodes(value) {
16
26
  const defaults = new Set([412, 429, 500, 502, 503, 504]);
17
27
  if (!value) {
@@ -248,6 +258,9 @@ function loadRouteFileConfig() {
248
258
  const host = process.env.HOST ?? "0.0.0.0";
249
259
  const port = Number.parseInt(process.env.PORT ?? "8787", 10);
250
260
  const timeoutMs = Number.parseInt(process.env.REQUEST_TIMEOUT_MS ?? "60000", 10);
261
+ const upstreamMaxConnections = parsePositiveInteger(process.env.UPSTREAM_MAX_CONNECTIONS, 200);
262
+ const upstreamKeepAliveTimeoutMs = parsePositiveInteger(process.env.UPSTREAM_KEEPALIVE_TIMEOUT_MS, 60_000);
263
+ const upstreamKeepAliveMaxTimeoutMs = parsePositiveInteger(process.env.UPSTREAM_KEEPALIVE_MAX_TIMEOUT_MS, 300_000);
251
264
  const upstreamBaseUrl = (process.env.UPSTREAM_BASE_URL ?? "https://api.openai.com").replace(/\/+$/, "");
252
265
  const routeFileConfig = loadRouteFileConfig();
253
266
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
@@ -262,6 +275,9 @@ export const config = {
262
275
  timeoutMs,
263
276
  upstreamBaseUrl,
264
277
  upstreamApiKey: process.env.UPSTREAM_API_KEY ?? "",
278
+ upstreamMaxConnections,
279
+ upstreamKeepAliveTimeoutMs,
280
+ upstreamKeepAliveMaxTimeoutMs,
265
281
  retryStatusCodes: routeFileConfig.retryStatusCodes ?? parseRetryCodes(process.env.RETRY_STATUS_CODES),
266
282
  globalFallbackModels: parseCsvList(process.env.GLOBAL_FALLBACK_MODELS),
267
283
  modelFallbackMap: parseModelFallbackMap(process.env.MODEL_FALLBACK_MAP),
@@ -0,0 +1,125 @@
1
+ const DEFAULT_WINDOW_MS = 12 * 60 * 60 * 1000;
2
+ const DEFAULT_MAX_SAMPLES_PER_MODEL = 5000;
3
+ export const DEFAULT_MODEL_HEALTH_WINDOW_MS = DEFAULT_WINDOW_MS;
4
+ const modelSamples = new Map();
5
+ function roundMs(value) {
6
+ return Math.round(value * 100) / 100;
7
+ }
8
+ function pruneModelSamples(samples, cutoffAt) {
9
+ let startIndex = 0;
10
+ while (startIndex < samples.length && samples[startIndex] && samples[startIndex].at < cutoffAt) {
11
+ startIndex += 1;
12
+ }
13
+ if (startIndex <= 0) {
14
+ return samples;
15
+ }
16
+ return samples.slice(startIndex);
17
+ }
18
+ function pruneExpiredSamples(cutoffAt) {
19
+ for (const [model, samples] of modelSamples.entries()) {
20
+ const pruned = pruneModelSamples(samples, cutoffAt);
21
+ if (pruned.length === 0) {
22
+ modelSamples.delete(model);
23
+ continue;
24
+ }
25
+ if (pruned !== samples) {
26
+ modelSamples.set(model, pruned);
27
+ }
28
+ }
29
+ }
30
+ export function recordModelRequestSample(model, params) {
31
+ if (!model) {
32
+ return;
33
+ }
34
+ if (!Number.isFinite(params.responseMs) || params.responseMs < 0) {
35
+ return;
36
+ }
37
+ const now = Date.now();
38
+ const sample = {
39
+ at: now,
40
+ ok: params.ok,
41
+ responseMs: params.responseMs,
42
+ statusCode: params.statusCode ?? null,
43
+ };
44
+ const existing = modelSamples.get(model) ?? [];
45
+ existing.push(sample);
46
+ if (existing.length > DEFAULT_MAX_SAMPLES_PER_MODEL) {
47
+ existing.splice(0, existing.length - DEFAULT_MAX_SAMPLES_PER_MODEL);
48
+ }
49
+ modelSamples.set(model, existing);
50
+ const cutoffAt = now - DEFAULT_WINDOW_MS;
51
+ pruneExpiredSamples(cutoffAt);
52
+ }
53
+ export function recordModelLoadSample(model, loadMs) {
54
+ recordModelRequestSample(model, {
55
+ ok: true,
56
+ responseMs: loadMs,
57
+ statusCode: 200,
58
+ });
59
+ }
60
+ function summarizeModel(model, samples) {
61
+ if (samples.length === 0) {
62
+ return null;
63
+ }
64
+ const accessCount = samples.length;
65
+ const successCount = samples.reduce((count, sample) => count + (sample.ok ? 1 : 0), 0);
66
+ const totalResponseMs = samples.reduce((total, sample) => total + sample.responseMs, 0);
67
+ const lastSample = samples[samples.length - 1] ?? null;
68
+ const avgResponseMs = totalResponseMs / accessCount;
69
+ const successRatePct = accessCount > 0 ? (successCount / accessCount) * 100 : 0;
70
+ return {
71
+ model,
72
+ accessCount,
73
+ avgResponseMs: roundMs(avgResponseMs),
74
+ lastResponseMs: roundMs(lastSample?.responseMs ?? 0),
75
+ lastSeenAt: new Date(lastSample?.at ?? Date.now()).toISOString(),
76
+ lastStatusCode: lastSample?.statusCode ?? null,
77
+ successCount,
78
+ successRatePct: roundMs(successRatePct),
79
+ };
80
+ }
81
+ export function getModelHealthWindow(windowMs = DEFAULT_WINDOW_MS) {
82
+ const normalizedWindowMs = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : DEFAULT_WINDOW_MS;
83
+ const cutoffAt = Date.now() - normalizedWindowMs;
84
+ pruneExpiredSamples(cutoffAt);
85
+ const summaries = [];
86
+ for (const [model, samples] of modelSamples.entries()) {
87
+ const filtered = pruneModelSamples(samples, cutoffAt);
88
+ if (filtered.length === 0) {
89
+ continue;
90
+ }
91
+ if (filtered !== samples) {
92
+ modelSamples.set(model, filtered);
93
+ }
94
+ const summary = summarizeModel(model, filtered);
95
+ if (summary) {
96
+ summaries.push(summary);
97
+ }
98
+ }
99
+ summaries.sort((a, b) => {
100
+ if (a.accessCount !== b.accessCount) {
101
+ return b.accessCount - a.accessCount;
102
+ }
103
+ if (a.successRatePct !== b.successRatePct) {
104
+ return b.successRatePct - a.successRatePct;
105
+ }
106
+ if (a.avgResponseMs !== b.avgResponseMs) {
107
+ return a.avgResponseMs - b.avgResponseMs;
108
+ }
109
+ return a.model.localeCompare(b.model);
110
+ });
111
+ return {
112
+ windowHours: roundMs(normalizedWindowMs / (60 * 60 * 1000)),
113
+ models: summaries.map((entry, index) => ({
114
+ rank: index + 1,
115
+ ...entry,
116
+ })),
117
+ };
118
+ }
119
+ export function getModelLoadRankingHealth(windowMs = DEFAULT_WINDOW_MS) {
120
+ const health = getModelHealthWindow(windowMs);
121
+ return {
122
+ windowHours: health.windowHours,
123
+ rankedModels: health.models,
124
+ };
125
+ }
@@ -1,5 +1,8 @@
1
1
  import { PassThrough, Readable } from "node:stream";
2
+ import { Agent } from "undici";
3
+ import { createAnthropicMessagesEventStreamTransformer, maybeTransformAnthropicMessagesRequest, transformOpenAiChatCompletionToAnthropicMessage, transformUpstreamErrorToAnthropicError, } from "./anthropic-compat.js";
2
4
  import { config } from "./config.js";
5
+ import { recordModelRequestSample } from "./model-load-metrics.js";
3
6
  const HOP_BY_HOP_HEADERS = new Set([
4
7
  "connection",
5
8
  "keep-alive",
@@ -13,8 +16,37 @@ const HOP_BY_HOP_HEADERS = new Set([
13
16
  const MAX_REQUEST_BODY_BYTES = 50 * 1024 * 1024;
14
17
  const AUTO_MODEL = "auto";
15
18
  let autoModelCursor = 0;
19
+ const upstreamAgent = new Agent({
20
+ connections: config.upstreamMaxConnections,
21
+ pipelining: 1,
22
+ keepAliveTimeout: config.upstreamKeepAliveTimeoutMs,
23
+ keepAliveMaxTimeout: config.upstreamKeepAliveMaxTimeoutMs,
24
+ });
25
+ const fetchWithDispatcher = fetch;
26
+ function formatGatewayLogValue(value) {
27
+ if (value === null || value === undefined || value === "") {
28
+ return "-";
29
+ }
30
+ const normalized = String(value);
31
+ return /\s|"/.test(normalized) ? JSON.stringify(normalized) : normalized;
32
+ }
33
+ function buildGatewayLogLine(protocol, event, fields) {
34
+ const parts = [
35
+ "[gateway]",
36
+ `protocol=${formatGatewayLogValue(protocol)}`,
37
+ `event=${formatGatewayLogValue(event)}`,
38
+ ];
39
+ for (const [key, value] of Object.entries(fields)) {
40
+ parts.push(`${key}=${formatGatewayLogValue(value)}`);
41
+ }
42
+ return parts.join(" ");
43
+ }
16
44
  function logProxyModelRoute(params) {
17
- console.log(`[gateway] requested_model=${params.requestedModel ?? "-"} used_model=${params.usedModel ?? "-"} route=${params.routeName ?? "-"}`);
45
+ console.log(buildGatewayLogLine(params.protocol, "routed", {
46
+ requested_model: params.requestedModel,
47
+ used_model: params.usedModel,
48
+ route: params.routeName,
49
+ }));
18
50
  }
19
51
  function resolveRouteNameForModel(modelId) {
20
52
  if (modelId && config.modelRouteMap[modelId]) {
@@ -23,7 +55,27 @@ function resolveRouteNameForModel(modelId) {
23
55
  return config.modelRouteMap["*"]?.routeName ?? null;
24
56
  }
25
57
  function logProxyModelSwitch(params) {
26
- console.log(`[gateway] switch trigger_status=${params.triggerStatus} from_model=${params.fromModel ?? "-"} from_route=${params.fromRoute ?? "-"} to_model=${params.toModel ?? "-"} to_route=${params.toRoute ?? "-"}`);
58
+ console.log(buildGatewayLogLine(params.protocol, "switch", {
59
+ trigger_status: params.triggerStatus,
60
+ from_model: params.fromModel,
61
+ from_route: params.fromRoute,
62
+ to_model: params.toModel,
63
+ to_route: params.toRoute,
64
+ }));
65
+ }
66
+ function resolveGatewayProtocolFromPath(requestPath) {
67
+ const { pathname } = parsePathnameAndSearch(requestPath);
68
+ if (pathname === "/anthropic" ||
69
+ pathname.startsWith("/anthropic/") ||
70
+ isAnthropicApiPath(pathname)) {
71
+ return "anthropic";
72
+ }
73
+ return "openai";
74
+ }
75
+ function resolveGatewayProtocol(request) {
76
+ const rawUrl = request.url ?? "/";
77
+ const normalizedRawUrl = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
78
+ return resolveGatewayProtocolFromPath(normalizedRawUrl);
27
79
  }
28
80
  function sendJson(response, statusCode, payload) {
29
81
  if (response.writableEnded) {
@@ -39,11 +91,25 @@ function normalizeRequestPath(request) {
39
91
  const rawUrl = request.url ?? "/";
40
92
  try {
41
93
  const parsed = new URL(rawUrl, "http://localhost");
42
- return `${parsed.pathname}${parsed.search}`;
94
+ return normalizeGatewayRequestPath(`${parsed.pathname}${parsed.search}`);
43
95
  }
44
96
  catch {
45
- return rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
97
+ const normalizedRawUrl = rawUrl.startsWith("/") ? rawUrl : `/${rawUrl}`;
98
+ return normalizeGatewayRequestPath(normalizedRawUrl);
99
+ }
100
+ }
101
+ function normalizeGatewayRequestPath(requestPath) {
102
+ const { pathname, search } = parsePathnameAndSearch(requestPath);
103
+ if (pathname === "/anthropic") {
104
+ return `/v1${search}`;
46
105
  }
106
+ if (pathname === "/anthropic/v1" || pathname.startsWith("/anthropic/v1/")) {
107
+ return `${pathname.slice("/anthropic".length)}${search}`;
108
+ }
109
+ if (pathname.startsWith("/anthropic/")) {
110
+ return `/v1${pathname.slice("/anthropic".length)}${search}`;
111
+ }
112
+ return `${pathname}${search}`;
47
113
  }
48
114
  function rotateCandidates(candidates, startIndex) {
49
115
  if (candidates.length <= 1) {
@@ -71,31 +137,102 @@ function buildModelCandidates(requestedModel) {
71
137
  // Non-auto requests are pinned to the exact model specified by client.
72
138
  return [requestedModel];
73
139
  }
74
- function buildRoutedUpstreamUrl(request, selectedRoute) {
140
+ function parsePathnameAndSearch(requestPath) {
141
+ try {
142
+ const parsed = new URL(requestPath, "http://localhost");
143
+ return {
144
+ pathname: parsed.pathname,
145
+ search: parsed.search,
146
+ };
147
+ }
148
+ catch {
149
+ const [pathnamePart, ...searchParts] = requestPath.split("?");
150
+ return {
151
+ pathname: pathnamePart || "/",
152
+ search: searchParts.length > 0 ? `?${searchParts.join("?")}` : "",
153
+ };
154
+ }
155
+ }
156
+ function isAnthropicApiPath(pathname) {
157
+ return (pathname === "/v1/messages" ||
158
+ pathname.startsWith("/v1/messages/") ||
159
+ pathname === "/v1/models" ||
160
+ pathname === "/v1/complete");
161
+ }
162
+ function rewriteFixedChatCompletionsRouteUrlForAnthropic(routeUrl, requestPath) {
163
+ const { pathname: requestPathname, search: requestSearch } = parsePathnameAndSearch(requestPath);
164
+ if (!isAnthropicApiPath(requestPathname)) {
165
+ return null;
166
+ }
167
+ let parsedRouteUrl;
168
+ try {
169
+ parsedRouteUrl = new URL(routeUrl);
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ const normalizedRoutePath = parsedRouteUrl.pathname.replace(/\/+$/, "");
175
+ const fixedChatCompletionsSuffix = "/v1/chat/completions";
176
+ if (!normalizedRoutePath.endsWith(fixedChatCompletionsSuffix)) {
177
+ return null;
178
+ }
179
+ const routePrefixPath = normalizedRoutePath.slice(0, -fixedChatCompletionsSuffix.length);
180
+ parsedRouteUrl.pathname = `${routePrefixPath}${requestPathname}`.replace(/\/{2,}/g, "/");
181
+ parsedRouteUrl.search = requestSearch;
182
+ return parsedRouteUrl.toString();
183
+ }
184
+ function buildRoutedUpstreamUrl(requestPath, selectedRoute) {
75
185
  if (!selectedRoute) {
76
- return `${config.upstreamBaseUrl}${normalizeRequestPath(request)}`;
186
+ return `${config.upstreamBaseUrl}${requestPath}`;
77
187
  }
78
188
  if (!selectedRoute.isBaseUrl) {
189
+ // Backward-compatible Anthropic support when route URL is fixed to /v1/chat/completions.
190
+ const anthropicCompatUrl = rewriteFixedChatCompletionsRouteUrlForAnthropic(selectedRoute.url, requestPath);
191
+ if (anthropicCompatUrl) {
192
+ return anthropicCompatUrl;
193
+ }
79
194
  return selectedRoute.url;
80
195
  }
81
196
  const routeBase = selectedRoute.url.replace(/\/+$/, "");
82
- const requestPath = normalizeRequestPath(request);
83
197
  if (routeBase.endsWith("/v1") && requestPath.startsWith("/v1")) {
84
198
  return `${routeBase}${requestPath.slice(3)}`;
85
199
  }
86
200
  return `${routeBase}${requestPath}`;
87
201
  }
88
- function resolveUpstreamTarget(request, modelId) {
202
+ function resolveUpstreamTarget(requestPath, modelId) {
89
203
  const modelRoute = modelId ? config.modelRouteMap[modelId] ?? null : null;
90
204
  const wildcardRoute = config.modelRouteMap["*"] ?? null;
91
205
  const selectedRoute = modelRoute ?? wildcardRoute;
92
206
  return {
93
- upstreamUrl: buildRoutedUpstreamUrl(request, selectedRoute),
207
+ upstreamUrl: buildRoutedUpstreamUrl(requestPath, selectedRoute),
94
208
  selectedRoute,
95
209
  };
96
210
  }
211
+ async function logUpstreamErrorResponse(params) {
212
+ let detail = "-";
213
+ try {
214
+ const raw = await params.response.clone().text();
215
+ const normalized = raw.replace(/\s+/g, " ").trim();
216
+ if (normalized) {
217
+ detail = normalized.slice(0, 2000);
218
+ }
219
+ }
220
+ catch {
221
+ detail = "<unavailable>";
222
+ }
223
+ console.error(buildGatewayLogLine(params.protocol, "upstream_error", {
224
+ status: params.response.status,
225
+ path: params.requestPath,
226
+ route: params.routeName,
227
+ model: params.modelId,
228
+ upstream: params.upstreamUrl,
229
+ detail,
230
+ }));
231
+ }
97
232
  function buildUpstreamHeaders(reqHeaders, bodyLength, selectedRoute) {
98
233
  const headers = new Headers();
234
+ const selectedAuthHeader = selectedRoute?.authHeader || "authorization";
235
+ const conflictingAuthHeaders = ["authorization", "x-api-key", "api-key"];
99
236
  for (const [key, value] of Object.entries(reqHeaders)) {
100
237
  if (value === undefined) {
101
238
  continue;
@@ -106,13 +243,20 @@ function buildUpstreamHeaders(reqHeaders, bodyLength, selectedRoute) {
106
243
  }
107
244
  headers.set(key, Array.isArray(value) ? value.join(",") : String(value));
108
245
  }
246
+ if (selectedRoute?.apiKey) {
247
+ for (const headerName of conflictingAuthHeaders) {
248
+ if (headerName !== selectedAuthHeader) {
249
+ headers.delete(headerName);
250
+ }
251
+ }
252
+ }
109
253
  if (selectedRoute?.headers) {
110
254
  for (const [key, value] of Object.entries(selectedRoute.headers)) {
111
255
  headers.set(key, value);
112
256
  }
113
257
  }
114
258
  if (selectedRoute?.apiKey) {
115
- const authHeader = selectedRoute.authHeader || "authorization";
259
+ const authHeader = selectedAuthHeader;
116
260
  const authPrefix = selectedRoute.authPrefix ?? "Bearer ";
117
261
  if (!headers.has(authHeader)) {
118
262
  headers.set(authHeader, `${authPrefix}${selectedRoute.apiKey}`);
@@ -224,10 +368,61 @@ async function fetchWithTimeout(url, options, timeoutMs) {
224
368
  const controller = new AbortController();
225
369
  const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
226
370
  try {
227
- return await fetch(url, { ...options, signal: controller.signal });
371
+ return await fetchWithDispatcher(url, {
372
+ ...options,
373
+ signal: controller.signal,
374
+ dispatcher: upstreamAgent,
375
+ });
376
+ }
377
+ finally {
378
+ clearTimeout(timeoutId);
379
+ }
380
+ }
381
+ function createClientAbortSignal(request, response) {
382
+ const controller = new AbortController();
383
+ let aborted = false;
384
+ const abort = () => {
385
+ if (aborted) {
386
+ return;
387
+ }
388
+ aborted = true;
389
+ controller.abort();
390
+ };
391
+ request.once("aborted", abort);
392
+ response.once("close", () => {
393
+ if (!response.writableEnded) {
394
+ abort();
395
+ }
396
+ });
397
+ return controller.signal;
398
+ }
399
+ async function fetchWithTimeoutAndClientSignal(url, options, timeoutMs, clientSignal) {
400
+ if (!clientSignal) {
401
+ return fetchWithTimeout(url, options, timeoutMs);
402
+ }
403
+ const controller = new AbortController();
404
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
405
+ const onClientAbort = () => {
406
+ if (!controller.signal.aborted) {
407
+ controller.abort();
408
+ }
409
+ };
410
+ if (clientSignal.aborted) {
411
+ onClientAbort();
412
+ }
413
+ else {
414
+ clientSignal.addEventListener("abort", onClientAbort, { once: true });
415
+ }
416
+ try {
417
+ return await fetchWithDispatcher(url, {
418
+ ...options,
419
+ signal: controller.signal,
420
+ dispatcher: upstreamAgent,
421
+ });
228
422
  }
229
423
  finally {
230
424
  clearTimeout(timeoutId);
425
+ clientSignal.removeEventListener("abort", onClientAbort);
231
426
  }
232
427
  }
233
428
  async function disposeBody(response) {
@@ -295,6 +490,9 @@ async function readRequestBody(request) {
295
490
  export async function proxyRequest(request, response) {
296
491
  const method = (request.method ?? "GET").toUpperCase();
297
492
  const supportsBody = method !== "GET" && method !== "HEAD";
493
+ const clientSignal = createClientAbortSignal(request, response);
494
+ const normalizedRequestPath = normalizeRequestPath(request);
495
+ const requestProtocol = resolveGatewayProtocol(request);
298
496
  let incomingBody = Buffer.alloc(0);
299
497
  if (supportsBody) {
300
498
  try {
@@ -344,23 +542,64 @@ export async function proxyRequest(request, response) {
344
542
  let switchNotice = null;
345
543
  for (let attemptIndex = 0; attemptIndex < modelCandidates.length; attemptIndex += 1) {
346
544
  const modelId = modelCandidates[attemptIndex];
347
- let bodyBuffer = supportsBody && incomingBody.length > 0 ? incomingBody : undefined;
348
- if (supportsBody && parsedJsonBody && modelId) {
349
- bodyBuffer = Buffer.from(JSON.stringify({
545
+ let requestPath = normalizedRequestPath;
546
+ let responseFormat = null;
547
+ let requestJsonPayload = null;
548
+ if (supportsBody && parsedJsonBody) {
549
+ requestJsonPayload = {
350
550
  ...parsedJsonBody,
351
- model: modelId,
352
- }), "utf8");
551
+ ...(modelId ? { model: modelId } : {}),
552
+ };
353
553
  }
354
- const { upstreamUrl, selectedRoute } = resolveUpstreamTarget(request, modelId);
554
+ let { upstreamUrl, selectedRoute } = resolveUpstreamTarget(requestPath, modelId);
355
555
  lastAttemptRouteName = selectedRoute?.routeName ?? null;
556
+ if (requestJsonPayload) {
557
+ const compatRequest = maybeTransformAnthropicMessagesRequest({
558
+ requestPath,
559
+ upstreamUrl,
560
+ body: requestJsonPayload,
561
+ });
562
+ if (compatRequest.error) {
563
+ console.error(buildGatewayLogLine(requestProtocol, "compat_error", {
564
+ path: requestPath,
565
+ route: selectedRoute?.routeName ?? null,
566
+ model: modelId,
567
+ detail: compatRequest.error,
568
+ }));
569
+ sendJson(response, 400, {
570
+ error: {
571
+ message: compatRequest.error,
572
+ },
573
+ });
574
+ return;
575
+ }
576
+ requestPath = compatRequest.requestPath;
577
+ requestJsonPayload = compatRequest.body;
578
+ responseFormat = compatRequest.responseFormat;
579
+ if (responseFormat) {
580
+ upstreamUrl = buildRoutedUpstreamUrl(requestPath, selectedRoute);
581
+ }
582
+ }
583
+ let bodyBuffer = supportsBody && incomingBody.length > 0 ? incomingBody : undefined;
584
+ if (supportsBody && requestJsonPayload) {
585
+ bodyBuffer = Buffer.from(JSON.stringify(requestJsonPayload), "utf8");
586
+ }
356
587
  const requestBody = bodyBuffer ? new Uint8Array(bodyBuffer) : undefined;
357
588
  const headers = buildUpstreamHeaders(request.headers, bodyBuffer ? bodyBuffer.length : undefined, selectedRoute);
589
+ const attemptStartedAt = Date.now();
358
590
  try {
359
- const upstreamResponse = await fetchWithTimeout(upstreamUrl, {
591
+ const upstreamResponse = await fetchWithTimeoutAndClientSignal(upstreamUrl, {
360
592
  method,
361
593
  headers,
362
594
  body: requestBody,
363
- }, config.timeoutMs);
595
+ }, config.timeoutMs, clientSignal);
596
+ const headerLoadMs = Date.now() - attemptStartedAt;
597
+ const modelForMetric = modelId ?? requestedModel;
598
+ recordModelRequestSample(modelForMetric, {
599
+ ok: upstreamResponse.ok,
600
+ responseMs: headerLoadMs,
601
+ statusCode: upstreamResponse.status,
602
+ });
364
603
  const contentType = (upstreamResponse.headers.get("content-type") ?? "").toLowerCase();
365
604
  const isEventStream = contentType.includes("text/event-stream");
366
605
  const isJsonResponse = contentType.includes("application/json");
@@ -378,6 +617,7 @@ export async function proxyRequest(request, response) {
378
617
  const triggerStatus = retryTriggerStatus ?? upstreamResponse.status;
379
618
  const nextRouteName = resolveRouteNameForModel(nextModel);
380
619
  logProxyModelSwitch({
620
+ protocol: requestProtocol,
381
621
  triggerStatus,
382
622
  fromModel: modelId,
383
623
  toModel: nextModel,
@@ -396,6 +636,16 @@ export async function proxyRequest(request, response) {
396
636
  await disposeBody(upstreamResponse);
397
637
  continue;
398
638
  }
639
+ if (!upstreamResponse.ok) {
640
+ await logUpstreamErrorResponse({
641
+ protocol: requestProtocol,
642
+ requestPath,
643
+ upstreamUrl,
644
+ routeName: selectedRoute?.routeName ?? null,
645
+ modelId,
646
+ response: upstreamResponse,
647
+ });
648
+ }
399
649
  const attemptCount = attemptIndex + 1;
400
650
  const effectiveSwitchNotice = switchNotice;
401
651
  copyResponseHeaders(upstreamResponse, response);
@@ -407,6 +657,7 @@ export async function proxyRequest(request, response) {
407
657
  response.setHeader("x-gateway-switched", "1");
408
658
  }
409
659
  logProxyModelRoute({
660
+ protocol: requestProtocol,
410
661
  requestedModel,
411
662
  usedModel: modelId,
412
663
  routeName: selectedRoute?.routeName ?? null,
@@ -416,6 +667,61 @@ export async function proxyRequest(request, response) {
416
667
  response.end();
417
668
  return;
418
669
  }
670
+ if (responseFormat === "anthropic-messages" && isEventStream) {
671
+ const nodeStream = Readable.fromWeb(upstreamResponse.body);
672
+ const anthropicStream = nodeStream.pipe(createAnthropicMessagesEventStreamTransformer(modelId));
673
+ response.removeHeader("content-length");
674
+ response.setHeader("content-type", "text/event-stream; charset=utf-8");
675
+ if (effectiveSwitchNotice) {
676
+ createSsePrefixedStream(anthropicStream, effectiveSwitchNotice).pipe(response);
677
+ return;
678
+ }
679
+ anthropicStream.on("error", () => {
680
+ if (!response.writableEnded) {
681
+ response.destroy();
682
+ }
683
+ });
684
+ anthropicStream.pipe(response);
685
+ return;
686
+ }
687
+ if (responseFormat === "anthropic-messages" && isJsonResponse && !isEventStream) {
688
+ const rawText = await upstreamResponse.text();
689
+ response.removeHeader("content-length");
690
+ response.setHeader("content-type", "application/json; charset=utf-8");
691
+ try {
692
+ const parsed = JSON.parse(rawText);
693
+ if (!upstreamResponse.ok) {
694
+ response.end(JSON.stringify(transformUpstreamErrorToAnthropicError(parsed, upstreamResponse.status)));
695
+ return;
696
+ }
697
+ const transformed = transformOpenAiChatCompletionToAnthropicMessage(parsed, modelId);
698
+ if (transformed.value) {
699
+ response.end(JSON.stringify(transformed.value));
700
+ return;
701
+ }
702
+ console.error(buildGatewayLogLine(requestProtocol, "compat_error", {
703
+ path: requestPath,
704
+ route: selectedRoute?.routeName ?? null,
705
+ model: modelId,
706
+ detail: transformed.error ?? "Unknown transform error",
707
+ }));
708
+ sendJson(response, 502, {
709
+ error: {
710
+ message: "Gateway failed to translate the OpenAI-compatible response to Anthropic format.",
711
+ detail: transformed.error ?? "Unknown transform error",
712
+ },
713
+ });
714
+ return;
715
+ }
716
+ catch {
717
+ if (!upstreamResponse.ok) {
718
+ response.end(JSON.stringify(transformUpstreamErrorToAnthropicError({
719
+ message: rawText,
720
+ }, upstreamResponse.status)));
721
+ return;
722
+ }
723
+ }
724
+ }
419
725
  if (effectiveSwitchNotice && isJsonResponse && !isEventStream) {
420
726
  const rawText = await upstreamResponse.text();
421
727
  response.removeHeader("content-length");
@@ -449,6 +755,11 @@ export async function proxyRequest(request, response) {
449
755
  }
450
756
  catch (error) {
451
757
  lastError = error;
758
+ recordModelRequestSample(modelId ?? requestedModel, {
759
+ ok: false,
760
+ responseMs: Date.now() - attemptStartedAt,
761
+ statusCode: null,
762
+ });
452
763
  if (attemptIndex < modelCandidates.length - 1) {
453
764
  continue;
454
765
  }
@@ -461,6 +772,7 @@ export async function proxyRequest(request, response) {
461
772
  const errorStatusCode = timeoutLike ? 504 : 502;
462
773
  const lastTriedModel = modelCandidates[modelCandidates.length - 1] ?? null;
463
774
  logProxyModelRoute({
775
+ protocol: requestProtocol,
464
776
  requestedModel,
465
777
  usedModel: lastTriedModel,
466
778
  routeName: lastAttemptRouteName,