skalpel 2.0.11 → 2.0.13

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.
@@ -41,7 +41,163 @@ module.exports = __toCommonJS(proxy_exports);
41
41
  // src/proxy/server.ts
42
42
  var import_node_http = __toESM(require("http"), 1);
43
43
 
44
+ // 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
51
+ });
52
+
53
+ // src/proxy/envelope.ts
54
+ function isAnthropicShaped(body) {
55
+ if (typeof body !== "object" || body === null) return false;
56
+ const b = body;
57
+ if (b.type !== "error") return false;
58
+ if (typeof b.error !== "object" || b.error === null) return false;
59
+ return true;
60
+ }
61
+ function defaultErrorTypeFor(status) {
62
+ if (status === 400) return "invalid_request_error";
63
+ if (status === 401 || status === 403) return "authentication_error";
64
+ if (status === 404) return "not_found_error";
65
+ if (status === 408) return "timeout_error";
66
+ if (status === 429) return "rate_limit_error";
67
+ if (status >= 500) return "api_error";
68
+ if (status >= 400) return "invalid_request_error";
69
+ return "api_error";
70
+ }
71
+ function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
72
+ let parsed = upstreamBody;
73
+ if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
74
+ try {
75
+ parsed = JSON.parse(upstreamBody);
76
+ } catch {
77
+ parsed = upstreamBody;
78
+ }
79
+ }
80
+ let type = defaultErrorTypeFor(status);
81
+ let message;
82
+ if (isAnthropicShaped(parsed)) {
83
+ const inner = parsed.error;
84
+ if (typeof inner.type === "string" && inner.type.length > 0) {
85
+ type = inner.type;
86
+ }
87
+ message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
88
+ } else if (typeof parsed === "string" && parsed.length > 0) {
89
+ message = parsed;
90
+ } else {
91
+ message = defaultMessageForStatus(status);
92
+ }
93
+ const envelope = {
94
+ type: "error",
95
+ error: {
96
+ type,
97
+ message,
98
+ status_code: status,
99
+ origin
100
+ }
101
+ };
102
+ if (hint !== void 0) envelope.error.hint = hint;
103
+ if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
104
+ return envelope;
105
+ }
106
+ function defaultMessageForStatus(status) {
107
+ if (status === 401) return "Authentication failed";
108
+ if (status === 403) return "Forbidden";
109
+ if (status === 404) return "Not found";
110
+ if (status === 408) return "Request timed out";
111
+ if (status === 429) return "Rate limit exceeded";
112
+ if (status === 502) return "Bad gateway";
113
+ if (status === 503) return "Service unavailable";
114
+ if (status === 504) return "Gateway timeout";
115
+ if (status >= 500) return "Upstream error";
116
+ if (status >= 400) return "Client error";
117
+ return "Error";
118
+ }
119
+
120
+ // src/proxy/recovery.ts
121
+ var import_node_crypto = require("crypto");
122
+ function parseRetryAfterHeader(header) {
123
+ if (!header) return void 0;
124
+ const trimmed = header.trim();
125
+ if (!trimmed) return void 0;
126
+ const n = Number(trimmed);
127
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
128
+ const dateMs = Date.parse(trimmed);
129
+ if (Number.isFinite(dateMs)) {
130
+ return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
131
+ }
132
+ return void 0;
133
+ }
134
+ function sleep(ms) {
135
+ return new Promise((resolve) => setTimeout(resolve, ms));
136
+ }
137
+ var MAX_RETRY_AFTER_SECONDS = 60;
138
+ var DEFAULT_BACKOFF_SECONDS = 2;
139
+ async function handle429WithRetryAfter(response, retryFn, logger) {
140
+ const headerVal = response.headers.get("retry-after");
141
+ const parsed = parseRetryAfterHeader(headerVal);
142
+ if (parsed === void 0) {
143
+ await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
144
+ const retried2 = await retryFn();
145
+ logger.info("proxy.recovery.429_retry_count increment");
146
+ return retried2;
147
+ }
148
+ if (parsed > MAX_RETRY_AFTER_SECONDS) {
149
+ return response;
150
+ }
151
+ await sleep(parsed * 1e3);
152
+ const retried = await retryFn();
153
+ logger.info("proxy.recovery.429_retry_count increment");
154
+ return retried;
155
+ }
156
+ var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
157
+ async function handleTimeoutWithRetry(err, retryFn, logger) {
158
+ const code = err.code;
159
+ if (!code || !TIMEOUT_CODES.has(code)) {
160
+ throw err;
161
+ }
162
+ await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
163
+ const retried = await retryFn();
164
+ logger.info("proxy.recovery.timeout_retry_count increment");
165
+ return retried;
166
+ }
167
+ function tokenFingerprint(authHeader) {
168
+ if (authHeader === void 0) return "none";
169
+ return (0, import_node_crypto.createHash)("sha256").update(authHeader).digest("hex").slice(0, 12);
170
+ }
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);
181
+ }
182
+ };
183
+ var refreshMutex = new LruMutexMap();
184
+
44
185
  // src/proxy/streaming.ts
186
+ var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
187
+ var HTTP_BAD_GATEWAY = 502;
188
+ function parseRetryAfter(header) {
189
+ if (!header) return void 0;
190
+ const trimmed = header.trim();
191
+ if (!trimmed) return void 0;
192
+ const n = Number(trimmed);
193
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
194
+ const dateMs = Date.parse(trimmed);
195
+ if (Number.isFinite(dateMs)) {
196
+ const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
197
+ return delta;
198
+ }
199
+ return void 0;
200
+ }
45
201
  var HOP_BY_HOP = /* @__PURE__ */ new Set([
46
202
  "connection",
47
203
  "keep-alive",
@@ -63,17 +219,11 @@ function stripSkalpelHeaders(headers) {
63
219
  delete cleaned["X-Skalpel-Source"];
64
220
  delete cleaned["X-Skalpel-Agent-Type"];
65
221
  delete cleaned["X-Skalpel-SDK-Version"];
222
+ delete cleaned["X-Skalpel-Auth-Mode"];
66
223
  return cleaned;
67
224
  }
68
- function isSkalpelBackendFailure(response, err) {
69
- if (err) return true;
70
- if (!response) return true;
71
- if (response.status >= 500) return true;
72
- if (response.status === 403) return true;
73
- return false;
74
- }
75
225
  async function doStreamingFetch(url, body, headers) {
76
- return fetch(url, { method: "POST", headers, body });
226
+ return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
77
227
  }
78
228
  async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
79
229
  let response = null;
@@ -85,7 +235,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
85
235
  } catch (err) {
86
236
  fetchError = err;
87
237
  }
88
- if (isSkalpelBackendFailure(response, fetchError)) {
238
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
89
239
  logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
90
240
  usedFallback = true;
91
241
  response = null;
@@ -104,15 +254,40 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
104
254
  fetchError = err;
105
255
  }
106
256
  }
257
+ const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
258
+ const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
259
+ if (fetchError) {
260
+ const code = fetchError.code;
261
+ if (code && TIMEOUT_CODES2.has(code)) {
262
+ try {
263
+ response = await handleTimeoutWithRetry(
264
+ fetchError,
265
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
266
+ logger
267
+ );
268
+ fetchError = null;
269
+ } catch (retryErr) {
270
+ fetchError = retryErr;
271
+ }
272
+ }
273
+ }
274
+ if (response && response.status === 429) {
275
+ response = await handle429WithRetryAfter(
276
+ response,
277
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
278
+ logger
279
+ );
280
+ }
107
281
  if (!response || fetchError) {
108
282
  const errMsg = fetchError ? fetchError.message : "no response from upstream";
109
283
  logger.error(`streaming fetch failed: ${errMsg}`);
110
- res.writeHead(502, {
284
+ res.writeHead(HTTP_BAD_GATEWAY, {
111
285
  "Content-Type": "text/event-stream",
112
286
  "Cache-Control": "no-cache"
113
287
  });
288
+ const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
114
289
  res.write(`event: error
115
- data: ${JSON.stringify({ error: errMsg })}
290
+ data: ${JSON.stringify(envelope)}
116
291
 
117
292
  `);
118
293
  res.end();
@@ -122,17 +297,39 @@ data: ${JSON.stringify({ error: errMsg })}
122
297
  logger.info("streaming: using direct Anthropic API fallback");
123
298
  }
124
299
  if (response.status >= 300) {
125
- const errorBody = Buffer.from(await response.arrayBuffer());
126
- logger.error(`streaming upstream error: status=${response.status} body=${errorBody.toString().slice(0, 500)}`);
127
- const passthroughHeaders = {};
128
- for (const [key, value] of response.headers.entries()) {
129
- if (!STRIP_HEADERS.has(key)) {
130
- passthroughHeaders[key] = value;
131
- }
300
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
301
+ const originHeader = response.headers.get("x-skalpel-origin");
302
+ let origin;
303
+ if (originHeader === "backend") origin = "skalpel-backend";
304
+ else if (originHeader === "provider") origin = "provider";
305
+ else origin = "provider";
306
+ let rawBody = "";
307
+ let bodyReadFailed = false;
308
+ try {
309
+ rawBody = Buffer.from(await response.arrayBuffer()).toString();
310
+ } catch (readErr) {
311
+ bodyReadFailed = true;
312
+ logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
313
+ }
314
+ if (!bodyReadFailed) {
315
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
132
316
  }
133
- passthroughHeaders["content-length"] = String(errorBody.length);
134
- res.writeHead(response.status, passthroughHeaders);
135
- res.end(errorBody);
317
+ const envelope = bodyReadFailed ? buildErrorEnvelope(
318
+ response.status,
319
+ "",
320
+ "skalpel-proxy",
321
+ "mid-stream abort",
322
+ retryAfter
323
+ ) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
324
+ res.writeHead(response.status, {
325
+ "Content-Type": "text/event-stream",
326
+ "Cache-Control": "no-cache"
327
+ });
328
+ res.write(`event: error
329
+ data: ${JSON.stringify(envelope)}
330
+
331
+ `);
332
+ res.end();
136
333
  return;
137
334
  }
138
335
  const sseHeaders = {};
@@ -163,8 +360,16 @@ data: ${JSON.stringify({ error: "no response body" })}
163
360
  }
164
361
  } catch (err) {
165
362
  logger.error(`streaming error: ${err.message}`);
363
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
364
+ const envelope = buildErrorEnvelope(
365
+ response.status,
366
+ err.message,
367
+ "skalpel-proxy",
368
+ "mid-stream abort",
369
+ retryAfter
370
+ );
166
371
  res.write(`event: error
167
- data: ${JSON.stringify({ error: err.message })}
372
+ data: ${JSON.stringify(envelope)}
168
373
 
169
374
  `);
170
375
  }
@@ -172,6 +377,8 @@ data: ${JSON.stringify({ error: err.message })}
172
377
  }
173
378
 
174
379
  // src/proxy/handler.ts
380
+ var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
381
+ var HTTP_BAD_GATEWAY2 = 502;
175
382
  var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
176
383
  function collectBody(req) {
177
384
  return new Promise((resolve, reject) => {
@@ -182,38 +389,71 @@ function collectBody(req) {
182
389
  });
183
390
  }
184
391
  function shouldRouteToSkalpel(path4, source) {
185
- if (isPassthroughMode()) return false;
186
392
  if (source !== "claude-code") return true;
187
393
  const pathname = path4.split("?")[0];
188
394
  return SKALPEL_EXACT_PATHS.has(pathname);
189
395
  }
190
- function isSkalpelBackendFailure2(response, err) {
396
+ async function isSkalpelBackendFailure(response, err, logger) {
191
397
  if (err) return true;
192
398
  if (!response) return true;
193
- if (response.status >= 500) return true;
194
- if (response.status === 403) return true;
195
- return false;
399
+ if (response.status < 500) return false;
400
+ const origin = response.headers?.get("x-skalpel-origin");
401
+ if (origin === "provider") return false;
402
+ if (origin === "backend") return true;
403
+ try {
404
+ const text = await response.clone().text();
405
+ if (!text) {
406
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
407
+ return true;
408
+ }
409
+ let shape = "non-anthropic";
410
+ try {
411
+ const parsed = JSON.parse(text);
412
+ if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
413
+ shape = "anthropic";
414
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
415
+ return false;
416
+ }
417
+ } catch {
418
+ }
419
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
420
+ return true;
421
+ } catch {
422
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
423
+ return true;
424
+ }
196
425
  }
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
+ ]);
197
437
  function buildForwardHeaders(req, config, source, useSkalpel) {
198
438
  const forwardHeaders = {};
199
439
  for (const [key, value] of Object.entries(req.headers)) {
200
- if (value !== void 0) {
201
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
202
- }
440
+ if (value === void 0) continue;
441
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
442
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
203
443
  }
204
- delete forwardHeaders["host"];
205
- delete forwardHeaders["connection"];
206
444
  if (useSkalpel) {
207
445
  forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
208
446
  forwardHeaders["X-Skalpel-Source"] = source;
209
447
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
210
448
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
449
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
211
450
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
212
451
  const authHeader = forwardHeaders["authorization"] ?? "";
213
452
  if (authHeader.toLowerCase().startsWith("bearer ")) {
214
453
  const token = authHeader.slice(7).trim();
215
454
  if (token.startsWith("sk-ant-")) {
216
455
  forwardHeaders["x-api-key"] = token;
456
+ delete forwardHeaders["authorization"];
217
457
  }
218
458
  }
219
459
  }
@@ -226,6 +466,7 @@ function stripSkalpelHeaders2(headers) {
226
466
  delete cleaned["X-Skalpel-Source"];
227
467
  delete cleaned["X-Skalpel-Agent-Type"];
228
468
  delete cleaned["X-Skalpel-SDK-Version"];
469
+ delete cleaned["X-Skalpel-Auth-Mode"];
229
470
  return cleaned;
230
471
  }
231
472
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -253,6 +494,11 @@ async function handleRequest(req, res, config, source, logger) {
253
494
  const start = Date.now();
254
495
  const method = req.method ?? "GET";
255
496
  const path4 = req.url ?? "/";
497
+ const fp = tokenFingerprint(
498
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
499
+ );
500
+ logger.info(`${source} ${method} ${path4} token=${fp}`);
501
+ let response = null;
256
502
  try {
257
503
  const body = await collectBody(req);
258
504
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -267,45 +513,91 @@ async function handleRequest(req, res, config, source, logger) {
267
513
  }
268
514
  if (isStreaming) {
269
515
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
270
- const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
516
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
271
517
  await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
272
518
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
273
519
  return;
274
520
  }
275
521
  const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
276
- const directUrl = `${config.anthropicDirectUrl}${path4}`;
522
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
277
523
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
278
- let response = null;
279
524
  let fetchError = null;
280
525
  let usedFallback = false;
281
526
  if (useSkalpel) {
282
527
  try {
283
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
528
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
284
529
  } catch (err) {
285
530
  fetchError = err;
286
531
  }
287
- if (isSkalpelBackendFailure2(response, fetchError)) {
532
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
288
533
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
289
534
  usedFallback = true;
290
535
  response = null;
291
536
  fetchError = null;
292
537
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
293
538
  try {
294
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
539
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
295
540
  } catch (err) {
296
541
  fetchError = err;
297
542
  }
298
543
  }
299
544
  } else {
300
545
  try {
301
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
546
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
302
547
  } catch (err) {
303
548
  fetchError = err;
304
549
  }
305
550
  }
551
+ const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
552
+ const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
553
+ if (fetchError) {
554
+ const code = fetchError.code;
555
+ if (code && TIMEOUT_CODES3.has(code)) {
556
+ try {
557
+ response = await handleTimeoutWithRetry(
558
+ fetchError,
559
+ () => fetch(fetchUrl, {
560
+ method,
561
+ headers: fetchHeaders,
562
+ body: fetchBody,
563
+ dispatcher: skalpelDispatcher
564
+ }),
565
+ logger
566
+ );
567
+ fetchError = null;
568
+ } catch (retryErr) {
569
+ fetchError = retryErr;
570
+ }
571
+ }
572
+ }
306
573
  if (!response || fetchError) {
574
+ response = null;
307
575
  throw fetchError ?? new Error("no response from upstream");
308
576
  }
577
+ if (response.status === 429) {
578
+ response = await handle429WithRetryAfter(
579
+ response,
580
+ () => fetch(fetchUrl, {
581
+ method,
582
+ headers: fetchHeaders,
583
+ body: fetchBody,
584
+ dispatcher: skalpelDispatcher
585
+ }),
586
+ logger
587
+ );
588
+ }
589
+ if (response.status === 401 && (source === "claude-code" || source === "codex")) {
590
+ const fp2 = tokenFingerprint(
591
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
592
+ );
593
+ logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
594
+ const body401 = Buffer.from(await response.arrayBuffer());
595
+ const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
596
+ res.writeHead(401, { "Content-Type": "application/json" });
597
+ res.end(JSON.stringify(envelope));
598
+ logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
599
+ return;
600
+ }
309
601
  const responseHeaders = extractResponseHeaders(response);
310
602
  const responseBody = Buffer.from(await response.arrayBuffer());
311
603
  responseHeaders["content-length"] = String(responseBody.length);
@@ -315,21 +607,38 @@ async function handleRequest(req, res, config, source, logger) {
315
607
  } catch (err) {
316
608
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
317
609
  if (!res.headersSent) {
318
- res.writeHead(502, { "Content-Type": "application/json" });
319
- res.end(JSON.stringify({ error: "proxy_error", message: err.message }));
610
+ if (response !== null) {
611
+ const upstreamStatus = response.status;
612
+ const envelope = buildErrorEnvelope(
613
+ upstreamStatus,
614
+ "",
615
+ "skalpel-proxy",
616
+ "body read failed after upstream status"
617
+ );
618
+ res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
619
+ res.end(JSON.stringify(envelope));
620
+ } else {
621
+ const envelope = buildErrorEnvelope(
622
+ HTTP_BAD_GATEWAY2,
623
+ err.message,
624
+ "skalpel-proxy"
625
+ );
626
+ res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
627
+ res.end(JSON.stringify(envelope));
628
+ }
320
629
  }
321
630
  }
322
631
  }
323
632
 
324
633
  // src/proxy/health.ts
325
- function handleHealthRequest(res, config, startTime, passthrough = false) {
634
+ function handleHealthRequest(res, config, startTime) {
326
635
  const body = JSON.stringify({
327
636
  status: "ok",
328
- mode: passthrough ? "passthrough" : "normal",
329
637
  uptime: Date.now() - startTime,
330
638
  ports: {
331
639
  anthropic: config.anthropicPort,
332
- openai: config.openaiPort
640
+ openai: config.openaiPort,
641
+ cursor: config.cursorPort
333
642
  },
334
643
  version: "proxy-1.0.0"
335
644
  });
@@ -340,13 +649,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
340
649
  // src/proxy/pid.ts
341
650
  var import_node_fs = __toESM(require("fs"), 1);
342
651
  var import_node_path = __toESM(require("path"), 1);
652
+ var import_node_child_process = require("child_process");
343
653
  function writePid(pidFile) {
344
654
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(pidFile), { recursive: true });
345
- import_node_fs.default.writeFileSync(pidFile, String(process.pid));
655
+ const record = {
656
+ pid: process.pid,
657
+ startTime: getStartTime(process.pid)
658
+ };
659
+ import_node_fs.default.writeFileSync(pidFile, JSON.stringify(record));
346
660
  }
347
661
  function readPid(pidFile) {
348
662
  try {
349
663
  const raw = import_node_fs.default.readFileSync(pidFile, "utf-8").trim();
664
+ try {
665
+ const parsed = JSON.parse(raw);
666
+ if (parsed && typeof parsed === "object" && typeof parsed.pid === "number" && !isNaN(parsed.pid)) {
667
+ const record = parsed;
668
+ if (record.startTime == null) {
669
+ return isRunning(record.pid) ? record.pid : null;
670
+ }
671
+ return isRunningWithIdentity(record.pid, record.startTime) ? record.pid : null;
672
+ }
673
+ } catch {
674
+ }
350
675
  const pid = parseInt(raw, 10);
351
676
  if (isNaN(pid)) return null;
352
677
  return isRunning(pid) ? pid : null;
@@ -362,6 +687,37 @@ function isRunning(pid) {
362
687
  return false;
363
688
  }
364
689
  }
690
+ function getStartTime(pid) {
691
+ try {
692
+ if (process.platform === "linux") {
693
+ const stat = import_node_fs.default.readFileSync(`/proc/${pid}/stat`, "utf-8");
694
+ const rparen = stat.lastIndexOf(")");
695
+ if (rparen < 0) return null;
696
+ const fields = stat.slice(rparen + 2).split(" ");
697
+ return fields[19] ?? null;
698
+ }
699
+ if (process.platform === "darwin") {
700
+ const out = (0, import_node_child_process.execSync)(`ps -p ${pid} -o lstart=`, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] });
701
+ const text = out.toString().trim();
702
+ return text || null;
703
+ }
704
+ return null;
705
+ } catch {
706
+ return null;
707
+ }
708
+ }
709
+ function isRunningWithIdentity(pid, expectedStartTime) {
710
+ try {
711
+ if (process.platform !== "linux" && process.platform !== "darwin") {
712
+ return isRunning(pid);
713
+ }
714
+ const current = getStartTime(pid);
715
+ if (current == null) return false;
716
+ return current === expectedStartTime;
717
+ } catch {
718
+ return false;
719
+ }
720
+ }
365
721
  function removePid(pidFile) {
366
722
  try {
367
723
  import_node_fs.default.unlinkSync(pidFile);
@@ -375,12 +731,14 @@ var import_node_path2 = __toESM(require("path"), 1);
375
731
  var MAX_SIZE = 5 * 1024 * 1024;
376
732
  var MAX_ROTATIONS = 3;
377
733
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
378
- var Logger = class {
734
+ var Logger = class _Logger {
379
735
  logFile;
380
736
  level;
381
- constructor(logFile, level = "info") {
737
+ prefix;
738
+ constructor(logFile, level = "info", prefix = "") {
382
739
  this.logFile = logFile;
383
740
  this.level = level;
741
+ this.prefix = prefix;
384
742
  import_node_fs2.default.mkdirSync(import_node_path2.default.dirname(logFile), { recursive: true });
385
743
  }
386
744
  debug(msg) {
@@ -395,9 +753,16 @@ var Logger = class {
395
753
  error(msg) {
396
754
  this.log("error", msg);
397
755
  }
756
+ /** Returns a new Logger that writes to the same file but prefixes every
757
+ * emitted line with `[conn=<connId>] `. The parent logger continues to
758
+ * work unchanged. IPv6 colons should already be sanitized by the caller. */
759
+ child(connId) {
760
+ const child = new _Logger(this.logFile, this.level, `[conn=${connId}] `);
761
+ return child;
762
+ }
398
763
  log(level, msg) {
399
764
  if (LEVELS[level] < LEVELS[this.level]) return;
400
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
765
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
401
766
  `;
402
767
  if (level === "debug" || level === "error") {
403
768
  process.stderr.write(line);
@@ -428,61 +793,41 @@ var Logger = class {
428
793
 
429
794
  // src/proxy/server.ts
430
795
  var proxyStartTime = 0;
431
- var passthroughMode = false;
432
- function isPassthroughMode() {
433
- return passthroughMode;
434
- }
435
- function collectAdminBody(req) {
436
- return new Promise((resolve, reject) => {
437
- const chunks = [];
438
- req.on("data", (chunk) => chunks.push(chunk));
439
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
440
- req.on("error", reject);
441
- });
442
- }
443
- function handleAdminMode(req, res, logger) {
444
- collectAdminBody(req).then((body) => {
445
- try {
446
- const { mode } = JSON.parse(body);
447
- passthroughMode = mode === "passthrough";
448
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
449
- res.writeHead(200, { "Content-Type": "application/json" });
450
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
451
- } catch {
452
- res.writeHead(400, { "Content-Type": "application/json" });
453
- res.end(JSON.stringify({ error: "invalid JSON body" }));
454
- }
455
- }).catch(() => {
456
- res.writeHead(500, { "Content-Type": "application/json" });
457
- res.end(JSON.stringify({ error: "failed to read body" }));
458
- });
796
+ var connCounter = 0;
797
+ function computeConnId(req) {
798
+ const addr = req.socket.remoteAddress ?? "unknown";
799
+ const port = req.socket.remotePort ?? 0;
800
+ const counter = (++connCounter).toString(36);
801
+ const raw = addr + "|" + port + "|" + Date.now().toString(36) + "|" + counter + "|" + Math.floor(Math.random() * 4096).toString(16);
802
+ return raw.replace(/:/g, "_");
459
803
  }
460
804
  function startProxy(config) {
461
805
  const logger = new Logger(config.logFile, config.logLevel);
462
806
  const startTime = Date.now();
463
807
  proxyStartTime = Date.now();
464
- passthroughMode = false;
465
808
  const anthropicServer = import_node_http.default.createServer((req, res) => {
466
809
  if (req.url === "/health" && req.method === "GET") {
467
- handleHealthRequest(res, config, startTime, isPassthroughMode());
468
- return;
469
- }
470
- if (req.url === "/admin/mode" && req.method === "POST") {
471
- handleAdminMode(req, res, logger);
810
+ handleHealthRequest(res, config, startTime);
472
811
  return;
473
812
  }
474
- handleRequest(req, res, config, "claude-code", logger);
813
+ const connId = computeConnId(req);
814
+ handleRequest(req, res, config, "claude-code", logger.child(connId));
475
815
  });
476
816
  const openaiServer = import_node_http.default.createServer((req, res) => {
477
817
  if (req.url === "/health" && req.method === "GET") {
478
- handleHealthRequest(res, config, startTime, isPassthroughMode());
818
+ handleHealthRequest(res, config, startTime);
479
819
  return;
480
820
  }
481
- if (req.url === "/admin/mode" && req.method === "POST") {
482
- handleAdminMode(req, res, logger);
821
+ const connId = computeConnId(req);
822
+ handleRequest(req, res, config, "codex", logger.child(connId));
823
+ });
824
+ const cursorServer = import_node_http.default.createServer((req, res) => {
825
+ if (req.url === "/health" && req.method === "GET") {
826
+ handleHealthRequest(res, config, startTime);
483
827
  return;
484
828
  }
485
- handleRequest(req, res, config, "codex", logger);
829
+ const connId = computeConnId(req);
830
+ handleRequest(req, res, config, "cursor", logger.child(connId));
486
831
  });
487
832
  anthropicServer.on("error", (err) => {
488
833
  if (err.code === "EADDRINUSE") {
@@ -502,18 +847,31 @@ function startProxy(config) {
502
847
  removePid(config.pidFile);
503
848
  process.exit(1);
504
849
  });
850
+ cursorServer.on("error", (err) => {
851
+ if (err.code === "EADDRINUSE") {
852
+ logger.error(`Port ${config.cursorPort} is already in use. Another Skalpel proxy or process may be running.`);
853
+ } else {
854
+ logger.error(`Cursor proxy failed to bind port ${config.cursorPort}: ${err.message}`);
855
+ }
856
+ removePid(config.pidFile);
857
+ process.exit(1);
858
+ });
505
859
  anthropicServer.listen(config.anthropicPort, () => {
506
860
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
507
861
  });
508
862
  openaiServer.listen(config.openaiPort, () => {
509
863
  logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
510
864
  });
865
+ cursorServer.listen(config.cursorPort, () => {
866
+ logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
867
+ });
511
868
  writePid(config.pidFile);
512
- logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort}`);
869
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
513
870
  const cleanup = () => {
514
871
  logger.info("Shutting down proxy...");
515
872
  anthropicServer.close();
516
873
  openaiServer.close();
874
+ cursorServer.close();
517
875
  removePid(config.pidFile);
518
876
  process.exit(0);
519
877
  };
@@ -529,7 +887,7 @@ function startProxy(config) {
529
887
  removePid(config.pidFile);
530
888
  process.exit(1);
531
889
  });
532
- return { anthropicServer, openaiServer };
890
+ return { anthropicServer, openaiServer, cursorServer };
533
891
  }
534
892
  function stopProxy(config) {
535
893
  const pid = readPid(config.pidFile);
@@ -548,7 +906,8 @@ function getProxyStatus(config) {
548
906
  pid,
549
907
  uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
550
908
  anthropicPort: config.anthropicPort,
551
- openaiPort: config.openaiPort
909
+ openaiPort: config.openaiPort,
910
+ cursorPort: config.cursorPort
552
911
  };
553
912
  }
554
913
 
@@ -566,13 +925,20 @@ var DEFAULTS = {
566
925
  apiKey: "",
567
926
  remoteBaseUrl: "https://api.skalpel.ai",
568
927
  anthropicDirectUrl: "https://api.anthropic.com",
928
+ openaiDirectUrl: "https://api.openai.com",
569
929
  anthropicPort: 18100,
570
930
  openaiPort: 18101,
931
+ cursorPort: 18102,
932
+ cursorDirectUrl: "https://api.openai.com",
571
933
  logLevel: "info",
572
934
  logFile: "~/.skalpel/logs/proxy.log",
573
935
  pidFile: "~/.skalpel/proxy.pid",
574
- configFile: "~/.skalpel/config.json"
936
+ configFile: "~/.skalpel/config.json",
937
+ mode: "proxy"
575
938
  };
939
+ function coerceMode(value) {
940
+ return value === "direct" ? "direct" : "proxy";
941
+ }
576
942
  function loadConfig(configPath) {
577
943
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
578
944
  let fileConfig = {};
@@ -585,18 +951,27 @@ function loadConfig(configPath) {
585
951
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
586
952
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
587
953
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
954
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
588
955
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
589
956
  openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
957
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
958
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
590
959
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
591
960
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
592
961
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
593
- configFile: filePath
962
+ configFile: filePath,
963
+ mode: coerceMode(fileConfig.mode)
594
964
  };
595
965
  }
596
966
  function saveConfig(config) {
597
967
  const dir = import_node_path3.default.dirname(config.configFile);
598
968
  import_node_fs3.default.mkdirSync(dir, { recursive: true });
599
- import_node_fs3.default.writeFileSync(config.configFile, JSON.stringify(config, null, 2) + "\n");
969
+ const { mode, ...rest } = config;
970
+ const serializable = { ...rest };
971
+ if (mode === "direct") {
972
+ serializable.mode = mode;
973
+ }
974
+ import_node_fs3.default.writeFileSync(config.configFile, JSON.stringify(serializable, null, 2) + "\n");
600
975
  }
601
976
  // Annotate the CommonJS export names for ESM import in node:
602
977
  0 && (module.exports = {