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