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.
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
6
  var __getProtoOf = Object.getPrototypeOf;
7
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
8
11
  var __export = (target, all) => {
9
12
  for (var name in all)
10
13
  __defProp(target, name, { get: all[name], enumerable: true });
@@ -27,27 +30,19 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
30
  ));
28
31
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
32
 
30
- // src/proxy/index.ts
31
- var proxy_exports = {};
32
- __export(proxy_exports, {
33
- getProxyStatus: () => getProxyStatus,
34
- loadConfig: () => loadConfig,
35
- saveConfig: () => saveConfig,
36
- startProxy: () => startProxy,
37
- stopProxy: () => stopProxy
38
- });
39
- module.exports = __toCommonJS(proxy_exports);
40
-
41
- // src/proxy/server.ts
42
- var import_node_http = __toESM(require("http"), 1);
43
-
44
33
  // src/proxy/dispatcher.ts
45
- var import_undici = require("undici");
46
- var skalpelDispatcher = new import_undici.Agent({
47
- keepAliveTimeout: 1e4,
48
- keepAliveMaxTimeout: 6e4,
49
- connections: 100,
50
- pipelining: 1
34
+ var import_undici, skalpelDispatcher;
35
+ var init_dispatcher = __esm({
36
+ "src/proxy/dispatcher.ts"() {
37
+ "use strict";
38
+ import_undici = require("undici");
39
+ skalpelDispatcher = new import_undici.Agent({
40
+ keepAliveTimeout: 1e4,
41
+ keepAliveMaxTimeout: 6e4,
42
+ connections: 100,
43
+ pipelining: 1
44
+ });
45
+ }
51
46
  });
52
47
 
53
48
  // src/proxy/envelope.ts
@@ -116,9 +111,13 @@ function defaultMessageForStatus(status) {
116
111
  if (status >= 400) return "Client error";
117
112
  return "Error";
118
113
  }
114
+ var init_envelope = __esm({
115
+ "src/proxy/envelope.ts"() {
116
+ "use strict";
117
+ }
118
+ });
119
119
 
120
120
  // src/proxy/recovery.ts
121
- var import_node_crypto = require("crypto");
122
121
  function parseRetryAfterHeader(header) {
123
122
  if (!header) return void 0;
124
123
  const trimmed = header.trim();
@@ -134,11 +133,10 @@ function parseRetryAfterHeader(header) {
134
133
  function sleep(ms) {
135
134
  return new Promise((resolve) => setTimeout(resolve, ms));
136
135
  }
137
- var MAX_RETRY_AFTER_SECONDS = 60;
138
- var DEFAULT_BACKOFF_SECONDS = 2;
139
136
  async function handle429WithRetryAfter(response, retryFn, logger) {
140
137
  const headerVal = response.headers.get("retry-after");
141
138
  const parsed = parseRetryAfterHeader(headerVal);
139
+ logger.debug(`429 recovery retryAfterHeader=${headerVal ?? "none"} parsed=${parsed ?? "none"}`);
142
140
  if (parsed === void 0) {
143
141
  await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
144
142
  const retried2 = await retryFn();
@@ -146,6 +144,7 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
146
144
  return retried2;
147
145
  }
148
146
  if (parsed > MAX_RETRY_AFTER_SECONDS) {
147
+ logger.warn(`429 recovery capped: retryAfter=${parsed}s exceeds max=${MAX_RETRY_AFTER_SECONDS}s, passing 429 through`);
149
148
  return response;
150
149
  }
151
150
  await sleep(parsed * 1e3);
@@ -153,12 +152,12 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
153
152
  logger.info("proxy.recovery.429_retry_count increment");
154
153
  return retried;
155
154
  }
156
- var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
157
155
  async function handleTimeoutWithRetry(err, retryFn, logger) {
158
156
  const code = err.code;
159
157
  if (!code || !TIMEOUT_CODES.has(code)) {
160
158
  throw err;
161
159
  }
160
+ logger.warn(`timeout recovery code=${code}`);
162
161
  await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
163
162
  const retried = await retryFn();
164
163
  logger.info("proxy.recovery.timeout_retry_count increment");
@@ -168,23 +167,49 @@ function tokenFingerprint(authHeader) {
168
167
  if (authHeader === void 0) return "none";
169
168
  return (0, import_node_crypto.createHash)("sha256").update(authHeader).digest("hex").slice(0, 12);
170
169
  }
171
- var MUTEX_MAX_ENTRIES = 1024;
172
- var LruMutexMap = class extends Map {
173
- set(key, value) {
174
- if (this.has(key)) {
175
- super.delete(key);
176
- } else if (this.size >= MUTEX_MAX_ENTRIES) {
177
- const oldest = this.keys().next().value;
178
- if (oldest !== void 0) super.delete(oldest);
179
- }
180
- return super.set(key, value);
170
+ var import_node_crypto, MAX_RETRY_AFTER_SECONDS, DEFAULT_BACKOFF_SECONDS, TIMEOUT_CODES, MUTEX_MAX_ENTRIES, LruMutexMap, refreshMutex;
171
+ var init_recovery = __esm({
172
+ "src/proxy/recovery.ts"() {
173
+ "use strict";
174
+ import_node_crypto = require("crypto");
175
+ MAX_RETRY_AFTER_SECONDS = 60;
176
+ DEFAULT_BACKOFF_SECONDS = 2;
177
+ TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
178
+ MUTEX_MAX_ENTRIES = 1024;
179
+ LruMutexMap = class extends Map {
180
+ set(key, value) {
181
+ if (this.has(key)) {
182
+ super.delete(key);
183
+ } else if (this.size >= MUTEX_MAX_ENTRIES) {
184
+ const oldest = this.keys().next().value;
185
+ if (oldest !== void 0) super.delete(oldest);
186
+ }
187
+ return super.set(key, value);
188
+ }
189
+ };
190
+ refreshMutex = new LruMutexMap();
181
191
  }
182
- };
183
- var refreshMutex = new LruMutexMap();
192
+ });
193
+
194
+ // src/proxy/fetch-error.ts
195
+ function formatFetchErrorForLog(err, url) {
196
+ if (err instanceof Error) {
197
+ const code = err.code;
198
+ const parts = [];
199
+ if (code) parts.push(code);
200
+ parts.push(err.message);
201
+ parts.push(`url=${url}`);
202
+ return parts.join(" ");
203
+ }
204
+ return `${String(err)} url=${url}`;
205
+ }
206
+ var init_fetch_error = __esm({
207
+ "src/proxy/fetch-error.ts"() {
208
+ "use strict";
209
+ }
210
+ });
184
211
 
185
212
  // src/proxy/streaming.ts
186
- var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
187
- var HTTP_BAD_GATEWAY = 502;
188
213
  function parseRetryAfter(header) {
189
214
  if (!header) return void 0;
190
215
  const trimmed = header.trim();
@@ -198,21 +223,6 @@ function parseRetryAfter(header) {
198
223
  }
199
224
  return void 0;
200
225
  }
201
- var HOP_BY_HOP = /* @__PURE__ */ new Set([
202
- "connection",
203
- "keep-alive",
204
- "proxy-authenticate",
205
- "proxy-authorization",
206
- "te",
207
- "trailer",
208
- "transfer-encoding",
209
- "upgrade"
210
- ]);
211
- var STRIP_HEADERS = /* @__PURE__ */ new Set([
212
- ...HOP_BY_HOP,
213
- "content-encoding",
214
- "content-length"
215
- ]);
216
226
  function stripSkalpelHeaders(headers) {
217
227
  const cleaned = { ...headers };
218
228
  delete cleaned["X-Skalpel-API-Key"];
@@ -230,29 +240,35 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
230
240
  let fetchError = null;
231
241
  let usedFallback = false;
232
242
  if (useSkalpel) {
243
+ logger.info(`streaming fetch sending url=${skalpelUrl}`);
233
244
  try {
234
245
  response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
235
246
  } catch (err) {
236
247
  fetchError = err;
237
248
  }
249
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${skalpelUrl}`);
238
250
  if (await isSkalpelBackendFailure(response, fetchError, logger)) {
239
- logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
251
+ logger.warn(`streaming: Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
240
252
  usedFallback = true;
241
253
  response = null;
242
254
  fetchError = null;
243
255
  const directHeaders = stripSkalpelHeaders(forwardHeaders);
256
+ logger.info(`streaming fetch sending url=${directUrl} fallback=true`);
244
257
  try {
245
258
  response = await doStreamingFetch(directUrl, body, directHeaders);
246
259
  } catch (err) {
247
260
  fetchError = err;
248
261
  }
262
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl} fallback=true`);
249
263
  }
250
264
  } else {
265
+ logger.info(`streaming fetch sending url=${directUrl}`);
251
266
  try {
252
267
  response = await doStreamingFetch(directUrl, body, forwardHeaders);
253
268
  } catch (err) {
254
269
  fetchError = err;
255
270
  }
271
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl}`);
256
272
  }
257
273
  const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
258
274
  const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
@@ -279,7 +295,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
279
295
  );
280
296
  }
281
297
  if (!response || fetchError) {
282
- const errMsg = fetchError ? fetchError.message : "no response from upstream";
298
+ const errMsg = fetchError ? formatFetchErrorForLog(fetchError, finalUrl) : "no response from upstream";
283
299
  logger.error(`streaming fetch failed: ${errMsg}`);
284
300
  res.writeHead(HTTP_BAD_GATEWAY, {
285
301
  "Content-Type": "text/event-stream",
@@ -352,12 +368,19 @@ data: ${JSON.stringify({ error: "no response body" })}
352
368
  try {
353
369
  const reader = response.body.getReader();
354
370
  const decoder = new TextDecoder();
371
+ let chunkCount = 0;
372
+ let totalBytes = 0;
373
+ logger.info("streaming started");
355
374
  while (true) {
356
375
  const { done, value } = await reader.read();
357
376
  if (done) break;
377
+ chunkCount++;
378
+ totalBytes += value.byteLength;
379
+ logger.debug(`streaming chunk #${chunkCount} bytes=${value.byteLength} totalBytes=${totalBytes}`);
358
380
  const chunk = decoder.decode(value, { stream: true });
359
381
  res.write(chunk);
360
382
  }
383
+ logger.info(`streaming completed chunks=${chunkCount} totalBytes=${totalBytes}`);
361
384
  } catch (err) {
362
385
  logger.error(`streaming error: ${err.message}`);
363
386
  const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
@@ -375,11 +398,165 @@ data: ${JSON.stringify(envelope)}
375
398
  }
376
399
  res.end();
377
400
  }
401
+ var TIMEOUT_CODES2, HTTP_BAD_GATEWAY, HOP_BY_HOP, STRIP_HEADERS;
402
+ var init_streaming = __esm({
403
+ "src/proxy/streaming.ts"() {
404
+ "use strict";
405
+ init_dispatcher();
406
+ init_handler();
407
+ init_envelope();
408
+ init_recovery();
409
+ init_fetch_error();
410
+ TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
411
+ HTTP_BAD_GATEWAY = 502;
412
+ HOP_BY_HOP = /* @__PURE__ */ new Set([
413
+ "connection",
414
+ "keep-alive",
415
+ "proxy-authenticate",
416
+ "proxy-authorization",
417
+ "te",
418
+ "trailer",
419
+ "transfer-encoding",
420
+ "upgrade"
421
+ ]);
422
+ STRIP_HEADERS = /* @__PURE__ */ new Set([
423
+ ...HOP_BY_HOP,
424
+ "content-encoding",
425
+ "content-length"
426
+ ]);
427
+ }
428
+ });
429
+
430
+ // src/proxy/ws-client.ts
431
+ function defaultBackoffBaseMs() {
432
+ const raw = process.env.SKALPEL_WS_BACKOFF_BASE_MS;
433
+ const parsed = raw === void 0 ? NaN : parseInt(raw, 10);
434
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e3;
435
+ }
436
+ function computeBackoff(attempt, baseMs) {
437
+ const exp = Math.min(MAX_BACKOFF_MS, baseMs * Math.pow(2, attempt));
438
+ const jitter = exp * (0.2 * (Math.random() * 2 - 1));
439
+ return Math.max(0, Math.floor(exp + jitter));
440
+ }
441
+ var import_node_events, import_ws, WS_SUBPROTOCOL, MAX_RECONNECTS, MAX_BACKOFF_MS, NON_TRANSIENT_CLOSE_CODES, BackendWsClient;
442
+ var init_ws_client = __esm({
443
+ "src/proxy/ws-client.ts"() {
444
+ "use strict";
445
+ import_node_events = require("events");
446
+ import_ws = __toESM(require("ws"), 1);
447
+ WS_SUBPROTOCOL = "skalpel-codex-v1";
448
+ MAX_RECONNECTS = 5;
449
+ MAX_BACKOFF_MS = 6e4;
450
+ NON_TRANSIENT_CLOSE_CODES = /* @__PURE__ */ new Set([4e3, 4001, 4002, 4004]);
451
+ BackendWsClient = class extends import_node_events.EventEmitter {
452
+ opts;
453
+ ws = null;
454
+ reconnectAttempts = 0;
455
+ closedByUser = false;
456
+ pendingReconnect = null;
457
+ constructor(opts) {
458
+ super();
459
+ this.opts = opts;
460
+ }
461
+ async connect() {
462
+ return new Promise((resolve, reject) => {
463
+ const ws = new import_ws.default(this.opts.url, [WS_SUBPROTOCOL], {
464
+ headers: {
465
+ "X-Skalpel-API-Key": this.opts.apiKey,
466
+ Authorization: `Bearer ${this.opts.oauthToken}`,
467
+ "x-skalpel-source": this.opts.source
468
+ }
469
+ });
470
+ this.ws = ws;
471
+ ws.once("open", () => {
472
+ this.emit("open");
473
+ resolve();
474
+ });
475
+ ws.on("message", (data) => {
476
+ const text = data.toString("utf-8");
477
+ let parsed = null;
478
+ try {
479
+ parsed = JSON.parse(text);
480
+ } catch {
481
+ this.emit("error", new Error(`invalid frame: ${text.slice(0, 100)}`));
482
+ return;
483
+ }
484
+ this.emit("frame", parsed);
485
+ });
486
+ ws.on("error", (err) => {
487
+ this.opts.logger.debug(`ws-client error: ${err.message}`);
488
+ this.emit("error", err);
489
+ });
490
+ ws.once("close", (code, reasonBuf) => {
491
+ const reason = reasonBuf.toString("utf-8");
492
+ this.opts.logger.info(`ws-client close code=${code} reason=${reason}`);
493
+ this.ws = null;
494
+ if (this.closedByUser || code === 1e3) {
495
+ this.emit("close", code, reason);
496
+ return;
497
+ }
498
+ if (NON_TRANSIENT_CLOSE_CODES.has(code)) {
499
+ this.emit("close", code, reason);
500
+ this.emit("fallback", `close_${code}:${reason}`);
501
+ return;
502
+ }
503
+ this.scheduleReconnect(resolve, reject);
504
+ this.emit("close", code, reason);
505
+ });
506
+ });
507
+ }
508
+ scheduleReconnect(initialResolve, initialReject) {
509
+ if (this.reconnectAttempts >= MAX_RECONNECTS) {
510
+ this.emit("fallback", "reconnect_exhausted");
511
+ return;
512
+ }
513
+ this.reconnectAttempts += 1;
514
+ const delay = computeBackoff(this.reconnectAttempts, defaultBackoffBaseMs());
515
+ this.opts.logger.info(
516
+ `ws-client reconnect attempt=${this.reconnectAttempts} delay=${delay}ms`
517
+ );
518
+ this.pendingReconnect = setTimeout(() => {
519
+ this.pendingReconnect = null;
520
+ this.connect().catch((err) => {
521
+ this.opts.logger.debug(`reconnect failed: ${err.message}`);
522
+ });
523
+ }, delay);
524
+ void initialResolve;
525
+ void initialReject;
526
+ }
527
+ send(frame) {
528
+ if (this.ws === null || this.ws.readyState !== import_ws.default.OPEN) {
529
+ throw new Error("ws-client send: socket not open");
530
+ }
531
+ this.ws.send(JSON.stringify(frame));
532
+ }
533
+ close(code = 1e3, reason = "client close") {
534
+ this.closedByUser = true;
535
+ if (this.pendingReconnect !== null) {
536
+ clearTimeout(this.pendingReconnect);
537
+ this.pendingReconnect = null;
538
+ }
539
+ if (this.ws !== null) {
540
+ try {
541
+ this.ws.close(code, reason);
542
+ } catch {
543
+ }
544
+ this.ws = null;
545
+ }
546
+ }
547
+ };
548
+ }
549
+ });
378
550
 
379
551
  // src/proxy/handler.ts
380
- var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
381
- var HTTP_BAD_GATEWAY2 = 502;
382
- var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
552
+ var handler_exports = {};
553
+ __export(handler_exports, {
554
+ buildForwardHeaders: () => buildForwardHeaders,
555
+ handleRequest: () => handleRequest,
556
+ handleWebSocketBridge: () => handleWebSocketBridge,
557
+ isSkalpelBackendFailure: () => isSkalpelBackendFailure,
558
+ shouldRouteToSkalpel: () => shouldRouteToSkalpel
559
+ });
383
560
  function collectBody(req) {
384
561
  return new Promise((resolve, reject) => {
385
562
  const chunks = [];
@@ -423,17 +600,6 @@ async function isSkalpelBackendFailure(response, err, logger) {
423
600
  return true;
424
601
  }
425
602
  }
426
- var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
427
- "host",
428
- "connection",
429
- "keep-alive",
430
- "proxy-authenticate",
431
- "proxy-authorization",
432
- "te",
433
- "trailer",
434
- "transfer-encoding",
435
- "upgrade"
436
- ]);
437
603
  function buildForwardHeaders(req, config, source, useSkalpel) {
438
604
  const forwardHeaders = {};
439
605
  for (const [key, value] of Object.entries(req.headers)) {
@@ -469,18 +635,6 @@ function stripSkalpelHeaders2(headers) {
469
635
  delete cleaned["X-Skalpel-Auth-Mode"];
470
636
  return cleaned;
471
637
  }
472
- var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
473
- "connection",
474
- "keep-alive",
475
- "proxy-authenticate",
476
- "proxy-authorization",
477
- "te",
478
- "trailer",
479
- "transfer-encoding",
480
- "upgrade",
481
- "content-encoding",
482
- "content-length"
483
- ]);
484
638
  function extractResponseHeaders(response) {
485
639
  const headers = {};
486
640
  for (const [key, value] of response.headers.entries()) {
@@ -498,11 +652,22 @@ async function handleRequest(req, res, config, source, logger) {
498
652
  typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
499
653
  );
500
654
  logger.info(`${source} ${method} ${path4} token=${fp}`);
655
+ if (source === "codex") {
656
+ const ua = req.headers["user-agent"] ?? "";
657
+ const authScheme = typeof req.headers.authorization === "string" ? req.headers.authorization.split(" ")[0] ?? "none" : "none";
658
+ const upgrade = req.headers.upgrade ?? "";
659
+ const connection = req.headers.connection ?? "";
660
+ const contentType = req.headers["content-type"] ?? "";
661
+ logger.debug(`codex-diag method=${method} path=${path4} ua=${ua} authScheme=${authScheme} upgrade=${upgrade} connection=${connection} contentType=${contentType} hasBody=${method !== "GET" && method !== "HEAD"}`);
662
+ }
501
663
  let response = null;
502
664
  try {
503
665
  const body = await collectBody(req);
666
+ logger.info(`body collected bytes=${body.length}`);
504
667
  const useSkalpel = shouldRouteToSkalpel(path4, source);
668
+ logger.info(`routing useSkalpel=${useSkalpel}`);
505
669
  const forwardHeaders = buildForwardHeaders(req, config, source, useSkalpel);
670
+ logger.debug(`headers built skalpelHeaders=${useSkalpel} authConverted=${!forwardHeaders["authorization"] && !!forwardHeaders["x-api-key"]}`);
506
671
  let isStreaming = false;
507
672
  if (body) {
508
673
  try {
@@ -511,6 +676,7 @@ async function handleRequest(req, res, config, source, logger) {
511
676
  } catch {
512
677
  }
513
678
  }
679
+ logger.info(`stream detection isStreaming=${isStreaming}`);
514
680
  if (isStreaming) {
515
681
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
516
682
  const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
@@ -524,35 +690,42 @@ async function handleRequest(req, res, config, source, logger) {
524
690
  let fetchError = null;
525
691
  let usedFallback = false;
526
692
  if (useSkalpel) {
693
+ logger.info(`fetch sending url=${skalpelUrl} method=${method}`);
527
694
  try {
528
695
  response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
529
696
  } catch (err) {
530
697
  fetchError = err;
531
698
  }
699
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${skalpelUrl}`);
532
700
  if (await isSkalpelBackendFailure(response, fetchError, logger)) {
533
- logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
701
+ logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
534
702
  usedFallback = true;
535
703
  response = null;
536
704
  fetchError = null;
537
705
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
706
+ logger.info(`fetch sending url=${directUrl} method=${method} fallback=true`);
538
707
  try {
539
708
  response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
540
709
  } catch (err) {
541
710
  fetchError = err;
542
711
  }
712
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl} fallback=true`);
543
713
  }
544
714
  } else {
715
+ logger.info(`fetch sending url=${directUrl} method=${method}`);
545
716
  try {
546
717
  response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
547
718
  } catch (err) {
548
719
  fetchError = err;
549
720
  }
721
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl}`);
550
722
  }
551
723
  const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
552
724
  const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
553
725
  if (fetchError) {
554
726
  const code = fetchError.code;
555
727
  if (code && TIMEOUT_CODES3.has(code)) {
728
+ logger.warn(`timeout detected code=${code} url=${fetchUrl}`);
556
729
  try {
557
730
  response = await handleTimeoutWithRetry(
558
731
  fetchError,
@@ -575,6 +748,7 @@ async function handleRequest(req, res, config, source, logger) {
575
748
  throw fetchError ?? new Error("no response from upstream");
576
749
  }
577
750
  if (response.status === 429) {
751
+ logger.info(`429 received url=${fetchUrl}`);
578
752
  response = await handle429WithRetryAfter(
579
753
  response,
580
754
  () => fetch(fetchUrl, {
@@ -601,11 +775,12 @@ async function handleRequest(req, res, config, source, logger) {
601
775
  const responseHeaders = extractResponseHeaders(response);
602
776
  const responseBody = Buffer.from(await response.arrayBuffer());
603
777
  responseHeaders["content-length"] = String(responseBody.length);
778
+ logger.info(`response forwarding status=${response.status} bodyBytes=${responseBody.length}`);
604
779
  res.writeHead(response.status, responseHeaders);
605
780
  res.end(responseBody);
606
781
  logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
607
782
  } catch (err) {
608
- logger.error(`${method} ${path4} source=${source} error=${err.message}`);
783
+ logger.error(`${method} ${path4} source=${source} error=${formatFetchErrorForLog(err, path4)}`);
609
784
  if (!res.headersSent) {
610
785
  if (response !== null) {
611
786
  const upstreamStatus = response.status;
@@ -629,9 +804,215 @@ async function handleRequest(req, res, config, source, logger) {
629
804
  }
630
805
  }
631
806
  }
807
+ async function handleWebSocketBridge(clientWs, req, config, source, logger) {
808
+ const backendUrl = buildBackendWsUrl(config);
809
+ const oauthHeader = req.headers["authorization"] ?? "";
810
+ const oauthToken = oauthHeader.toLowerCase().startsWith("bearer ") ? oauthHeader.slice(7).trim() : "";
811
+ const backend = new BackendWsClient({
812
+ url: backendUrl,
813
+ apiKey: config.apiKey,
814
+ oauthToken,
815
+ source,
816
+ logger
817
+ });
818
+ let fallbackActive = false;
819
+ let backendOpen = false;
820
+ const pendingClientFrames = [];
821
+ const inflightRequests = /* @__PURE__ */ new Map();
822
+ const flushPending = () => {
823
+ if (!backendOpen || fallbackActive) return;
824
+ while (pendingClientFrames.length > 0) {
825
+ const frame = pendingClientFrames.shift();
826
+ if (frame === void 0) break;
827
+ try {
828
+ backend.send(frame);
829
+ if (frame.type === "request") {
830
+ const id = String(frame.id ?? "");
831
+ if (id) inflightRequests.set(id, frame);
832
+ }
833
+ } catch (err) {
834
+ logger.warn(`bridge: flush backend.send failed: ${err.message}`);
835
+ pendingClientFrames.unshift(frame);
836
+ return;
837
+ }
838
+ }
839
+ };
840
+ clientWs.on("message", (data) => {
841
+ const text = data.toString("utf-8");
842
+ let parsed;
843
+ try {
844
+ parsed = JSON.parse(text);
845
+ } catch {
846
+ logger.warn("bridge: invalid client frame (dropped)");
847
+ return;
848
+ }
849
+ pendingClientFrames.push(parsed);
850
+ flushPending();
851
+ });
852
+ clientWs.on("close", (code, reason) => {
853
+ logger.info(`bridge: client closed code=${code} reason=${String(reason)}`);
854
+ backend.close(1e3, "client closed");
855
+ });
856
+ backend.on("open", () => {
857
+ backendOpen = true;
858
+ flushPending();
859
+ });
860
+ backend.on("frame", (frame) => {
861
+ try {
862
+ clientWs.send(JSON.stringify(frame));
863
+ } catch (err) {
864
+ logger.debug(`bridge: client.send failed: ${err.message}`);
865
+ }
866
+ if (typeof frame === "object" && frame !== null) {
867
+ const fr = frame;
868
+ const t = fr.type;
869
+ if (t === "done" || t === "error") {
870
+ const id = String(fr.id ?? "");
871
+ if (id) inflightRequests.delete(id);
872
+ }
873
+ }
874
+ });
875
+ backend.on("close", (code, reason) => {
876
+ logger.info(`bridge: backend closed code=${code} reason=${reason}`);
877
+ backendOpen = false;
878
+ });
879
+ backend.on("error", (err) => {
880
+ logger.debug(`bridge: backend error: ${err.message}`);
881
+ });
882
+ backend.on("fallback", (reason) => {
883
+ fallbackActive = true;
884
+ backendOpen = false;
885
+ logger.warn(`bridge: backend fallback reason=${reason} \u2014 switching to HTTP POST`);
886
+ const replay = [
887
+ ...inflightRequests.values(),
888
+ ...pendingClientFrames.splice(0)
889
+ ];
890
+ inflightRequests.clear();
891
+ drainPendingToHttp(clientWs, config, source, logger, replay).catch((httpErr) => {
892
+ logger.error(`bridge HTTP drain failed: ${httpErr.message}`);
893
+ try {
894
+ clientWs.close(4003, "fallback drain failed");
895
+ } catch {
896
+ }
897
+ });
898
+ });
899
+ try {
900
+ await backend.connect();
901
+ } catch (err) {
902
+ logger.error(`bridge: initial connect failed: ${err.message}`);
903
+ }
904
+ }
905
+ function buildBackendWsUrl(config) {
906
+ const base = config.remoteBaseUrl.replace(/^http/, "ws");
907
+ return `${base}/v1/responses`;
908
+ }
909
+ async function drainPendingToHttp(clientWs, config, source, logger, frames) {
910
+ for (const frame of frames) {
911
+ if (frame.type !== "request") continue;
912
+ const payload = frame.payload ?? {};
913
+ const id = String(frame.id ?? "");
914
+ try {
915
+ const resp = await fetch(`${config.remoteBaseUrl}/v1/responses`, {
916
+ method: "POST",
917
+ headers: {
918
+ "Content-Type": "application/json",
919
+ "X-Skalpel-API-Key": config.apiKey,
920
+ "x-skalpel-source": source,
921
+ Accept: "text/event-stream"
922
+ },
923
+ body: JSON.stringify(payload)
924
+ });
925
+ if (!resp.ok || resp.body === null) {
926
+ clientWs.send(
927
+ JSON.stringify({
928
+ type: "error",
929
+ id,
930
+ payload: { code: resp.status, detail: `http fallback status ${resp.status}` }
931
+ })
932
+ );
933
+ continue;
934
+ }
935
+ const reader = resp.body.getReader();
936
+ const decoder = new TextDecoder("utf-8");
937
+ while (true) {
938
+ const { done, value } = await reader.read();
939
+ if (done) break;
940
+ if (value === void 0) continue;
941
+ const chunkText = decoder.decode(value, { stream: true });
942
+ clientWs.send(
943
+ JSON.stringify({ type: "chunk", id, payload: { data: chunkText } })
944
+ );
945
+ }
946
+ clientWs.send(JSON.stringify({ type: "done", id, payload: {} }));
947
+ } catch (err) {
948
+ logger.warn(`http fallback frame failed: ${err.message}`);
949
+ clientWs.send(
950
+ JSON.stringify({
951
+ type: "error",
952
+ id,
953
+ payload: { code: 502, detail: err.message }
954
+ })
955
+ );
956
+ }
957
+ }
958
+ }
959
+ var TIMEOUT_CODES3, HTTP_BAD_GATEWAY2, SKALPEL_EXACT_PATHS, FORWARD_HEADER_STRIP, STRIP_RESPONSE_HEADERS;
960
+ var init_handler = __esm({
961
+ "src/proxy/handler.ts"() {
962
+ "use strict";
963
+ init_streaming();
964
+ init_dispatcher();
965
+ init_envelope();
966
+ init_ws_client();
967
+ init_recovery();
968
+ init_fetch_error();
969
+ TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
970
+ HTTP_BAD_GATEWAY2 = 502;
971
+ SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
972
+ FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
973
+ "host",
974
+ "connection",
975
+ "keep-alive",
976
+ "proxy-authenticate",
977
+ "proxy-authorization",
978
+ "te",
979
+ "trailer",
980
+ "transfer-encoding",
981
+ "upgrade"
982
+ ]);
983
+ STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
984
+ "connection",
985
+ "keep-alive",
986
+ "proxy-authenticate",
987
+ "proxy-authorization",
988
+ "te",
989
+ "trailer",
990
+ "transfer-encoding",
991
+ "upgrade",
992
+ "content-encoding",
993
+ "content-length"
994
+ ]);
995
+ }
996
+ });
997
+
998
+ // src/proxy/index.ts
999
+ var proxy_exports = {};
1000
+ __export(proxy_exports, {
1001
+ getProxyStatus: () => getProxyStatus,
1002
+ loadConfig: () => loadConfig,
1003
+ saveConfig: () => saveConfig,
1004
+ startProxy: () => startProxy,
1005
+ stopProxy: () => stopProxy
1006
+ });
1007
+ module.exports = __toCommonJS(proxy_exports);
1008
+
1009
+ // src/proxy/server.ts
1010
+ var import_node_http = __toESM(require("http"), 1);
1011
+ init_handler();
632
1012
 
633
1013
  // src/proxy/health.ts
634
- function handleHealthRequest(res, config, startTime) {
1014
+ function handleHealthRequest(res, config, startTime, logger) {
1015
+ logger?.debug("health check served");
635
1016
  const body = JSON.stringify({
636
1017
  status: "ok",
637
1018
  uptime: Date.now() - startTime,
@@ -725,6 +1106,20 @@ function removePid(pidFile) {
725
1106
  }
726
1107
  }
727
1108
 
1109
+ // src/proxy/health-check.ts
1110
+ async function isProxyAlive(port, timeoutMs = 2e3) {
1111
+ const controller = new AbortController();
1112
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1113
+ try {
1114
+ const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
1115
+ return res.ok;
1116
+ } catch {
1117
+ return false;
1118
+ } finally {
1119
+ clearTimeout(timer);
1120
+ }
1121
+ }
1122
+
728
1123
  // src/proxy/logger.ts
729
1124
  var import_node_fs2 = __toESM(require("fs"), 1);
730
1125
  var import_node_path2 = __toESM(require("path"), 1);
@@ -791,6 +1186,67 @@ var Logger = class _Logger {
791
1186
  }
792
1187
  };
793
1188
 
1189
+ // src/proxy/ws-server.ts
1190
+ var import_ws2 = require("ws");
1191
+ var WS_SUBPROTOCOL2 = "skalpel-codex-v1";
1192
+ var wss = new import_ws2.WebSocketServer({ noServer: true });
1193
+ function reject426(socket, payload) {
1194
+ const body = JSON.stringify(payload);
1195
+ socket.write(
1196
+ `HTTP/1.1 426 Upgrade Required\r
1197
+ Content-Type: application/json\r
1198
+ Content-Length: ${Buffer.byteLength(body)}\r
1199
+ Connection: close\r
1200
+ \r
1201
+ ` + body
1202
+ );
1203
+ socket.destroy();
1204
+ }
1205
+ function handleCodexUpgrade(req, socket, head, config, logger) {
1206
+ const wsFlag = process.env.SKALPEL_CODEX_WS ?? "1";
1207
+ if (wsFlag === "0") {
1208
+ logger.warn("ws-upgrade rejected: feature flag SKALPEL_CODEX_WS=0");
1209
+ reject426(socket, { error: "ws_disabled" });
1210
+ return;
1211
+ }
1212
+ const offered = req.headers["sec-websocket-protocol"] ?? "";
1213
+ const tokens = offered.split(",").map((t) => t.trim()).filter(Boolean);
1214
+ if (!tokens.includes(WS_SUBPROTOCOL2)) {
1215
+ logger.warn(`ws-upgrade rejected: unsupported subprotocol offered="${offered}"`);
1216
+ reject426(socket, { error: "unsupported_subprotocol" });
1217
+ return;
1218
+ }
1219
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
1220
+ logger.info(`ws-upgrade accepted path=${req.url ?? ""} subproto=${WS_SUBPROTOCOL2}`);
1221
+ Promise.resolve().then(() => (init_handler(), handler_exports)).then((mod) => {
1222
+ const bridge = mod.handleWebSocketBridge;
1223
+ if (typeof bridge !== "function") {
1224
+ clientWs.send(
1225
+ JSON.stringify({
1226
+ type: "error",
1227
+ payload: { code: "not_implemented" }
1228
+ })
1229
+ );
1230
+ clientWs.close(4003, "bridge pending");
1231
+ return;
1232
+ }
1233
+ void bridge(clientWs, req, config, "codex", logger);
1234
+ }).catch((err) => {
1235
+ logger.error(`ws bridge import failed: ${err?.message ?? String(err)}`);
1236
+ try {
1237
+ clientWs.send(
1238
+ JSON.stringify({
1239
+ type: "error",
1240
+ payload: { code: "bridge_import_failed" }
1241
+ })
1242
+ );
1243
+ } catch {
1244
+ }
1245
+ clientWs.close(4003, "bridge import failed");
1246
+ });
1247
+ });
1248
+ }
1249
+
794
1250
  // src/proxy/server.ts
795
1251
  var proxyStartTime = 0;
796
1252
  var connCounter = 0;
@@ -807,7 +1263,7 @@ function startProxy(config) {
807
1263
  proxyStartTime = Date.now();
808
1264
  const anthropicServer = import_node_http.default.createServer((req, res) => {
809
1265
  if (req.url === "/health" && req.method === "GET") {
810
- handleHealthRequest(res, config, startTime);
1266
+ handleHealthRequest(res, config, startTime, logger.child("health"));
811
1267
  return;
812
1268
  }
813
1269
  const connId = computeConnId(req);
@@ -815,7 +1271,7 @@ function startProxy(config) {
815
1271
  });
816
1272
  const openaiServer = import_node_http.default.createServer((req, res) => {
817
1273
  if (req.url === "/health" && req.method === "GET") {
818
- handleHealthRequest(res, config, startTime);
1274
+ handleHealthRequest(res, config, startTime, logger.child("health"));
819
1275
  return;
820
1276
  }
821
1277
  const connId = computeConnId(req);
@@ -823,12 +1279,71 @@ function startProxy(config) {
823
1279
  });
824
1280
  const cursorServer = import_node_http.default.createServer((req, res) => {
825
1281
  if (req.url === "/health" && req.method === "GET") {
826
- handleHealthRequest(res, config, startTime);
1282
+ handleHealthRequest(res, config, startTime, logger.child("health"));
827
1283
  return;
828
1284
  }
829
1285
  const connId = computeConnId(req);
830
1286
  handleRequest(req, res, config, "cursor", logger.child(connId));
831
1287
  });
1288
+ anthropicServer.on("upgrade", (req, socket, _head) => {
1289
+ const ua = req.headers["user-agent"] ?? "";
1290
+ logger.warn(`upgrade-attempt port=${config.anthropicPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1291
+ const body = JSON.stringify({
1292
+ error: "upgrade_required",
1293
+ message: "Skalpel proxy is HTTP-only",
1294
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1295
+ });
1296
+ socket.write(
1297
+ `HTTP/1.1 426 Upgrade Required\r
1298
+ Content-Type: application/json\r
1299
+ Content-Length: ${Buffer.byteLength(body)}\r
1300
+ Connection: close\r
1301
+ \r
1302
+ ` + body
1303
+ );
1304
+ socket.destroy();
1305
+ });
1306
+ openaiServer.on("upgrade", (req, socket, head) => {
1307
+ const ua = req.headers["user-agent"] ?? "";
1308
+ logger.warn(`upgrade-attempt port=${config.openaiPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1309
+ const pathname = (req.url ?? "").split("?")[0];
1310
+ if (pathname === "/v1/responses") {
1311
+ handleCodexUpgrade(req, socket, head, config, logger);
1312
+ return;
1313
+ }
1314
+ const body = JSON.stringify({
1315
+ error: "upgrade_required",
1316
+ message: "Skalpel proxy is HTTP-only",
1317
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1318
+ });
1319
+ socket.write(
1320
+ `HTTP/1.1 426 Upgrade Required\r
1321
+ Content-Type: application/json\r
1322
+ Content-Length: ${Buffer.byteLength(body)}\r
1323
+ Connection: close\r
1324
+ \r
1325
+ ` + body
1326
+ );
1327
+ socket.destroy();
1328
+ });
1329
+ cursorServer.on("upgrade", (req, socket, _head) => {
1330
+ const ua = req.headers["user-agent"] ?? "";
1331
+ logger.warn(`upgrade-attempt port=${config.cursorPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1332
+ const body = JSON.stringify({
1333
+ error: "upgrade_required",
1334
+ message: "Skalpel proxy is HTTP-only",
1335
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1336
+ });
1337
+ socket.write(
1338
+ `HTTP/1.1 426 Upgrade Required\r
1339
+ Content-Type: application/json\r
1340
+ Content-Length: ${Buffer.byteLength(body)}\r
1341
+ Connection: close\r
1342
+ \r
1343
+ ` + body
1344
+ );
1345
+ socket.destroy();
1346
+ });
832
1347
  anthropicServer.on("error", (err) => {
833
1348
  if (err.code === "EADDRINUSE") {
834
1349
  logger.error(`Port ${config.anthropicPort} is already in use. Another Skalpel proxy or process may be running.`);
@@ -856,17 +1371,26 @@ function startProxy(config) {
856
1371
  removePid(config.pidFile);
857
1372
  process.exit(1);
858
1373
  });
1374
+ let bound = 0;
1375
+ const onBound = () => {
1376
+ bound++;
1377
+ if (bound === 3) {
1378
+ writePid(config.pidFile);
1379
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
1380
+ }
1381
+ };
859
1382
  anthropicServer.listen(config.anthropicPort, () => {
860
1383
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
1384
+ onBound();
861
1385
  });
862
1386
  openaiServer.listen(config.openaiPort, () => {
863
1387
  logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
1388
+ onBound();
864
1389
  });
865
1390
  cursorServer.listen(config.cursorPort, () => {
866
1391
  logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
1392
+ onBound();
867
1393
  });
868
- writePid(config.pidFile);
869
- logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
870
1394
  const cleanup = () => {
871
1395
  logger.info("Shutting down proxy...");
872
1396
  anthropicServer.close();
@@ -899,12 +1423,23 @@ function stopProxy(config) {
899
1423
  removePid(config.pidFile);
900
1424
  return true;
901
1425
  }
902
- function getProxyStatus(config) {
1426
+ async function getProxyStatus(config) {
903
1427
  const pid = readPid(config.pidFile);
1428
+ if (pid !== null) {
1429
+ return {
1430
+ running: true,
1431
+ pid,
1432
+ uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
1433
+ anthropicPort: config.anthropicPort,
1434
+ openaiPort: config.openaiPort,
1435
+ cursorPort: config.cursorPort
1436
+ };
1437
+ }
1438
+ const alive = await isProxyAlive(config.anthropicPort);
904
1439
  return {
905
- running: pid !== null,
906
- pid,
907
- uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
1440
+ running: alive,
1441
+ pid: null,
1442
+ uptime: 0,
908
1443
  anthropicPort: config.anthropicPort,
909
1444
  openaiPort: config.openaiPort,
910
1445
  cursorPort: config.cursorPort