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/cli/index.js +547 -330
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/proxy-runner.js +437 -91
- package/dist/cli/proxy-runner.js.map +1 -1
- package/dist/index.cjs +584 -137
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +584 -137
- package/dist/index.js.map +1 -1
- package/dist/proxy/index.cjs +468 -93
- package/dist/proxy/index.cjs.map +1 -1
- package/dist/proxy/index.d.cts +7 -0
- package/dist/proxy/index.d.ts +7 -0
- package/dist/proxy/index.js +468 -93
- package/dist/proxy/index.js.map +1 -1
- package/package.json +6 -13
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
|
324
|
-
|
|
325
|
-
if ("baseURL" in c) c.baseURL =
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if ("
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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:
|
|
488
|
-
defaultHeaders:
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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(
|
|
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
|
|
935
|
+
async function isSkalpelBackendFailure(response, err, logger) {
|
|
658
936
|
if (err) return true;
|
|
659
937
|
if (!response) return true;
|
|
660
|
-
if (response.status
|
|
661
|
-
|
|
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
|
|
668
|
-
|
|
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 (
|
|
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
|
-
|
|
786
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
899
|
-
function
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1357
|
+
handleHealthRequest(res, config, startTime);
|
|
946
1358
|
return;
|
|
947
1359
|
}
|
|
948
|
-
|
|
949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|