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