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.
@@ -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}`);
92
273
  }
93
- passthroughHeaders["content-length"] = String(errorBody.length);
94
- res.writeHead(response.status, passthroughHeaders);
95
- res.end(errorBody);
274
+ if (!bodyReadFailed) {
275
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
276
+ }
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) => {
@@ -142,38 +349,71 @@ function collectBody(req) {
142
349
  });
143
350
  }
144
351
  function shouldRouteToSkalpel(path4, source) {
145
- if (isPassthroughMode()) return false;
146
352
  if (source !== "claude-code") return true;
147
353
  const pathname = path4.split("?")[0];
148
354
  return SKALPEL_EXACT_PATHS.has(pathname);
149
355
  }
150
- function isSkalpelBackendFailure2(response, err) {
356
+ async function isSkalpelBackendFailure(response, err, logger) {
151
357
  if (err) return true;
152
358
  if (!response) return true;
153
- if (response.status >= 500) return true;
154
- if (response.status === 403) return true;
155
- 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
+ }
156
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
+ ]);
157
397
  function buildForwardHeaders(req, config, source, useSkalpel) {
158
398
  const forwardHeaders = {};
159
399
  for (const [key, value] of Object.entries(req.headers)) {
160
- if (value !== void 0) {
161
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
162
- }
400
+ if (value === void 0) continue;
401
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
402
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
163
403
  }
164
- delete forwardHeaders["host"];
165
- delete forwardHeaders["connection"];
166
404
  if (useSkalpel) {
167
405
  forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
168
406
  forwardHeaders["X-Skalpel-Source"] = source;
169
407
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
170
408
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
409
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
171
410
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
172
411
  const authHeader = forwardHeaders["authorization"] ?? "";
173
412
  if (authHeader.toLowerCase().startsWith("bearer ")) {
174
413
  const token = authHeader.slice(7).trim();
175
414
  if (token.startsWith("sk-ant-")) {
176
415
  forwardHeaders["x-api-key"] = token;
416
+ delete forwardHeaders["authorization"];
177
417
  }
178
418
  }
179
419
  }
@@ -186,6 +426,7 @@ function stripSkalpelHeaders2(headers) {
186
426
  delete cleaned["X-Skalpel-Source"];
187
427
  delete cleaned["X-Skalpel-Agent-Type"];
188
428
  delete cleaned["X-Skalpel-SDK-Version"];
429
+ delete cleaned["X-Skalpel-Auth-Mode"];
189
430
  return cleaned;
190
431
  }
191
432
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -213,6 +454,11 @@ async function handleRequest(req, res, config, source, logger) {
213
454
  const start = Date.now();
214
455
  const method = req.method ?? "GET";
215
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;
216
462
  try {
217
463
  const body = await collectBody(req);
218
464
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -227,45 +473,91 @@ async function handleRequest(req, res, config, source, logger) {
227
473
  }
228
474
  if (isStreaming) {
229
475
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
230
- const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
476
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
231
477
  await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
232
478
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
233
479
  return;
234
480
  }
235
481
  const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
236
- const directUrl = `${config.anthropicDirectUrl}${path4}`;
482
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
237
483
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
238
- let response = null;
239
484
  let fetchError = null;
240
485
  let usedFallback = false;
241
486
  if (useSkalpel) {
242
487
  try {
243
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
488
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
244
489
  } catch (err) {
245
490
  fetchError = err;
246
491
  }
247
- if (isSkalpelBackendFailure2(response, fetchError)) {
492
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
248
493
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
249
494
  usedFallback = true;
250
495
  response = null;
251
496
  fetchError = null;
252
497
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
253
498
  try {
254
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
499
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
255
500
  } catch (err) {
256
501
  fetchError = err;
257
502
  }
258
503
  }
259
504
  } else {
260
505
  try {
261
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
506
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
262
507
  } catch (err) {
263
508
  fetchError = err;
264
509
  }
265
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
+ }
266
533
  if (!response || fetchError) {
534
+ response = null;
267
535
  throw fetchError ?? new Error("no response from upstream");
268
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
+ }
269
561
  const responseHeaders = extractResponseHeaders(response);
270
562
  const responseBody = Buffer.from(await response.arrayBuffer());
271
563
  responseHeaders["content-length"] = String(responseBody.length);
@@ -275,21 +567,38 @@ async function handleRequest(req, res, config, source, logger) {
275
567
  } catch (err) {
276
568
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
277
569
  if (!res.headersSent) {
278
- res.writeHead(502, { "Content-Type": "application/json" });
279
- 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
+ }
280
589
  }
281
590
  }
282
591
  }
283
592
 
284
593
  // src/proxy/health.ts
285
- function handleHealthRequest(res, config, startTime, passthrough = false) {
594
+ function handleHealthRequest(res, config, startTime) {
286
595
  const body = JSON.stringify({
287
596
  status: "ok",
288
- mode: passthrough ? "passthrough" : "normal",
289
597
  uptime: Date.now() - startTime,
290
598
  ports: {
291
599
  anthropic: config.anthropicPort,
292
- openai: config.openaiPort
600
+ openai: config.openaiPort,
601
+ cursor: config.cursorPort
293
602
  },
294
603
  version: "proxy-1.0.0"
295
604
  });
@@ -300,13 +609,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
300
609
  // src/proxy/pid.ts
301
610
  import fs from "fs";
302
611
  import path from "path";
612
+ import { execSync } from "child_process";
303
613
  function writePid(pidFile) {
304
614
  fs.mkdirSync(path.dirname(pidFile), { recursive: true });
305
- 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));
306
620
  }
307
621
  function readPid(pidFile) {
308
622
  try {
309
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
+ }
310
635
  const pid = parseInt(raw, 10);
311
636
  if (isNaN(pid)) return null;
312
637
  return isRunning(pid) ? pid : null;
@@ -322,6 +647,37 @@ function isRunning(pid) {
322
647
  return false;
323
648
  }
324
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
+ }
325
681
  function removePid(pidFile) {
326
682
  try {
327
683
  fs.unlinkSync(pidFile);
@@ -335,12 +691,14 @@ import path2 from "path";
335
691
  var MAX_SIZE = 5 * 1024 * 1024;
336
692
  var MAX_ROTATIONS = 3;
337
693
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
338
- var Logger = class {
694
+ var Logger = class _Logger {
339
695
  logFile;
340
696
  level;
341
- constructor(logFile, level = "info") {
697
+ prefix;
698
+ constructor(logFile, level = "info", prefix = "") {
342
699
  this.logFile = logFile;
343
700
  this.level = level;
701
+ this.prefix = prefix;
344
702
  fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
345
703
  }
346
704
  debug(msg) {
@@ -355,9 +713,16 @@ var Logger = class {
355
713
  error(msg) {
356
714
  this.log("error", msg);
357
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
+ }
358
723
  log(level, msg) {
359
724
  if (LEVELS[level] < LEVELS[this.level]) return;
360
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
725
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
361
726
  `;
362
727
  if (level === "debug" || level === "error") {
363
728
  process.stderr.write(line);
@@ -388,61 +753,41 @@ var Logger = class {
388
753
 
389
754
  // src/proxy/server.ts
390
755
  var proxyStartTime = 0;
391
- var passthroughMode = false;
392
- function isPassthroughMode() {
393
- return passthroughMode;
394
- }
395
- function collectAdminBody(req) {
396
- return new Promise((resolve, reject) => {
397
- const chunks = [];
398
- req.on("data", (chunk) => chunks.push(chunk));
399
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
400
- req.on("error", reject);
401
- });
402
- }
403
- function handleAdminMode(req, res, logger) {
404
- collectAdminBody(req).then((body) => {
405
- try {
406
- const { mode } = JSON.parse(body);
407
- passthroughMode = mode === "passthrough";
408
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
409
- res.writeHead(200, { "Content-Type": "application/json" });
410
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
411
- } catch {
412
- res.writeHead(400, { "Content-Type": "application/json" });
413
- res.end(JSON.stringify({ error: "invalid JSON body" }));
414
- }
415
- }).catch(() => {
416
- res.writeHead(500, { "Content-Type": "application/json" });
417
- res.end(JSON.stringify({ error: "failed to read body" }));
418
- });
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, "_");
419
763
  }
420
764
  function startProxy(config) {
421
765
  const logger = new Logger(config.logFile, config.logLevel);
422
766
  const startTime = Date.now();
423
767
  proxyStartTime = Date.now();
424
- passthroughMode = false;
425
768
  const anthropicServer = http.createServer((req, res) => {
426
769
  if (req.url === "/health" && req.method === "GET") {
427
- handleHealthRequest(res, config, startTime, isPassthroughMode());
428
- return;
429
- }
430
- if (req.url === "/admin/mode" && req.method === "POST") {
431
- handleAdminMode(req, res, logger);
770
+ handleHealthRequest(res, config, startTime);
432
771
  return;
433
772
  }
434
- handleRequest(req, res, config, "claude-code", logger);
773
+ const connId = computeConnId(req);
774
+ handleRequest(req, res, config, "claude-code", logger.child(connId));
435
775
  });
436
776
  const openaiServer = http.createServer((req, res) => {
437
777
  if (req.url === "/health" && req.method === "GET") {
438
- handleHealthRequest(res, config, startTime, isPassthroughMode());
778
+ handleHealthRequest(res, config, startTime);
439
779
  return;
440
780
  }
441
- if (req.url === "/admin/mode" && req.method === "POST") {
442
- 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);
443
787
  return;
444
788
  }
445
- handleRequest(req, res, config, "codex", logger);
789
+ const connId = computeConnId(req);
790
+ handleRequest(req, res, config, "cursor", logger.child(connId));
446
791
  });
447
792
  anthropicServer.on("error", (err) => {
448
793
  if (err.code === "EADDRINUSE") {
@@ -462,18 +807,31 @@ function startProxy(config) {
462
807
  removePid(config.pidFile);
463
808
  process.exit(1);
464
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
+ });
465
819
  anthropicServer.listen(config.anthropicPort, () => {
466
820
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
467
821
  });
468
822
  openaiServer.listen(config.openaiPort, () => {
469
823
  logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
470
824
  });
825
+ cursorServer.listen(config.cursorPort, () => {
826
+ logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
827
+ });
471
828
  writePid(config.pidFile);
472
- logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort}`);
829
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
473
830
  const cleanup = () => {
474
831
  logger.info("Shutting down proxy...");
475
832
  anthropicServer.close();
476
833
  openaiServer.close();
834
+ cursorServer.close();
477
835
  removePid(config.pidFile);
478
836
  process.exit(0);
479
837
  };
@@ -489,7 +847,7 @@ function startProxy(config) {
489
847
  removePid(config.pidFile);
490
848
  process.exit(1);
491
849
  });
492
- return { anthropicServer, openaiServer };
850
+ return { anthropicServer, openaiServer, cursorServer };
493
851
  }
494
852
  function stopProxy(config) {
495
853
  const pid = readPid(config.pidFile);
@@ -508,7 +866,8 @@ function getProxyStatus(config) {
508
866
  pid,
509
867
  uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
510
868
  anthropicPort: config.anthropicPort,
511
- openaiPort: config.openaiPort
869
+ openaiPort: config.openaiPort,
870
+ cursorPort: config.cursorPort
512
871
  };
513
872
  }
514
873
 
@@ -526,13 +885,20 @@ var DEFAULTS = {
526
885
  apiKey: "",
527
886
  remoteBaseUrl: "https://api.skalpel.ai",
528
887
  anthropicDirectUrl: "https://api.anthropic.com",
888
+ openaiDirectUrl: "https://api.openai.com",
529
889
  anthropicPort: 18100,
530
890
  openaiPort: 18101,
891
+ cursorPort: 18102,
892
+ cursorDirectUrl: "https://api.openai.com",
531
893
  logLevel: "info",
532
894
  logFile: "~/.skalpel/logs/proxy.log",
533
895
  pidFile: "~/.skalpel/proxy.pid",
534
- configFile: "~/.skalpel/config.json"
896
+ configFile: "~/.skalpel/config.json",
897
+ mode: "proxy"
535
898
  };
899
+ function coerceMode(value) {
900
+ return value === "direct" ? "direct" : "proxy";
901
+ }
536
902
  function loadConfig(configPath) {
537
903
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
538
904
  let fileConfig = {};
@@ -545,18 +911,27 @@ function loadConfig(configPath) {
545
911
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
546
912
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
547
913
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
914
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
548
915
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
549
916
  openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
917
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
918
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
550
919
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
551
920
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
552
921
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
553
- configFile: filePath
922
+ configFile: filePath,
923
+ mode: coerceMode(fileConfig.mode)
554
924
  };
555
925
  }
556
926
  function saveConfig(config) {
557
927
  const dir = path3.dirname(config.configFile);
558
928
  fs3.mkdirSync(dir, { recursive: true });
559
- 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");
560
935
  }
561
936
  export {
562
937
  getProxyStatus,