skalpel 2.0.6 → 2.0.7
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 +91 -38
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/proxy-runner.js +195 -73
- package/dist/cli/proxy-runner.js.map +1 -1
- package/dist/index.cjs +195 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +195 -73
- package/dist/index.js.map +1 -1
- package/dist/proxy/index.cjs +195 -73
- package/dist/proxy/index.cjs.map +1 -1
- package/dist/proxy/index.js +195 -73
- package/dist/proxy/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -509,42 +509,84 @@ async function createSkalpelAnthropic(options) {
|
|
|
509
509
|
var import_node_http = __toESM(require("http"), 1);
|
|
510
510
|
|
|
511
511
|
// src/proxy/streaming.ts
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
512
|
+
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
513
|
+
"connection",
|
|
514
|
+
"keep-alive",
|
|
515
|
+
"proxy-authenticate",
|
|
516
|
+
"proxy-authorization",
|
|
517
|
+
"te",
|
|
518
|
+
"trailer",
|
|
519
|
+
"transfer-encoding",
|
|
520
|
+
"upgrade"
|
|
521
|
+
]);
|
|
522
|
+
var STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
523
|
+
...HOP_BY_HOP,
|
|
524
|
+
"content-encoding",
|
|
525
|
+
"content-length"
|
|
526
|
+
]);
|
|
527
|
+
function stripSkalpelHeaders(headers) {
|
|
528
|
+
const cleaned = { ...headers };
|
|
529
|
+
delete cleaned["X-Skalpel-API-Key"];
|
|
530
|
+
delete cleaned["X-Skalpel-Source"];
|
|
531
|
+
delete cleaned["X-Skalpel-Agent-Type"];
|
|
532
|
+
delete cleaned["X-Skalpel-SDK-Version"];
|
|
533
|
+
return cleaned;
|
|
534
|
+
}
|
|
535
|
+
function isSkalpelBackendFailure(response, err) {
|
|
536
|
+
if (err) return true;
|
|
537
|
+
if (!response) return true;
|
|
538
|
+
if (response.status >= 500) return true;
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
async function doStreamingFetch(url, body, headers) {
|
|
542
|
+
return fetch(url, { method: "POST", headers, body });
|
|
543
|
+
}
|
|
544
|
+
async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
|
|
545
|
+
let response = null;
|
|
546
|
+
let fetchError = null;
|
|
547
|
+
let usedFallback = false;
|
|
548
|
+
if (useSkalpel) {
|
|
549
|
+
try {
|
|
550
|
+
response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
|
|
551
|
+
} catch (err) {
|
|
552
|
+
fetchError = err;
|
|
553
|
+
}
|
|
554
|
+
if (isSkalpelBackendFailure(response, fetchError)) {
|
|
555
|
+
logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
556
|
+
usedFallback = true;
|
|
557
|
+
response = null;
|
|
558
|
+
fetchError = null;
|
|
559
|
+
const directHeaders = stripSkalpelHeaders(forwardHeaders);
|
|
560
|
+
try {
|
|
561
|
+
response = await doStreamingFetch(directUrl, body, directHeaders);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
fetchError = err;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
} else {
|
|
567
|
+
try {
|
|
568
|
+
response = await doStreamingFetch(directUrl, body, forwardHeaders);
|
|
569
|
+
} catch (err) {
|
|
570
|
+
fetchError = err;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (!response || fetchError) {
|
|
574
|
+
const errMsg = fetchError ? fetchError.message : "no response from upstream";
|
|
575
|
+
logger.error(`streaming fetch failed: ${errMsg}`);
|
|
532
576
|
res.writeHead(502, {
|
|
533
577
|
"Content-Type": "text/event-stream",
|
|
534
578
|
"Cache-Control": "no-cache"
|
|
535
579
|
});
|
|
536
580
|
res.write(`event: error
|
|
537
|
-
data: ${JSON.stringify({ error:
|
|
581
|
+
data: ${JSON.stringify({ error: errMsg })}
|
|
538
582
|
|
|
539
583
|
`);
|
|
540
584
|
res.end();
|
|
541
585
|
return;
|
|
542
586
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
"content-length"
|
|
547
|
-
]);
|
|
587
|
+
if (usedFallback) {
|
|
588
|
+
logger.info("streaming: using direct Anthropic API fallback");
|
|
589
|
+
}
|
|
548
590
|
if (response.status >= 300) {
|
|
549
591
|
const errorBody = Buffer.from(await response.arrayBuffer());
|
|
550
592
|
logger.error(`streaming upstream error: status=${response.status} body=${errorBody.toString().slice(0, 500)}`);
|
|
@@ -610,6 +652,67 @@ function shouldRouteToSkalpel(path4, source) {
|
|
|
610
652
|
const pathname = path4.split("?")[0];
|
|
611
653
|
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
612
654
|
}
|
|
655
|
+
function isSkalpelBackendFailure2(response, err) {
|
|
656
|
+
if (err) return true;
|
|
657
|
+
if (!response) return true;
|
|
658
|
+
if (response.status >= 500) return true;
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
function buildForwardHeaders(req, config, source, useSkalpel) {
|
|
662
|
+
const forwardHeaders = {};
|
|
663
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
664
|
+
if (value !== void 0) {
|
|
665
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
delete forwardHeaders["host"];
|
|
669
|
+
delete forwardHeaders["connection"];
|
|
670
|
+
if (useSkalpel) {
|
|
671
|
+
forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
|
|
672
|
+
forwardHeaders["X-Skalpel-Source"] = source;
|
|
673
|
+
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
674
|
+
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
675
|
+
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
676
|
+
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
677
|
+
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
678
|
+
const token = authHeader.slice(7).trim();
|
|
679
|
+
if (token.startsWith("sk-ant-")) {
|
|
680
|
+
forwardHeaders["x-api-key"] = token;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return forwardHeaders;
|
|
686
|
+
}
|
|
687
|
+
function stripSkalpelHeaders2(headers) {
|
|
688
|
+
const cleaned = { ...headers };
|
|
689
|
+
delete cleaned["X-Skalpel-API-Key"];
|
|
690
|
+
delete cleaned["X-Skalpel-Source"];
|
|
691
|
+
delete cleaned["X-Skalpel-Agent-Type"];
|
|
692
|
+
delete cleaned["X-Skalpel-SDK-Version"];
|
|
693
|
+
return cleaned;
|
|
694
|
+
}
|
|
695
|
+
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
696
|
+
"connection",
|
|
697
|
+
"keep-alive",
|
|
698
|
+
"proxy-authenticate",
|
|
699
|
+
"proxy-authorization",
|
|
700
|
+
"te",
|
|
701
|
+
"trailer",
|
|
702
|
+
"transfer-encoding",
|
|
703
|
+
"upgrade",
|
|
704
|
+
"content-encoding",
|
|
705
|
+
"content-length"
|
|
706
|
+
]);
|
|
707
|
+
function extractResponseHeaders(response) {
|
|
708
|
+
const headers = {};
|
|
709
|
+
for (const [key, value] of response.headers.entries()) {
|
|
710
|
+
if (!STRIP_RESPONSE_HEADERS.has(key)) {
|
|
711
|
+
headers[key] = value;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return headers;
|
|
715
|
+
}
|
|
613
716
|
async function handleRequest(req, res, config, source, logger) {
|
|
614
717
|
const start = Date.now();
|
|
615
718
|
const method = req.method ?? "GET";
|
|
@@ -617,30 +720,7 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
617
720
|
try {
|
|
618
721
|
const body = await collectBody(req);
|
|
619
722
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
620
|
-
const
|
|
621
|
-
const forwardHeaders = {};
|
|
622
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
623
|
-
if (value !== void 0) {
|
|
624
|
-
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
delete forwardHeaders["host"];
|
|
628
|
-
delete forwardHeaders["connection"];
|
|
629
|
-
if (useSkalpel) {
|
|
630
|
-
forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
|
|
631
|
-
forwardHeaders["X-Skalpel-Source"] = source;
|
|
632
|
-
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
633
|
-
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
634
|
-
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
635
|
-
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
636
|
-
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
637
|
-
const token = authHeader.slice(7).trim();
|
|
638
|
-
if (token.startsWith("sk-ant-")) {
|
|
639
|
-
forwardHeaders["x-api-key"] = token;
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
723
|
+
const forwardHeaders = buildForwardHeaders(req, config, source, useSkalpel);
|
|
644
724
|
let isStreaming = false;
|
|
645
725
|
if (body) {
|
|
646
726
|
try {
|
|
@@ -650,38 +730,52 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
650
730
|
}
|
|
651
731
|
}
|
|
652
732
|
if (isStreaming) {
|
|
653
|
-
|
|
733
|
+
const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
|
|
734
|
+
const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
|
|
735
|
+
await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
|
|
654
736
|
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
655
737
|
return;
|
|
656
738
|
}
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
739
|
+
const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
|
|
740
|
+
const directUrl = `${config.anthropicDirectUrl}${path4}`;
|
|
741
|
+
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
742
|
+
let response = null;
|
|
743
|
+
let fetchError = null;
|
|
744
|
+
let usedFallback = false;
|
|
745
|
+
if (useSkalpel) {
|
|
746
|
+
try {
|
|
747
|
+
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
748
|
+
} catch (err) {
|
|
749
|
+
fetchError = err;
|
|
750
|
+
}
|
|
751
|
+
if (isSkalpelBackendFailure2(response, fetchError)) {
|
|
752
|
+
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
753
|
+
usedFallback = true;
|
|
754
|
+
response = null;
|
|
755
|
+
fetchError = null;
|
|
756
|
+
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
757
|
+
try {
|
|
758
|
+
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
|
|
759
|
+
} catch (err) {
|
|
760
|
+
fetchError = err;
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
} else {
|
|
764
|
+
try {
|
|
765
|
+
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
766
|
+
} catch (err) {
|
|
767
|
+
fetchError = err;
|
|
678
768
|
}
|
|
679
769
|
}
|
|
770
|
+
if (!response || fetchError) {
|
|
771
|
+
throw fetchError ?? new Error("no response from upstream");
|
|
772
|
+
}
|
|
773
|
+
const responseHeaders = extractResponseHeaders(response);
|
|
680
774
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
681
775
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
682
776
|
res.writeHead(response.status, responseHeaders);
|
|
683
777
|
res.end(responseBody);
|
|
684
|
-
logger.info(`${method} ${path4} source=${source} status=${response.status} latency=${Date.now() - start}ms`);
|
|
778
|
+
logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
|
|
685
779
|
} catch (err) {
|
|
686
780
|
logger.error(`${method} ${path4} source=${source} error=${err.message}`);
|
|
687
781
|
if (!res.headersSent) {
|
|
@@ -815,6 +909,24 @@ function startProxy(config) {
|
|
|
815
909
|
}
|
|
816
910
|
handleRequest(req, res, config, "codex", logger);
|
|
817
911
|
});
|
|
912
|
+
anthropicServer.on("error", (err) => {
|
|
913
|
+
if (err.code === "EADDRINUSE") {
|
|
914
|
+
logger.error(`Port ${config.anthropicPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
915
|
+
} else {
|
|
916
|
+
logger.error(`Anthropic proxy failed to bind port ${config.anthropicPort}: ${err.message}`);
|
|
917
|
+
}
|
|
918
|
+
removePid(config.pidFile);
|
|
919
|
+
process.exit(1);
|
|
920
|
+
});
|
|
921
|
+
openaiServer.on("error", (err) => {
|
|
922
|
+
if (err.code === "EADDRINUSE") {
|
|
923
|
+
logger.error(`Port ${config.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
924
|
+
} else {
|
|
925
|
+
logger.error(`OpenAI proxy failed to bind port ${config.openaiPort}: ${err.message}`);
|
|
926
|
+
}
|
|
927
|
+
removePid(config.pidFile);
|
|
928
|
+
process.exit(1);
|
|
929
|
+
});
|
|
818
930
|
anthropicServer.listen(config.anthropicPort, () => {
|
|
819
931
|
logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
|
|
820
932
|
});
|
|
@@ -832,6 +944,16 @@ function startProxy(config) {
|
|
|
832
944
|
};
|
|
833
945
|
process.on("SIGTERM", cleanup);
|
|
834
946
|
process.on("SIGINT", cleanup);
|
|
947
|
+
process.on("uncaughtException", (err) => {
|
|
948
|
+
logger.error(`Uncaught exception: ${err.message}`);
|
|
949
|
+
removePid(config.pidFile);
|
|
950
|
+
process.exit(1);
|
|
951
|
+
});
|
|
952
|
+
process.on("unhandledRejection", (reason) => {
|
|
953
|
+
logger.error(`Unhandled rejection: ${reason}`);
|
|
954
|
+
removePid(config.pidFile);
|
|
955
|
+
process.exit(1);
|
|
956
|
+
});
|
|
835
957
|
return { anthropicServer, openaiServer };
|
|
836
958
|
}
|
|
837
959
|
function stopProxy(config) {
|