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