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.
@@ -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) => {
@@ -143,38 +350,72 @@ function collectBody(req) {
143
350
  req.on("error", reject);
144
351
  });
145
352
  }
146
- function shouldRouteToSkalpel(path4, _source) {
147
- if (isPassthroughMode()) return false;
353
+ function shouldRouteToSkalpel(path4, source) {
354
+ if (source !== "claude-code") return true;
148
355
  const pathname = path4.split("?")[0];
149
356
  return SKALPEL_EXACT_PATHS.has(pathname);
150
357
  }
151
- function isSkalpelBackendFailure2(response, err) {
358
+ async function isSkalpelBackendFailure(response, err, logger) {
152
359
  if (err) return true;
153
360
  if (!response) return true;
154
- if (response.status >= 500) return true;
155
- if (response.status === 403) return true;
156
- 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
+ }
157
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
+ ]);
158
399
  function buildForwardHeaders(req, config2, source, useSkalpel) {
159
400
  const forwardHeaders = {};
160
401
  for (const [key, value] of Object.entries(req.headers)) {
161
- if (value !== void 0) {
162
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
163
- }
402
+ if (value === void 0) continue;
403
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
404
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
164
405
  }
165
- delete forwardHeaders["host"];
166
- delete forwardHeaders["connection"];
167
406
  if (useSkalpel) {
168
407
  forwardHeaders["X-Skalpel-API-Key"] = config2.apiKey;
169
408
  forwardHeaders["X-Skalpel-Source"] = source;
170
409
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
171
410
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
411
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
172
412
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
173
413
  const authHeader = forwardHeaders["authorization"] ?? "";
174
414
  if (authHeader.toLowerCase().startsWith("bearer ")) {
175
415
  const token = authHeader.slice(7).trim();
176
416
  if (token.startsWith("sk-ant-")) {
177
417
  forwardHeaders["x-api-key"] = token;
418
+ delete forwardHeaders["authorization"];
178
419
  }
179
420
  }
180
421
  }
@@ -187,6 +428,7 @@ function stripSkalpelHeaders2(headers) {
187
428
  delete cleaned["X-Skalpel-Source"];
188
429
  delete cleaned["X-Skalpel-Agent-Type"];
189
430
  delete cleaned["X-Skalpel-SDK-Version"];
431
+ delete cleaned["X-Skalpel-Auth-Mode"];
190
432
  return cleaned;
191
433
  }
192
434
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -214,6 +456,11 @@ async function handleRequest(req, res, config2, source, logger) {
214
456
  const start = Date.now();
215
457
  const method = req.method ?? "GET";
216
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;
217
464
  try {
218
465
  const body = await collectBody(req);
219
466
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -228,45 +475,91 @@ async function handleRequest(req, res, config2, source, logger) {
228
475
  }
229
476
  if (isStreaming) {
230
477
  const skalpelUrl2 = `${config2.remoteBaseUrl}${path4}`;
231
- const directUrl2 = `${config2.anthropicDirectUrl}${path4}`;
478
+ const directUrl2 = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
232
479
  await handleStreamingRequest(req, res, config2, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
233
480
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
234
481
  return;
235
482
  }
236
483
  const skalpelUrl = `${config2.remoteBaseUrl}${path4}`;
237
- const directUrl = `${config2.anthropicDirectUrl}${path4}`;
484
+ const directUrl = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
238
485
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
239
- let response = null;
240
486
  let fetchError = null;
241
487
  let usedFallback = false;
242
488
  if (useSkalpel) {
243
489
  try {
244
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
490
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
245
491
  } catch (err) {
246
492
  fetchError = err;
247
493
  }
248
- if (isSkalpelBackendFailure2(response, fetchError)) {
494
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
249
495
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
250
496
  usedFallback = true;
251
497
  response = null;
252
498
  fetchError = null;
253
499
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
254
500
  try {
255
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
501
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
256
502
  } catch (err) {
257
503
  fetchError = err;
258
504
  }
259
505
  }
260
506
  } else {
261
507
  try {
262
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
508
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
263
509
  } catch (err) {
264
510
  fetchError = err;
265
511
  }
266
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
+ }
267
535
  if (!response || fetchError) {
536
+ response = null;
268
537
  throw fetchError ?? new Error("no response from upstream");
269
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
+ }
270
563
  const responseHeaders = extractResponseHeaders(response);
271
564
  const responseBody = Buffer.from(await response.arrayBuffer());
272
565
  responseHeaders["content-length"] = String(responseBody.length);
@@ -276,20 +569,38 @@ async function handleRequest(req, res, config2, source, logger) {
276
569
  } catch (err) {
277
570
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
278
571
  if (!res.headersSent) {
279
- res.writeHead(502, { "Content-Type": "application/json" });
280
- 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
+ }
281
591
  }
282
592
  }
283
593
  }
284
594
 
285
595
  // src/proxy/health.ts
286
- function handleHealthRequest(res, config2, startTime, passthrough = false) {
596
+ function handleHealthRequest(res, config2, startTime) {
287
597
  const body = JSON.stringify({
288
598
  status: "ok",
289
- mode: passthrough ? "passthrough" : "normal",
290
599
  uptime: Date.now() - startTime,
291
600
  ports: {
292
- anthropic: config2.anthropicPort
601
+ anthropic: config2.anthropicPort,
602
+ openai: config2.openaiPort,
603
+ cursor: config2.cursorPort
293
604
  },
294
605
  version: "proxy-1.0.0"
295
606
  });
@@ -300,9 +611,33 @@ function handleHealthRequest(res, config2, startTime, passthrough = false) {
300
611
  // src/proxy/pid.ts
301
612
  import fs from "fs";
302
613
  import path from "path";
614
+ import { execSync } from "child_process";
303
615
  function writePid(pidFile) {
304
616
  fs.mkdirSync(path.dirname(pidFile), { recursive: true });
305
- 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
+ }
306
641
  }
307
642
  function removePid(pidFile) {
308
643
  try {
@@ -317,12 +652,14 @@ import path2 from "path";
317
652
  var MAX_SIZE = 5 * 1024 * 1024;
318
653
  var MAX_ROTATIONS = 3;
319
654
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
320
- var Logger = class {
655
+ var Logger = class _Logger {
321
656
  logFile;
322
657
  level;
323
- constructor(logFile, level = "info") {
658
+ prefix;
659
+ constructor(logFile, level = "info", prefix = "") {
324
660
  this.logFile = logFile;
325
661
  this.level = level;
662
+ this.prefix = prefix;
326
663
  fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
327
664
  }
328
665
  debug(msg) {
@@ -337,9 +674,16 @@ var Logger = class {
337
674
  error(msg) {
338
675
  this.log("error", msg);
339
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
+ }
340
684
  log(level, msg) {
341
685
  if (LEVELS[level] < LEVELS[this.level]) return;
342
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
686
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
343
687
  `;
344
688
  if (level === "debug" || level === "error") {
345
689
  process.stderr.write(line);
@@ -370,50 +714,41 @@ var Logger = class {
370
714
 
371
715
  // src/proxy/server.ts
372
716
  var proxyStartTime = 0;
373
- var passthroughMode = false;
374
- function isPassthroughMode() {
375
- return passthroughMode;
376
- }
377
- function collectAdminBody(req) {
378
- return new Promise((resolve, reject) => {
379
- const chunks = [];
380
- req.on("data", (chunk) => chunks.push(chunk));
381
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
382
- req.on("error", reject);
383
- });
384
- }
385
- function handleAdminMode(req, res, logger) {
386
- collectAdminBody(req).then((body) => {
387
- try {
388
- const { mode } = JSON.parse(body);
389
- passthroughMode = mode === "passthrough";
390
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
391
- res.writeHead(200, { "Content-Type": "application/json" });
392
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
393
- } catch {
394
- res.writeHead(400, { "Content-Type": "application/json" });
395
- res.end(JSON.stringify({ error: "invalid JSON body" }));
396
- }
397
- }).catch(() => {
398
- res.writeHead(500, { "Content-Type": "application/json" });
399
- res.end(JSON.stringify({ error: "failed to read body" }));
400
- });
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, "_");
401
724
  }
402
725
  function startProxy(config2) {
403
726
  const logger = new Logger(config2.logFile, config2.logLevel);
404
727
  const startTime = Date.now();
405
728
  proxyStartTime = Date.now();
406
- passthroughMode = false;
407
729
  const anthropicServer = http.createServer((req, res) => {
408
730
  if (req.url === "/health" && req.method === "GET") {
409
- handleHealthRequest(res, config2, startTime, isPassthroughMode());
731
+ handleHealthRequest(res, config2, startTime);
410
732
  return;
411
733
  }
412
- if (req.url === "/admin/mode" && req.method === "POST") {
413
- handleAdminMode(req, res, logger);
734
+ const connId = computeConnId(req);
735
+ handleRequest(req, res, config2, "claude-code", logger.child(connId));
736
+ });
737
+ const openaiServer = http.createServer((req, res) => {
738
+ if (req.url === "/health" && req.method === "GET") {
739
+ handleHealthRequest(res, config2, startTime);
414
740
  return;
415
741
  }
416
- handleRequest(req, res, config2, "claude-code", 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);
748
+ return;
749
+ }
750
+ const connId = computeConnId(req);
751
+ handleRequest(req, res, config2, "cursor", logger.child(connId));
417
752
  });
418
753
  anthropicServer.on("error", (err) => {
419
754
  if (err.code === "EADDRINUSE") {
@@ -424,14 +759,40 @@ function startProxy(config2) {
424
759
  removePid(config2.pidFile);
425
760
  process.exit(1);
426
761
  });
762
+ openaiServer.on("error", (err) => {
763
+ if (err.code === "EADDRINUSE") {
764
+ logger.error(`Port ${config2.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
765
+ } else {
766
+ logger.error(`OpenAI proxy failed to bind port ${config2.openaiPort}: ${err.message}`);
767
+ }
768
+ removePid(config2.pidFile);
769
+ process.exit(1);
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
+ });
427
780
  anthropicServer.listen(config2.anthropicPort, () => {
428
781
  logger.info(`Anthropic proxy listening on port ${config2.anthropicPort}`);
429
782
  });
783
+ openaiServer.listen(config2.openaiPort, () => {
784
+ logger.info(`OpenAI proxy listening on port ${config2.openaiPort}`);
785
+ });
786
+ cursorServer.listen(config2.cursorPort, () => {
787
+ logger.info(`Cursor proxy listening on port ${config2.cursorPort}`);
788
+ });
430
789
  writePid(config2.pidFile);
431
- logger.info(`Proxy started (pid=${process.pid}) port=${config2.anthropicPort}`);
790
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config2.anthropicPort},${config2.openaiPort},${config2.cursorPort}`);
432
791
  const cleanup = () => {
433
792
  logger.info("Shutting down proxy...");
434
793
  anthropicServer.close();
794
+ openaiServer.close();
795
+ cursorServer.close();
435
796
  removePid(config2.pidFile);
436
797
  process.exit(0);
437
798
  };
@@ -447,7 +808,7 @@ function startProxy(config2) {
447
808
  removePid(config2.pidFile);
448
809
  process.exit(1);
449
810
  });
450
- return { anthropicServer };
811
+ return { anthropicServer, openaiServer, cursorServer };
451
812
  }
452
813
 
453
814
  // src/proxy/config.ts
@@ -464,12 +825,20 @@ var DEFAULTS = {
464
825
  apiKey: "",
465
826
  remoteBaseUrl: "https://api.skalpel.ai",
466
827
  anthropicDirectUrl: "https://api.anthropic.com",
828
+ openaiDirectUrl: "https://api.openai.com",
467
829
  anthropicPort: 18100,
830
+ openaiPort: 18101,
831
+ cursorPort: 18102,
832
+ cursorDirectUrl: "https://api.openai.com",
468
833
  logLevel: "info",
469
834
  logFile: "~/.skalpel/logs/proxy.log",
470
835
  pidFile: "~/.skalpel/proxy.pid",
471
- configFile: "~/.skalpel/config.json"
836
+ configFile: "~/.skalpel/config.json",
837
+ mode: "proxy"
472
838
  };
839
+ function coerceMode(value) {
840
+ return value === "direct" ? "direct" : "proxy";
841
+ }
473
842
  function loadConfig(configPath) {
474
843
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
475
844
  let fileConfig = {};
@@ -482,11 +851,16 @@ function loadConfig(configPath) {
482
851
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
483
852
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
484
853
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
854
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
485
855
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
856
+ openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
857
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
858
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
486
859
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
487
860
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
488
861
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
489
- configFile: filePath
862
+ configFile: filePath,
863
+ mode: coerceMode(fileConfig.mode)
490
864
  };
491
865
  }
492
866