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.
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}`);
852
+ }
853
+ if (!bodyReadFailed) {
854
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
599
855
  }
600
- passthroughHeaders["content-length"] = String(errorBody.length);
601
- res.writeHead(response.status, passthroughHeaders);
602
- res.end(errorBody);
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) => {
@@ -649,38 +928,71 @@ function collectBody(req) {
649
928
  });
650
929
  }
651
930
  function shouldRouteToSkalpel(path4, source) {
652
- if (isPassthroughMode()) return false;
653
931
  if (source !== "claude-code") return true;
654
932
  const pathname = path4.split("?")[0];
655
933
  return SKALPEL_EXACT_PATHS.has(pathname);
656
934
  }
657
- function isSkalpelBackendFailure2(response, err) {
935
+ async function isSkalpelBackendFailure(response, err, logger) {
658
936
  if (err) return true;
659
937
  if (!response) return true;
660
- if (response.status >= 500) return true;
661
- if (response.status === 403) return true;
662
- 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
+ }
663
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
+ ]);
664
976
  function buildForwardHeaders(req, config, source, useSkalpel) {
665
977
  const forwardHeaders = {};
666
978
  for (const [key, value] of Object.entries(req.headers)) {
667
- if (value !== void 0) {
668
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
669
- }
979
+ if (value === void 0) continue;
980
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
981
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
670
982
  }
671
- delete forwardHeaders["host"];
672
- delete forwardHeaders["connection"];
673
983
  if (useSkalpel) {
674
984
  forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
675
985
  forwardHeaders["X-Skalpel-Source"] = source;
676
986
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
677
987
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
988
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
678
989
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
679
990
  const authHeader = forwardHeaders["authorization"] ?? "";
680
991
  if (authHeader.toLowerCase().startsWith("bearer ")) {
681
992
  const token = authHeader.slice(7).trim();
682
993
  if (token.startsWith("sk-ant-")) {
683
994
  forwardHeaders["x-api-key"] = token;
995
+ delete forwardHeaders["authorization"];
684
996
  }
685
997
  }
686
998
  }
@@ -693,6 +1005,7 @@ function stripSkalpelHeaders2(headers) {
693
1005
  delete cleaned["X-Skalpel-Source"];
694
1006
  delete cleaned["X-Skalpel-Agent-Type"];
695
1007
  delete cleaned["X-Skalpel-SDK-Version"];
1008
+ delete cleaned["X-Skalpel-Auth-Mode"];
696
1009
  return cleaned;
697
1010
  }
698
1011
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -720,6 +1033,11 @@ async function handleRequest(req, res, config, source, logger) {
720
1033
  const start = Date.now();
721
1034
  const method = req.method ?? "GET";
722
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;
723
1041
  try {
724
1042
  const body = await collectBody(req);
725
1043
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -734,45 +1052,91 @@ async function handleRequest(req, res, config, source, logger) {
734
1052
  }
735
1053
  if (isStreaming) {
736
1054
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
737
- const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
1055
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
738
1056
  await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
739
1057
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
740
1058
  return;
741
1059
  }
742
1060
  const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
743
- const directUrl = `${config.anthropicDirectUrl}${path4}`;
1061
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
744
1062
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
745
- let response = null;
746
1063
  let fetchError = null;
747
1064
  let usedFallback = false;
748
1065
  if (useSkalpel) {
749
1066
  try {
750
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
1067
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
751
1068
  } catch (err) {
752
1069
  fetchError = err;
753
1070
  }
754
- if (isSkalpelBackendFailure2(response, fetchError)) {
1071
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
755
1072
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
756
1073
  usedFallback = true;
757
1074
  response = null;
758
1075
  fetchError = null;
759
1076
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
760
1077
  try {
761
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
1078
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
762
1079
  } catch (err) {
763
1080
  fetchError = err;
764
1081
  }
765
1082
  }
766
1083
  } else {
767
1084
  try {
768
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
1085
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
769
1086
  } catch (err) {
770
1087
  fetchError = err;
771
1088
  }
772
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
+ }
773
1112
  if (!response || fetchError) {
1113
+ response = null;
774
1114
  throw fetchError ?? new Error("no response from upstream");
775
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
+ }
776
1140
  const responseHeaders = extractResponseHeaders(response);
777
1141
  const responseBody = Buffer.from(await response.arrayBuffer());
778
1142
  responseHeaders["content-length"] = String(responseBody.length);
@@ -782,21 +1146,38 @@ async function handleRequest(req, res, config, source, logger) {
782
1146
  } catch (err) {
783
1147
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
784
1148
  if (!res.headersSent) {
785
- res.writeHead(502, { "Content-Type": "application/json" });
786
- 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
+ }
787
1168
  }
788
1169
  }
789
1170
  }
790
1171
 
791
1172
  // src/proxy/health.ts
792
- function handleHealthRequest(res, config, startTime, passthrough = false) {
1173
+ function handleHealthRequest(res, config, startTime) {
793
1174
  const body = JSON.stringify({
794
1175
  status: "ok",
795
- mode: passthrough ? "passthrough" : "normal",
796
1176
  uptime: Date.now() - startTime,
797
1177
  ports: {
798
1178
  anthropic: config.anthropicPort,
799
- openai: config.openaiPort
1179
+ openai: config.openaiPort,
1180
+ cursor: config.cursorPort
800
1181
  },
801
1182
  version: "proxy-1.0.0"
802
1183
  });
@@ -807,13 +1188,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
807
1188
  // src/proxy/pid.ts
808
1189
  var import_node_fs = __toESM(require("fs"), 1);
809
1190
  var import_node_path = __toESM(require("path"), 1);
1191
+ var import_node_child_process = require("child_process");
810
1192
  function writePid(pidFile) {
811
1193
  import_node_fs.default.mkdirSync(import_node_path.default.dirname(pidFile), { recursive: true });
812
- 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));
813
1199
  }
814
1200
  function readPid(pidFile) {
815
1201
  try {
816
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
+ }
817
1214
  const pid = parseInt(raw, 10);
818
1215
  if (isNaN(pid)) return null;
819
1216
  return isRunning(pid) ? pid : null;
@@ -829,6 +1226,37 @@ function isRunning(pid) {
829
1226
  return false;
830
1227
  }
831
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
+ }
832
1260
  function removePid(pidFile) {
833
1261
  try {
834
1262
  import_node_fs.default.unlinkSync(pidFile);
@@ -842,12 +1270,14 @@ var import_node_path2 = __toESM(require("path"), 1);
842
1270
  var MAX_SIZE = 5 * 1024 * 1024;
843
1271
  var MAX_ROTATIONS = 3;
844
1272
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
845
- var Logger = class {
1273
+ var Logger = class _Logger {
846
1274
  logFile;
847
1275
  level;
848
- constructor(logFile, level = "info") {
1276
+ prefix;
1277
+ constructor(logFile, level = "info", prefix = "") {
849
1278
  this.logFile = logFile;
850
1279
  this.level = level;
1280
+ this.prefix = prefix;
851
1281
  import_node_fs2.default.mkdirSync(import_node_path2.default.dirname(logFile), { recursive: true });
852
1282
  }
853
1283
  debug(msg) {
@@ -862,9 +1292,16 @@ var Logger = class {
862
1292
  error(msg) {
863
1293
  this.log("error", msg);
864
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
+ }
865
1302
  log(level, msg) {
866
1303
  if (LEVELS[level] < LEVELS[this.level]) return;
867
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
1304
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
868
1305
  `;
869
1306
  if (level === "debug" || level === "error") {
870
1307
  process.stderr.write(line);
@@ -895,61 +1332,41 @@ var Logger = class {
895
1332
 
896
1333
  // src/proxy/server.ts
897
1334
  var proxyStartTime = 0;
898
- var passthroughMode = false;
899
- function isPassthroughMode() {
900
- return passthroughMode;
901
- }
902
- function collectAdminBody(req) {
903
- return new Promise((resolve, reject) => {
904
- const chunks = [];
905
- req.on("data", (chunk) => chunks.push(chunk));
906
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
907
- req.on("error", reject);
908
- });
909
- }
910
- function handleAdminMode(req, res, logger) {
911
- collectAdminBody(req).then((body) => {
912
- try {
913
- const { mode } = JSON.parse(body);
914
- passthroughMode = mode === "passthrough";
915
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
916
- res.writeHead(200, { "Content-Type": "application/json" });
917
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
918
- } catch {
919
- res.writeHead(400, { "Content-Type": "application/json" });
920
- res.end(JSON.stringify({ error: "invalid JSON body" }));
921
- }
922
- }).catch(() => {
923
- res.writeHead(500, { "Content-Type": "application/json" });
924
- res.end(JSON.stringify({ error: "failed to read body" }));
925
- });
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, "_");
926
1342
  }
927
1343
  function startProxy(config) {
928
1344
  const logger = new Logger(config.logFile, config.logLevel);
929
1345
  const startTime = Date.now();
930
1346
  proxyStartTime = Date.now();
931
- passthroughMode = false;
932
1347
  const anthropicServer = import_node_http.default.createServer((req, res) => {
933
1348
  if (req.url === "/health" && req.method === "GET") {
934
- handleHealthRequest(res, config, startTime, isPassthroughMode());
935
- return;
936
- }
937
- if (req.url === "/admin/mode" && req.method === "POST") {
938
- handleAdminMode(req, res, logger);
1349
+ handleHealthRequest(res, config, startTime);
939
1350
  return;
940
1351
  }
941
- handleRequest(req, res, config, "claude-code", logger);
1352
+ const connId = computeConnId(req);
1353
+ handleRequest(req, res, config, "claude-code", logger.child(connId));
942
1354
  });
943
1355
  const openaiServer = import_node_http.default.createServer((req, res) => {
944
1356
  if (req.url === "/health" && req.method === "GET") {
945
- handleHealthRequest(res, config, startTime, isPassthroughMode());
1357
+ handleHealthRequest(res, config, startTime);
946
1358
  return;
947
1359
  }
948
- if (req.url === "/admin/mode" && req.method === "POST") {
949
- handleAdminMode(req, res, 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);
950
1366
  return;
951
1367
  }
952
- handleRequest(req, res, config, "codex", logger);
1368
+ const connId = computeConnId(req);
1369
+ handleRequest(req, res, config, "cursor", logger.child(connId));
953
1370
  });
954
1371
  anthropicServer.on("error", (err) => {
955
1372
  if (err.code === "EADDRINUSE") {
@@ -969,18 +1386,31 @@ function startProxy(config) {
969
1386
  removePid(config.pidFile);
970
1387
  process.exit(1);
971
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
+ });
972
1398
  anthropicServer.listen(config.anthropicPort, () => {
973
1399
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
974
1400
  });
975
1401
  openaiServer.listen(config.openaiPort, () => {
976
1402
  logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
977
1403
  });
1404
+ cursorServer.listen(config.cursorPort, () => {
1405
+ logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
1406
+ });
978
1407
  writePid(config.pidFile);
979
- logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort}`);
1408
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
980
1409
  const cleanup = () => {
981
1410
  logger.info("Shutting down proxy...");
982
1411
  anthropicServer.close();
983
1412
  openaiServer.close();
1413
+ cursorServer.close();
984
1414
  removePid(config.pidFile);
985
1415
  process.exit(0);
986
1416
  };
@@ -996,7 +1426,7 @@ function startProxy(config) {
996
1426
  removePid(config.pidFile);
997
1427
  process.exit(1);
998
1428
  });
999
- return { anthropicServer, openaiServer };
1429
+ return { anthropicServer, openaiServer, cursorServer };
1000
1430
  }
1001
1431
  function stopProxy(config) {
1002
1432
  const pid = readPid(config.pidFile);
@@ -1015,7 +1445,8 @@ function getProxyStatus(config) {
1015
1445
  pid,
1016
1446
  uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
1017
1447
  anthropicPort: config.anthropicPort,
1018
- openaiPort: config.openaiPort
1448
+ openaiPort: config.openaiPort,
1449
+ cursorPort: config.cursorPort
1019
1450
  };
1020
1451
  }
1021
1452
 
@@ -1033,13 +1464,20 @@ var DEFAULTS = {
1033
1464
  apiKey: "",
1034
1465
  remoteBaseUrl: "https://api.skalpel.ai",
1035
1466
  anthropicDirectUrl: "https://api.anthropic.com",
1467
+ openaiDirectUrl: "https://api.openai.com",
1036
1468
  anthropicPort: 18100,
1037
1469
  openaiPort: 18101,
1470
+ cursorPort: 18102,
1471
+ cursorDirectUrl: "https://api.openai.com",
1038
1472
  logLevel: "info",
1039
1473
  logFile: "~/.skalpel/logs/proxy.log",
1040
1474
  pidFile: "~/.skalpel/proxy.pid",
1041
- configFile: "~/.skalpel/config.json"
1475
+ configFile: "~/.skalpel/config.json",
1476
+ mode: "proxy"
1042
1477
  };
1478
+ function coerceMode(value) {
1479
+ return value === "direct" ? "direct" : "proxy";
1480
+ }
1043
1481
  function loadConfig(configPath) {
1044
1482
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
1045
1483
  let fileConfig = {};
@@ -1052,18 +1490,27 @@ function loadConfig(configPath) {
1052
1490
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
1053
1491
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
1054
1492
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
1493
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
1055
1494
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
1056
1495
  openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
1496
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
1497
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
1057
1498
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
1058
1499
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
1059
1500
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
1060
- configFile: filePath
1501
+ configFile: filePath,
1502
+ mode: coerceMode(fileConfig.mode)
1061
1503
  };
1062
1504
  }
1063
1505
  function saveConfig(config) {
1064
1506
  const dir = import_node_path3.default.dirname(config.configFile);
1065
1507
  import_node_fs3.default.mkdirSync(dir, { recursive: true });
1066
- 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");
1067
1514
  }
1068
1515
  // Annotate the CommonJS export names for ESM import in node:
1069
1516
  0 && (module.exports = {