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.
package/dist/index.cjs CHANGED
@@ -108,8 +108,36 @@ var SkalpelUnavailableError = class extends SkalpelError {
108
108
  this.name = "SkalpelUnavailableError";
109
109
  }
110
110
  };
111
+ var SkalpelClientRequestError = class extends SkalpelError {
112
+ constructor(message, statusCode) {
113
+ super(message, "SKALPEL_CLIENT_REQUEST", statusCode);
114
+ this.name = "SkalpelClientRequestError";
115
+ }
116
+ };
111
117
 
112
118
  // src/fallback.ts
119
+ var TRULY_CLIENT_4XX = /* @__PURE__ */ new Set([
120
+ 400,
121
+ 403,
122
+ 404,
123
+ 405,
124
+ 409,
125
+ 410,
126
+ 411,
127
+ 413,
128
+ 415,
129
+ 417,
130
+ 418,
131
+ 421,
132
+ 422,
133
+ 423,
134
+ 424,
135
+ 425,
136
+ 426,
137
+ 428,
138
+ 431,
139
+ 451
140
+ ]);
113
141
  function sleep(ms) {
114
142
  return new Promise((resolve) => setTimeout(resolve, ms));
115
143
  }
@@ -125,12 +153,21 @@ function classifyError(err) {
125
153
  const retryAfter = error.headers?.["retry-after"] ? parseInt(error.headers["retry-after"], 10) : void 0;
126
154
  return new SkalpelRateLimitError(message, retryAfter);
127
155
  }
156
+ if (status === 408) {
157
+ return new SkalpelTimeoutError(message);
158
+ }
128
159
  if (error.code === "ETIMEDOUT" || error.code === "TIMEOUT" || error.code === "UND_ERR_HEADERS_TIMEOUT") {
129
160
  return new SkalpelTimeoutError(message);
130
161
  }
131
162
  if (status && status >= 500) {
132
163
  return new SkalpelUnavailableError(message, status);
133
164
  }
165
+ if (status && TRULY_CLIENT_4XX.has(status)) {
166
+ return new SkalpelClientRequestError(message, status);
167
+ }
168
+ if (status && status >= 400 && status < 500) {
169
+ return new SkalpelClientRequestError(message, status);
170
+ }
134
171
  return new SkalpelUnavailableError(message, status);
135
172
  }
136
173
  async function withFallback(primaryFn, fallbackFn, options = {}) {
@@ -144,6 +181,9 @@ async function withFallback(primaryFn, fallbackFn, options = {}) {
144
181
  if (lastError instanceof SkalpelAuthError) {
145
182
  throw lastError;
146
183
  }
184
+ if (lastError instanceof SkalpelClientRequestError) {
185
+ throw lastError;
186
+ }
147
187
  if (lastError instanceof SkalpelRateLimitError && lastError.retryAfter && attempt < retries) {
148
188
  await sleep(lastError.retryAfter * 1e3);
149
189
  continue;
@@ -170,6 +210,21 @@ async function withFallback(primaryFn, fallbackFn, options = {}) {
170
210
  var VERSION = "1.0.5";
171
211
 
172
212
  // src/client.ts
213
+ var AsyncMutex = class {
214
+ locked = false;
215
+ queue = [];
216
+ async acquire() {
217
+ if (this.locked) {
218
+ await new Promise((resolve) => this.queue.push(resolve));
219
+ }
220
+ this.locked = true;
221
+ return () => {
222
+ this.locked = false;
223
+ const next = this.queue.shift();
224
+ if (next) next();
225
+ };
226
+ }
227
+ };
173
228
  function resolveConfig(options) {
174
229
  return {
175
230
  apiKey: options.apiKey,
@@ -236,29 +291,33 @@ function wrapOpenAI(client, config) {
236
291
  const c = client;
237
292
  const skalpelHeaders = buildSkalpelHeaders(config);
238
293
  const originalBaseURL = c.baseURL;
294
+ const mutex = new AsyncMutex();
239
295
  function createMethodProxy(target, methodName) {
240
296
  const originalMethod = target[methodName];
241
297
  return async function(...args) {
242
298
  const primaryFn = async () => {
243
- c.baseURL = `${config.baseURL}/v1`;
244
- const requestArgs = args[0];
245
- const extraHeaders = skalpelHeaders;
246
- let callArgs;
247
- if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
248
- const opts = args[1];
249
- opts.headers = { ...extraHeaders, ...opts.headers };
250
- callArgs = [requestArgs, opts, ...args.slice(2)];
251
- } else {
252
- callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
253
- }
299
+ const release = await mutex.acquire();
254
300
  try {
255
- const result = await originalMethod.apply(target, callArgs);
256
- extractMetadataFromResponse(result, config);
257
- c.baseURL = originalBaseURL;
258
- return result;
259
- } catch (err) {
260
- c.baseURL = originalBaseURL;
261
- throw err;
301
+ c.baseURL = `${config.baseURL}/v1`;
302
+ const requestArgs = args[0];
303
+ const extraHeaders = skalpelHeaders;
304
+ let callArgs;
305
+ if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
306
+ const opts = args[1];
307
+ opts.headers = { ...extraHeaders, ...opts.headers };
308
+ callArgs = [requestArgs, opts, ...args.slice(2)];
309
+ } else {
310
+ callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
311
+ }
312
+ try {
313
+ const result = await originalMethod.apply(target, callArgs);
314
+ extractMetadataFromResponse(result, config);
315
+ return result;
316
+ } finally {
317
+ c.baseURL = originalBaseURL;
318
+ }
319
+ } finally {
320
+ release();
262
321
  }
263
322
  };
264
323
  const fallbackFn = async () => {
@@ -302,33 +361,36 @@ function wrapAnthropic(client, config) {
302
361
  const c = client;
303
362
  const skalpelHeaders = buildSkalpelHeaders(config);
304
363
  const originalBaseURL = c.baseURL ?? c._client?.baseURL;
364
+ const mutex = new AsyncMutex();
305
365
  function createMethodProxy(target, methodName) {
306
366
  const originalMethod = target[methodName];
307
367
  return async function(...args) {
308
368
  const primaryFn = async () => {
309
- const proxyURL = config.baseURL;
310
- if ("baseURL" in c) c.baseURL = proxyURL;
311
- if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
312
- const requestArgs = args[0];
313
- const extraHeaders = skalpelHeaders;
314
- let callArgs;
315
- if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
316
- const opts = args[1];
317
- opts.headers = { ...extraHeaders, ...opts.headers };
318
- callArgs = [requestArgs, opts, ...args.slice(2)];
319
- } else {
320
- callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
321
- }
369
+ const release = await mutex.acquire();
322
370
  try {
323
- const result = await originalMethod.apply(target, callArgs);
324
- extractMetadataFromResponse(result, config);
325
- if ("baseURL" in c) c.baseURL = originalBaseURL;
326
- if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
327
- return result;
328
- } catch (err) {
329
- if ("baseURL" in c) c.baseURL = originalBaseURL;
330
- if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
331
- throw err;
371
+ const proxyURL = config.baseURL;
372
+ if ("baseURL" in c) c.baseURL = proxyURL;
373
+ if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
374
+ const requestArgs = args[0];
375
+ const extraHeaders = skalpelHeaders;
376
+ let callArgs;
377
+ if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
378
+ const opts = args[1];
379
+ opts.headers = { ...extraHeaders, ...opts.headers };
380
+ callArgs = [requestArgs, opts, ...args.slice(2)];
381
+ } else {
382
+ callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
383
+ }
384
+ try {
385
+ const result = await originalMethod.apply(target, callArgs);
386
+ extractMetadataFromResponse(result, config);
387
+ return result;
388
+ } finally {
389
+ if ("baseURL" in c) c.baseURL = originalBaseURL;
390
+ if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
391
+ }
392
+ } finally {
393
+ release();
332
394
  }
333
395
  };
334
396
  const fallbackFn = async () => {
@@ -390,7 +452,11 @@ async function contextRequest(config, path4, body) {
390
452
  });
391
453
  if (!response.ok) {
392
454
  const text = await response.text().catch(() => "");
393
- throw new Error(`Skalpel context API error ${response.status}: ${text}`);
455
+ const message = `Skalpel context API error ${response.status}: ${text}`;
456
+ if (response.status >= 400 && response.status < 500) {
457
+ throw new SkalpelClientRequestError(message, response.status);
458
+ }
459
+ throw new SkalpelUnavailableError(message, response.status);
394
460
  }
395
461
  return await response.json();
396
462
  } finally {
@@ -469,6 +535,7 @@ async function resolveContext(options, params) {
469
535
  }
470
536
 
471
537
  // src/url-swap.ts
538
+ var SKALPEL_MANAGED_SENTINEL = "skalpel-managed";
472
539
  function buildHeaders(options) {
473
540
  const headers = {
474
541
  "X-Skalpel-SDK-Version": VERSION,
@@ -482,10 +549,14 @@ function buildHeaders(options) {
482
549
  async function createSkalpelOpenAI(options) {
483
550
  const { default: OpenAI } = await import("openai");
484
551
  const baseURL = `${options.baseURL ?? "https://api.skalpel.ai"}/v1`;
552
+ const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
485
553
  return new OpenAI({
486
554
  baseURL,
487
- apiKey: options.apiKey,
488
- defaultHeaders: buildHeaders(options),
555
+ apiKey: sdkApiKey,
556
+ defaultHeaders: {
557
+ ...buildHeaders(options),
558
+ "Authorization": `Bearer ${options.apiKey}`
559
+ },
489
560
  timeout: options.timeout ?? 3e4,
490
561
  maxRetries: options.retries ?? 2
491
562
  });
@@ -493,9 +564,10 @@ async function createSkalpelOpenAI(options) {
493
564
  async function createSkalpelAnthropic(options) {
494
565
  const { default: Anthropic } = await import("@anthropic-ai/sdk");
495
566
  const baseURL = options.baseURL ?? "https://api.skalpel.ai";
567
+ const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
496
568
  return new Anthropic({
497
569
  baseURL,
498
- apiKey: options.providerApiKey ?? options.apiKey,
570
+ apiKey: sdkApiKey,
499
571
  defaultHeaders: {
500
572
  ...buildHeaders(options),
501
573
  "Authorization": `Bearer ${options.apiKey}`
@@ -508,7 +580,163 @@ async function createSkalpelAnthropic(options) {
508
580
  // src/proxy/server.ts
509
581
  var import_node_http = __toESM(require("http"), 1);
510
582
 
583
+ // src/proxy/dispatcher.ts
584
+ var import_undici = require("undici");
585
+ var skalpelDispatcher = new import_undici.Agent({
586
+ keepAliveTimeout: 1e4,
587
+ keepAliveMaxTimeout: 6e4,
588
+ connections: 100,
589
+ pipelining: 1
590
+ });
591
+
592
+ // src/proxy/envelope.ts
593
+ function isAnthropicShaped(body) {
594
+ if (typeof body !== "object" || body === null) return false;
595
+ const b = body;
596
+ if (b.type !== "error") return false;
597
+ if (typeof b.error !== "object" || b.error === null) return false;
598
+ return true;
599
+ }
600
+ function defaultErrorTypeFor(status) {
601
+ if (status === 400) return "invalid_request_error";
602
+ if (status === 401 || status === 403) return "authentication_error";
603
+ if (status === 404) return "not_found_error";
604
+ if (status === 408) return "timeout_error";
605
+ if (status === 429) return "rate_limit_error";
606
+ if (status >= 500) return "api_error";
607
+ if (status >= 400) return "invalid_request_error";
608
+ return "api_error";
609
+ }
610
+ function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
611
+ let parsed = upstreamBody;
612
+ if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
613
+ try {
614
+ parsed = JSON.parse(upstreamBody);
615
+ } catch {
616
+ parsed = upstreamBody;
617
+ }
618
+ }
619
+ let type = defaultErrorTypeFor(status);
620
+ let message;
621
+ if (isAnthropicShaped(parsed)) {
622
+ const inner = parsed.error;
623
+ if (typeof inner.type === "string" && inner.type.length > 0) {
624
+ type = inner.type;
625
+ }
626
+ message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
627
+ } else if (typeof parsed === "string" && parsed.length > 0) {
628
+ message = parsed;
629
+ } else {
630
+ message = defaultMessageForStatus(status);
631
+ }
632
+ const envelope = {
633
+ type: "error",
634
+ error: {
635
+ type,
636
+ message,
637
+ status_code: status,
638
+ origin
639
+ }
640
+ };
641
+ if (hint !== void 0) envelope.error.hint = hint;
642
+ if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
643
+ return envelope;
644
+ }
645
+ function defaultMessageForStatus(status) {
646
+ if (status === 401) return "Authentication failed";
647
+ if (status === 403) return "Forbidden";
648
+ if (status === 404) return "Not found";
649
+ if (status === 408) return "Request timed out";
650
+ if (status === 429) return "Rate limit exceeded";
651
+ if (status === 502) return "Bad gateway";
652
+ if (status === 503) return "Service unavailable";
653
+ if (status === 504) return "Gateway timeout";
654
+ if (status >= 500) return "Upstream error";
655
+ if (status >= 400) return "Client error";
656
+ return "Error";
657
+ }
658
+
659
+ // src/proxy/recovery.ts
660
+ var import_node_crypto = require("crypto");
661
+ function parseRetryAfterHeader(header) {
662
+ if (!header) return void 0;
663
+ const trimmed = header.trim();
664
+ if (!trimmed) return void 0;
665
+ const n = Number(trimmed);
666
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
667
+ const dateMs = Date.parse(trimmed);
668
+ if (Number.isFinite(dateMs)) {
669
+ return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
670
+ }
671
+ return void 0;
672
+ }
673
+ function sleep2(ms) {
674
+ return new Promise((resolve) => setTimeout(resolve, ms));
675
+ }
676
+ var MAX_RETRY_AFTER_SECONDS = 60;
677
+ var DEFAULT_BACKOFF_SECONDS = 2;
678
+ async function handle429WithRetryAfter(response, retryFn, logger) {
679
+ const headerVal = response.headers.get("retry-after");
680
+ const parsed = parseRetryAfterHeader(headerVal);
681
+ if (parsed === void 0) {
682
+ await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
683
+ const retried2 = await retryFn();
684
+ logger.info("proxy.recovery.429_retry_count increment");
685
+ return retried2;
686
+ }
687
+ if (parsed > MAX_RETRY_AFTER_SECONDS) {
688
+ return response;
689
+ }
690
+ await sleep2(parsed * 1e3);
691
+ const retried = await retryFn();
692
+ logger.info("proxy.recovery.429_retry_count increment");
693
+ return retried;
694
+ }
695
+ var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
696
+ async function handleTimeoutWithRetry(err, retryFn, logger) {
697
+ const code = err.code;
698
+ if (!code || !TIMEOUT_CODES.has(code)) {
699
+ throw err;
700
+ }
701
+ await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
702
+ const retried = await retryFn();
703
+ logger.info("proxy.recovery.timeout_retry_count increment");
704
+ return retried;
705
+ }
706
+ function tokenFingerprint(authHeader) {
707
+ if (authHeader === void 0) return "none";
708
+ return (0, import_node_crypto.createHash)("sha256").update(authHeader).digest("hex").slice(0, 12);
709
+ }
710
+ var MUTEX_MAX_ENTRIES = 1024;
711
+ var LruMutexMap = class extends Map {
712
+ set(key, value) {
713
+ if (this.has(key)) {
714
+ super.delete(key);
715
+ } else if (this.size >= MUTEX_MAX_ENTRIES) {
716
+ const oldest = this.keys().next().value;
717
+ if (oldest !== void 0) super.delete(oldest);
718
+ }
719
+ return super.set(key, value);
720
+ }
721
+ };
722
+ var refreshMutex = new LruMutexMap();
723
+
511
724
  // src/proxy/streaming.ts
725
+ var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
726
+ var HTTP_BAD_GATEWAY = 502;
727
+ function parseRetryAfter(header) {
728
+ if (!header) return void 0;
729
+ const trimmed = header.trim();
730
+ if (!trimmed) return void 0;
731
+ const n = Number(trimmed);
732
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
733
+ const dateMs = Date.parse(trimmed);
734
+ if (Number.isFinite(dateMs)) {
735
+ const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
736
+ return delta;
737
+ }
738
+ return void 0;
739
+ }
512
740
  var HOP_BY_HOP = /* @__PURE__ */ new Set([
513
741
  "connection",
514
742
  "keep-alive",
@@ -530,17 +758,11 @@ function stripSkalpelHeaders(headers) {
530
758
  delete cleaned["X-Skalpel-Source"];
531
759
  delete cleaned["X-Skalpel-Agent-Type"];
532
760
  delete cleaned["X-Skalpel-SDK-Version"];
761
+ delete cleaned["X-Skalpel-Auth-Mode"];
533
762
  return cleaned;
534
763
  }
535
- function isSkalpelBackendFailure(response, err) {
536
- if (err) return true;
537
- if (!response) return true;
538
- if (response.status >= 500) return true;
539
- if (response.status === 403) return true;
540
- return false;
541
- }
542
764
  async function doStreamingFetch(url, body, headers) {
543
- return fetch(url, { method: "POST", headers, body });
765
+ return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
544
766
  }
545
767
  async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
546
768
  let response = null;
@@ -552,7 +774,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
552
774
  } catch (err) {
553
775
  fetchError = err;
554
776
  }
555
- if (isSkalpelBackendFailure(response, fetchError)) {
777
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
556
778
  logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
557
779
  usedFallback = true;
558
780
  response = null;
@@ -571,15 +793,40 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
571
793
  fetchError = err;
572
794
  }
573
795
  }
796
+ const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
797
+ const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
798
+ if (fetchError) {
799
+ const code = fetchError.code;
800
+ if (code && TIMEOUT_CODES2.has(code)) {
801
+ try {
802
+ response = await handleTimeoutWithRetry(
803
+ fetchError,
804
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
805
+ logger
806
+ );
807
+ fetchError = null;
808
+ } catch (retryErr) {
809
+ fetchError = retryErr;
810
+ }
811
+ }
812
+ }
813
+ if (response && response.status === 429) {
814
+ response = await handle429WithRetryAfter(
815
+ response,
816
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
817
+ logger
818
+ );
819
+ }
574
820
  if (!response || fetchError) {
575
821
  const errMsg = fetchError ? fetchError.message : "no response from upstream";
576
822
  logger.error(`streaming fetch failed: ${errMsg}`);
577
- res.writeHead(502, {
823
+ res.writeHead(HTTP_BAD_GATEWAY, {
578
824
  "Content-Type": "text/event-stream",
579
825
  "Cache-Control": "no-cache"
580
826
  });
827
+ const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
581
828
  res.write(`event: error
582
- data: ${JSON.stringify({ error: errMsg })}
829
+ data: ${JSON.stringify(envelope)}
583
830
 
584
831
  `);
585
832
  res.end();
@@ -589,17 +836,39 @@ data: ${JSON.stringify({ error: errMsg })}
589
836
  logger.info("streaming: using direct Anthropic API fallback");
590
837
  }
591
838
  if (response.status >= 300) {
592
- const errorBody = Buffer.from(await response.arrayBuffer());
593
- logger.error(`streaming upstream error: status=${response.status} body=${errorBody.toString().slice(0, 500)}`);
594
- const passthroughHeaders = {};
595
- for (const [key, value] of response.headers.entries()) {
596
- if (!STRIP_HEADERS.has(key)) {
597
- passthroughHeaders[key] = value;
598
- }
839
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
840
+ const originHeader = response.headers.get("x-skalpel-origin");
841
+ let origin;
842
+ if (originHeader === "backend") origin = "skalpel-backend";
843
+ else if (originHeader === "provider") origin = "provider";
844
+ else origin = "provider";
845
+ let rawBody = "";
846
+ let bodyReadFailed = false;
847
+ try {
848
+ rawBody = Buffer.from(await response.arrayBuffer()).toString();
849
+ } catch (readErr) {
850
+ bodyReadFailed = true;
851
+ logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
599
852
  }
600
- passthroughHeaders["content-length"] = String(errorBody.length);
601
- res.writeHead(response.status, passthroughHeaders);
602
- res.end(errorBody);
853
+ if (!bodyReadFailed) {
854
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
855
+ }
856
+ const envelope = bodyReadFailed ? buildErrorEnvelope(
857
+ response.status,
858
+ "",
859
+ "skalpel-proxy",
860
+ "mid-stream abort",
861
+ retryAfter
862
+ ) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
863
+ res.writeHead(response.status, {
864
+ "Content-Type": "text/event-stream",
865
+ "Cache-Control": "no-cache"
866
+ });
867
+ res.write(`event: error
868
+ data: ${JSON.stringify(envelope)}
869
+
870
+ `);
871
+ res.end();
603
872
  return;
604
873
  }
605
874
  const sseHeaders = {};
@@ -630,8 +899,16 @@ data: ${JSON.stringify({ error: "no response body" })}
630
899
  }
631
900
  } catch (err) {
632
901
  logger.error(`streaming error: ${err.message}`);
902
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
903
+ const envelope = buildErrorEnvelope(
904
+ response.status,
905
+ err.message,
906
+ "skalpel-proxy",
907
+ "mid-stream abort",
908
+ retryAfter
909
+ );
633
910
  res.write(`event: error
634
- data: ${JSON.stringify({ error: err.message })}
911
+ data: ${JSON.stringify(envelope)}
635
912
 
636
913
  `);
637
914
  }
@@ -639,6 +916,8 @@ data: ${JSON.stringify({ error: err.message })}
639
916
  }
640
917
 
641
918
  // src/proxy/handler.ts
919
+ var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
920
+ var HTTP_BAD_GATEWAY2 = 502;
642
921
  var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
643
922
  function collectBody(req) {
644
923
  return new Promise((resolve, reject) => {
@@ -648,38 +927,72 @@ function collectBody(req) {
648
927
  req.on("error", reject);
649
928
  });
650
929
  }
651
- function shouldRouteToSkalpel(path4, _source) {
652
- if (isPassthroughMode()) return false;
930
+ function shouldRouteToSkalpel(path4, source) {
931
+ if (source !== "claude-code") return true;
653
932
  const pathname = path4.split("?")[0];
654
933
  return SKALPEL_EXACT_PATHS.has(pathname);
655
934
  }
656
- function isSkalpelBackendFailure2(response, err) {
935
+ async function isSkalpelBackendFailure(response, err, logger) {
657
936
  if (err) return true;
658
937
  if (!response) return true;
659
- if (response.status >= 500) return true;
660
- if (response.status === 403) return true;
661
- return false;
938
+ if (response.status < 500) return false;
939
+ const origin = response.headers?.get("x-skalpel-origin");
940
+ if (origin === "provider") return false;
941
+ if (origin === "backend") return true;
942
+ try {
943
+ const text = await response.clone().text();
944
+ if (!text) {
945
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
946
+ return true;
947
+ }
948
+ let shape = "non-anthropic";
949
+ try {
950
+ const parsed = JSON.parse(text);
951
+ if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
952
+ shape = "anthropic";
953
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
954
+ return false;
955
+ }
956
+ } catch {
957
+ }
958
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
959
+ return true;
960
+ } catch {
961
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
962
+ return true;
963
+ }
662
964
  }
965
+ var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
966
+ "host",
967
+ "connection",
968
+ "keep-alive",
969
+ "proxy-authenticate",
970
+ "proxy-authorization",
971
+ "te",
972
+ "trailer",
973
+ "transfer-encoding",
974
+ "upgrade"
975
+ ]);
663
976
  function buildForwardHeaders(req, config, source, useSkalpel) {
664
977
  const forwardHeaders = {};
665
978
  for (const [key, value] of Object.entries(req.headers)) {
666
- if (value !== void 0) {
667
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
668
- }
979
+ if (value === void 0) continue;
980
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
981
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
669
982
  }
670
- delete forwardHeaders["host"];
671
- delete forwardHeaders["connection"];
672
983
  if (useSkalpel) {
673
984
  forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
674
985
  forwardHeaders["X-Skalpel-Source"] = source;
675
986
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
676
987
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
988
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
677
989
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
678
990
  const authHeader = forwardHeaders["authorization"] ?? "";
679
991
  if (authHeader.toLowerCase().startsWith("bearer ")) {
680
992
  const token = authHeader.slice(7).trim();
681
993
  if (token.startsWith("sk-ant-")) {
682
994
  forwardHeaders["x-api-key"] = token;
995
+ delete forwardHeaders["authorization"];
683
996
  }
684
997
  }
685
998
  }
@@ -692,6 +1005,7 @@ function stripSkalpelHeaders2(headers) {
692
1005
  delete cleaned["X-Skalpel-Source"];
693
1006
  delete cleaned["X-Skalpel-Agent-Type"];
694
1007
  delete cleaned["X-Skalpel-SDK-Version"];
1008
+ delete cleaned["X-Skalpel-Auth-Mode"];
695
1009
  return cleaned;
696
1010
  }
697
1011
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -719,6 +1033,11 @@ async function handleRequest(req, res, config, source, logger) {
719
1033
  const start = Date.now();
720
1034
  const method = req.method ?? "GET";
721
1035
  const path4 = req.url ?? "/";
1036
+ const fp = tokenFingerprint(
1037
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
1038
+ );
1039
+ logger.info(`${source} ${method} ${path4} token=${fp}`);
1040
+ let response = null;
722
1041
  try {
723
1042
  const body = await collectBody(req);
724
1043
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -733,45 +1052,91 @@ async function handleRequest(req, res, config, source, logger) {
733
1052
  }
734
1053
  if (isStreaming) {
735
1054
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
736
- const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
1055
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
737
1056
  await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
738
1057
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
739
1058
  return;
740
1059
  }
741
1060
  const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
742
- const directUrl = `${config.anthropicDirectUrl}${path4}`;
1061
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
743
1062
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
744
- let response = null;
745
1063
  let fetchError = null;
746
1064
  let usedFallback = false;
747
1065
  if (useSkalpel) {
748
1066
  try {
749
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
1067
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
750
1068
  } catch (err) {
751
1069
  fetchError = err;
752
1070
  }
753
- if (isSkalpelBackendFailure2(response, fetchError)) {
1071
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
754
1072
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
755
1073
  usedFallback = true;
756
1074
  response = null;
757
1075
  fetchError = null;
758
1076
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
759
1077
  try {
760
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
1078
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
761
1079
  } catch (err) {
762
1080
  fetchError = err;
763
1081
  }
764
1082
  }
765
1083
  } else {
766
1084
  try {
767
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
1085
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
768
1086
  } catch (err) {
769
1087
  fetchError = err;
770
1088
  }
771
1089
  }
1090
+ const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
1091
+ const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
1092
+ if (fetchError) {
1093
+ const code = fetchError.code;
1094
+ if (code && TIMEOUT_CODES3.has(code)) {
1095
+ try {
1096
+ response = await handleTimeoutWithRetry(
1097
+ fetchError,
1098
+ () => fetch(fetchUrl, {
1099
+ method,
1100
+ headers: fetchHeaders,
1101
+ body: fetchBody,
1102
+ dispatcher: skalpelDispatcher
1103
+ }),
1104
+ logger
1105
+ );
1106
+ fetchError = null;
1107
+ } catch (retryErr) {
1108
+ fetchError = retryErr;
1109
+ }
1110
+ }
1111
+ }
772
1112
  if (!response || fetchError) {
1113
+ response = null;
773
1114
  throw fetchError ?? new Error("no response from upstream");
774
1115
  }
1116
+ if (response.status === 429) {
1117
+ response = await handle429WithRetryAfter(
1118
+ response,
1119
+ () => fetch(fetchUrl, {
1120
+ method,
1121
+ headers: fetchHeaders,
1122
+ body: fetchBody,
1123
+ dispatcher: skalpelDispatcher
1124
+ }),
1125
+ logger
1126
+ );
1127
+ }
1128
+ if (response.status === 401 && (source === "claude-code" || source === "codex")) {
1129
+ const fp2 = tokenFingerprint(
1130
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
1131
+ );
1132
+ logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
1133
+ const body401 = Buffer.from(await response.arrayBuffer());
1134
+ const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
1135
+ res.writeHead(401, { "Content-Type": "application/json" });
1136
+ res.end(JSON.stringify(envelope));
1137
+ logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
1138
+ return;
1139
+ }
775
1140
  const responseHeaders = extractResponseHeaders(response);
776
1141
  const responseBody = Buffer.from(await response.arrayBuffer());
777
1142
  responseHeaders["content-length"] = String(responseBody.length);
@@ -781,20 +1146,38 @@ async function handleRequest(req, res, config, source, logger) {
781
1146
  } catch (err) {
782
1147
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
783
1148
  if (!res.headersSent) {
784
- res.writeHead(502, { "Content-Type": "application/json" });
785
- res.end(JSON.stringify({ error: "proxy_error", message: err.message }));
1149
+ if (response !== null) {
1150
+ const upstreamStatus = response.status;
1151
+ const envelope = buildErrorEnvelope(
1152
+ upstreamStatus,
1153
+ "",
1154
+ "skalpel-proxy",
1155
+ "body read failed after upstream status"
1156
+ );
1157
+ res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
1158
+ res.end(JSON.stringify(envelope));
1159
+ } else {
1160
+ const envelope = buildErrorEnvelope(
1161
+ HTTP_BAD_GATEWAY2,
1162
+ err.message,
1163
+ "skalpel-proxy"
1164
+ );
1165
+ res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
1166
+ res.end(JSON.stringify(envelope));
1167
+ }
786
1168
  }
787
1169
  }
788
1170
  }
789
1171
 
790
1172
  // src/proxy/health.ts
791
- function handleHealthRequest(res, config, startTime, passthrough = false) {
1173
+ function handleHealthRequest(res, config, startTime) {
792
1174
  const body = JSON.stringify({
793
1175
  status: "ok",
794
- mode: passthrough ? "passthrough" : "normal",
795
1176
  uptime: Date.now() - startTime,
796
1177
  ports: {
797
- anthropic: config.anthropicPort
1178
+ anthropic: config.anthropicPort,
1179
+ openai: config.openaiPort,
1180
+ cursor: config.cursorPort
798
1181
  },
799
1182
  version: "proxy-1.0.0"
800
1183
  });
@@ -805,13 +1188,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
805
1188
  // src/proxy/pid.ts
806
1189
  var import_node_fs = __toESM(require("fs"), 1);
807
1190
  var import_node_path = __toESM(require("path"), 1);
1191
+ var import_node_child_process = require("child_process");
808
1192
  function writePid(pidFile) {
809
1193
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(pidFile), { recursive: true });
810
- import_node_fs.default.writeFileSync(pidFile, String(process.pid));
1194
+ const record = {
1195
+ pid: process.pid,
1196
+ startTime: getStartTime(process.pid)
1197
+ };
1198
+ import_node_fs.default.writeFileSync(pidFile, JSON.stringify(record));
811
1199
  }
812
1200
  function readPid(pidFile) {
813
1201
  try {
814
1202
  const raw = import_node_fs.default.readFileSync(pidFile, "utf-8").trim();
1203
+ try {
1204
+ const parsed = JSON.parse(raw);
1205
+ if (parsed && typeof parsed === "object" && typeof parsed.pid === "number" && !isNaN(parsed.pid)) {
1206
+ const record = parsed;
1207
+ if (record.startTime == null) {
1208
+ return isRunning(record.pid) ? record.pid : null;
1209
+ }
1210
+ return isRunningWithIdentity(record.pid, record.startTime) ? record.pid : null;
1211
+ }
1212
+ } catch {
1213
+ }
815
1214
  const pid = parseInt(raw, 10);
816
1215
  if (isNaN(pid)) return null;
817
1216
  return isRunning(pid) ? pid : null;
@@ -827,6 +1226,37 @@ function isRunning(pid) {
827
1226
  return false;
828
1227
  }
829
1228
  }
1229
+ function getStartTime(pid) {
1230
+ try {
1231
+ if (process.platform === "linux") {
1232
+ const stat = import_node_fs.default.readFileSync(`/proc/${pid}/stat`, "utf-8");
1233
+ const rparen = stat.lastIndexOf(")");
1234
+ if (rparen < 0) return null;
1235
+ const fields = stat.slice(rparen + 2).split(" ");
1236
+ return fields[19] ?? null;
1237
+ }
1238
+ if (process.platform === "darwin") {
1239
+ const out = (0, import_node_child_process.execSync)(`ps -p ${pid} -o lstart=`, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] });
1240
+ const text = out.toString().trim();
1241
+ return text || null;
1242
+ }
1243
+ return null;
1244
+ } catch {
1245
+ return null;
1246
+ }
1247
+ }
1248
+ function isRunningWithIdentity(pid, expectedStartTime) {
1249
+ try {
1250
+ if (process.platform !== "linux" && process.platform !== "darwin") {
1251
+ return isRunning(pid);
1252
+ }
1253
+ const current = getStartTime(pid);
1254
+ if (current == null) return false;
1255
+ return current === expectedStartTime;
1256
+ } catch {
1257
+ return false;
1258
+ }
1259
+ }
830
1260
  function removePid(pidFile) {
831
1261
  try {
832
1262
  import_node_fs.default.unlinkSync(pidFile);
@@ -840,12 +1270,14 @@ var import_node_path2 = __toESM(require("path"), 1);
840
1270
  var MAX_SIZE = 5 * 1024 * 1024;
841
1271
  var MAX_ROTATIONS = 3;
842
1272
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
843
- var Logger = class {
1273
+ var Logger = class _Logger {
844
1274
  logFile;
845
1275
  level;
846
- constructor(logFile, level = "info") {
1276
+ prefix;
1277
+ constructor(logFile, level = "info", prefix = "") {
847
1278
  this.logFile = logFile;
848
1279
  this.level = level;
1280
+ this.prefix = prefix;
849
1281
  import_node_fs2.default.mkdirSync(import_node_path2.default.dirname(logFile), { recursive: true });
850
1282
  }
851
1283
  debug(msg) {
@@ -860,9 +1292,16 @@ var Logger = class {
860
1292
  error(msg) {
861
1293
  this.log("error", msg);
862
1294
  }
1295
+ /** Returns a new Logger that writes to the same file but prefixes every
1296
+ * emitted line with `[conn=<connId>] `. The parent logger continues to
1297
+ * work unchanged. IPv6 colons should already be sanitized by the caller. */
1298
+ child(connId) {
1299
+ const child = new _Logger(this.logFile, this.level, `[conn=${connId}] `);
1300
+ return child;
1301
+ }
863
1302
  log(level, msg) {
864
1303
  if (LEVELS[level] < LEVELS[this.level]) return;
865
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
1304
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
866
1305
  `;
867
1306
  if (level === "debug" || level === "error") {
868
1307
  process.stderr.write(line);
@@ -893,50 +1332,41 @@ var Logger = class {
893
1332
 
894
1333
  // src/proxy/server.ts
895
1334
  var proxyStartTime = 0;
896
- var passthroughMode = false;
897
- function isPassthroughMode() {
898
- return passthroughMode;
899
- }
900
- function collectAdminBody(req) {
901
- return new Promise((resolve, reject) => {
902
- const chunks = [];
903
- req.on("data", (chunk) => chunks.push(chunk));
904
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
905
- req.on("error", reject);
906
- });
907
- }
908
- function handleAdminMode(req, res, logger) {
909
- collectAdminBody(req).then((body) => {
910
- try {
911
- const { mode } = JSON.parse(body);
912
- passthroughMode = mode === "passthrough";
913
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
914
- res.writeHead(200, { "Content-Type": "application/json" });
915
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
916
- } catch {
917
- res.writeHead(400, { "Content-Type": "application/json" });
918
- res.end(JSON.stringify({ error: "invalid JSON body" }));
919
- }
920
- }).catch(() => {
921
- res.writeHead(500, { "Content-Type": "application/json" });
922
- res.end(JSON.stringify({ error: "failed to read body" }));
923
- });
1335
+ var connCounter = 0;
1336
+ function computeConnId(req) {
1337
+ const addr = req.socket.remoteAddress ?? "unknown";
1338
+ const port = req.socket.remotePort ?? 0;
1339
+ const counter = (++connCounter).toString(36);
1340
+ const raw = addr + "|" + port + "|" + Date.now().toString(36) + "|" + counter + "|" + Math.floor(Math.random() * 4096).toString(16);
1341
+ return raw.replace(/:/g, "_");
924
1342
  }
925
1343
  function startProxy(config) {
926
1344
  const logger = new Logger(config.logFile, config.logLevel);
927
1345
  const startTime = Date.now();
928
1346
  proxyStartTime = Date.now();
929
- passthroughMode = false;
930
1347
  const anthropicServer = import_node_http.default.createServer((req, res) => {
931
1348
  if (req.url === "/health" && req.method === "GET") {
932
- handleHealthRequest(res, config, startTime, isPassthroughMode());
1349
+ handleHealthRequest(res, config, startTime);
933
1350
  return;
934
1351
  }
935
- if (req.url === "/admin/mode" && req.method === "POST") {
936
- handleAdminMode(req, res, logger);
1352
+ const connId = computeConnId(req);
1353
+ handleRequest(req, res, config, "claude-code", logger.child(connId));
1354
+ });
1355
+ const openaiServer = import_node_http.default.createServer((req, res) => {
1356
+ if (req.url === "/health" && req.method === "GET") {
1357
+ handleHealthRequest(res, config, startTime);
937
1358
  return;
938
1359
  }
939
- handleRequest(req, res, config, "claude-code", logger);
1360
+ const connId = computeConnId(req);
1361
+ handleRequest(req, res, config, "codex", logger.child(connId));
1362
+ });
1363
+ const cursorServer = import_node_http.default.createServer((req, res) => {
1364
+ if (req.url === "/health" && req.method === "GET") {
1365
+ handleHealthRequest(res, config, startTime);
1366
+ return;
1367
+ }
1368
+ const connId = computeConnId(req);
1369
+ handleRequest(req, res, config, "cursor", logger.child(connId));
940
1370
  });
941
1371
  anthropicServer.on("error", (err) => {
942
1372
  if (err.code === "EADDRINUSE") {
@@ -947,14 +1377,40 @@ function startProxy(config) {
947
1377
  removePid(config.pidFile);
948
1378
  process.exit(1);
949
1379
  });
1380
+ openaiServer.on("error", (err) => {
1381
+ if (err.code === "EADDRINUSE") {
1382
+ logger.error(`Port ${config.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
1383
+ } else {
1384
+ logger.error(`OpenAI proxy failed to bind port ${config.openaiPort}: ${err.message}`);
1385
+ }
1386
+ removePid(config.pidFile);
1387
+ process.exit(1);
1388
+ });
1389
+ cursorServer.on("error", (err) => {
1390
+ if (err.code === "EADDRINUSE") {
1391
+ logger.error(`Port ${config.cursorPort} is already in use. Another Skalpel proxy or process may be running.`);
1392
+ } else {
1393
+ logger.error(`Cursor proxy failed to bind port ${config.cursorPort}: ${err.message}`);
1394
+ }
1395
+ removePid(config.pidFile);
1396
+ process.exit(1);
1397
+ });
950
1398
  anthropicServer.listen(config.anthropicPort, () => {
951
1399
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
952
1400
  });
1401
+ openaiServer.listen(config.openaiPort, () => {
1402
+ logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
1403
+ });
1404
+ cursorServer.listen(config.cursorPort, () => {
1405
+ logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
1406
+ });
953
1407
  writePid(config.pidFile);
954
- logger.info(`Proxy started (pid=${process.pid}) port=${config.anthropicPort}`);
1408
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
955
1409
  const cleanup = () => {
956
1410
  logger.info("Shutting down proxy...");
957
1411
  anthropicServer.close();
1412
+ openaiServer.close();
1413
+ cursorServer.close();
958
1414
  removePid(config.pidFile);
959
1415
  process.exit(0);
960
1416
  };
@@ -970,7 +1426,7 @@ function startProxy(config) {
970
1426
  removePid(config.pidFile);
971
1427
  process.exit(1);
972
1428
  });
973
- return { anthropicServer };
1429
+ return { anthropicServer, openaiServer, cursorServer };
974
1430
  }
975
1431
  function stopProxy(config) {
976
1432
  const pid = readPid(config.pidFile);
@@ -988,7 +1444,9 @@ function getProxyStatus(config) {
988
1444
  running: pid !== null,
989
1445
  pid,
990
1446
  uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
991
- anthropicPort: config.anthropicPort
1447
+ anthropicPort: config.anthropicPort,
1448
+ openaiPort: config.openaiPort,
1449
+ cursorPort: config.cursorPort
992
1450
  };
993
1451
  }
994
1452
 
@@ -1006,12 +1464,20 @@ var DEFAULTS = {
1006
1464
  apiKey: "",
1007
1465
  remoteBaseUrl: "https://api.skalpel.ai",
1008
1466
  anthropicDirectUrl: "https://api.anthropic.com",
1467
+ openaiDirectUrl: "https://api.openai.com",
1009
1468
  anthropicPort: 18100,
1469
+ openaiPort: 18101,
1470
+ cursorPort: 18102,
1471
+ cursorDirectUrl: "https://api.openai.com",
1010
1472
  logLevel: "info",
1011
1473
  logFile: "~/.skalpel/logs/proxy.log",
1012
1474
  pidFile: "~/.skalpel/proxy.pid",
1013
- configFile: "~/.skalpel/config.json"
1475
+ configFile: "~/.skalpel/config.json",
1476
+ mode: "proxy"
1014
1477
  };
1478
+ function coerceMode(value) {
1479
+ return value === "direct" ? "direct" : "proxy";
1480
+ }
1015
1481
  function loadConfig(configPath) {
1016
1482
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
1017
1483
  let fileConfig = {};
@@ -1024,17 +1490,27 @@ function loadConfig(configPath) {
1024
1490
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
1025
1491
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
1026
1492
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
1493
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
1027
1494
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
1495
+ openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
1496
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
1497
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
1028
1498
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
1029
1499
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
1030
1500
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
1031
- configFile: filePath
1501
+ configFile: filePath,
1502
+ mode: coerceMode(fileConfig.mode)
1032
1503
  };
1033
1504
  }
1034
1505
  function saveConfig(config) {
1035
1506
  const dir = import_node_path3.default.dirname(config.configFile);
1036
1507
  import_node_fs3.default.mkdirSync(dir, { recursive: true });
1037
- import_node_fs3.default.writeFileSync(config.configFile, JSON.stringify(config, null, 2) + "\n");
1508
+ const { mode, ...rest } = config;
1509
+ const serializable = { ...rest };
1510
+ if (mode === "direct") {
1511
+ serializable.mode = mode;
1512
+ }
1513
+ import_node_fs3.default.writeFileSync(config.configFile, JSON.stringify(serializable, null, 2) + "\n");
1038
1514
  }
1039
1515
  // Annotate the CommonJS export names for ESM import in node:
1040
1516
  0 && (module.exports = {