skalpel 2.0.13 → 2.0.15

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,15 +1,27 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/proxy/server.ts
4
- import http from "http";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
5
11
 
6
12
  // src/proxy/dispatcher.ts
7
13
  import { Agent } from "undici";
8
- var skalpelDispatcher = new Agent({
9
- keepAliveTimeout: 1e4,
10
- keepAliveMaxTimeout: 6e4,
11
- connections: 100,
12
- pipelining: 1
14
+ var skalpelDispatcher;
15
+ var init_dispatcher = __esm({
16
+ "src/proxy/dispatcher.ts"() {
17
+ "use strict";
18
+ skalpelDispatcher = new Agent({
19
+ keepAliveTimeout: 1e4,
20
+ keepAliveMaxTimeout: 6e4,
21
+ connections: 100,
22
+ pipelining: 1
23
+ });
24
+ }
13
25
  });
14
26
 
15
27
  // src/proxy/envelope.ts
@@ -78,6 +90,11 @@ function defaultMessageForStatus(status) {
78
90
  if (status >= 400) return "Client error";
79
91
  return "Error";
80
92
  }
93
+ var init_envelope = __esm({
94
+ "src/proxy/envelope.ts"() {
95
+ "use strict";
96
+ }
97
+ });
81
98
 
82
99
  // src/proxy/recovery.ts
83
100
  import { createHash } from "crypto";
@@ -96,11 +113,10 @@ function parseRetryAfterHeader(header) {
96
113
  function sleep(ms) {
97
114
  return new Promise((resolve) => setTimeout(resolve, ms));
98
115
  }
99
- var MAX_RETRY_AFTER_SECONDS = 60;
100
- var DEFAULT_BACKOFF_SECONDS = 2;
101
116
  async function handle429WithRetryAfter(response, retryFn, logger) {
102
117
  const headerVal = response.headers.get("retry-after");
103
118
  const parsed = parseRetryAfterHeader(headerVal);
119
+ logger.debug(`429 recovery retryAfterHeader=${headerVal ?? "none"} parsed=${parsed ?? "none"}`);
104
120
  if (parsed === void 0) {
105
121
  await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
106
122
  const retried2 = await retryFn();
@@ -108,6 +124,7 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
108
124
  return retried2;
109
125
  }
110
126
  if (parsed > MAX_RETRY_AFTER_SECONDS) {
127
+ logger.warn(`429 recovery capped: retryAfter=${parsed}s exceeds max=${MAX_RETRY_AFTER_SECONDS}s, passing 429 through`);
111
128
  return response;
112
129
  }
113
130
  await sleep(parsed * 1e3);
@@ -115,12 +132,12 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
115
132
  logger.info("proxy.recovery.429_retry_count increment");
116
133
  return retried;
117
134
  }
118
- var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
119
135
  async function handleTimeoutWithRetry(err, retryFn, logger) {
120
136
  const code = err.code;
121
137
  if (!code || !TIMEOUT_CODES.has(code)) {
122
138
  throw err;
123
139
  }
140
+ logger.warn(`timeout recovery code=${code}`);
124
141
  await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
125
142
  const retried = await retryFn();
126
143
  logger.info("proxy.recovery.timeout_retry_count increment");
@@ -130,23 +147,48 @@ function tokenFingerprint(authHeader) {
130
147
  if (authHeader === void 0) return "none";
131
148
  return createHash("sha256").update(authHeader).digest("hex").slice(0, 12);
132
149
  }
133
- var MUTEX_MAX_ENTRIES = 1024;
134
- var LruMutexMap = class extends Map {
135
- set(key, value) {
136
- if (this.has(key)) {
137
- super.delete(key);
138
- } else if (this.size >= MUTEX_MAX_ENTRIES) {
139
- const oldest = this.keys().next().value;
140
- if (oldest !== void 0) super.delete(oldest);
141
- }
142
- return super.set(key, value);
150
+ var MAX_RETRY_AFTER_SECONDS, DEFAULT_BACKOFF_SECONDS, TIMEOUT_CODES, MUTEX_MAX_ENTRIES, LruMutexMap, refreshMutex;
151
+ var init_recovery = __esm({
152
+ "src/proxy/recovery.ts"() {
153
+ "use strict";
154
+ MAX_RETRY_AFTER_SECONDS = 60;
155
+ DEFAULT_BACKOFF_SECONDS = 2;
156
+ TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
157
+ MUTEX_MAX_ENTRIES = 1024;
158
+ LruMutexMap = class extends Map {
159
+ set(key, value) {
160
+ if (this.has(key)) {
161
+ super.delete(key);
162
+ } else if (this.size >= MUTEX_MAX_ENTRIES) {
163
+ const oldest = this.keys().next().value;
164
+ if (oldest !== void 0) super.delete(oldest);
165
+ }
166
+ return super.set(key, value);
167
+ }
168
+ };
169
+ refreshMutex = new LruMutexMap();
143
170
  }
144
- };
145
- var refreshMutex = new LruMutexMap();
171
+ });
172
+
173
+ // src/proxy/fetch-error.ts
174
+ function formatFetchErrorForLog(err, url) {
175
+ if (err instanceof Error) {
176
+ const code = err.code;
177
+ const parts = [];
178
+ if (code) parts.push(code);
179
+ parts.push(err.message);
180
+ parts.push(`url=${url}`);
181
+ return parts.join(" ");
182
+ }
183
+ return `${String(err)} url=${url}`;
184
+ }
185
+ var init_fetch_error = __esm({
186
+ "src/proxy/fetch-error.ts"() {
187
+ "use strict";
188
+ }
189
+ });
146
190
 
147
191
  // src/proxy/streaming.ts
148
- var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
149
- var HTTP_BAD_GATEWAY = 502;
150
192
  function parseRetryAfter(header) {
151
193
  if (!header) return void 0;
152
194
  const trimmed = header.trim();
@@ -160,21 +202,6 @@ function parseRetryAfter(header) {
160
202
  }
161
203
  return void 0;
162
204
  }
163
- var HOP_BY_HOP = /* @__PURE__ */ new Set([
164
- "connection",
165
- "keep-alive",
166
- "proxy-authenticate",
167
- "proxy-authorization",
168
- "te",
169
- "trailer",
170
- "transfer-encoding",
171
- "upgrade"
172
- ]);
173
- var STRIP_HEADERS = /* @__PURE__ */ new Set([
174
- ...HOP_BY_HOP,
175
- "content-encoding",
176
- "content-length"
177
- ]);
178
205
  function stripSkalpelHeaders(headers) {
179
206
  const cleaned = { ...headers };
180
207
  delete cleaned["X-Skalpel-API-Key"];
@@ -192,29 +219,35 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
192
219
  let fetchError = null;
193
220
  let usedFallback = false;
194
221
  if (useSkalpel) {
222
+ logger.info(`streaming fetch sending url=${skalpelUrl}`);
195
223
  try {
196
224
  response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
197
225
  } catch (err) {
198
226
  fetchError = err;
199
227
  }
228
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${skalpelUrl}`);
200
229
  if (await isSkalpelBackendFailure(response, fetchError, logger)) {
201
- logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
230
+ logger.warn(`streaming: Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
202
231
  usedFallback = true;
203
232
  response = null;
204
233
  fetchError = null;
205
234
  const directHeaders = stripSkalpelHeaders(forwardHeaders);
235
+ logger.info(`streaming fetch sending url=${directUrl} fallback=true`);
206
236
  try {
207
237
  response = await doStreamingFetch(directUrl, body, directHeaders);
208
238
  } catch (err) {
209
239
  fetchError = err;
210
240
  }
241
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl} fallback=true`);
211
242
  }
212
243
  } else {
244
+ logger.info(`streaming fetch sending url=${directUrl}`);
213
245
  try {
214
246
  response = await doStreamingFetch(directUrl, body, forwardHeaders);
215
247
  } catch (err) {
216
248
  fetchError = err;
217
249
  }
250
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl}`);
218
251
  }
219
252
  const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
220
253
  const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
@@ -241,7 +274,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
241
274
  );
242
275
  }
243
276
  if (!response || fetchError) {
244
- const errMsg = fetchError ? fetchError.message : "no response from upstream";
277
+ const errMsg = fetchError ? formatFetchErrorForLog(fetchError, finalUrl) : "no response from upstream";
245
278
  logger.error(`streaming fetch failed: ${errMsg}`);
246
279
  res.writeHead(HTTP_BAD_GATEWAY, {
247
280
  "Content-Type": "text/event-stream",
@@ -314,12 +347,19 @@ data: ${JSON.stringify({ error: "no response body" })}
314
347
  try {
315
348
  const reader = response.body.getReader();
316
349
  const decoder = new TextDecoder();
350
+ let chunkCount = 0;
351
+ let totalBytes = 0;
352
+ logger.info("streaming started");
317
353
  while (true) {
318
354
  const { done, value } = await reader.read();
319
355
  if (done) break;
356
+ chunkCount++;
357
+ totalBytes += value.byteLength;
358
+ logger.debug(`streaming chunk #${chunkCount} bytes=${value.byteLength} totalBytes=${totalBytes}`);
320
359
  const chunk = decoder.decode(value, { stream: true });
321
360
  res.write(chunk);
322
361
  }
362
+ logger.info(`streaming completed chunks=${chunkCount} totalBytes=${totalBytes}`);
323
363
  } catch (err) {
324
364
  logger.error(`streaming error: ${err.message}`);
325
365
  const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
@@ -337,11 +377,165 @@ data: ${JSON.stringify(envelope)}
337
377
  }
338
378
  res.end();
339
379
  }
380
+ var TIMEOUT_CODES2, HTTP_BAD_GATEWAY, HOP_BY_HOP, STRIP_HEADERS;
381
+ var init_streaming = __esm({
382
+ "src/proxy/streaming.ts"() {
383
+ "use strict";
384
+ init_dispatcher();
385
+ init_handler();
386
+ init_envelope();
387
+ init_recovery();
388
+ init_fetch_error();
389
+ TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
390
+ HTTP_BAD_GATEWAY = 502;
391
+ HOP_BY_HOP = /* @__PURE__ */ new Set([
392
+ "connection",
393
+ "keep-alive",
394
+ "proxy-authenticate",
395
+ "proxy-authorization",
396
+ "te",
397
+ "trailer",
398
+ "transfer-encoding",
399
+ "upgrade"
400
+ ]);
401
+ STRIP_HEADERS = /* @__PURE__ */ new Set([
402
+ ...HOP_BY_HOP,
403
+ "content-encoding",
404
+ "content-length"
405
+ ]);
406
+ }
407
+ });
408
+
409
+ // src/proxy/ws-client.ts
410
+ import { EventEmitter } from "events";
411
+ import WebSocket from "ws";
412
+ function defaultBackoffBaseMs() {
413
+ const raw = process.env.SKALPEL_WS_BACKOFF_BASE_MS;
414
+ const parsed = raw === void 0 ? NaN : parseInt(raw, 10);
415
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e3;
416
+ }
417
+ function computeBackoff(attempt, baseMs) {
418
+ const exp = Math.min(MAX_BACKOFF_MS, baseMs * Math.pow(2, attempt));
419
+ const jitter = exp * (0.2 * (Math.random() * 2 - 1));
420
+ return Math.max(0, Math.floor(exp + jitter));
421
+ }
422
+ var WS_SUBPROTOCOL, MAX_RECONNECTS, MAX_BACKOFF_MS, NON_TRANSIENT_CLOSE_CODES, BackendWsClient;
423
+ var init_ws_client = __esm({
424
+ "src/proxy/ws-client.ts"() {
425
+ "use strict";
426
+ WS_SUBPROTOCOL = "skalpel-codex-v1";
427
+ MAX_RECONNECTS = 5;
428
+ MAX_BACKOFF_MS = 6e4;
429
+ NON_TRANSIENT_CLOSE_CODES = /* @__PURE__ */ new Set([4e3, 4001, 4002, 4004]);
430
+ BackendWsClient = class extends EventEmitter {
431
+ opts;
432
+ ws = null;
433
+ reconnectAttempts = 0;
434
+ closedByUser = false;
435
+ pendingReconnect = null;
436
+ constructor(opts) {
437
+ super();
438
+ this.opts = opts;
439
+ }
440
+ async connect() {
441
+ return new Promise((resolve, reject) => {
442
+ const ws = new WebSocket(this.opts.url, [WS_SUBPROTOCOL], {
443
+ headers: {
444
+ "X-Skalpel-API-Key": this.opts.apiKey,
445
+ Authorization: `Bearer ${this.opts.oauthToken}`,
446
+ "x-skalpel-source": this.opts.source
447
+ }
448
+ });
449
+ this.ws = ws;
450
+ ws.once("open", () => {
451
+ this.emit("open");
452
+ resolve();
453
+ });
454
+ ws.on("message", (data) => {
455
+ const text = data.toString("utf-8");
456
+ let parsed = null;
457
+ try {
458
+ parsed = JSON.parse(text);
459
+ } catch {
460
+ this.emit("error", new Error(`invalid frame: ${text.slice(0, 100)}`));
461
+ return;
462
+ }
463
+ this.emit("frame", parsed);
464
+ });
465
+ ws.on("error", (err) => {
466
+ this.opts.logger.debug(`ws-client error: ${err.message}`);
467
+ this.emit("error", err);
468
+ });
469
+ ws.once("close", (code, reasonBuf) => {
470
+ const reason = reasonBuf.toString("utf-8");
471
+ this.opts.logger.info(`ws-client close code=${code} reason=${reason}`);
472
+ this.ws = null;
473
+ if (this.closedByUser || code === 1e3) {
474
+ this.emit("close", code, reason);
475
+ return;
476
+ }
477
+ if (NON_TRANSIENT_CLOSE_CODES.has(code)) {
478
+ this.emit("close", code, reason);
479
+ this.emit("fallback", `close_${code}:${reason}`);
480
+ return;
481
+ }
482
+ this.scheduleReconnect(resolve, reject);
483
+ this.emit("close", code, reason);
484
+ });
485
+ });
486
+ }
487
+ scheduleReconnect(initialResolve, initialReject) {
488
+ if (this.reconnectAttempts >= MAX_RECONNECTS) {
489
+ this.emit("fallback", "reconnect_exhausted");
490
+ return;
491
+ }
492
+ this.reconnectAttempts += 1;
493
+ const delay = computeBackoff(this.reconnectAttempts, defaultBackoffBaseMs());
494
+ this.opts.logger.info(
495
+ `ws-client reconnect attempt=${this.reconnectAttempts} delay=${delay}ms`
496
+ );
497
+ this.pendingReconnect = setTimeout(() => {
498
+ this.pendingReconnect = null;
499
+ this.connect().catch((err) => {
500
+ this.opts.logger.debug(`reconnect failed: ${err.message}`);
501
+ });
502
+ }, delay);
503
+ void initialResolve;
504
+ void initialReject;
505
+ }
506
+ send(frame) {
507
+ if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) {
508
+ throw new Error("ws-client send: socket not open");
509
+ }
510
+ this.ws.send(JSON.stringify(frame));
511
+ }
512
+ close(code = 1e3, reason = "client close") {
513
+ this.closedByUser = true;
514
+ if (this.pendingReconnect !== null) {
515
+ clearTimeout(this.pendingReconnect);
516
+ this.pendingReconnect = null;
517
+ }
518
+ if (this.ws !== null) {
519
+ try {
520
+ this.ws.close(code, reason);
521
+ } catch {
522
+ }
523
+ this.ws = null;
524
+ }
525
+ }
526
+ };
527
+ }
528
+ });
340
529
 
341
530
  // src/proxy/handler.ts
342
- var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
343
- var HTTP_BAD_GATEWAY2 = 502;
344
- var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
531
+ var handler_exports = {};
532
+ __export(handler_exports, {
533
+ buildForwardHeaders: () => buildForwardHeaders,
534
+ handleRequest: () => handleRequest,
535
+ handleWebSocketBridge: () => handleWebSocketBridge,
536
+ isSkalpelBackendFailure: () => isSkalpelBackendFailure,
537
+ shouldRouteToSkalpel: () => shouldRouteToSkalpel
538
+ });
345
539
  function collectBody(req) {
346
540
  return new Promise((resolve, reject) => {
347
541
  const chunks = [];
@@ -385,17 +579,6 @@ async function isSkalpelBackendFailure(response, err, logger) {
385
579
  return true;
386
580
  }
387
581
  }
388
- var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
389
- "host",
390
- "connection",
391
- "keep-alive",
392
- "proxy-authenticate",
393
- "proxy-authorization",
394
- "te",
395
- "trailer",
396
- "transfer-encoding",
397
- "upgrade"
398
- ]);
399
582
  function buildForwardHeaders(req, config2, source, useSkalpel) {
400
583
  const forwardHeaders = {};
401
584
  for (const [key, value] of Object.entries(req.headers)) {
@@ -431,18 +614,6 @@ function stripSkalpelHeaders2(headers) {
431
614
  delete cleaned["X-Skalpel-Auth-Mode"];
432
615
  return cleaned;
433
616
  }
434
- var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
435
- "connection",
436
- "keep-alive",
437
- "proxy-authenticate",
438
- "proxy-authorization",
439
- "te",
440
- "trailer",
441
- "transfer-encoding",
442
- "upgrade",
443
- "content-encoding",
444
- "content-length"
445
- ]);
446
617
  function extractResponseHeaders(response) {
447
618
  const headers = {};
448
619
  for (const [key, value] of response.headers.entries()) {
@@ -460,11 +631,22 @@ async function handleRequest(req, res, config2, source, logger) {
460
631
  typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
461
632
  );
462
633
  logger.info(`${source} ${method} ${path4} token=${fp}`);
634
+ if (source === "codex") {
635
+ const ua = req.headers["user-agent"] ?? "";
636
+ const authScheme = typeof req.headers.authorization === "string" ? req.headers.authorization.split(" ")[0] ?? "none" : "none";
637
+ const upgrade = req.headers.upgrade ?? "";
638
+ const connection = req.headers.connection ?? "";
639
+ const contentType = req.headers["content-type"] ?? "";
640
+ logger.debug(`codex-diag method=${method} path=${path4} ua=${ua} authScheme=${authScheme} upgrade=${upgrade} connection=${connection} contentType=${contentType} hasBody=${method !== "GET" && method !== "HEAD"}`);
641
+ }
463
642
  let response = null;
464
643
  try {
465
644
  const body = await collectBody(req);
645
+ logger.info(`body collected bytes=${body.length}`);
466
646
  const useSkalpel = shouldRouteToSkalpel(path4, source);
647
+ logger.info(`routing useSkalpel=${useSkalpel}`);
467
648
  const forwardHeaders = buildForwardHeaders(req, config2, source, useSkalpel);
649
+ logger.debug(`headers built skalpelHeaders=${useSkalpel} authConverted=${!forwardHeaders["authorization"] && !!forwardHeaders["x-api-key"]}`);
468
650
  let isStreaming = false;
469
651
  if (body) {
470
652
  try {
@@ -473,6 +655,7 @@ async function handleRequest(req, res, config2, source, logger) {
473
655
  } catch {
474
656
  }
475
657
  }
658
+ logger.info(`stream detection isStreaming=${isStreaming}`);
476
659
  if (isStreaming) {
477
660
  const skalpelUrl2 = `${config2.remoteBaseUrl}${path4}`;
478
661
  const directUrl2 = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
@@ -486,35 +669,42 @@ async function handleRequest(req, res, config2, source, logger) {
486
669
  let fetchError = null;
487
670
  let usedFallback = false;
488
671
  if (useSkalpel) {
672
+ logger.info(`fetch sending url=${skalpelUrl} method=${method}`);
489
673
  try {
490
674
  response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
491
675
  } catch (err) {
492
676
  fetchError = err;
493
677
  }
678
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${skalpelUrl}`);
494
679
  if (await isSkalpelBackendFailure(response, fetchError, logger)) {
495
- logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
680
+ logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
496
681
  usedFallback = true;
497
682
  response = null;
498
683
  fetchError = null;
499
684
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
685
+ logger.info(`fetch sending url=${directUrl} method=${method} fallback=true`);
500
686
  try {
501
687
  response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
502
688
  } catch (err) {
503
689
  fetchError = err;
504
690
  }
691
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl} fallback=true`);
505
692
  }
506
693
  } else {
694
+ logger.info(`fetch sending url=${directUrl} method=${method}`);
507
695
  try {
508
696
  response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
509
697
  } catch (err) {
510
698
  fetchError = err;
511
699
  }
700
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl}`);
512
701
  }
513
702
  const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
514
703
  const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
515
704
  if (fetchError) {
516
705
  const code = fetchError.code;
517
706
  if (code && TIMEOUT_CODES3.has(code)) {
707
+ logger.warn(`timeout detected code=${code} url=${fetchUrl}`);
518
708
  try {
519
709
  response = await handleTimeoutWithRetry(
520
710
  fetchError,
@@ -537,6 +727,7 @@ async function handleRequest(req, res, config2, source, logger) {
537
727
  throw fetchError ?? new Error("no response from upstream");
538
728
  }
539
729
  if (response.status === 429) {
730
+ logger.info(`429 received url=${fetchUrl}`);
540
731
  response = await handle429WithRetryAfter(
541
732
  response,
542
733
  () => fetch(fetchUrl, {
@@ -563,11 +754,12 @@ async function handleRequest(req, res, config2, source, logger) {
563
754
  const responseHeaders = extractResponseHeaders(response);
564
755
  const responseBody = Buffer.from(await response.arrayBuffer());
565
756
  responseHeaders["content-length"] = String(responseBody.length);
757
+ logger.info(`response forwarding status=${response.status} bodyBytes=${responseBody.length}`);
566
758
  res.writeHead(response.status, responseHeaders);
567
759
  res.end(responseBody);
568
760
  logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
569
761
  } catch (err) {
570
- logger.error(`${method} ${path4} source=${source} error=${err.message}`);
762
+ logger.error(`${method} ${path4} source=${source} error=${formatFetchErrorForLog(err, path4)}`);
571
763
  if (!res.headersSent) {
572
764
  if (response !== null) {
573
765
  const upstreamStatus = response.status;
@@ -591,9 +783,204 @@ async function handleRequest(req, res, config2, source, logger) {
591
783
  }
592
784
  }
593
785
  }
786
+ async function handleWebSocketBridge(clientWs, req, config2, source, logger) {
787
+ const backendUrl = buildBackendWsUrl(config2);
788
+ const oauthHeader = req.headers["authorization"] ?? "";
789
+ const oauthToken = oauthHeader.toLowerCase().startsWith("bearer ") ? oauthHeader.slice(7).trim() : "";
790
+ const backend = new BackendWsClient({
791
+ url: backendUrl,
792
+ apiKey: config2.apiKey,
793
+ oauthToken,
794
+ source,
795
+ logger
796
+ });
797
+ let fallbackActive = false;
798
+ let backendOpen = false;
799
+ const pendingClientFrames = [];
800
+ const inflightRequests = /* @__PURE__ */ new Map();
801
+ const flushPending = () => {
802
+ if (!backendOpen || fallbackActive) return;
803
+ while (pendingClientFrames.length > 0) {
804
+ const frame = pendingClientFrames.shift();
805
+ if (frame === void 0) break;
806
+ try {
807
+ backend.send(frame);
808
+ if (frame.type === "request") {
809
+ const id = String(frame.id ?? "");
810
+ if (id) inflightRequests.set(id, frame);
811
+ }
812
+ } catch (err) {
813
+ logger.warn(`bridge: flush backend.send failed: ${err.message}`);
814
+ pendingClientFrames.unshift(frame);
815
+ return;
816
+ }
817
+ }
818
+ };
819
+ clientWs.on("message", (data) => {
820
+ const text = data.toString("utf-8");
821
+ let parsed;
822
+ try {
823
+ parsed = JSON.parse(text);
824
+ } catch {
825
+ logger.warn("bridge: invalid client frame (dropped)");
826
+ return;
827
+ }
828
+ pendingClientFrames.push(parsed);
829
+ flushPending();
830
+ });
831
+ clientWs.on("close", (code, reason) => {
832
+ logger.info(`bridge: client closed code=${code} reason=${String(reason)}`);
833
+ backend.close(1e3, "client closed");
834
+ });
835
+ backend.on("open", () => {
836
+ backendOpen = true;
837
+ flushPending();
838
+ });
839
+ backend.on("frame", (frame) => {
840
+ try {
841
+ clientWs.send(JSON.stringify(frame));
842
+ } catch (err) {
843
+ logger.debug(`bridge: client.send failed: ${err.message}`);
844
+ }
845
+ if (typeof frame === "object" && frame !== null) {
846
+ const fr = frame;
847
+ const t = fr.type;
848
+ if (t === "done" || t === "error") {
849
+ const id = String(fr.id ?? "");
850
+ if (id) inflightRequests.delete(id);
851
+ }
852
+ }
853
+ });
854
+ backend.on("close", (code, reason) => {
855
+ logger.info(`bridge: backend closed code=${code} reason=${reason}`);
856
+ backendOpen = false;
857
+ });
858
+ backend.on("error", (err) => {
859
+ logger.debug(`bridge: backend error: ${err.message}`);
860
+ });
861
+ backend.on("fallback", (reason) => {
862
+ fallbackActive = true;
863
+ backendOpen = false;
864
+ logger.warn(`bridge: backend fallback reason=${reason} \u2014 switching to HTTP POST`);
865
+ const replay = [
866
+ ...inflightRequests.values(),
867
+ ...pendingClientFrames.splice(0)
868
+ ];
869
+ inflightRequests.clear();
870
+ drainPendingToHttp(clientWs, config2, source, logger, replay).catch((httpErr) => {
871
+ logger.error(`bridge HTTP drain failed: ${httpErr.message}`);
872
+ try {
873
+ clientWs.close(4003, "fallback drain failed");
874
+ } catch {
875
+ }
876
+ });
877
+ });
878
+ try {
879
+ await backend.connect();
880
+ } catch (err) {
881
+ logger.error(`bridge: initial connect failed: ${err.message}`);
882
+ }
883
+ }
884
+ function buildBackendWsUrl(config2) {
885
+ const base = config2.remoteBaseUrl.replace(/^http/, "ws");
886
+ return `${base}/v1/responses`;
887
+ }
888
+ async function drainPendingToHttp(clientWs, config2, source, logger, frames) {
889
+ for (const frame of frames) {
890
+ if (frame.type !== "request") continue;
891
+ const payload = frame.payload ?? {};
892
+ const id = String(frame.id ?? "");
893
+ try {
894
+ const resp = await fetch(`${config2.remoteBaseUrl}/v1/responses`, {
895
+ method: "POST",
896
+ headers: {
897
+ "Content-Type": "application/json",
898
+ "X-Skalpel-API-Key": config2.apiKey,
899
+ "x-skalpel-source": source,
900
+ Accept: "text/event-stream"
901
+ },
902
+ body: JSON.stringify(payload)
903
+ });
904
+ if (!resp.ok || resp.body === null) {
905
+ clientWs.send(
906
+ JSON.stringify({
907
+ type: "error",
908
+ id,
909
+ payload: { code: resp.status, detail: `http fallback status ${resp.status}` }
910
+ })
911
+ );
912
+ continue;
913
+ }
914
+ const reader = resp.body.getReader();
915
+ const decoder = new TextDecoder("utf-8");
916
+ while (true) {
917
+ const { done, value } = await reader.read();
918
+ if (done) break;
919
+ if (value === void 0) continue;
920
+ const chunkText = decoder.decode(value, { stream: true });
921
+ clientWs.send(
922
+ JSON.stringify({ type: "chunk", id, payload: { data: chunkText } })
923
+ );
924
+ }
925
+ clientWs.send(JSON.stringify({ type: "done", id, payload: {} }));
926
+ } catch (err) {
927
+ logger.warn(`http fallback frame failed: ${err.message}`);
928
+ clientWs.send(
929
+ JSON.stringify({
930
+ type: "error",
931
+ id,
932
+ payload: { code: 502, detail: err.message }
933
+ })
934
+ );
935
+ }
936
+ }
937
+ }
938
+ var TIMEOUT_CODES3, HTTP_BAD_GATEWAY2, SKALPEL_EXACT_PATHS, FORWARD_HEADER_STRIP, STRIP_RESPONSE_HEADERS;
939
+ var init_handler = __esm({
940
+ "src/proxy/handler.ts"() {
941
+ "use strict";
942
+ init_streaming();
943
+ init_dispatcher();
944
+ init_envelope();
945
+ init_ws_client();
946
+ init_recovery();
947
+ init_fetch_error();
948
+ TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
949
+ HTTP_BAD_GATEWAY2 = 502;
950
+ SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
951
+ FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
952
+ "host",
953
+ "connection",
954
+ "keep-alive",
955
+ "proxy-authenticate",
956
+ "proxy-authorization",
957
+ "te",
958
+ "trailer",
959
+ "transfer-encoding",
960
+ "upgrade"
961
+ ]);
962
+ STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
963
+ "connection",
964
+ "keep-alive",
965
+ "proxy-authenticate",
966
+ "proxy-authorization",
967
+ "te",
968
+ "trailer",
969
+ "transfer-encoding",
970
+ "upgrade",
971
+ "content-encoding",
972
+ "content-length"
973
+ ]);
974
+ }
975
+ });
976
+
977
+ // src/proxy/server.ts
978
+ init_handler();
979
+ import http from "http";
594
980
 
595
981
  // src/proxy/health.ts
596
- function handleHealthRequest(res, config2, startTime) {
982
+ function handleHealthRequest(res, config2, startTime, logger) {
983
+ logger?.debug("health check served");
597
984
  const body = JSON.stringify({
598
985
  status: "ok",
599
986
  uptime: Date.now() - startTime,
@@ -712,6 +1099,67 @@ var Logger = class _Logger {
712
1099
  }
713
1100
  };
714
1101
 
1102
+ // src/proxy/ws-server.ts
1103
+ import { WebSocketServer } from "ws";
1104
+ var WS_SUBPROTOCOL2 = "skalpel-codex-v1";
1105
+ var wss = new WebSocketServer({ noServer: true });
1106
+ function reject426(socket, payload) {
1107
+ const body = JSON.stringify(payload);
1108
+ socket.write(
1109
+ `HTTP/1.1 426 Upgrade Required\r
1110
+ Content-Type: application/json\r
1111
+ Content-Length: ${Buffer.byteLength(body)}\r
1112
+ Connection: close\r
1113
+ \r
1114
+ ` + body
1115
+ );
1116
+ socket.destroy();
1117
+ }
1118
+ function handleCodexUpgrade(req, socket, head, config2, logger) {
1119
+ const wsFlag = process.env.SKALPEL_CODEX_WS ?? "1";
1120
+ if (wsFlag === "0") {
1121
+ logger.warn("ws-upgrade rejected: feature flag SKALPEL_CODEX_WS=0");
1122
+ reject426(socket, { error: "ws_disabled" });
1123
+ return;
1124
+ }
1125
+ const offered = req.headers["sec-websocket-protocol"] ?? "";
1126
+ const tokens = offered.split(",").map((t) => t.trim()).filter(Boolean);
1127
+ if (!tokens.includes(WS_SUBPROTOCOL2)) {
1128
+ logger.warn(`ws-upgrade rejected: unsupported subprotocol offered="${offered}"`);
1129
+ reject426(socket, { error: "unsupported_subprotocol" });
1130
+ return;
1131
+ }
1132
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
1133
+ logger.info(`ws-upgrade accepted path=${req.url ?? ""} subproto=${WS_SUBPROTOCOL2}`);
1134
+ Promise.resolve().then(() => (init_handler(), handler_exports)).then((mod) => {
1135
+ const bridge = mod.handleWebSocketBridge;
1136
+ if (typeof bridge !== "function") {
1137
+ clientWs.send(
1138
+ JSON.stringify({
1139
+ type: "error",
1140
+ payload: { code: "not_implemented" }
1141
+ })
1142
+ );
1143
+ clientWs.close(4003, "bridge pending");
1144
+ return;
1145
+ }
1146
+ void bridge(clientWs, req, config2, "codex", logger);
1147
+ }).catch((err) => {
1148
+ logger.error(`ws bridge import failed: ${err?.message ?? String(err)}`);
1149
+ try {
1150
+ clientWs.send(
1151
+ JSON.stringify({
1152
+ type: "error",
1153
+ payload: { code: "bridge_import_failed" }
1154
+ })
1155
+ );
1156
+ } catch {
1157
+ }
1158
+ clientWs.close(4003, "bridge import failed");
1159
+ });
1160
+ });
1161
+ }
1162
+
715
1163
  // src/proxy/server.ts
716
1164
  var proxyStartTime = 0;
717
1165
  var connCounter = 0;
@@ -728,7 +1176,7 @@ function startProxy(config2) {
728
1176
  proxyStartTime = Date.now();
729
1177
  const anthropicServer = http.createServer((req, res) => {
730
1178
  if (req.url === "/health" && req.method === "GET") {
731
- handleHealthRequest(res, config2, startTime);
1179
+ handleHealthRequest(res, config2, startTime, logger.child("health"));
732
1180
  return;
733
1181
  }
734
1182
  const connId = computeConnId(req);
@@ -736,7 +1184,7 @@ function startProxy(config2) {
736
1184
  });
737
1185
  const openaiServer = http.createServer((req, res) => {
738
1186
  if (req.url === "/health" && req.method === "GET") {
739
- handleHealthRequest(res, config2, startTime);
1187
+ handleHealthRequest(res, config2, startTime, logger.child("health"));
740
1188
  return;
741
1189
  }
742
1190
  const connId = computeConnId(req);
@@ -744,12 +1192,71 @@ function startProxy(config2) {
744
1192
  });
745
1193
  const cursorServer = http.createServer((req, res) => {
746
1194
  if (req.url === "/health" && req.method === "GET") {
747
- handleHealthRequest(res, config2, startTime);
1195
+ handleHealthRequest(res, config2, startTime, logger.child("health"));
748
1196
  return;
749
1197
  }
750
1198
  const connId = computeConnId(req);
751
1199
  handleRequest(req, res, config2, "cursor", logger.child(connId));
752
1200
  });
1201
+ anthropicServer.on("upgrade", (req, socket, _head) => {
1202
+ const ua = req.headers["user-agent"] ?? "";
1203
+ logger.warn(`upgrade-attempt port=${config2.anthropicPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1204
+ const body = JSON.stringify({
1205
+ error: "upgrade_required",
1206
+ message: "Skalpel proxy is HTTP-only",
1207
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1208
+ });
1209
+ socket.write(
1210
+ `HTTP/1.1 426 Upgrade Required\r
1211
+ Content-Type: application/json\r
1212
+ Content-Length: ${Buffer.byteLength(body)}\r
1213
+ Connection: close\r
1214
+ \r
1215
+ ` + body
1216
+ );
1217
+ socket.destroy();
1218
+ });
1219
+ openaiServer.on("upgrade", (req, socket, head) => {
1220
+ const ua = req.headers["user-agent"] ?? "";
1221
+ logger.warn(`upgrade-attempt port=${config2.openaiPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1222
+ const pathname = (req.url ?? "").split("?")[0];
1223
+ if (pathname === "/v1/responses") {
1224
+ handleCodexUpgrade(req, socket, head, config2, logger);
1225
+ return;
1226
+ }
1227
+ const body = JSON.stringify({
1228
+ error: "upgrade_required",
1229
+ message: "Skalpel proxy is HTTP-only",
1230
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1231
+ });
1232
+ socket.write(
1233
+ `HTTP/1.1 426 Upgrade Required\r
1234
+ Content-Type: application/json\r
1235
+ Content-Length: ${Buffer.byteLength(body)}\r
1236
+ Connection: close\r
1237
+ \r
1238
+ ` + body
1239
+ );
1240
+ socket.destroy();
1241
+ });
1242
+ cursorServer.on("upgrade", (req, socket, _head) => {
1243
+ const ua = req.headers["user-agent"] ?? "";
1244
+ logger.warn(`upgrade-attempt port=${config2.cursorPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1245
+ const body = JSON.stringify({
1246
+ error: "upgrade_required",
1247
+ message: "Skalpel proxy is HTTP-only",
1248
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1249
+ });
1250
+ socket.write(
1251
+ `HTTP/1.1 426 Upgrade Required\r
1252
+ Content-Type: application/json\r
1253
+ Content-Length: ${Buffer.byteLength(body)}\r
1254
+ Connection: close\r
1255
+ \r
1256
+ ` + body
1257
+ );
1258
+ socket.destroy();
1259
+ });
753
1260
  anthropicServer.on("error", (err) => {
754
1261
  if (err.code === "EADDRINUSE") {
755
1262
  logger.error(`Port ${config2.anthropicPort} is already in use. Another Skalpel proxy or process may be running.`);
@@ -777,17 +1284,26 @@ function startProxy(config2) {
777
1284
  removePid(config2.pidFile);
778
1285
  process.exit(1);
779
1286
  });
1287
+ let bound = 0;
1288
+ const onBound = () => {
1289
+ bound++;
1290
+ if (bound === 3) {
1291
+ writePid(config2.pidFile);
1292
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config2.anthropicPort},${config2.openaiPort},${config2.cursorPort}`);
1293
+ }
1294
+ };
780
1295
  anthropicServer.listen(config2.anthropicPort, () => {
781
1296
  logger.info(`Anthropic proxy listening on port ${config2.anthropicPort}`);
1297
+ onBound();
782
1298
  });
783
1299
  openaiServer.listen(config2.openaiPort, () => {
784
1300
  logger.info(`OpenAI proxy listening on port ${config2.openaiPort}`);
1301
+ onBound();
785
1302
  });
786
1303
  cursorServer.listen(config2.cursorPort, () => {
787
1304
  logger.info(`Cursor proxy listening on port ${config2.cursorPort}`);
1305
+ onBound();
788
1306
  });
789
- writePid(config2.pidFile);
790
- logger.info(`Proxy started (pid=${process.pid}) ports=${config2.anthropicPort},${config2.openaiPort},${config2.cursorPort}`);
791
1307
  const cleanup = () => {
792
1308
  logger.info("Shutting down proxy...");
793
1309
  anthropicServer.close();