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.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}`);
547
800
  }
548
- passthroughHeaders["content-length"] = String(errorBody.length);
549
- res.writeHead(response.status, passthroughHeaders);
550
- res.end(errorBody);
801
+ if (!bodyReadFailed) {
802
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
803
+ }
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) => {
@@ -596,38 +875,72 @@ function collectBody(req) {
596
875
  req.on("error", reject);
597
876
  });
598
877
  }
599
- function shouldRouteToSkalpel(path4, _source) {
600
- if (isPassthroughMode()) return false;
878
+ function shouldRouteToSkalpel(path4, source) {
879
+ if (source !== "claude-code") return true;
601
880
  const pathname = path4.split("?")[0];
602
881
  return SKALPEL_EXACT_PATHS.has(pathname);
603
882
  }
604
- function isSkalpelBackendFailure2(response, err) {
883
+ async function isSkalpelBackendFailure(response, err, logger) {
605
884
  if (err) return true;
606
885
  if (!response) return true;
607
- if (response.status >= 500) return true;
608
- if (response.status === 403) return true;
609
- 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
+ }
610
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
+ ]);
611
924
  function buildForwardHeaders(req, config, source, useSkalpel) {
612
925
  const forwardHeaders = {};
613
926
  for (const [key, value] of Object.entries(req.headers)) {
614
- if (value !== void 0) {
615
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
616
- }
927
+ if (value === void 0) continue;
928
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
929
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
617
930
  }
618
- delete forwardHeaders["host"];
619
- delete forwardHeaders["connection"];
620
931
  if (useSkalpel) {
621
932
  forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
622
933
  forwardHeaders["X-Skalpel-Source"] = source;
623
934
  forwardHeaders["X-Skalpel-Agent-Type"] = source;
624
935
  forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
936
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
625
937
  if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
626
938
  const authHeader = forwardHeaders["authorization"] ?? "";
627
939
  if (authHeader.toLowerCase().startsWith("bearer ")) {
628
940
  const token = authHeader.slice(7).trim();
629
941
  if (token.startsWith("sk-ant-")) {
630
942
  forwardHeaders["x-api-key"] = token;
943
+ delete forwardHeaders["authorization"];
631
944
  }
632
945
  }
633
946
  }
@@ -640,6 +953,7 @@ function stripSkalpelHeaders2(headers) {
640
953
  delete cleaned["X-Skalpel-Source"];
641
954
  delete cleaned["X-Skalpel-Agent-Type"];
642
955
  delete cleaned["X-Skalpel-SDK-Version"];
956
+ delete cleaned["X-Skalpel-Auth-Mode"];
643
957
  return cleaned;
644
958
  }
645
959
  var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
@@ -667,6 +981,11 @@ async function handleRequest(req, res, config, source, logger) {
667
981
  const start = Date.now();
668
982
  const method = req.method ?? "GET";
669
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;
670
989
  try {
671
990
  const body = await collectBody(req);
672
991
  const useSkalpel = shouldRouteToSkalpel(path4, source);
@@ -681,45 +1000,91 @@ async function handleRequest(req, res, config, source, logger) {
681
1000
  }
682
1001
  if (isStreaming) {
683
1002
  const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
684
- const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
1003
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
685
1004
  await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
686
1005
  logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
687
1006
  return;
688
1007
  }
689
1008
  const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
690
- const directUrl = `${config.anthropicDirectUrl}${path4}`;
1009
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
691
1010
  const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
692
- let response = null;
693
1011
  let fetchError = null;
694
1012
  let usedFallback = false;
695
1013
  if (useSkalpel) {
696
1014
  try {
697
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
1015
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
698
1016
  } catch (err) {
699
1017
  fetchError = err;
700
1018
  }
701
- if (isSkalpelBackendFailure2(response, fetchError)) {
1019
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
702
1020
  logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
703
1021
  usedFallback = true;
704
1022
  response = null;
705
1023
  fetchError = null;
706
1024
  const directHeaders = stripSkalpelHeaders2(forwardHeaders);
707
1025
  try {
708
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
1026
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
709
1027
  } catch (err) {
710
1028
  fetchError = err;
711
1029
  }
712
1030
  }
713
1031
  } else {
714
1032
  try {
715
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
1033
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
716
1034
  } catch (err) {
717
1035
  fetchError = err;
718
1036
  }
719
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
+ }
720
1060
  if (!response || fetchError) {
1061
+ response = null;
721
1062
  throw fetchError ?? new Error("no response from upstream");
722
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
+ }
723
1088
  const responseHeaders = extractResponseHeaders(response);
724
1089
  const responseBody = Buffer.from(await response.arrayBuffer());
725
1090
  responseHeaders["content-length"] = String(responseBody.length);
@@ -729,20 +1094,38 @@ async function handleRequest(req, res, config, source, logger) {
729
1094
  } catch (err) {
730
1095
  logger.error(`${method} ${path4} source=${source} error=${err.message}`);
731
1096
  if (!res.headersSent) {
732
- res.writeHead(502, { "Content-Type": "application/json" });
733
- 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
+ }
734
1116
  }
735
1117
  }
736
1118
  }
737
1119
 
738
1120
  // src/proxy/health.ts
739
- function handleHealthRequest(res, config, startTime, passthrough = false) {
1121
+ function handleHealthRequest(res, config, startTime) {
740
1122
  const body = JSON.stringify({
741
1123
  status: "ok",
742
- mode: passthrough ? "passthrough" : "normal",
743
1124
  uptime: Date.now() - startTime,
744
1125
  ports: {
745
- anthropic: config.anthropicPort
1126
+ anthropic: config.anthropicPort,
1127
+ openai: config.openaiPort,
1128
+ cursor: config.cursorPort
746
1129
  },
747
1130
  version: "proxy-1.0.0"
748
1131
  });
@@ -753,13 +1136,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
753
1136
  // src/proxy/pid.ts
754
1137
  import fs from "fs";
755
1138
  import path from "path";
1139
+ import { execSync } from "child_process";
756
1140
  function writePid(pidFile) {
757
1141
  fs.mkdirSync(path.dirname(pidFile), { recursive: true });
758
- 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));
759
1147
  }
760
1148
  function readPid(pidFile) {
761
1149
  try {
762
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
+ }
763
1162
  const pid = parseInt(raw, 10);
764
1163
  if (isNaN(pid)) return null;
765
1164
  return isRunning(pid) ? pid : null;
@@ -775,6 +1174,37 @@ function isRunning(pid) {
775
1174
  return false;
776
1175
  }
777
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
+ }
778
1208
  function removePid(pidFile) {
779
1209
  try {
780
1210
  fs.unlinkSync(pidFile);
@@ -788,12 +1218,14 @@ import path2 from "path";
788
1218
  var MAX_SIZE = 5 * 1024 * 1024;
789
1219
  var MAX_ROTATIONS = 3;
790
1220
  var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
791
- var Logger = class {
1221
+ var Logger = class _Logger {
792
1222
  logFile;
793
1223
  level;
794
- constructor(logFile, level = "info") {
1224
+ prefix;
1225
+ constructor(logFile, level = "info", prefix = "") {
795
1226
  this.logFile = logFile;
796
1227
  this.level = level;
1228
+ this.prefix = prefix;
797
1229
  fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
798
1230
  }
799
1231
  debug(msg) {
@@ -808,9 +1240,16 @@ var Logger = class {
808
1240
  error(msg) {
809
1241
  this.log("error", msg);
810
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
+ }
811
1250
  log(level, msg) {
812
1251
  if (LEVELS[level] < LEVELS[this.level]) return;
813
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
1252
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
814
1253
  `;
815
1254
  if (level === "debug" || level === "error") {
816
1255
  process.stderr.write(line);
@@ -841,50 +1280,41 @@ var Logger = class {
841
1280
 
842
1281
  // src/proxy/server.ts
843
1282
  var proxyStartTime = 0;
844
- var passthroughMode = false;
845
- function isPassthroughMode() {
846
- return passthroughMode;
847
- }
848
- function collectAdminBody(req) {
849
- return new Promise((resolve, reject) => {
850
- const chunks = [];
851
- req.on("data", (chunk) => chunks.push(chunk));
852
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
853
- req.on("error", reject);
854
- });
855
- }
856
- function handleAdminMode(req, res, logger) {
857
- collectAdminBody(req).then((body) => {
858
- try {
859
- const { mode } = JSON.parse(body);
860
- passthroughMode = mode === "passthrough";
861
- logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
862
- res.writeHead(200, { "Content-Type": "application/json" });
863
- res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
864
- } catch {
865
- res.writeHead(400, { "Content-Type": "application/json" });
866
- res.end(JSON.stringify({ error: "invalid JSON body" }));
867
- }
868
- }).catch(() => {
869
- res.writeHead(500, { "Content-Type": "application/json" });
870
- res.end(JSON.stringify({ error: "failed to read body" }));
871
- });
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, "_");
872
1290
  }
873
1291
  function startProxy(config) {
874
1292
  const logger = new Logger(config.logFile, config.logLevel);
875
1293
  const startTime = Date.now();
876
1294
  proxyStartTime = Date.now();
877
- passthroughMode = false;
878
1295
  const anthropicServer = http.createServer((req, res) => {
879
1296
  if (req.url === "/health" && req.method === "GET") {
880
- handleHealthRequest(res, config, startTime, isPassthroughMode());
1297
+ handleHealthRequest(res, config, startTime);
881
1298
  return;
882
1299
  }
883
- if (req.url === "/admin/mode" && req.method === "POST") {
884
- handleAdminMode(req, res, logger);
1300
+ const connId = computeConnId(req);
1301
+ handleRequest(req, res, config, "claude-code", logger.child(connId));
1302
+ });
1303
+ const openaiServer = http.createServer((req, res) => {
1304
+ if (req.url === "/health" && req.method === "GET") {
1305
+ handleHealthRequest(res, config, startTime);
885
1306
  return;
886
1307
  }
887
- handleRequest(req, res, config, "claude-code", 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);
1314
+ return;
1315
+ }
1316
+ const connId = computeConnId(req);
1317
+ handleRequest(req, res, config, "cursor", logger.child(connId));
888
1318
  });
889
1319
  anthropicServer.on("error", (err) => {
890
1320
  if (err.code === "EADDRINUSE") {
@@ -895,14 +1325,40 @@ function startProxy(config) {
895
1325
  removePid(config.pidFile);
896
1326
  process.exit(1);
897
1327
  });
1328
+ openaiServer.on("error", (err) => {
1329
+ if (err.code === "EADDRINUSE") {
1330
+ logger.error(`Port ${config.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
1331
+ } else {
1332
+ logger.error(`OpenAI proxy failed to bind port ${config.openaiPort}: ${err.message}`);
1333
+ }
1334
+ removePid(config.pidFile);
1335
+ process.exit(1);
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
+ });
898
1346
  anthropicServer.listen(config.anthropicPort, () => {
899
1347
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
900
1348
  });
1349
+ openaiServer.listen(config.openaiPort, () => {
1350
+ logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
1351
+ });
1352
+ cursorServer.listen(config.cursorPort, () => {
1353
+ logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
1354
+ });
901
1355
  writePid(config.pidFile);
902
- logger.info(`Proxy started (pid=${process.pid}) port=${config.anthropicPort}`);
1356
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
903
1357
  const cleanup = () => {
904
1358
  logger.info("Shutting down proxy...");
905
1359
  anthropicServer.close();
1360
+ openaiServer.close();
1361
+ cursorServer.close();
906
1362
  removePid(config.pidFile);
907
1363
  process.exit(0);
908
1364
  };
@@ -918,7 +1374,7 @@ function startProxy(config) {
918
1374
  removePid(config.pidFile);
919
1375
  process.exit(1);
920
1376
  });
921
- return { anthropicServer };
1377
+ return { anthropicServer, openaiServer, cursorServer };
922
1378
  }
923
1379
  function stopProxy(config) {
924
1380
  const pid = readPid(config.pidFile);
@@ -936,7 +1392,9 @@ function getProxyStatus(config) {
936
1392
  running: pid !== null,
937
1393
  pid,
938
1394
  uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
939
- anthropicPort: config.anthropicPort
1395
+ anthropicPort: config.anthropicPort,
1396
+ openaiPort: config.openaiPort,
1397
+ cursorPort: config.cursorPort
940
1398
  };
941
1399
  }
942
1400
 
@@ -954,12 +1412,20 @@ var DEFAULTS = {
954
1412
  apiKey: "",
955
1413
  remoteBaseUrl: "https://api.skalpel.ai",
956
1414
  anthropicDirectUrl: "https://api.anthropic.com",
1415
+ openaiDirectUrl: "https://api.openai.com",
957
1416
  anthropicPort: 18100,
1417
+ openaiPort: 18101,
1418
+ cursorPort: 18102,
1419
+ cursorDirectUrl: "https://api.openai.com",
958
1420
  logLevel: "info",
959
1421
  logFile: "~/.skalpel/logs/proxy.log",
960
1422
  pidFile: "~/.skalpel/proxy.pid",
961
- configFile: "~/.skalpel/config.json"
1423
+ configFile: "~/.skalpel/config.json",
1424
+ mode: "proxy"
962
1425
  };
1426
+ function coerceMode(value) {
1427
+ return value === "direct" ? "direct" : "proxy";
1428
+ }
963
1429
  function loadConfig(configPath) {
964
1430
  const filePath = expandHome(configPath ?? DEFAULTS.configFile);
965
1431
  let fileConfig = {};
@@ -972,17 +1438,27 @@ function loadConfig(configPath) {
972
1438
  apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
973
1439
  remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
974
1440
  anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
1441
+ openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
975
1442
  anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
1443
+ openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
1444
+ cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
1445
+ cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
976
1446
  logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
977
1447
  logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
978
1448
  pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
979
- configFile: filePath
1449
+ configFile: filePath,
1450
+ mode: coerceMode(fileConfig.mode)
980
1451
  };
981
1452
  }
982
1453
  function saveConfig(config) {
983
1454
  const dir = path3.dirname(config.configFile);
984
1455
  fs3.mkdirSync(dir, { recursive: true });
985
- 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");
986
1462
  }
987
1463
  export {
988
1464
  SkalpelAuthError,