skalpel 2.0.12 → 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}`);
132
313
  }
133
- passthroughHeaders["content-length"] = String(errorBody.length);
134
- res.writeHead(response.status, passthroughHeaders);
135
- res.end(errorBody);
314
+ if (!bodyReadFailed) {
315
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
316
+ }
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) => {
@@ -181,38 +388,72 @@ function collectBody(req) {
181
388
  req.on("error", reject);
182
389
  });
183
390
  }
184
- function shouldRouteToSkalpel(path4, _source) {
185
- if (isPassthroughMode()) return false;
391
+ function shouldRouteToSkalpel(path4, source) {
392
+ if (source !== "claude-code") return true;
186
393
  const pathname = path4.split("?")[0];
187
394
  return SKALPEL_EXACT_PATHS.has(pathname);
188
395
  }
189
- function isSkalpelBackendFailure2(response, err) {
396
+ async function isSkalpelBackendFailure(response, err, logger) {
190
397
  if (err) return true;
191
398
  if (!response) return true;
192
- if (response.status >= 500) return true;
193
- if (response.status === 403) return true;
194
- 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
+ }
195
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
+ ]);
196
437
  function buildForwardHeaders(req, config, source, useSkalpel) {
197
438
  const forwardHeaders = {};
198
439
  for (const [key, value] of Object.entries(req.headers)) {
199
- if (value !== void 0) {
200
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
201
- }
440
+ if (value === void 0) continue;
441
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
442
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
202
443
  }
203
- delete forwardHeaders["host"];
204
- delete forwardHeaders["connection"];
205
444
  if (useSkalpel) {
206
445
  forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
207
446
  forwardHeaders["X-Skalpel-Source"] = source;
208
447
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
209
448
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
449
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
210
450
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
211
451
  const authHeader = forwardHeaders["authorization"] ?? "";
212
452
  if (authHeader.toLowerCase().startsWith("bearer ")) {
213
453
  const token = authHeader.slice(7).trim();
214
454
  if (token.startsWith("sk-ant-")) {
215
455
  forwardHeaders["x-api-key"] = token;
456
+ delete forwardHeaders["authorization"];
216
457
  }
217
458
  }
218
459
  }
@@ -225,6 +466,7 @@ function stripSkalpelHeaders2(headers) {
225
466
  delete cleaned["X-Skalpel-Source"];
226
467
  delete cleaned["X-Skalpel-Agent-Type"];
227
468
  delete cleaned["X-Skalpel-SDK-Version"];
469
+ delete cleaned["X-Skalpel-Auth-Mode"];
228
470
  return cleaned;
229
471
  }
230
472
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -252,6 +494,11 @@ async function handleRequest(req, res, config, source, logger) {
252
494
  const start = Date.now();
253
495
  const method = req.method ?? "GET";
254
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;
255
502
  try {
256
503
  const body = await collectBody(req);
257
504
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -266,45 +513,91 @@ async function handleRequest(req, res, config, source, logger) {
266
513
  }
267
514
  if (isStreaming) {
268
515
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
269
- const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
516
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
270
517
  await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
271
518
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
272
519
  return;
273
520
  }
274
521
  const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
275
- const directUrl = `${config.anthropicDirectUrl}${path4}`;
522
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
276
523
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
277
- let response = null;
278
524
  let fetchError = null;
279
525
  let usedFallback = false;
280
526
  if (useSkalpel) {
281
527
  try {
282
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
528
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
283
529
  } catch (err) {
284
530
  fetchError = err;
285
531
  }
286
- if (isSkalpelBackendFailure2(response, fetchError)) {
532
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
287
533
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
288
534
  usedFallback = true;
289
535
  response = null;
290
536
  fetchError = null;
291
537
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
292
538
  try {
293
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
539
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
294
540
  } catch (err) {
295
541
  fetchError = err;
296
542
  }
297
543
  }
298
544
  } else {
299
545
  try {
300
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
546
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
301
547
  } catch (err) {
302
548
  fetchError = err;
303
549
  }
304
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
+ }
305
573
  if (!response || fetchError) {
574
+ response = null;
306
575
  throw fetchError ?? new Error("no response from upstream");
307
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
+ }
308
601
  const responseHeaders = extractResponseHeaders(response);
309
602
  const responseBody = Buffer.from(await response.arrayBuffer());
310
603
  responseHeaders["content-length"] = String(responseBody.length);
@@ -314,20 +607,38 @@ async function handleRequest(req, res, config, source, logger) {
314
607
  } catch (err) {
315
608
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
316
609
  if (!res.headersSent) {
317
- res.writeHead(502, { "Content-Type": "application/json" });
318
- 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
+ }
319
629
  }
320
630
  }
321
631
  }
322
632
 
323
633
  // src/proxy/health.ts
324
- function handleHealthRequest(res, config, startTime, passthrough = false) {
634
+ function handleHealthRequest(res, config, startTime) {
325
635
  const body = JSON.stringify({
326
636
  status: "ok",
327
- mode: passthrough ? "passthrough" : "normal",
328
637
  uptime: Date.now() - startTime,
329
638
  ports: {
330
- anthropic: config.anthropicPort
639
+ anthropic: config.anthropicPort,
640
+ openai: config.openaiPort,
641
+ cursor: config.cursorPort
331
642
  },
332
643
  version: "proxy-1.0.0"
333
644
  });
@@ -338,13 +649,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
338
649
  // src/proxy/pid.ts
339
650
  var import_node_fs = __toESM(require("fs"), 1);
340
651
  var import_node_path = __toESM(require("path"), 1);
652
+ var import_node_child_process = require("child_process");
341
653
  function writePid(pidFile) {
342
654
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(pidFile), { recursive: true });
343
- 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));
344
660
  }
345
661
  function readPid(pidFile) {
346
662
  try {
347
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
+ }
348
675
  const pid = parseInt(raw, 10);
349
676
  if (isNaN(pid)) return null;
350
677
  return isRunning(pid) ? pid : null;
@@ -360,6 +687,37 @@ function isRunning(pid) {
360
687
  return false;
361
688
  }
362
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
+ }
363
721
  function removePid(pidFile) {
364
722
  try {
365
723
  import_node_fs.default.unlinkSync(pidFile);
@@ -373,12 +731,14 @@ var import_node_path2 = __toESM(require("path"), 1);
373
731
  var MAX_SIZE = 5 * 1024 * 1024;
374
732
  var MAX_ROTATIONS = 3;
375
733
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
376
- var Logger = class {
734
+ var Logger = class _Logger {
377
735
  logFile;
378
736
  level;
379
- constructor(logFile, level = "info") {
737
+ prefix;
738
+ constructor(logFile, level = "info", prefix = "") {
380
739
  this.logFile = logFile;
381
740
  this.level = level;
741
+ this.prefix = prefix;
382
742
  import_node_fs2.default.mkdirSync(import_node_path2.default.dirname(logFile), { recursive: true });
383
743
  }
384
744
  debug(msg) {
@@ -393,9 +753,16 @@ var Logger = class {
393
753
  error(msg) {
394
754
  this.log("error", msg);
395
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
+ }
396
763
  log(level, msg) {
397
764
  if (LEVELS[level] < LEVELS[this.level]) return;
398
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
765
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
399
766
  `;
400
767
  if (level === "debug" || level === "error") {
401
768
  process.stderr.write(line);
@@ -426,50 +793,41 @@ var Logger = class {
426
793
 
427
794
  // src/proxy/server.ts
428
795
  var proxyStartTime = 0;
429
- var passthroughMode = false;
430
- function isPassthroughMode() {
431
- return passthroughMode;
432
- }
433
- function collectAdminBody(req) {
434
- return new Promise((resolve, reject) => {
435
- const chunks = [];
436
- req.on("data", (chunk) => chunks.push(chunk));
437
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
438
- req.on("error", reject);
439
- });
440
- }
441
- function handleAdminMode(req, res, logger) {
442
- collectAdminBody(req).then((body) => {
443
- try {
444
- const { mode } = JSON.parse(body);
445
- passthroughMode = mode === "passthrough";
446
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
447
- res.writeHead(200, { "Content-Type": "application/json" });
448
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
449
- } catch {
450
- res.writeHead(400, { "Content-Type": "application/json" });
451
- res.end(JSON.stringify({ error: "invalid JSON body" }));
452
- }
453
- }).catch(() => {
454
- res.writeHead(500, { "Content-Type": "application/json" });
455
- res.end(JSON.stringify({ error: "failed to read body" }));
456
- });
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, "_");
457
803
  }
458
804
  function startProxy(config) {
459
805
  const logger = new Logger(config.logFile, config.logLevel);
460
806
  const startTime = Date.now();
461
807
  proxyStartTime = Date.now();
462
- passthroughMode = false;
463
808
  const anthropicServer = import_node_http.default.createServer((req, res) => {
464
809
  if (req.url === "/health" && req.method === "GET") {
465
- handleHealthRequest(res, config, startTime, isPassthroughMode());
810
+ handleHealthRequest(res, config, startTime);
811
+ return;
812
+ }
813
+ const connId = computeConnId(req);
814
+ handleRequest(req, res, config, "claude-code", logger.child(connId));
815
+ });
816
+ const openaiServer = import_node_http.default.createServer((req, res) => {
817
+ if (req.url === "/health" && req.method === "GET") {
818
+ handleHealthRequest(res, config, startTime);
466
819
  return;
467
820
  }
468
- if (req.url === "/admin/mode" && req.method === "POST") {
469
- 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);
470
827
  return;
471
828
  }
472
- handleRequest(req, res, config, "claude-code", logger);
829
+ const connId = computeConnId(req);
830
+ handleRequest(req, res, config, "cursor", logger.child(connId));
473
831
  });
474
832
  anthropicServer.on("error", (err) => {
475
833
  if (err.code === "EADDRINUSE") {
@@ -480,14 +838,40 @@ function startProxy(config) {
480
838
  removePid(config.pidFile);
481
839
  process.exit(1);
482
840
  });
841
+ openaiServer.on("error", (err) => {
842
+ if (err.code === "EADDRINUSE") {
843
+ logger.error(`Port ${config.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
844
+ } else {
845
+ logger.error(`OpenAI proxy failed to bind port ${config.openaiPort}: ${err.message}`);
846
+ }
847
+ removePid(config.pidFile);
848
+ process.exit(1);
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
+ });
483
859
  anthropicServer.listen(config.anthropicPort, () => {
484
860
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
485
861
  });
862
+ openaiServer.listen(config.openaiPort, () => {
863
+ logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
864
+ });
865
+ cursorServer.listen(config.cursorPort, () => {
866
+ logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
867
+ });
486
868
  writePid(config.pidFile);
487
- logger.info(`Proxy started (pid=${process.pid}) port=${config.anthropicPort}`);
869
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
488
870
  const cleanup = () => {
489
871
  logger.info("Shutting down proxy...");
490
872
  anthropicServer.close();
873
+ openaiServer.close();
874
+ cursorServer.close();
491
875
  removePid(config.pidFile);
492
876
  process.exit(0);
493
877
  };
@@ -503,7 +887,7 @@ function startProxy(config) {
503
887
  removePid(config.pidFile);
504
888
  process.exit(1);
505
889
  });
506
- return { anthropicServer };
890
+ return { anthropicServer, openaiServer, cursorServer };
507
891
  }
508
892
  function stopProxy(config) {
509
893
  const pid = readPid(config.pidFile);
@@ -521,7 +905,9 @@ function getProxyStatus(config) {
521
905
  running: pid !== null,
522
906
  pid,
523
907
  uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
524
- anthropicPort: config.anthropicPort
908
+ anthropicPort: config.anthropicPort,
909
+ openaiPort: config.openaiPort,
910
+ cursorPort: config.cursorPort
525
911
  };
526
912
  }
527
913
 
@@ -539,12 +925,20 @@ var DEFAULTS = {
539
925
  apiKey: "",
540
926
  remoteBaseUrl: "https://api.skalpel.ai",
541
927
  anthropicDirectUrl: "https://api.anthropic.com",
928
+ openaiDirectUrl: "https://api.openai.com",
542
929
  anthropicPort: 18100,
930
+ openaiPort: 18101,
931
+ cursorPort: 18102,
932
+ cursorDirectUrl: "https://api.openai.com",
543
933
  logLevel: "info",
544
934
  logFile: "~/.skalpel/logs/proxy.log",
545
935
  pidFile: "~/.skalpel/proxy.pid",
546
- configFile: "~/.skalpel/config.json"
936
+ configFile: "~/.skalpel/config.json",
937
+ mode: "proxy"
547
938
  };
939
+ function coerceMode(value) {
940
+ return value === "direct" ? "direct" : "proxy";
941
+ }
548
942
  function loadConfig(configPath) {
549
943
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
550
944
  let fileConfig = {};
@@ -557,17 +951,27 @@ function loadConfig(configPath) {
557
951
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
558
952
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
559
953
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
954
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
560
955
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
956
+ openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
957
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
958
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
561
959
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
562
960
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
563
961
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
564
- configFile: filePath
962
+ configFile: filePath,
963
+ mode: coerceMode(fileConfig.mode)
565
964
  };
566
965
  }
567
966
  function saveConfig(config) {
568
967
  const dir = import_node_path3.default.dirname(config.configFile);
569
968
  import_node_fs3.default.mkdirSync(dir, { recursive: true });
570
- 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");
571
975
  }
572
976
  // Annotate the CommonJS export names for ESM import in node:
573
977
  0 && (module.exports = {