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.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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
|
272
|
-
|
|
273
|
-
if ("baseURL" in c) c.baseURL =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if ("
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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:
|
|
436
|
-
defaultHeaders:
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
787
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
788
|
+
const originHeader = response.headers.get("x-skalpel-origin");
|
|
789
|
+
let origin;
|
|
790
|
+
if (originHeader === "backend") origin = "skalpel-backend";
|
|
791
|
+
else if (originHeader === "provider") origin = "provider";
|
|
792
|
+
else origin = "provider";
|
|
793
|
+
let rawBody = "";
|
|
794
|
+
let bodyReadFailed = false;
|
|
795
|
+
try {
|
|
796
|
+
rawBody = Buffer.from(await response.arrayBuffer()).toString();
|
|
797
|
+
} catch (readErr) {
|
|
798
|
+
bodyReadFailed = true;
|
|
799
|
+
logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
|
|
800
|
+
}
|
|
801
|
+
if (!bodyReadFailed) {
|
|
802
|
+
logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
|
|
547
803
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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(
|
|
859
|
+
data: ${JSON.stringify(envelope)}
|
|
583
860
|
|
|
584
861
|
`);
|
|
585
862
|
}
|
|
@@ -587,6 +864,8 @@ data: ${JSON.stringify({ error: err.message })}
|
|
|
587
864
|
}
|
|
588
865
|
|
|
589
866
|
// src/proxy/handler.ts
|
|
867
|
+
var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
868
|
+
var HTTP_BAD_GATEWAY2 = 502;
|
|
590
869
|
var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
|
|
591
870
|
function collectBody(req) {
|
|
592
871
|
return new Promise((resolve, reject) => {
|
|
@@ -597,38 +876,71 @@ function collectBody(req) {
|
|
|
597
876
|
});
|
|
598
877
|
}
|
|
599
878
|
function shouldRouteToSkalpel(path4, source) {
|
|
600
|
-
if (isPassthroughMode()) return false;
|
|
601
879
|
if (source !== "claude-code") return true;
|
|
602
880
|
const pathname = path4.split("?")[0];
|
|
603
881
|
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
604
882
|
}
|
|
605
|
-
function
|
|
883
|
+
async function isSkalpelBackendFailure(response, err, logger) {
|
|
606
884
|
if (err) return true;
|
|
607
885
|
if (!response) return true;
|
|
608
|
-
if (response.status
|
|
609
|
-
|
|
610
|
-
return false;
|
|
886
|
+
if (response.status < 500) return false;
|
|
887
|
+
const origin = response.headers?.get("x-skalpel-origin");
|
|
888
|
+
if (origin === "provider") return false;
|
|
889
|
+
if (origin === "backend") return true;
|
|
890
|
+
try {
|
|
891
|
+
const text = await response.clone().text();
|
|
892
|
+
if (!text) {
|
|
893
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
let shape = "non-anthropic";
|
|
897
|
+
try {
|
|
898
|
+
const parsed = JSON.parse(text);
|
|
899
|
+
if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
|
|
900
|
+
shape = "anthropic";
|
|
901
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
|
|
902
|
+
return false;
|
|
903
|
+
}
|
|
904
|
+
} catch {
|
|
905
|
+
}
|
|
906
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
|
|
907
|
+
return true;
|
|
908
|
+
} catch {
|
|
909
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
611
912
|
}
|
|
913
|
+
var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
|
|
914
|
+
"host",
|
|
915
|
+
"connection",
|
|
916
|
+
"keep-alive",
|
|
917
|
+
"proxy-authenticate",
|
|
918
|
+
"proxy-authorization",
|
|
919
|
+
"te",
|
|
920
|
+
"trailer",
|
|
921
|
+
"transfer-encoding",
|
|
922
|
+
"upgrade"
|
|
923
|
+
]);
|
|
612
924
|
function buildForwardHeaders(req, config, source, useSkalpel) {
|
|
613
925
|
const forwardHeaders = {};
|
|
614
926
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
615
|
-
if (value
|
|
616
|
-
|
|
617
|
-
|
|
927
|
+
if (value === void 0) continue;
|
|
928
|
+
if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
|
|
929
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
618
930
|
}
|
|
619
|
-
delete forwardHeaders["host"];
|
|
620
|
-
delete forwardHeaders["connection"];
|
|
621
931
|
if (useSkalpel) {
|
|
622
932
|
forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
|
|
623
933
|
forwardHeaders["X-Skalpel-Source"] = source;
|
|
624
934
|
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
625
935
|
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
936
|
+
forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
|
|
626
937
|
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
627
938
|
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
628
939
|
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
629
940
|
const token = authHeader.slice(7).trim();
|
|
630
941
|
if (token.startsWith("sk-ant-")) {
|
|
631
942
|
forwardHeaders["x-api-key"] = token;
|
|
943
|
+
delete forwardHeaders["authorization"];
|
|
632
944
|
}
|
|
633
945
|
}
|
|
634
946
|
}
|
|
@@ -641,6 +953,7 @@ function stripSkalpelHeaders2(headers) {
|
|
|
641
953
|
delete cleaned["X-Skalpel-Source"];
|
|
642
954
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
643
955
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
956
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
644
957
|
return cleaned;
|
|
645
958
|
}
|
|
646
959
|
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
@@ -668,6 +981,11 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
668
981
|
const start = Date.now();
|
|
669
982
|
const method = req.method ?? "GET";
|
|
670
983
|
const path4 = req.url ?? "/";
|
|
984
|
+
const fp = tokenFingerprint(
|
|
985
|
+
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
986
|
+
);
|
|
987
|
+
logger.info(`${source} ${method} ${path4} token=${fp}`);
|
|
988
|
+
let response = null;
|
|
671
989
|
try {
|
|
672
990
|
const body = await collectBody(req);
|
|
673
991
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
@@ -682,45 +1000,91 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
682
1000
|
}
|
|
683
1001
|
if (isStreaming) {
|
|
684
1002
|
const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
|
|
685
|
-
const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
|
|
1003
|
+
const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
686
1004
|
await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
|
|
687
1005
|
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
688
1006
|
return;
|
|
689
1007
|
}
|
|
690
1008
|
const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
|
|
691
|
-
const directUrl = `${config.anthropicDirectUrl}${path4}`;
|
|
1009
|
+
const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
692
1010
|
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
693
|
-
let response = null;
|
|
694
1011
|
let fetchError = null;
|
|
695
1012
|
let usedFallback = false;
|
|
696
1013
|
if (useSkalpel) {
|
|
697
1014
|
try {
|
|
698
|
-
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
1015
|
+
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
699
1016
|
} catch (err) {
|
|
700
1017
|
fetchError = err;
|
|
701
1018
|
}
|
|
702
|
-
if (
|
|
1019
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
703
1020
|
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
704
1021
|
usedFallback = true;
|
|
705
1022
|
response = null;
|
|
706
1023
|
fetchError = null;
|
|
707
1024
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
708
1025
|
try {
|
|
709
|
-
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
|
|
1026
|
+
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
710
1027
|
} catch (err) {
|
|
711
1028
|
fetchError = err;
|
|
712
1029
|
}
|
|
713
1030
|
}
|
|
714
1031
|
} else {
|
|
715
1032
|
try {
|
|
716
|
-
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
1033
|
+
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
717
1034
|
} catch (err) {
|
|
718
1035
|
fetchError = err;
|
|
719
1036
|
}
|
|
720
1037
|
}
|
|
1038
|
+
const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
1039
|
+
const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
|
|
1040
|
+
if (fetchError) {
|
|
1041
|
+
const code = fetchError.code;
|
|
1042
|
+
if (code && TIMEOUT_CODES3.has(code)) {
|
|
1043
|
+
try {
|
|
1044
|
+
response = await handleTimeoutWithRetry(
|
|
1045
|
+
fetchError,
|
|
1046
|
+
() => fetch(fetchUrl, {
|
|
1047
|
+
method,
|
|
1048
|
+
headers: fetchHeaders,
|
|
1049
|
+
body: fetchBody,
|
|
1050
|
+
dispatcher: skalpelDispatcher
|
|
1051
|
+
}),
|
|
1052
|
+
logger
|
|
1053
|
+
);
|
|
1054
|
+
fetchError = null;
|
|
1055
|
+
} catch (retryErr) {
|
|
1056
|
+
fetchError = retryErr;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
721
1060
|
if (!response || fetchError) {
|
|
1061
|
+
response = null;
|
|
722
1062
|
throw fetchError ?? new Error("no response from upstream");
|
|
723
1063
|
}
|
|
1064
|
+
if (response.status === 429) {
|
|
1065
|
+
response = await handle429WithRetryAfter(
|
|
1066
|
+
response,
|
|
1067
|
+
() => fetch(fetchUrl, {
|
|
1068
|
+
method,
|
|
1069
|
+
headers: fetchHeaders,
|
|
1070
|
+
body: fetchBody,
|
|
1071
|
+
dispatcher: skalpelDispatcher
|
|
1072
|
+
}),
|
|
1073
|
+
logger
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
if (response.status === 401 && (source === "claude-code" || source === "codex")) {
|
|
1077
|
+
const fp2 = tokenFingerprint(
|
|
1078
|
+
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
1079
|
+
);
|
|
1080
|
+
logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
|
|
1081
|
+
const body401 = Buffer.from(await response.arrayBuffer());
|
|
1082
|
+
const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
|
|
1083
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1084
|
+
res.end(JSON.stringify(envelope));
|
|
1085
|
+
logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
724
1088
|
const responseHeaders = extractResponseHeaders(response);
|
|
725
1089
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
726
1090
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
@@ -730,21 +1094,38 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
730
1094
|
} catch (err) {
|
|
731
1095
|
logger.error(`${method} ${path4} source=${source} error=${err.message}`);
|
|
732
1096
|
if (!res.headersSent) {
|
|
733
|
-
|
|
734
|
-
|
|
1097
|
+
if (response !== null) {
|
|
1098
|
+
const upstreamStatus = response.status;
|
|
1099
|
+
const envelope = buildErrorEnvelope(
|
|
1100
|
+
upstreamStatus,
|
|
1101
|
+
"",
|
|
1102
|
+
"skalpel-proxy",
|
|
1103
|
+
"body read failed after upstream status"
|
|
1104
|
+
);
|
|
1105
|
+
res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
|
|
1106
|
+
res.end(JSON.stringify(envelope));
|
|
1107
|
+
} else {
|
|
1108
|
+
const envelope = buildErrorEnvelope(
|
|
1109
|
+
HTTP_BAD_GATEWAY2,
|
|
1110
|
+
err.message,
|
|
1111
|
+
"skalpel-proxy"
|
|
1112
|
+
);
|
|
1113
|
+
res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
|
|
1114
|
+
res.end(JSON.stringify(envelope));
|
|
1115
|
+
}
|
|
735
1116
|
}
|
|
736
1117
|
}
|
|
737
1118
|
}
|
|
738
1119
|
|
|
739
1120
|
// src/proxy/health.ts
|
|
740
|
-
function handleHealthRequest(res, config, startTime
|
|
1121
|
+
function handleHealthRequest(res, config, startTime) {
|
|
741
1122
|
const body = JSON.stringify({
|
|
742
1123
|
status: "ok",
|
|
743
|
-
mode: passthrough ? "passthrough" : "normal",
|
|
744
1124
|
uptime: Date.now() - startTime,
|
|
745
1125
|
ports: {
|
|
746
1126
|
anthropic: config.anthropicPort,
|
|
747
|
-
openai: config.openaiPort
|
|
1127
|
+
openai: config.openaiPort,
|
|
1128
|
+
cursor: config.cursorPort
|
|
748
1129
|
},
|
|
749
1130
|
version: "proxy-1.0.0"
|
|
750
1131
|
});
|
|
@@ -755,13 +1136,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
|
|
|
755
1136
|
// src/proxy/pid.ts
|
|
756
1137
|
import fs from "fs";
|
|
757
1138
|
import path from "path";
|
|
1139
|
+
import { execSync } from "child_process";
|
|
758
1140
|
function writePid(pidFile) {
|
|
759
1141
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
760
|
-
|
|
1142
|
+
const record = {
|
|
1143
|
+
pid: process.pid,
|
|
1144
|
+
startTime: getStartTime(process.pid)
|
|
1145
|
+
};
|
|
1146
|
+
fs.writeFileSync(pidFile, JSON.stringify(record));
|
|
761
1147
|
}
|
|
762
1148
|
function readPid(pidFile) {
|
|
763
1149
|
try {
|
|
764
1150
|
const raw = fs.readFileSync(pidFile, "utf-8").trim();
|
|
1151
|
+
try {
|
|
1152
|
+
const parsed = JSON.parse(raw);
|
|
1153
|
+
if (parsed && typeof parsed === "object" && typeof parsed.pid === "number" && !isNaN(parsed.pid)) {
|
|
1154
|
+
const record = parsed;
|
|
1155
|
+
if (record.startTime == null) {
|
|
1156
|
+
return isRunning(record.pid) ? record.pid : null;
|
|
1157
|
+
}
|
|
1158
|
+
return isRunningWithIdentity(record.pid, record.startTime) ? record.pid : null;
|
|
1159
|
+
}
|
|
1160
|
+
} catch {
|
|
1161
|
+
}
|
|
765
1162
|
const pid = parseInt(raw, 10);
|
|
766
1163
|
if (isNaN(pid)) return null;
|
|
767
1164
|
return isRunning(pid) ? pid : null;
|
|
@@ -777,6 +1174,37 @@ function isRunning(pid) {
|
|
|
777
1174
|
return false;
|
|
778
1175
|
}
|
|
779
1176
|
}
|
|
1177
|
+
function getStartTime(pid) {
|
|
1178
|
+
try {
|
|
1179
|
+
if (process.platform === "linux") {
|
|
1180
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
1181
|
+
const rparen = stat.lastIndexOf(")");
|
|
1182
|
+
if (rparen < 0) return null;
|
|
1183
|
+
const fields = stat.slice(rparen + 2).split(" ");
|
|
1184
|
+
return fields[19] ?? null;
|
|
1185
|
+
}
|
|
1186
|
+
if (process.platform === "darwin") {
|
|
1187
|
+
const out = execSync(`ps -p ${pid} -o lstart=`, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] });
|
|
1188
|
+
const text = out.toString().trim();
|
|
1189
|
+
return text || null;
|
|
1190
|
+
}
|
|
1191
|
+
return null;
|
|
1192
|
+
} catch {
|
|
1193
|
+
return null;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
function isRunningWithIdentity(pid, expectedStartTime) {
|
|
1197
|
+
try {
|
|
1198
|
+
if (process.platform !== "linux" && process.platform !== "darwin") {
|
|
1199
|
+
return isRunning(pid);
|
|
1200
|
+
}
|
|
1201
|
+
const current = getStartTime(pid);
|
|
1202
|
+
if (current == null) return false;
|
|
1203
|
+
return current === expectedStartTime;
|
|
1204
|
+
} catch {
|
|
1205
|
+
return false;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
780
1208
|
function removePid(pidFile) {
|
|
781
1209
|
try {
|
|
782
1210
|
fs.unlinkSync(pidFile);
|
|
@@ -790,12 +1218,14 @@ import path2 from "path";
|
|
|
790
1218
|
var MAX_SIZE = 5 * 1024 * 1024;
|
|
791
1219
|
var MAX_ROTATIONS = 3;
|
|
792
1220
|
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
793
|
-
var Logger = class {
|
|
1221
|
+
var Logger = class _Logger {
|
|
794
1222
|
logFile;
|
|
795
1223
|
level;
|
|
796
|
-
|
|
1224
|
+
prefix;
|
|
1225
|
+
constructor(logFile, level = "info", prefix = "") {
|
|
797
1226
|
this.logFile = logFile;
|
|
798
1227
|
this.level = level;
|
|
1228
|
+
this.prefix = prefix;
|
|
799
1229
|
fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
|
|
800
1230
|
}
|
|
801
1231
|
debug(msg) {
|
|
@@ -810,9 +1240,16 @@ var Logger = class {
|
|
|
810
1240
|
error(msg) {
|
|
811
1241
|
this.log("error", msg);
|
|
812
1242
|
}
|
|
1243
|
+
/** Returns a new Logger that writes to the same file but prefixes every
|
|
1244
|
+
* emitted line with `[conn=<connId>] `. The parent logger continues to
|
|
1245
|
+
* work unchanged. IPv6 colons should already be sanitized by the caller. */
|
|
1246
|
+
child(connId) {
|
|
1247
|
+
const child = new _Logger(this.logFile, this.level, `[conn=${connId}] `);
|
|
1248
|
+
return child;
|
|
1249
|
+
}
|
|
813
1250
|
log(level, msg) {
|
|
814
1251
|
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
815
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
|
|
1252
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
|
|
816
1253
|
`;
|
|
817
1254
|
if (level === "debug" || level === "error") {
|
|
818
1255
|
process.stderr.write(line);
|
|
@@ -843,61 +1280,41 @@ var Logger = class {
|
|
|
843
1280
|
|
|
844
1281
|
// src/proxy/server.ts
|
|
845
1282
|
var proxyStartTime = 0;
|
|
846
|
-
var
|
|
847
|
-
function
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
854
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
855
|
-
req.on("error", reject);
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
function handleAdminMode(req, res, logger) {
|
|
859
|
-
collectAdminBody(req).then((body) => {
|
|
860
|
-
try {
|
|
861
|
-
const { mode } = JSON.parse(body);
|
|
862
|
-
passthroughMode = mode === "passthrough";
|
|
863
|
-
logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
|
|
864
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
865
|
-
res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
|
|
866
|
-
} catch {
|
|
867
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
868
|
-
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
869
|
-
}
|
|
870
|
-
}).catch(() => {
|
|
871
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
872
|
-
res.end(JSON.stringify({ error: "failed to read body" }));
|
|
873
|
-
});
|
|
1283
|
+
var connCounter = 0;
|
|
1284
|
+
function computeConnId(req) {
|
|
1285
|
+
const addr = req.socket.remoteAddress ?? "unknown";
|
|
1286
|
+
const port = req.socket.remotePort ?? 0;
|
|
1287
|
+
const counter = (++connCounter).toString(36);
|
|
1288
|
+
const raw = addr + "|" + port + "|" + Date.now().toString(36) + "|" + counter + "|" + Math.floor(Math.random() * 4096).toString(16);
|
|
1289
|
+
return raw.replace(/:/g, "_");
|
|
874
1290
|
}
|
|
875
1291
|
function startProxy(config) {
|
|
876
1292
|
const logger = new Logger(config.logFile, config.logLevel);
|
|
877
1293
|
const startTime = Date.now();
|
|
878
1294
|
proxyStartTime = Date.now();
|
|
879
|
-
passthroughMode = false;
|
|
880
1295
|
const anthropicServer = http.createServer((req, res) => {
|
|
881
1296
|
if (req.url === "/health" && req.method === "GET") {
|
|
882
|
-
handleHealthRequest(res, config, startTime
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
if (req.url === "/admin/mode" && req.method === "POST") {
|
|
886
|
-
handleAdminMode(req, res, logger);
|
|
1297
|
+
handleHealthRequest(res, config, startTime);
|
|
887
1298
|
return;
|
|
888
1299
|
}
|
|
889
|
-
|
|
1300
|
+
const connId = computeConnId(req);
|
|
1301
|
+
handleRequest(req, res, config, "claude-code", logger.child(connId));
|
|
890
1302
|
});
|
|
891
1303
|
const openaiServer = http.createServer((req, res) => {
|
|
892
1304
|
if (req.url === "/health" && req.method === "GET") {
|
|
893
|
-
handleHealthRequest(res, config, startTime
|
|
1305
|
+
handleHealthRequest(res, config, startTime);
|
|
894
1306
|
return;
|
|
895
1307
|
}
|
|
896
|
-
|
|
897
|
-
|
|
1308
|
+
const connId = computeConnId(req);
|
|
1309
|
+
handleRequest(req, res, config, "codex", logger.child(connId));
|
|
1310
|
+
});
|
|
1311
|
+
const cursorServer = http.createServer((req, res) => {
|
|
1312
|
+
if (req.url === "/health" && req.method === "GET") {
|
|
1313
|
+
handleHealthRequest(res, config, startTime);
|
|
898
1314
|
return;
|
|
899
1315
|
}
|
|
900
|
-
|
|
1316
|
+
const connId = computeConnId(req);
|
|
1317
|
+
handleRequest(req, res, config, "cursor", logger.child(connId));
|
|
901
1318
|
});
|
|
902
1319
|
anthropicServer.on("error", (err) => {
|
|
903
1320
|
if (err.code === "EADDRINUSE") {
|
|
@@ -917,18 +1334,31 @@ function startProxy(config) {
|
|
|
917
1334
|
removePid(config.pidFile);
|
|
918
1335
|
process.exit(1);
|
|
919
1336
|
});
|
|
1337
|
+
cursorServer.on("error", (err) => {
|
|
1338
|
+
if (err.code === "EADDRINUSE") {
|
|
1339
|
+
logger.error(`Port ${config.cursorPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
1340
|
+
} else {
|
|
1341
|
+
logger.error(`Cursor proxy failed to bind port ${config.cursorPort}: ${err.message}`);
|
|
1342
|
+
}
|
|
1343
|
+
removePid(config.pidFile);
|
|
1344
|
+
process.exit(1);
|
|
1345
|
+
});
|
|
920
1346
|
anthropicServer.listen(config.anthropicPort, () => {
|
|
921
1347
|
logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
|
|
922
1348
|
});
|
|
923
1349
|
openaiServer.listen(config.openaiPort, () => {
|
|
924
1350
|
logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
|
|
925
1351
|
});
|
|
1352
|
+
cursorServer.listen(config.cursorPort, () => {
|
|
1353
|
+
logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
|
|
1354
|
+
});
|
|
926
1355
|
writePid(config.pidFile);
|
|
927
|
-
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort}`);
|
|
1356
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
928
1357
|
const cleanup = () => {
|
|
929
1358
|
logger.info("Shutting down proxy...");
|
|
930
1359
|
anthropicServer.close();
|
|
931
1360
|
openaiServer.close();
|
|
1361
|
+
cursorServer.close();
|
|
932
1362
|
removePid(config.pidFile);
|
|
933
1363
|
process.exit(0);
|
|
934
1364
|
};
|
|
@@ -944,7 +1374,7 @@ function startProxy(config) {
|
|
|
944
1374
|
removePid(config.pidFile);
|
|
945
1375
|
process.exit(1);
|
|
946
1376
|
});
|
|
947
|
-
return { anthropicServer, openaiServer };
|
|
1377
|
+
return { anthropicServer, openaiServer, cursorServer };
|
|
948
1378
|
}
|
|
949
1379
|
function stopProxy(config) {
|
|
950
1380
|
const pid = readPid(config.pidFile);
|
|
@@ -963,7 +1393,8 @@ function getProxyStatus(config) {
|
|
|
963
1393
|
pid,
|
|
964
1394
|
uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
|
|
965
1395
|
anthropicPort: config.anthropicPort,
|
|
966
|
-
openaiPort: config.openaiPort
|
|
1396
|
+
openaiPort: config.openaiPort,
|
|
1397
|
+
cursorPort: config.cursorPort
|
|
967
1398
|
};
|
|
968
1399
|
}
|
|
969
1400
|
|
|
@@ -981,13 +1412,20 @@ var DEFAULTS = {
|
|
|
981
1412
|
apiKey: "",
|
|
982
1413
|
remoteBaseUrl: "https://api.skalpel.ai",
|
|
983
1414
|
anthropicDirectUrl: "https://api.anthropic.com",
|
|
1415
|
+
openaiDirectUrl: "https://api.openai.com",
|
|
984
1416
|
anthropicPort: 18100,
|
|
985
1417
|
openaiPort: 18101,
|
|
1418
|
+
cursorPort: 18102,
|
|
1419
|
+
cursorDirectUrl: "https://api.openai.com",
|
|
986
1420
|
logLevel: "info",
|
|
987
1421
|
logFile: "~/.skalpel/logs/proxy.log",
|
|
988
1422
|
pidFile: "~/.skalpel/proxy.pid",
|
|
989
|
-
configFile: "~/.skalpel/config.json"
|
|
1423
|
+
configFile: "~/.skalpel/config.json",
|
|
1424
|
+
mode: "proxy"
|
|
990
1425
|
};
|
|
1426
|
+
function coerceMode(value) {
|
|
1427
|
+
return value === "direct" ? "direct" : "proxy";
|
|
1428
|
+
}
|
|
991
1429
|
function loadConfig(configPath) {
|
|
992
1430
|
const filePath = expandHome(configPath ?? DEFAULTS.configFile);
|
|
993
1431
|
let fileConfig = {};
|
|
@@ -1000,18 +1438,27 @@ function loadConfig(configPath) {
|
|
|
1000
1438
|
apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
1001
1439
|
remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
|
|
1002
1440
|
anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
|
|
1441
|
+
openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
|
|
1003
1442
|
anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
|
|
1004
1443
|
openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
|
|
1444
|
+
cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
|
|
1445
|
+
cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
|
|
1005
1446
|
logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
|
|
1006
1447
|
logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
|
|
1007
1448
|
pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
|
|
1008
|
-
configFile: filePath
|
|
1449
|
+
configFile: filePath,
|
|
1450
|
+
mode: coerceMode(fileConfig.mode)
|
|
1009
1451
|
};
|
|
1010
1452
|
}
|
|
1011
1453
|
function saveConfig(config) {
|
|
1012
1454
|
const dir = path3.dirname(config.configFile);
|
|
1013
1455
|
fs3.mkdirSync(dir, { recursive: true });
|
|
1014
|
-
|
|
1456
|
+
const { mode, ...rest } = config;
|
|
1457
|
+
const serializable = { ...rest };
|
|
1458
|
+
if (mode === "direct") {
|
|
1459
|
+
serializable.mode = mode;
|
|
1460
|
+
}
|
|
1461
|
+
fs3.writeFileSync(config.configFile, JSON.stringify(serializable, null, 2) + "\n");
|
|
1015
1462
|
}
|
|
1016
1463
|
export {
|
|
1017
1464
|
SkalpelAuthError,
|