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