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.js CHANGED
@@ -56,8 +56,36 @@ var SkalpelUnavailableError = class extends SkalpelError {
56
56
  this.name = "SkalpelUnavailableError";
57
57
  }
58
58
  };
59
+ var SkalpelClientRequestError = class extends SkalpelError {
60
+ constructor(message, statusCode) {
61
+ super(message, "SKALPEL_CLIENT_REQUEST", statusCode);
62
+ this.name = "SkalpelClientRequestError";
63
+ }
64
+ };
59
65
 
60
66
  // src/fallback.ts
67
+ var TRULY_CLIENT_4XX = /* @__PURE__ */ new Set([
68
+ 400,
69
+ 403,
70
+ 404,
71
+ 405,
72
+ 409,
73
+ 410,
74
+ 411,
75
+ 413,
76
+ 415,
77
+ 417,
78
+ 418,
79
+ 421,
80
+ 422,
81
+ 423,
82
+ 424,
83
+ 425,
84
+ 426,
85
+ 428,
86
+ 431,
87
+ 451
88
+ ]);
61
89
  function sleep(ms) {
62
90
  return new Promise((resolve) => setTimeout(resolve, ms));
63
91
  }
@@ -73,12 +101,21 @@ function classifyError(err) {
73
101
  const retryAfter = error.headers?.["retry-after"] ? parseInt(error.headers["retry-after"], 10) : void 0;
74
102
  return new SkalpelRateLimitError(message, retryAfter);
75
103
  }
104
+ if (status === 408) {
105
+ return new SkalpelTimeoutError(message);
106
+ }
76
107
  if (error.code === "ETIMEDOUT" || error.code === "TIMEOUT" || error.code === "UND_ERR_HEADERS_TIMEOUT") {
77
108
  return new SkalpelTimeoutError(message);
78
109
  }
79
110
  if (status && status >= 500) {
80
111
  return new SkalpelUnavailableError(message, status);
81
112
  }
113
+ if (status && TRULY_CLIENT_4XX.has(status)) {
114
+ return new SkalpelClientRequestError(message, status);
115
+ }
116
+ if (status && status >= 400 && status < 500) {
117
+ return new SkalpelClientRequestError(message, status);
118
+ }
82
119
  return new SkalpelUnavailableError(message, status);
83
120
  }
84
121
  async function withFallback(primaryFn, fallbackFn, options = {}) {
@@ -92,6 +129,9 @@ async function withFallback(primaryFn, fallbackFn, options = {}) {
92
129
  if (lastError instanceof SkalpelAuthError) {
93
130
  throw lastError;
94
131
  }
132
+ if (lastError instanceof SkalpelClientRequestError) {
133
+ throw lastError;
134
+ }
95
135
  if (lastError instanceof SkalpelRateLimitError && lastError.retryAfter && attempt < retries) {
96
136
  await sleep(lastError.retryAfter * 1e3);
97
137
  continue;
@@ -118,6 +158,21 @@ async function withFallback(primaryFn, fallbackFn, options = {}) {
118
158
  var VERSION = "1.0.5";
119
159
 
120
160
  // src/client.ts
161
+ var AsyncMutex = class {
162
+ locked = false;
163
+ queue = [];
164
+ async acquire() {
165
+ if (this.locked) {
166
+ await new Promise((resolve) => this.queue.push(resolve));
167
+ }
168
+ this.locked = true;
169
+ return () => {
170
+ this.locked = false;
171
+ const next = this.queue.shift();
172
+ if (next) next();
173
+ };
174
+ }
175
+ };
121
176
  function resolveConfig(options) {
122
177
  return {
123
178
  apiKey: options.apiKey,
@@ -184,29 +239,33 @@ function wrapOpenAI(client, config) {
184
239
  const c = client;
185
240
  const skalpelHeaders = buildSkalpelHeaders(config);
186
241
  const originalBaseURL = c.baseURL;
242
+ const mutex = new AsyncMutex();
187
243
  function createMethodProxy(target, methodName) {
188
244
  const originalMethod = target[methodName];
189
245
  return async function(...args) {
190
246
  const primaryFn = async () => {
191
- c.baseURL = `${config.baseURL}/v1`;
192
- const requestArgs = args[0];
193
- const extraHeaders = skalpelHeaders;
194
- let callArgs;
195
- if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
196
- const opts = args[1];
197
- opts.headers = { ...extraHeaders, ...opts.headers };
198
- callArgs = [requestArgs, opts, ...args.slice(2)];
199
- } else {
200
- callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
201
- }
247
+ const release = await mutex.acquire();
202
248
  try {
203
- const result = await originalMethod.apply(target, callArgs);
204
- extractMetadataFromResponse(result, config);
205
- c.baseURL = originalBaseURL;
206
- return result;
207
- } catch (err) {
208
- c.baseURL = originalBaseURL;
209
- throw err;
249
+ c.baseURL = `${config.baseURL}/v1`;
250
+ const requestArgs = args[0];
251
+ const extraHeaders = skalpelHeaders;
252
+ let callArgs;
253
+ if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
254
+ const opts = args[1];
255
+ opts.headers = { ...extraHeaders, ...opts.headers };
256
+ callArgs = [requestArgs, opts, ...args.slice(2)];
257
+ } else {
258
+ callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
259
+ }
260
+ try {
261
+ const result = await originalMethod.apply(target, callArgs);
262
+ extractMetadataFromResponse(result, config);
263
+ return result;
264
+ } finally {
265
+ c.baseURL = originalBaseURL;
266
+ }
267
+ } finally {
268
+ release();
210
269
  }
211
270
  };
212
271
  const fallbackFn = async () => {
@@ -250,33 +309,36 @@ function wrapAnthropic(client, config) {
250
309
  const c = client;
251
310
  const skalpelHeaders = buildSkalpelHeaders(config);
252
311
  const originalBaseURL = c.baseURL ?? c._client?.baseURL;
312
+ const mutex = new AsyncMutex();
253
313
  function createMethodProxy(target, methodName) {
254
314
  const originalMethod = target[methodName];
255
315
  return async function(...args) {
256
316
  const primaryFn = async () => {
257
- const proxyURL = config.baseURL;
258
- if ("baseURL" in c) c.baseURL = proxyURL;
259
- if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
260
- const requestArgs = args[0];
261
- const extraHeaders = skalpelHeaders;
262
- let callArgs;
263
- if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
264
- const opts = args[1];
265
- opts.headers = { ...extraHeaders, ...opts.headers };
266
- callArgs = [requestArgs, opts, ...args.slice(2)];
267
- } else {
268
- callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
269
- }
317
+ const release = await mutex.acquire();
270
318
  try {
271
- const result = await originalMethod.apply(target, callArgs);
272
- extractMetadataFromResponse(result, config);
273
- if ("baseURL" in c) c.baseURL = originalBaseURL;
274
- if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
275
- return result;
276
- } catch (err) {
277
- if ("baseURL" in c) c.baseURL = originalBaseURL;
278
- if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
279
- throw err;
319
+ const proxyURL = config.baseURL;
320
+ if ("baseURL" in c) c.baseURL = proxyURL;
321
+ if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
322
+ const requestArgs = args[0];
323
+ const extraHeaders = skalpelHeaders;
324
+ let callArgs;
325
+ if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
326
+ const opts = args[1];
327
+ opts.headers = { ...extraHeaders, ...opts.headers };
328
+ callArgs = [requestArgs, opts, ...args.slice(2)];
329
+ } else {
330
+ callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
331
+ }
332
+ try {
333
+ const result = await originalMethod.apply(target, callArgs);
334
+ extractMetadataFromResponse(result, config);
335
+ return result;
336
+ } finally {
337
+ if ("baseURL" in c) c.baseURL = originalBaseURL;
338
+ if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
339
+ }
340
+ } finally {
341
+ release();
280
342
  }
281
343
  };
282
344
  const fallbackFn = async () => {
@@ -338,7 +400,11 @@ async function contextRequest(config, path4, body) {
338
400
  });
339
401
  if (!response.ok) {
340
402
  const text = await response.text().catch(() => "");
341
- throw new Error(`Skalpel context API error ${response.status}: ${text}`);
403
+ const message = `Skalpel context API error ${response.status}: ${text}`;
404
+ if (response.status >= 400 && response.status < 500) {
405
+ throw new SkalpelClientRequestError(message, response.status);
406
+ }
407
+ throw new SkalpelUnavailableError(message, response.status);
342
408
  }
343
409
  return await response.json();
344
410
  } finally {
@@ -417,6 +483,7 @@ async function resolveContext(options, params) {
417
483
  }
418
484
 
419
485
  // src/url-swap.ts
486
+ var SKALPEL_MANAGED_SENTINEL = "skalpel-managed";
420
487
  function buildHeaders(options) {
421
488
  const headers = {
422
489
  "X-Skalpel-SDK-Version": VERSION,
@@ -430,10 +497,14 @@ function buildHeaders(options) {
430
497
  async function createSkalpelOpenAI(options) {
431
498
  const { default: OpenAI } = await import("openai");
432
499
  const baseURL = `${options.baseURL ?? "https://api.skalpel.ai"}/v1`;
500
+ const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
433
501
  return new OpenAI({
434
502
  baseURL,
435
- apiKey: options.apiKey,
436
- defaultHeaders: buildHeaders(options),
503
+ apiKey: sdkApiKey,
504
+ defaultHeaders: {
505
+ ...buildHeaders(options),
506
+ "Authorization": `Bearer ${options.apiKey}`
507
+ },
437
508
  timeout: options.timeout ?? 3e4,
438
509
  maxRetries: options.retries ?? 2
439
510
  });
@@ -441,9 +512,10 @@ async function createSkalpelOpenAI(options) {
441
512
  async function createSkalpelAnthropic(options) {
442
513
  const { default: Anthropic } = await import("@anthropic-ai/sdk");
443
514
  const baseURL = options.baseURL ?? "https://api.skalpel.ai";
515
+ const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
444
516
  return new Anthropic({
445
517
  baseURL,
446
- apiKey: options.providerApiKey ?? options.apiKey,
518
+ apiKey: sdkApiKey,
447
519
  defaultHeaders: {
448
520
  ...buildHeaders(options),
449
521
  "Authorization": `Bearer ${options.apiKey}`
@@ -456,7 +528,163 @@ async function createSkalpelAnthropic(options) {
456
528
  // src/proxy/server.ts
457
529
  import http from "http";
458
530
 
531
+ // src/proxy/dispatcher.ts
532
+ import { Agent } from "undici";
533
+ var skalpelDispatcher = new Agent({
534
+ keepAliveTimeout: 1e4,
535
+ keepAliveMaxTimeout: 6e4,
536
+ connections: 100,
537
+ pipelining: 1
538
+ });
539
+
540
+ // src/proxy/envelope.ts
541
+ function isAnthropicShaped(body) {
542
+ if (typeof body !== "object" || body === null) return false;
543
+ const b = body;
544
+ if (b.type !== "error") return false;
545
+ if (typeof b.error !== "object" || b.error === null) return false;
546
+ return true;
547
+ }
548
+ function defaultErrorTypeFor(status) {
549
+ if (status === 400) return "invalid_request_error";
550
+ if (status === 401 || status === 403) return "authentication_error";
551
+ if (status === 404) return "not_found_error";
552
+ if (status === 408) return "timeout_error";
553
+ if (status === 429) return "rate_limit_error";
554
+ if (status >= 500) return "api_error";
555
+ if (status >= 400) return "invalid_request_error";
556
+ return "api_error";
557
+ }
558
+ function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
559
+ let parsed = upstreamBody;
560
+ if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
561
+ try {
562
+ parsed = JSON.parse(upstreamBody);
563
+ } catch {
564
+ parsed = upstreamBody;
565
+ }
566
+ }
567
+ let type = defaultErrorTypeFor(status);
568
+ let message;
569
+ if (isAnthropicShaped(parsed)) {
570
+ const inner = parsed.error;
571
+ if (typeof inner.type === "string" && inner.type.length > 0) {
572
+ type = inner.type;
573
+ }
574
+ message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
575
+ } else if (typeof parsed === "string" && parsed.length > 0) {
576
+ message = parsed;
577
+ } else {
578
+ message = defaultMessageForStatus(status);
579
+ }
580
+ const envelope = {
581
+ type: "error",
582
+ error: {
583
+ type,
584
+ message,
585
+ status_code: status,
586
+ origin
587
+ }
588
+ };
589
+ if (hint !== void 0) envelope.error.hint = hint;
590
+ if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
591
+ return envelope;
592
+ }
593
+ function defaultMessageForStatus(status) {
594
+ if (status === 401) return "Authentication failed";
595
+ if (status === 403) return "Forbidden";
596
+ if (status === 404) return "Not found";
597
+ if (status === 408) return "Request timed out";
598
+ if (status === 429) return "Rate limit exceeded";
599
+ if (status === 502) return "Bad gateway";
600
+ if (status === 503) return "Service unavailable";
601
+ if (status === 504) return "Gateway timeout";
602
+ if (status >= 500) return "Upstream error";
603
+ if (status >= 400) return "Client error";
604
+ return "Error";
605
+ }
606
+
607
+ // src/proxy/recovery.ts
608
+ import { createHash } from "crypto";
609
+ function parseRetryAfterHeader(header) {
610
+ if (!header) return void 0;
611
+ const trimmed = header.trim();
612
+ if (!trimmed) return void 0;
613
+ const n = Number(trimmed);
614
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
615
+ const dateMs = Date.parse(trimmed);
616
+ if (Number.isFinite(dateMs)) {
617
+ return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
618
+ }
619
+ return void 0;
620
+ }
621
+ function sleep2(ms) {
622
+ return new Promise((resolve) => setTimeout(resolve, ms));
623
+ }
624
+ var MAX_RETRY_AFTER_SECONDS = 60;
625
+ var DEFAULT_BACKOFF_SECONDS = 2;
626
+ async function handle429WithRetryAfter(response, retryFn, logger) {
627
+ const headerVal = response.headers.get("retry-after");
628
+ const parsed = parseRetryAfterHeader(headerVal);
629
+ if (parsed === void 0) {
630
+ await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
631
+ const retried2 = await retryFn();
632
+ logger.info("proxy.recovery.429_retry_count increment");
633
+ return retried2;
634
+ }
635
+ if (parsed > MAX_RETRY_AFTER_SECONDS) {
636
+ return response;
637
+ }
638
+ await sleep2(parsed * 1e3);
639
+ const retried = await retryFn();
640
+ logger.info("proxy.recovery.429_retry_count increment");
641
+ return retried;
642
+ }
643
+ var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
644
+ async function handleTimeoutWithRetry(err, retryFn, logger) {
645
+ const code = err.code;
646
+ if (!code || !TIMEOUT_CODES.has(code)) {
647
+ throw err;
648
+ }
649
+ await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
650
+ const retried = await retryFn();
651
+ logger.info("proxy.recovery.timeout_retry_count increment");
652
+ return retried;
653
+ }
654
+ function tokenFingerprint(authHeader) {
655
+ if (authHeader === void 0) return "none";
656
+ return createHash("sha256").update(authHeader).digest("hex").slice(0, 12);
657
+ }
658
+ var MUTEX_MAX_ENTRIES = 1024;
659
+ var LruMutexMap = class extends Map {
660
+ set(key, value) {
661
+ if (this.has(key)) {
662
+ super.delete(key);
663
+ } else if (this.size >= MUTEX_MAX_ENTRIES) {
664
+ const oldest = this.keys().next().value;
665
+ if (oldest !== void 0) super.delete(oldest);
666
+ }
667
+ return super.set(key, value);
668
+ }
669
+ };
670
+ var refreshMutex = new LruMutexMap();
671
+
459
672
  // src/proxy/streaming.ts
673
+ var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
674
+ var HTTP_BAD_GATEWAY = 502;
675
+ function parseRetryAfter(header) {
676
+ if (!header) return void 0;
677
+ const trimmed = header.trim();
678
+ if (!trimmed) return void 0;
679
+ const n = Number(trimmed);
680
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
681
+ const dateMs = Date.parse(trimmed);
682
+ if (Number.isFinite(dateMs)) {
683
+ const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
684
+ return delta;
685
+ }
686
+ return void 0;
687
+ }
460
688
  var HOP_BY_HOP = /* @__PURE__ */ new Set([
461
689
  "connection",
462
690
  "keep-alive",
@@ -478,17 +706,11 @@ function stripSkalpelHeaders(headers) {
478
706
  delete cleaned["X-Skalpel-Source"];
479
707
  delete cleaned["X-Skalpel-Agent-Type"];
480
708
  delete cleaned["X-Skalpel-SDK-Version"];
709
+ delete cleaned["X-Skalpel-Auth-Mode"];
481
710
  return cleaned;
482
711
  }
483
- function isSkalpelBackendFailure(response, err) {
484
- if (err) return true;
485
- if (!response) return true;
486
- if (response.status >= 500) return true;
487
- if (response.status === 403) return true;
488
- return false;
489
- }
490
712
  async function doStreamingFetch(url, body, headers) {
491
- return fetch(url, { method: "POST", headers, body });
713
+ return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
492
714
  }
493
715
  async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
494
716
  let response = null;
@@ -500,7 +722,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
500
722
  } catch (err) {
501
723
  fetchError = err;
502
724
  }
503
- if (isSkalpelBackendFailure(response, fetchError)) {
725
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
504
726
  logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
505
727
  usedFallback = true;
506
728
  response = null;
@@ -519,15 +741,40 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
519
741
  fetchError = err;
520
742
  }
521
743
  }
744
+ const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
745
+ const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
746
+ if (fetchError) {
747
+ const code = fetchError.code;
748
+ if (code && TIMEOUT_CODES2.has(code)) {
749
+ try {
750
+ response = await handleTimeoutWithRetry(
751
+ fetchError,
752
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
753
+ logger
754
+ );
755
+ fetchError = null;
756
+ } catch (retryErr) {
757
+ fetchError = retryErr;
758
+ }
759
+ }
760
+ }
761
+ if (response && response.status === 429) {
762
+ response = await handle429WithRetryAfter(
763
+ response,
764
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
765
+ logger
766
+ );
767
+ }
522
768
  if (!response || fetchError) {
523
769
  const errMsg = fetchError ? fetchError.message : "no response from upstream";
524
770
  logger.error(`streaming fetch failed: ${errMsg}`);
525
- res.writeHead(502, {
771
+ res.writeHead(HTTP_BAD_GATEWAY, {
526
772
  "Content-Type": "text/event-stream",
527
773
  "Cache-Control": "no-cache"
528
774
  });
775
+ const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
529
776
  res.write(`event: error
530
- data: ${JSON.stringify({ error: errMsg })}
777
+ data: ${JSON.stringify(envelope)}
531
778
 
532
779
  `);
533
780
  res.end();
@@ -537,17 +784,39 @@ data: ${JSON.stringify({ error: errMsg })}
537
784
  logger.info("streaming: using direct Anthropic API fallback");
538
785
  }
539
786
  if (response.status >= 300) {
540
- const errorBody = Buffer.from(await response.arrayBuffer());
541
- logger.error(`streaming upstream error: status=${response.status} body=${errorBody.toString().slice(0, 500)}`);
542
- const passthroughHeaders = {};
543
- for (const [key, value] of response.headers.entries()) {
544
- if (!STRIP_HEADERS.has(key)) {
545
- passthroughHeaders[key] = value;
546
- }
787
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
788
+ const originHeader = response.headers.get("x-skalpel-origin");
789
+ let origin;
790
+ if (originHeader === "backend") origin = "skalpel-backend";
791
+ else if (originHeader === "provider") origin = "provider";
792
+ else origin = "provider";
793
+ let rawBody = "";
794
+ let bodyReadFailed = false;
795
+ try {
796
+ rawBody = Buffer.from(await response.arrayBuffer()).toString();
797
+ } catch (readErr) {
798
+ bodyReadFailed = true;
799
+ logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
800
+ }
801
+ if (!bodyReadFailed) {
802
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
547
803
  }
548
- passthroughHeaders["content-length"] = String(errorBody.length);
549
- res.writeHead(response.status, passthroughHeaders);
550
- res.end(errorBody);
804
+ const envelope = bodyReadFailed ? buildErrorEnvelope(
805
+ response.status,
806
+ "",
807
+ "skalpel-proxy",
808
+ "mid-stream abort",
809
+ retryAfter
810
+ ) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
811
+ res.writeHead(response.status, {
812
+ "Content-Type": "text/event-stream",
813
+ "Cache-Control": "no-cache"
814
+ });
815
+ res.write(`event: error
816
+ data: ${JSON.stringify(envelope)}
817
+
818
+ `);
819
+ res.end();
551
820
  return;
552
821
  }
553
822
  const sseHeaders = {};
@@ -578,8 +847,16 @@ data: ${JSON.stringify({ error: "no response body" })}
578
847
  }
579
848
  } catch (err) {
580
849
  logger.error(`streaming error: ${err.message}`);
850
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
851
+ const envelope = buildErrorEnvelope(
852
+ response.status,
853
+ err.message,
854
+ "skalpel-proxy",
855
+ "mid-stream abort",
856
+ retryAfter
857
+ );
581
858
  res.write(`event: error
582
- data: ${JSON.stringify({ error: err.message })}
859
+ data: ${JSON.stringify(envelope)}
583
860
 
584
861
  `);
585
862
  }
@@ -587,6 +864,8 @@ data: ${JSON.stringify({ error: err.message })}
587
864
  }
588
865
 
589
866
  // src/proxy/handler.ts
867
+ var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
868
+ var HTTP_BAD_GATEWAY2 = 502;
590
869
  var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
591
870
  function collectBody(req) {
592
871
  return new Promise((resolve, reject) => {
@@ -597,38 +876,71 @@ function collectBody(req) {
597
876
  });
598
877
  }
599
878
  function shouldRouteToSkalpel(path4, source) {
600
- if (isPassthroughMode()) return false;
601
879
  if (source !== "claude-code") return true;
602
880
  const pathname = path4.split("?")[0];
603
881
  return SKALPEL_EXACT_PATHS.has(pathname);
604
882
  }
605
- function isSkalpelBackendFailure2(response, err) {
883
+ async function isSkalpelBackendFailure(response, err, logger) {
606
884
  if (err) return true;
607
885
  if (!response) return true;
608
- if (response.status >= 500) return true;
609
- if (response.status === 403) return true;
610
- return false;
886
+ if (response.status < 500) return false;
887
+ const origin = response.headers?.get("x-skalpel-origin");
888
+ if (origin === "provider") return false;
889
+ if (origin === "backend") return true;
890
+ try {
891
+ const text = await response.clone().text();
892
+ if (!text) {
893
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
894
+ return true;
895
+ }
896
+ let shape = "non-anthropic";
897
+ try {
898
+ const parsed = JSON.parse(text);
899
+ if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
900
+ shape = "anthropic";
901
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
902
+ return false;
903
+ }
904
+ } catch {
905
+ }
906
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
907
+ return true;
908
+ } catch {
909
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
910
+ return true;
911
+ }
611
912
  }
913
+ var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
914
+ "host",
915
+ "connection",
916
+ "keep-alive",
917
+ "proxy-authenticate",
918
+ "proxy-authorization",
919
+ "te",
920
+ "trailer",
921
+ "transfer-encoding",
922
+ "upgrade"
923
+ ]);
612
924
  function buildForwardHeaders(req, config, source, useSkalpel) {
613
925
  const forwardHeaders = {};
614
926
  for (const [key, value] of Object.entries(req.headers)) {
615
- if (value !== void 0) {
616
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
617
- }
927
+ if (value === void 0) continue;
928
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
929
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
618
930
  }
619
- delete forwardHeaders["host"];
620
- delete forwardHeaders["connection"];
621
931
  if (useSkalpel) {
622
932
  forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
623
933
  forwardHeaders["X-Skalpel-Source"] = source;
624
934
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
625
935
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
936
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
626
937
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
627
938
  const authHeader = forwardHeaders["authorization"] ?? "";
628
939
  if (authHeader.toLowerCase().startsWith("bearer ")) {
629
940
  const token = authHeader.slice(7).trim();
630
941
  if (token.startsWith("sk-ant-")) {
631
942
  forwardHeaders["x-api-key"] = token;
943
+ delete forwardHeaders["authorization"];
632
944
  }
633
945
  }
634
946
  }
@@ -641,6 +953,7 @@ function stripSkalpelHeaders2(headers) {
641
953
  delete cleaned["X-Skalpel-Source"];
642
954
  delete cleaned["X-Skalpel-Agent-Type"];
643
955
  delete cleaned["X-Skalpel-SDK-Version"];
956
+ delete cleaned["X-Skalpel-Auth-Mode"];
644
957
  return cleaned;
645
958
  }
646
959
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -668,6 +981,11 @@ async function handleRequest(req, res, config, source, logger) {
668
981
  const start = Date.now();
669
982
  const method = req.method ?? "GET";
670
983
  const path4 = req.url ?? "/";
984
+ const fp = tokenFingerprint(
985
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
986
+ );
987
+ logger.info(`${source} ${method} ${path4} token=${fp}`);
988
+ let response = null;
671
989
  try {
672
990
  const body = await collectBody(req);
673
991
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -682,45 +1000,91 @@ async function handleRequest(req, res, config, source, logger) {
682
1000
  }
683
1001
  if (isStreaming) {
684
1002
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
685
- const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
1003
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
686
1004
  await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
687
1005
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
688
1006
  return;
689
1007
  }
690
1008
  const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
691
- const directUrl = `${config.anthropicDirectUrl}${path4}`;
1009
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
692
1010
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
693
- let response = null;
694
1011
  let fetchError = null;
695
1012
  let usedFallback = false;
696
1013
  if (useSkalpel) {
697
1014
  try {
698
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
1015
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
699
1016
  } catch (err) {
700
1017
  fetchError = err;
701
1018
  }
702
- if (isSkalpelBackendFailure2(response, fetchError)) {
1019
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
703
1020
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
704
1021
  usedFallback = true;
705
1022
  response = null;
706
1023
  fetchError = null;
707
1024
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
708
1025
  try {
709
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
1026
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
710
1027
  } catch (err) {
711
1028
  fetchError = err;
712
1029
  }
713
1030
  }
714
1031
  } else {
715
1032
  try {
716
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
1033
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
717
1034
  } catch (err) {
718
1035
  fetchError = err;
719
1036
  }
720
1037
  }
1038
+ const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
1039
+ const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
1040
+ if (fetchError) {
1041
+ const code = fetchError.code;
1042
+ if (code && TIMEOUT_CODES3.has(code)) {
1043
+ try {
1044
+ response = await handleTimeoutWithRetry(
1045
+ fetchError,
1046
+ () => fetch(fetchUrl, {
1047
+ method,
1048
+ headers: fetchHeaders,
1049
+ body: fetchBody,
1050
+ dispatcher: skalpelDispatcher
1051
+ }),
1052
+ logger
1053
+ );
1054
+ fetchError = null;
1055
+ } catch (retryErr) {
1056
+ fetchError = retryErr;
1057
+ }
1058
+ }
1059
+ }
721
1060
  if (!response || fetchError) {
1061
+ response = null;
722
1062
  throw fetchError ?? new Error("no response from upstream");
723
1063
  }
1064
+ if (response.status === 429) {
1065
+ response = await handle429WithRetryAfter(
1066
+ response,
1067
+ () => fetch(fetchUrl, {
1068
+ method,
1069
+ headers: fetchHeaders,
1070
+ body: fetchBody,
1071
+ dispatcher: skalpelDispatcher
1072
+ }),
1073
+ logger
1074
+ );
1075
+ }
1076
+ if (response.status === 401 && (source === "claude-code" || source === "codex")) {
1077
+ const fp2 = tokenFingerprint(
1078
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
1079
+ );
1080
+ logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
1081
+ const body401 = Buffer.from(await response.arrayBuffer());
1082
+ const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
1083
+ res.writeHead(401, { "Content-Type": "application/json" });
1084
+ res.end(JSON.stringify(envelope));
1085
+ logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
1086
+ return;
1087
+ }
724
1088
  const responseHeaders = extractResponseHeaders(response);
725
1089
  const responseBody = Buffer.from(await response.arrayBuffer());
726
1090
  responseHeaders["content-length"] = String(responseBody.length);
@@ -730,21 +1094,38 @@ async function handleRequest(req, res, config, source, logger) {
730
1094
  } catch (err) {
731
1095
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
732
1096
  if (!res.headersSent) {
733
- res.writeHead(502, { "Content-Type": "application/json" });
734
- res.end(JSON.stringify({ error: "proxy_error", message: err.message }));
1097
+ if (response !== null) {
1098
+ const upstreamStatus = response.status;
1099
+ const envelope = buildErrorEnvelope(
1100
+ upstreamStatus,
1101
+ "",
1102
+ "skalpel-proxy",
1103
+ "body read failed after upstream status"
1104
+ );
1105
+ res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
1106
+ res.end(JSON.stringify(envelope));
1107
+ } else {
1108
+ const envelope = buildErrorEnvelope(
1109
+ HTTP_BAD_GATEWAY2,
1110
+ err.message,
1111
+ "skalpel-proxy"
1112
+ );
1113
+ res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
1114
+ res.end(JSON.stringify(envelope));
1115
+ }
735
1116
  }
736
1117
  }
737
1118
  }
738
1119
 
739
1120
  // src/proxy/health.ts
740
- function handleHealthRequest(res, config, startTime, passthrough = false) {
1121
+ function handleHealthRequest(res, config, startTime) {
741
1122
  const body = JSON.stringify({
742
1123
  status: "ok",
743
- mode: passthrough ? "passthrough" : "normal",
744
1124
  uptime: Date.now() - startTime,
745
1125
  ports: {
746
1126
  anthropic: config.anthropicPort,
747
- openai: config.openaiPort
1127
+ openai: config.openaiPort,
1128
+ cursor: config.cursorPort
748
1129
  },
749
1130
  version: "proxy-1.0.0"
750
1131
  });
@@ -755,13 +1136,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
755
1136
  // src/proxy/pid.ts
756
1137
  import fs from "fs";
757
1138
  import path from "path";
1139
+ import { execSync } from "child_process";
758
1140
  function writePid(pidFile) {
759
1141
  fs.mkdirSync(path.dirname(pidFile), { recursive: true });
760
- fs.writeFileSync(pidFile, String(process.pid));
1142
+ const record = {
1143
+ pid: process.pid,
1144
+ startTime: getStartTime(process.pid)
1145
+ };
1146
+ fs.writeFileSync(pidFile, JSON.stringify(record));
761
1147
  }
762
1148
  function readPid(pidFile) {
763
1149
  try {
764
1150
  const raw = fs.readFileSync(pidFile, "utf-8").trim();
1151
+ try {
1152
+ const parsed = JSON.parse(raw);
1153
+ if (parsed && typeof parsed === "object" && typeof parsed.pid === "number" && !isNaN(parsed.pid)) {
1154
+ const record = parsed;
1155
+ if (record.startTime == null) {
1156
+ return isRunning(record.pid) ? record.pid : null;
1157
+ }
1158
+ return isRunningWithIdentity(record.pid, record.startTime) ? record.pid : null;
1159
+ }
1160
+ } catch {
1161
+ }
765
1162
  const pid = parseInt(raw, 10);
766
1163
  if (isNaN(pid)) return null;
767
1164
  return isRunning(pid) ? pid : null;
@@ -777,6 +1174,37 @@ function isRunning(pid) {
777
1174
  return false;
778
1175
  }
779
1176
  }
1177
+ function getStartTime(pid) {
1178
+ try {
1179
+ if (process.platform === "linux") {
1180
+ const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
1181
+ const rparen = stat.lastIndexOf(")");
1182
+ if (rparen < 0) return null;
1183
+ const fields = stat.slice(rparen + 2).split(" ");
1184
+ return fields[19] ?? null;
1185
+ }
1186
+ if (process.platform === "darwin") {
1187
+ const out = execSync(`ps -p ${pid} -o lstart=`, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] });
1188
+ const text = out.toString().trim();
1189
+ return text || null;
1190
+ }
1191
+ return null;
1192
+ } catch {
1193
+ return null;
1194
+ }
1195
+ }
1196
+ function isRunningWithIdentity(pid, expectedStartTime) {
1197
+ try {
1198
+ if (process.platform !== "linux" && process.platform !== "darwin") {
1199
+ return isRunning(pid);
1200
+ }
1201
+ const current = getStartTime(pid);
1202
+ if (current == null) return false;
1203
+ return current === expectedStartTime;
1204
+ } catch {
1205
+ return false;
1206
+ }
1207
+ }
780
1208
  function removePid(pidFile) {
781
1209
  try {
782
1210
  fs.unlinkSync(pidFile);
@@ -790,12 +1218,14 @@ import path2 from "path";
790
1218
  var MAX_SIZE = 5 * 1024 * 1024;
791
1219
  var MAX_ROTATIONS = 3;
792
1220
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
793
- var Logger = class {
1221
+ var Logger = class _Logger {
794
1222
  logFile;
795
1223
  level;
796
- constructor(logFile, level = "info") {
1224
+ prefix;
1225
+ constructor(logFile, level = "info", prefix = "") {
797
1226
  this.logFile = logFile;
798
1227
  this.level = level;
1228
+ this.prefix = prefix;
799
1229
  fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
800
1230
  }
801
1231
  debug(msg) {
@@ -810,9 +1240,16 @@ var Logger = class {
810
1240
  error(msg) {
811
1241
  this.log("error", msg);
812
1242
  }
1243
+ /** Returns a new Logger that writes to the same file but prefixes every
1244
+ * emitted line with `[conn=<connId>] `. The parent logger continues to
1245
+ * work unchanged. IPv6 colons should already be sanitized by the caller. */
1246
+ child(connId) {
1247
+ const child = new _Logger(this.logFile, this.level, `[conn=${connId}] `);
1248
+ return child;
1249
+ }
813
1250
  log(level, msg) {
814
1251
  if (LEVELS[level] < LEVELS[this.level]) return;
815
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
1252
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
816
1253
  `;
817
1254
  if (level === "debug" || level === "error") {
818
1255
  process.stderr.write(line);
@@ -843,61 +1280,41 @@ var Logger = class {
843
1280
 
844
1281
  // src/proxy/server.ts
845
1282
  var proxyStartTime = 0;
846
- var passthroughMode = false;
847
- function isPassthroughMode() {
848
- return passthroughMode;
849
- }
850
- function collectAdminBody(req) {
851
- return new Promise((resolve, reject) => {
852
- const chunks = [];
853
- req.on("data", (chunk) => chunks.push(chunk));
854
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
855
- req.on("error", reject);
856
- });
857
- }
858
- function handleAdminMode(req, res, logger) {
859
- collectAdminBody(req).then((body) => {
860
- try {
861
- const { mode } = JSON.parse(body);
862
- passthroughMode = mode === "passthrough";
863
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
864
- res.writeHead(200, { "Content-Type": "application/json" });
865
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
866
- } catch {
867
- res.writeHead(400, { "Content-Type": "application/json" });
868
- res.end(JSON.stringify({ error: "invalid JSON body" }));
869
- }
870
- }).catch(() => {
871
- res.writeHead(500, { "Content-Type": "application/json" });
872
- res.end(JSON.stringify({ error: "failed to read body" }));
873
- });
1283
+ var connCounter = 0;
1284
+ function computeConnId(req) {
1285
+ const addr = req.socket.remoteAddress ?? "unknown";
1286
+ const port = req.socket.remotePort ?? 0;
1287
+ const counter = (++connCounter).toString(36);
1288
+ const raw = addr + "|" + port + "|" + Date.now().toString(36) + "|" + counter + "|" + Math.floor(Math.random() * 4096).toString(16);
1289
+ return raw.replace(/:/g, "_");
874
1290
  }
875
1291
  function startProxy(config) {
876
1292
  const logger = new Logger(config.logFile, config.logLevel);
877
1293
  const startTime = Date.now();
878
1294
  proxyStartTime = Date.now();
879
- passthroughMode = false;
880
1295
  const anthropicServer = http.createServer((req, res) => {
881
1296
  if (req.url === "/health" && req.method === "GET") {
882
- handleHealthRequest(res, config, startTime, isPassthroughMode());
883
- return;
884
- }
885
- if (req.url === "/admin/mode" && req.method === "POST") {
886
- handleAdminMode(req, res, logger);
1297
+ handleHealthRequest(res, config, startTime);
887
1298
  return;
888
1299
  }
889
- handleRequest(req, res, config, "claude-code", logger);
1300
+ const connId = computeConnId(req);
1301
+ handleRequest(req, res, config, "claude-code", logger.child(connId));
890
1302
  });
891
1303
  const openaiServer = http.createServer((req, res) => {
892
1304
  if (req.url === "/health" && req.method === "GET") {
893
- handleHealthRequest(res, config, startTime, isPassthroughMode());
1305
+ handleHealthRequest(res, config, startTime);
894
1306
  return;
895
1307
  }
896
- if (req.url === "/admin/mode" && req.method === "POST") {
897
- handleAdminMode(req, res, logger);
1308
+ const connId = computeConnId(req);
1309
+ handleRequest(req, res, config, "codex", logger.child(connId));
1310
+ });
1311
+ const cursorServer = http.createServer((req, res) => {
1312
+ if (req.url === "/health" && req.method === "GET") {
1313
+ handleHealthRequest(res, config, startTime);
898
1314
  return;
899
1315
  }
900
- handleRequest(req, res, config, "codex", logger);
1316
+ const connId = computeConnId(req);
1317
+ handleRequest(req, res, config, "cursor", logger.child(connId));
901
1318
  });
902
1319
  anthropicServer.on("error", (err) => {
903
1320
  if (err.code === "EADDRINUSE") {
@@ -917,18 +1334,31 @@ function startProxy(config) {
917
1334
  removePid(config.pidFile);
918
1335
  process.exit(1);
919
1336
  });
1337
+ cursorServer.on("error", (err) => {
1338
+ if (err.code === "EADDRINUSE") {
1339
+ logger.error(`Port ${config.cursorPort} is already in use. Another Skalpel proxy or process may be running.`);
1340
+ } else {
1341
+ logger.error(`Cursor proxy failed to bind port ${config.cursorPort}: ${err.message}`);
1342
+ }
1343
+ removePid(config.pidFile);
1344
+ process.exit(1);
1345
+ });
920
1346
  anthropicServer.listen(config.anthropicPort, () => {
921
1347
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
922
1348
  });
923
1349
  openaiServer.listen(config.openaiPort, () => {
924
1350
  logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
925
1351
  });
1352
+ cursorServer.listen(config.cursorPort, () => {
1353
+ logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
1354
+ });
926
1355
  writePid(config.pidFile);
927
- logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort}`);
1356
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
928
1357
  const cleanup = () => {
929
1358
  logger.info("Shutting down proxy...");
930
1359
  anthropicServer.close();
931
1360
  openaiServer.close();
1361
+ cursorServer.close();
932
1362
  removePid(config.pidFile);
933
1363
  process.exit(0);
934
1364
  };
@@ -944,7 +1374,7 @@ function startProxy(config) {
944
1374
  removePid(config.pidFile);
945
1375
  process.exit(1);
946
1376
  });
947
- return { anthropicServer, openaiServer };
1377
+ return { anthropicServer, openaiServer, cursorServer };
948
1378
  }
949
1379
  function stopProxy(config) {
950
1380
  const pid = readPid(config.pidFile);
@@ -963,7 +1393,8 @@ function getProxyStatus(config) {
963
1393
  pid,
964
1394
  uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
965
1395
  anthropicPort: config.anthropicPort,
966
- openaiPort: config.openaiPort
1396
+ openaiPort: config.openaiPort,
1397
+ cursorPort: config.cursorPort
967
1398
  };
968
1399
  }
969
1400
 
@@ -981,13 +1412,20 @@ var DEFAULTS = {
981
1412
  apiKey: "",
982
1413
  remoteBaseUrl: "https://api.skalpel.ai",
983
1414
  anthropicDirectUrl: "https://api.anthropic.com",
1415
+ openaiDirectUrl: "https://api.openai.com",
984
1416
  anthropicPort: 18100,
985
1417
  openaiPort: 18101,
1418
+ cursorPort: 18102,
1419
+ cursorDirectUrl: "https://api.openai.com",
986
1420
  logLevel: "info",
987
1421
  logFile: "~/.skalpel/logs/proxy.log",
988
1422
  pidFile: "~/.skalpel/proxy.pid",
989
- configFile: "~/.skalpel/config.json"
1423
+ configFile: "~/.skalpel/config.json",
1424
+ mode: "proxy"
990
1425
  };
1426
+ function coerceMode(value) {
1427
+ return value === "direct" ? "direct" : "proxy";
1428
+ }
991
1429
  function loadConfig(configPath) {
992
1430
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
993
1431
  let fileConfig = {};
@@ -1000,18 +1438,27 @@ function loadConfig(configPath) {
1000
1438
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
1001
1439
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
1002
1440
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
1441
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
1003
1442
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
1004
1443
  openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
1444
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
1445
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
1005
1446
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
1006
1447
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
1007
1448
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
1008
- configFile: filePath
1449
+ configFile: filePath,
1450
+ mode: coerceMode(fileConfig.mode)
1009
1451
  };
1010
1452
  }
1011
1453
  function saveConfig(config) {
1012
1454
  const dir = path3.dirname(config.configFile);
1013
1455
  fs3.mkdirSync(dir, { recursive: true });
1014
- fs3.writeFileSync(config.configFile, JSON.stringify(config, null, 2) + "\n");
1456
+ const { mode, ...rest } = config;
1457
+ const serializable = { ...rest };
1458
+ if (mode === "direct") {
1459
+ serializable.mode = mode;
1460
+ }
1461
+ fs3.writeFileSync(config.configFile, JSON.stringify(serializable, null, 2) + "\n");
1015
1462
  }
1016
1463
  export {
1017
1464
  SkalpelAuthError,