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.cjs
CHANGED
|
@@ -108,8 +108,36 @@ var SkalpelUnavailableError = class extends SkalpelError {
|
|
|
108
108
|
this.name = "SkalpelUnavailableError";
|
|
109
109
|
}
|
|
110
110
|
};
|
|
111
|
+
var SkalpelClientRequestError = class extends SkalpelError {
|
|
112
|
+
constructor(message, statusCode) {
|
|
113
|
+
super(message, "SKALPEL_CLIENT_REQUEST", statusCode);
|
|
114
|
+
this.name = "SkalpelClientRequestError";
|
|
115
|
+
}
|
|
116
|
+
};
|
|
111
117
|
|
|
112
118
|
// src/fallback.ts
|
|
119
|
+
var TRULY_CLIENT_4XX = /* @__PURE__ */ new Set([
|
|
120
|
+
400,
|
|
121
|
+
403,
|
|
122
|
+
404,
|
|
123
|
+
405,
|
|
124
|
+
409,
|
|
125
|
+
410,
|
|
126
|
+
411,
|
|
127
|
+
413,
|
|
128
|
+
415,
|
|
129
|
+
417,
|
|
130
|
+
418,
|
|
131
|
+
421,
|
|
132
|
+
422,
|
|
133
|
+
423,
|
|
134
|
+
424,
|
|
135
|
+
425,
|
|
136
|
+
426,
|
|
137
|
+
428,
|
|
138
|
+
431,
|
|
139
|
+
451
|
|
140
|
+
]);
|
|
113
141
|
function sleep(ms) {
|
|
114
142
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
115
143
|
}
|
|
@@ -125,12 +153,21 @@ function classifyError(err) {
|
|
|
125
153
|
const retryAfter = error.headers?.["retry-after"] ? parseInt(error.headers["retry-after"], 10) : void 0;
|
|
126
154
|
return new SkalpelRateLimitError(message, retryAfter);
|
|
127
155
|
}
|
|
156
|
+
if (status === 408) {
|
|
157
|
+
return new SkalpelTimeoutError(message);
|
|
158
|
+
}
|
|
128
159
|
if (error.code === "ETIMEDOUT" || error.code === "TIMEOUT" || error.code === "UND_ERR_HEADERS_TIMEOUT") {
|
|
129
160
|
return new SkalpelTimeoutError(message);
|
|
130
161
|
}
|
|
131
162
|
if (status && status >= 500) {
|
|
132
163
|
return new SkalpelUnavailableError(message, status);
|
|
133
164
|
}
|
|
165
|
+
if (status && TRULY_CLIENT_4XX.has(status)) {
|
|
166
|
+
return new SkalpelClientRequestError(message, status);
|
|
167
|
+
}
|
|
168
|
+
if (status && status >= 400 && status < 500) {
|
|
169
|
+
return new SkalpelClientRequestError(message, status);
|
|
170
|
+
}
|
|
134
171
|
return new SkalpelUnavailableError(message, status);
|
|
135
172
|
}
|
|
136
173
|
async function withFallback(primaryFn, fallbackFn, options = {}) {
|
|
@@ -144,6 +181,9 @@ async function withFallback(primaryFn, fallbackFn, options = {}) {
|
|
|
144
181
|
if (lastError instanceof SkalpelAuthError) {
|
|
145
182
|
throw lastError;
|
|
146
183
|
}
|
|
184
|
+
if (lastError instanceof SkalpelClientRequestError) {
|
|
185
|
+
throw lastError;
|
|
186
|
+
}
|
|
147
187
|
if (lastError instanceof SkalpelRateLimitError && lastError.retryAfter && attempt < retries) {
|
|
148
188
|
await sleep(lastError.retryAfter * 1e3);
|
|
149
189
|
continue;
|
|
@@ -170,6 +210,21 @@ async function withFallback(primaryFn, fallbackFn, options = {}) {
|
|
|
170
210
|
var VERSION = "1.0.5";
|
|
171
211
|
|
|
172
212
|
// src/client.ts
|
|
213
|
+
var AsyncMutex = class {
|
|
214
|
+
locked = false;
|
|
215
|
+
queue = [];
|
|
216
|
+
async acquire() {
|
|
217
|
+
if (this.locked) {
|
|
218
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
219
|
+
}
|
|
220
|
+
this.locked = true;
|
|
221
|
+
return () => {
|
|
222
|
+
this.locked = false;
|
|
223
|
+
const next = this.queue.shift();
|
|
224
|
+
if (next) next();
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
};
|
|
173
228
|
function resolveConfig(options) {
|
|
174
229
|
return {
|
|
175
230
|
apiKey: options.apiKey,
|
|
@@ -236,29 +291,33 @@ function wrapOpenAI(client, config) {
|
|
|
236
291
|
const c = client;
|
|
237
292
|
const skalpelHeaders = buildSkalpelHeaders(config);
|
|
238
293
|
const originalBaseURL = c.baseURL;
|
|
294
|
+
const mutex = new AsyncMutex();
|
|
239
295
|
function createMethodProxy(target, methodName) {
|
|
240
296
|
const originalMethod = target[methodName];
|
|
241
297
|
return async function(...args) {
|
|
242
298
|
const primaryFn = async () => {
|
|
243
|
-
|
|
244
|
-
const requestArgs = args[0];
|
|
245
|
-
const extraHeaders = skalpelHeaders;
|
|
246
|
-
let callArgs;
|
|
247
|
-
if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
|
|
248
|
-
const opts = args[1];
|
|
249
|
-
opts.headers = { ...extraHeaders, ...opts.headers };
|
|
250
|
-
callArgs = [requestArgs, opts, ...args.slice(2)];
|
|
251
|
-
} else {
|
|
252
|
-
callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
|
|
253
|
-
}
|
|
299
|
+
const release = await mutex.acquire();
|
|
254
300
|
try {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
301
|
+
c.baseURL = `${config.baseURL}/v1`;
|
|
302
|
+
const requestArgs = args[0];
|
|
303
|
+
const extraHeaders = skalpelHeaders;
|
|
304
|
+
let callArgs;
|
|
305
|
+
if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
|
|
306
|
+
const opts = args[1];
|
|
307
|
+
opts.headers = { ...extraHeaders, ...opts.headers };
|
|
308
|
+
callArgs = [requestArgs, opts, ...args.slice(2)];
|
|
309
|
+
} else {
|
|
310
|
+
callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const result = await originalMethod.apply(target, callArgs);
|
|
314
|
+
extractMetadataFromResponse(result, config);
|
|
315
|
+
return result;
|
|
316
|
+
} finally {
|
|
317
|
+
c.baseURL = originalBaseURL;
|
|
318
|
+
}
|
|
319
|
+
} finally {
|
|
320
|
+
release();
|
|
262
321
|
}
|
|
263
322
|
};
|
|
264
323
|
const fallbackFn = async () => {
|
|
@@ -302,33 +361,36 @@ function wrapAnthropic(client, config) {
|
|
|
302
361
|
const c = client;
|
|
303
362
|
const skalpelHeaders = buildSkalpelHeaders(config);
|
|
304
363
|
const originalBaseURL = c.baseURL ?? c._client?.baseURL;
|
|
364
|
+
const mutex = new AsyncMutex();
|
|
305
365
|
function createMethodProxy(target, methodName) {
|
|
306
366
|
const originalMethod = target[methodName];
|
|
307
367
|
return async function(...args) {
|
|
308
368
|
const primaryFn = async () => {
|
|
309
|
-
const
|
|
310
|
-
if ("baseURL" in c) c.baseURL = proxyURL;
|
|
311
|
-
if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
|
|
312
|
-
const requestArgs = args[0];
|
|
313
|
-
const extraHeaders = skalpelHeaders;
|
|
314
|
-
let callArgs;
|
|
315
|
-
if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
|
|
316
|
-
const opts = args[1];
|
|
317
|
-
opts.headers = { ...extraHeaders, ...opts.headers };
|
|
318
|
-
callArgs = [requestArgs, opts, ...args.slice(2)];
|
|
319
|
-
} else {
|
|
320
|
-
callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
|
|
321
|
-
}
|
|
369
|
+
const release = await mutex.acquire();
|
|
322
370
|
try {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
if ("baseURL" in c) c.baseURL =
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
if ("
|
|
330
|
-
|
|
331
|
-
|
|
371
|
+
const proxyURL = config.baseURL;
|
|
372
|
+
if ("baseURL" in c) c.baseURL = proxyURL;
|
|
373
|
+
if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
|
|
374
|
+
const requestArgs = args[0];
|
|
375
|
+
const extraHeaders = skalpelHeaders;
|
|
376
|
+
let callArgs;
|
|
377
|
+
if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
|
|
378
|
+
const opts = args[1];
|
|
379
|
+
opts.headers = { ...extraHeaders, ...opts.headers };
|
|
380
|
+
callArgs = [requestArgs, opts, ...args.slice(2)];
|
|
381
|
+
} else {
|
|
382
|
+
callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const result = await originalMethod.apply(target, callArgs);
|
|
386
|
+
extractMetadataFromResponse(result, config);
|
|
387
|
+
return result;
|
|
388
|
+
} finally {
|
|
389
|
+
if ("baseURL" in c) c.baseURL = originalBaseURL;
|
|
390
|
+
if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
|
|
391
|
+
}
|
|
392
|
+
} finally {
|
|
393
|
+
release();
|
|
332
394
|
}
|
|
333
395
|
};
|
|
334
396
|
const fallbackFn = async () => {
|
|
@@ -390,7 +452,11 @@ async function contextRequest(config, path4, body) {
|
|
|
390
452
|
});
|
|
391
453
|
if (!response.ok) {
|
|
392
454
|
const text = await response.text().catch(() => "");
|
|
393
|
-
|
|
455
|
+
const message = `Skalpel context API error ${response.status}: ${text}`;
|
|
456
|
+
if (response.status >= 400 && response.status < 500) {
|
|
457
|
+
throw new SkalpelClientRequestError(message, response.status);
|
|
458
|
+
}
|
|
459
|
+
throw new SkalpelUnavailableError(message, response.status);
|
|
394
460
|
}
|
|
395
461
|
return await response.json();
|
|
396
462
|
} finally {
|
|
@@ -469,6 +535,7 @@ async function resolveContext(options, params) {
|
|
|
469
535
|
}
|
|
470
536
|
|
|
471
537
|
// src/url-swap.ts
|
|
538
|
+
var SKALPEL_MANAGED_SENTINEL = "skalpel-managed";
|
|
472
539
|
function buildHeaders(options) {
|
|
473
540
|
const headers = {
|
|
474
541
|
"X-Skalpel-SDK-Version": VERSION,
|
|
@@ -482,10 +549,14 @@ function buildHeaders(options) {
|
|
|
482
549
|
async function createSkalpelOpenAI(options) {
|
|
483
550
|
const { default: OpenAI } = await import("openai");
|
|
484
551
|
const baseURL = `${options.baseURL ?? "https://api.skalpel.ai"}/v1`;
|
|
552
|
+
const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
|
|
485
553
|
return new OpenAI({
|
|
486
554
|
baseURL,
|
|
487
|
-
apiKey:
|
|
488
|
-
defaultHeaders:
|
|
555
|
+
apiKey: sdkApiKey,
|
|
556
|
+
defaultHeaders: {
|
|
557
|
+
...buildHeaders(options),
|
|
558
|
+
"Authorization": `Bearer ${options.apiKey}`
|
|
559
|
+
},
|
|
489
560
|
timeout: options.timeout ?? 3e4,
|
|
490
561
|
maxRetries: options.retries ?? 2
|
|
491
562
|
});
|
|
@@ -493,9 +564,10 @@ async function createSkalpelOpenAI(options) {
|
|
|
493
564
|
async function createSkalpelAnthropic(options) {
|
|
494
565
|
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
495
566
|
const baseURL = options.baseURL ?? "https://api.skalpel.ai";
|
|
567
|
+
const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
|
|
496
568
|
return new Anthropic({
|
|
497
569
|
baseURL,
|
|
498
|
-
apiKey:
|
|
570
|
+
apiKey: sdkApiKey,
|
|
499
571
|
defaultHeaders: {
|
|
500
572
|
...buildHeaders(options),
|
|
501
573
|
"Authorization": `Bearer ${options.apiKey}`
|
|
@@ -508,7 +580,163 @@ async function createSkalpelAnthropic(options) {
|
|
|
508
580
|
// src/proxy/server.ts
|
|
509
581
|
var import_node_http = __toESM(require("http"), 1);
|
|
510
582
|
|
|
583
|
+
// src/proxy/dispatcher.ts
|
|
584
|
+
var import_undici = require("undici");
|
|
585
|
+
var skalpelDispatcher = new import_undici.Agent({
|
|
586
|
+
keepAliveTimeout: 1e4,
|
|
587
|
+
keepAliveMaxTimeout: 6e4,
|
|
588
|
+
connections: 100,
|
|
589
|
+
pipelining: 1
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// src/proxy/envelope.ts
|
|
593
|
+
function isAnthropicShaped(body) {
|
|
594
|
+
if (typeof body !== "object" || body === null) return false;
|
|
595
|
+
const b = body;
|
|
596
|
+
if (b.type !== "error") return false;
|
|
597
|
+
if (typeof b.error !== "object" || b.error === null) return false;
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
function defaultErrorTypeFor(status) {
|
|
601
|
+
if (status === 400) return "invalid_request_error";
|
|
602
|
+
if (status === 401 || status === 403) return "authentication_error";
|
|
603
|
+
if (status === 404) return "not_found_error";
|
|
604
|
+
if (status === 408) return "timeout_error";
|
|
605
|
+
if (status === 429) return "rate_limit_error";
|
|
606
|
+
if (status >= 500) return "api_error";
|
|
607
|
+
if (status >= 400) return "invalid_request_error";
|
|
608
|
+
return "api_error";
|
|
609
|
+
}
|
|
610
|
+
function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
|
|
611
|
+
let parsed = upstreamBody;
|
|
612
|
+
if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
|
|
613
|
+
try {
|
|
614
|
+
parsed = JSON.parse(upstreamBody);
|
|
615
|
+
} catch {
|
|
616
|
+
parsed = upstreamBody;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
let type = defaultErrorTypeFor(status);
|
|
620
|
+
let message;
|
|
621
|
+
if (isAnthropicShaped(parsed)) {
|
|
622
|
+
const inner = parsed.error;
|
|
623
|
+
if (typeof inner.type === "string" && inner.type.length > 0) {
|
|
624
|
+
type = inner.type;
|
|
625
|
+
}
|
|
626
|
+
message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
|
|
627
|
+
} else if (typeof parsed === "string" && parsed.length > 0) {
|
|
628
|
+
message = parsed;
|
|
629
|
+
} else {
|
|
630
|
+
message = defaultMessageForStatus(status);
|
|
631
|
+
}
|
|
632
|
+
const envelope = {
|
|
633
|
+
type: "error",
|
|
634
|
+
error: {
|
|
635
|
+
type,
|
|
636
|
+
message,
|
|
637
|
+
status_code: status,
|
|
638
|
+
origin
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
if (hint !== void 0) envelope.error.hint = hint;
|
|
642
|
+
if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
|
|
643
|
+
return envelope;
|
|
644
|
+
}
|
|
645
|
+
function defaultMessageForStatus(status) {
|
|
646
|
+
if (status === 401) return "Authentication failed";
|
|
647
|
+
if (status === 403) return "Forbidden";
|
|
648
|
+
if (status === 404) return "Not found";
|
|
649
|
+
if (status === 408) return "Request timed out";
|
|
650
|
+
if (status === 429) return "Rate limit exceeded";
|
|
651
|
+
if (status === 502) return "Bad gateway";
|
|
652
|
+
if (status === 503) return "Service unavailable";
|
|
653
|
+
if (status === 504) return "Gateway timeout";
|
|
654
|
+
if (status >= 500) return "Upstream error";
|
|
655
|
+
if (status >= 400) return "Client error";
|
|
656
|
+
return "Error";
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/proxy/recovery.ts
|
|
660
|
+
var import_node_crypto = require("crypto");
|
|
661
|
+
function parseRetryAfterHeader(header) {
|
|
662
|
+
if (!header) return void 0;
|
|
663
|
+
const trimmed = header.trim();
|
|
664
|
+
if (!trimmed) return void 0;
|
|
665
|
+
const n = Number(trimmed);
|
|
666
|
+
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
667
|
+
const dateMs = Date.parse(trimmed);
|
|
668
|
+
if (Number.isFinite(dateMs)) {
|
|
669
|
+
return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
|
|
670
|
+
}
|
|
671
|
+
return void 0;
|
|
672
|
+
}
|
|
673
|
+
function sleep2(ms) {
|
|
674
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
675
|
+
}
|
|
676
|
+
var MAX_RETRY_AFTER_SECONDS = 60;
|
|
677
|
+
var DEFAULT_BACKOFF_SECONDS = 2;
|
|
678
|
+
async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
679
|
+
const headerVal = response.headers.get("retry-after");
|
|
680
|
+
const parsed = parseRetryAfterHeader(headerVal);
|
|
681
|
+
if (parsed === void 0) {
|
|
682
|
+
await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
683
|
+
const retried2 = await retryFn();
|
|
684
|
+
logger.info("proxy.recovery.429_retry_count increment");
|
|
685
|
+
return retried2;
|
|
686
|
+
}
|
|
687
|
+
if (parsed > MAX_RETRY_AFTER_SECONDS) {
|
|
688
|
+
return response;
|
|
689
|
+
}
|
|
690
|
+
await sleep2(parsed * 1e3);
|
|
691
|
+
const retried = await retryFn();
|
|
692
|
+
logger.info("proxy.recovery.429_retry_count increment");
|
|
693
|
+
return retried;
|
|
694
|
+
}
|
|
695
|
+
var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
696
|
+
async function handleTimeoutWithRetry(err, retryFn, logger) {
|
|
697
|
+
const code = err.code;
|
|
698
|
+
if (!code || !TIMEOUT_CODES.has(code)) {
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
702
|
+
const retried = await retryFn();
|
|
703
|
+
logger.info("proxy.recovery.timeout_retry_count increment");
|
|
704
|
+
return retried;
|
|
705
|
+
}
|
|
706
|
+
function tokenFingerprint(authHeader) {
|
|
707
|
+
if (authHeader === void 0) return "none";
|
|
708
|
+
return (0, import_node_crypto.createHash)("sha256").update(authHeader).digest("hex").slice(0, 12);
|
|
709
|
+
}
|
|
710
|
+
var MUTEX_MAX_ENTRIES = 1024;
|
|
711
|
+
var LruMutexMap = class extends Map {
|
|
712
|
+
set(key, value) {
|
|
713
|
+
if (this.has(key)) {
|
|
714
|
+
super.delete(key);
|
|
715
|
+
} else if (this.size >= MUTEX_MAX_ENTRIES) {
|
|
716
|
+
const oldest = this.keys().next().value;
|
|
717
|
+
if (oldest !== void 0) super.delete(oldest);
|
|
718
|
+
}
|
|
719
|
+
return super.set(key, value);
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
var refreshMutex = new LruMutexMap();
|
|
723
|
+
|
|
511
724
|
// src/proxy/streaming.ts
|
|
725
|
+
var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
726
|
+
var HTTP_BAD_GATEWAY = 502;
|
|
727
|
+
function parseRetryAfter(header) {
|
|
728
|
+
if (!header) return void 0;
|
|
729
|
+
const trimmed = header.trim();
|
|
730
|
+
if (!trimmed) return void 0;
|
|
731
|
+
const n = Number(trimmed);
|
|
732
|
+
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
733
|
+
const dateMs = Date.parse(trimmed);
|
|
734
|
+
if (Number.isFinite(dateMs)) {
|
|
735
|
+
const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
|
|
736
|
+
return delta;
|
|
737
|
+
}
|
|
738
|
+
return void 0;
|
|
739
|
+
}
|
|
512
740
|
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
513
741
|
"connection",
|
|
514
742
|
"keep-alive",
|
|
@@ -530,17 +758,11 @@ function stripSkalpelHeaders(headers) {
|
|
|
530
758
|
delete cleaned["X-Skalpel-Source"];
|
|
531
759
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
532
760
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
761
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
533
762
|
return cleaned;
|
|
534
763
|
}
|
|
535
|
-
function isSkalpelBackendFailure(response, err) {
|
|
536
|
-
if (err) return true;
|
|
537
|
-
if (!response) return true;
|
|
538
|
-
if (response.status >= 500) return true;
|
|
539
|
-
if (response.status === 403) return true;
|
|
540
|
-
return false;
|
|
541
|
-
}
|
|
542
764
|
async function doStreamingFetch(url, body, headers) {
|
|
543
|
-
return fetch(url, { method: "POST", headers, body });
|
|
765
|
+
return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
|
|
544
766
|
}
|
|
545
767
|
async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
|
|
546
768
|
let response = null;
|
|
@@ -552,7 +774,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
552
774
|
} catch (err) {
|
|
553
775
|
fetchError = err;
|
|
554
776
|
}
|
|
555
|
-
if (isSkalpelBackendFailure(response, fetchError)) {
|
|
777
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
556
778
|
logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
557
779
|
usedFallback = true;
|
|
558
780
|
response = null;
|
|
@@ -571,15 +793,40 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
571
793
|
fetchError = err;
|
|
572
794
|
}
|
|
573
795
|
}
|
|
796
|
+
const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
797
|
+
const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
|
|
798
|
+
if (fetchError) {
|
|
799
|
+
const code = fetchError.code;
|
|
800
|
+
if (code && TIMEOUT_CODES2.has(code)) {
|
|
801
|
+
try {
|
|
802
|
+
response = await handleTimeoutWithRetry(
|
|
803
|
+
fetchError,
|
|
804
|
+
() => doStreamingFetch(finalUrl, body, finalHeaders),
|
|
805
|
+
logger
|
|
806
|
+
);
|
|
807
|
+
fetchError = null;
|
|
808
|
+
} catch (retryErr) {
|
|
809
|
+
fetchError = retryErr;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (response && response.status === 429) {
|
|
814
|
+
response = await handle429WithRetryAfter(
|
|
815
|
+
response,
|
|
816
|
+
() => doStreamingFetch(finalUrl, body, finalHeaders),
|
|
817
|
+
logger
|
|
818
|
+
);
|
|
819
|
+
}
|
|
574
820
|
if (!response || fetchError) {
|
|
575
821
|
const errMsg = fetchError ? fetchError.message : "no response from upstream";
|
|
576
822
|
logger.error(`streaming fetch failed: ${errMsg}`);
|
|
577
|
-
res.writeHead(
|
|
823
|
+
res.writeHead(HTTP_BAD_GATEWAY, {
|
|
578
824
|
"Content-Type": "text/event-stream",
|
|
579
825
|
"Cache-Control": "no-cache"
|
|
580
826
|
});
|
|
827
|
+
const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
|
|
581
828
|
res.write(`event: error
|
|
582
|
-
data: ${JSON.stringify(
|
|
829
|
+
data: ${JSON.stringify(envelope)}
|
|
583
830
|
|
|
584
831
|
`);
|
|
585
832
|
res.end();
|
|
@@ -589,17 +836,39 @@ data: ${JSON.stringify({ error: errMsg })}
|
|
|
589
836
|
logger.info("streaming: using direct Anthropic API fallback");
|
|
590
837
|
}
|
|
591
838
|
if (response.status >= 300) {
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
839
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
840
|
+
const originHeader = response.headers.get("x-skalpel-origin");
|
|
841
|
+
let origin;
|
|
842
|
+
if (originHeader === "backend") origin = "skalpel-backend";
|
|
843
|
+
else if (originHeader === "provider") origin = "provider";
|
|
844
|
+
else origin = "provider";
|
|
845
|
+
let rawBody = "";
|
|
846
|
+
let bodyReadFailed = false;
|
|
847
|
+
try {
|
|
848
|
+
rawBody = Buffer.from(await response.arrayBuffer()).toString();
|
|
849
|
+
} catch (readErr) {
|
|
850
|
+
bodyReadFailed = true;
|
|
851
|
+
logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
|
|
599
852
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
853
|
+
if (!bodyReadFailed) {
|
|
854
|
+
logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
|
|
855
|
+
}
|
|
856
|
+
const envelope = bodyReadFailed ? buildErrorEnvelope(
|
|
857
|
+
response.status,
|
|
858
|
+
"",
|
|
859
|
+
"skalpel-proxy",
|
|
860
|
+
"mid-stream abort",
|
|
861
|
+
retryAfter
|
|
862
|
+
) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
|
|
863
|
+
res.writeHead(response.status, {
|
|
864
|
+
"Content-Type": "text/event-stream",
|
|
865
|
+
"Cache-Control": "no-cache"
|
|
866
|
+
});
|
|
867
|
+
res.write(`event: error
|
|
868
|
+
data: ${JSON.stringify(envelope)}
|
|
869
|
+
|
|
870
|
+
`);
|
|
871
|
+
res.end();
|
|
603
872
|
return;
|
|
604
873
|
}
|
|
605
874
|
const sseHeaders = {};
|
|
@@ -630,8 +899,16 @@ data: ${JSON.stringify({ error: "no response body" })}
|
|
|
630
899
|
}
|
|
631
900
|
} catch (err) {
|
|
632
901
|
logger.error(`streaming error: ${err.message}`);
|
|
902
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
903
|
+
const envelope = buildErrorEnvelope(
|
|
904
|
+
response.status,
|
|
905
|
+
err.message,
|
|
906
|
+
"skalpel-proxy",
|
|
907
|
+
"mid-stream abort",
|
|
908
|
+
retryAfter
|
|
909
|
+
);
|
|
633
910
|
res.write(`event: error
|
|
634
|
-
data: ${JSON.stringify(
|
|
911
|
+
data: ${JSON.stringify(envelope)}
|
|
635
912
|
|
|
636
913
|
`);
|
|
637
914
|
}
|
|
@@ -639,6 +916,8 @@ data: ${JSON.stringify({ error: err.message })}
|
|
|
639
916
|
}
|
|
640
917
|
|
|
641
918
|
// src/proxy/handler.ts
|
|
919
|
+
var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
920
|
+
var HTTP_BAD_GATEWAY2 = 502;
|
|
642
921
|
var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
|
|
643
922
|
function collectBody(req) {
|
|
644
923
|
return new Promise((resolve, reject) => {
|
|
@@ -648,38 +927,72 @@ function collectBody(req) {
|
|
|
648
927
|
req.on("error", reject);
|
|
649
928
|
});
|
|
650
929
|
}
|
|
651
|
-
function shouldRouteToSkalpel(path4,
|
|
652
|
-
if (
|
|
930
|
+
function shouldRouteToSkalpel(path4, source) {
|
|
931
|
+
if (source !== "claude-code") return true;
|
|
653
932
|
const pathname = path4.split("?")[0];
|
|
654
933
|
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
655
934
|
}
|
|
656
|
-
function
|
|
935
|
+
async function isSkalpelBackendFailure(response, err, logger) {
|
|
657
936
|
if (err) return true;
|
|
658
937
|
if (!response) return true;
|
|
659
|
-
if (response.status
|
|
660
|
-
|
|
661
|
-
return false;
|
|
938
|
+
if (response.status < 500) return false;
|
|
939
|
+
const origin = response.headers?.get("x-skalpel-origin");
|
|
940
|
+
if (origin === "provider") return false;
|
|
941
|
+
if (origin === "backend") return true;
|
|
942
|
+
try {
|
|
943
|
+
const text = await response.clone().text();
|
|
944
|
+
if (!text) {
|
|
945
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
let shape = "non-anthropic";
|
|
949
|
+
try {
|
|
950
|
+
const parsed = JSON.parse(text);
|
|
951
|
+
if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
|
|
952
|
+
shape = "anthropic";
|
|
953
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
} catch {
|
|
957
|
+
}
|
|
958
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
|
|
959
|
+
return true;
|
|
960
|
+
} catch {
|
|
961
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
662
964
|
}
|
|
965
|
+
var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
|
|
966
|
+
"host",
|
|
967
|
+
"connection",
|
|
968
|
+
"keep-alive",
|
|
969
|
+
"proxy-authenticate",
|
|
970
|
+
"proxy-authorization",
|
|
971
|
+
"te",
|
|
972
|
+
"trailer",
|
|
973
|
+
"transfer-encoding",
|
|
974
|
+
"upgrade"
|
|
975
|
+
]);
|
|
663
976
|
function buildForwardHeaders(req, config, source, useSkalpel) {
|
|
664
977
|
const forwardHeaders = {};
|
|
665
978
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
666
|
-
if (value
|
|
667
|
-
|
|
668
|
-
|
|
979
|
+
if (value === void 0) continue;
|
|
980
|
+
if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
|
|
981
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
669
982
|
}
|
|
670
|
-
delete forwardHeaders["host"];
|
|
671
|
-
delete forwardHeaders["connection"];
|
|
672
983
|
if (useSkalpel) {
|
|
673
984
|
forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
|
|
674
985
|
forwardHeaders["X-Skalpel-Source"] = source;
|
|
675
986
|
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
676
987
|
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
988
|
+
forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
|
|
677
989
|
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
678
990
|
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
679
991
|
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
680
992
|
const token = authHeader.slice(7).trim();
|
|
681
993
|
if (token.startsWith("sk-ant-")) {
|
|
682
994
|
forwardHeaders["x-api-key"] = token;
|
|
995
|
+
delete forwardHeaders["authorization"];
|
|
683
996
|
}
|
|
684
997
|
}
|
|
685
998
|
}
|
|
@@ -692,6 +1005,7 @@ function stripSkalpelHeaders2(headers) {
|
|
|
692
1005
|
delete cleaned["X-Skalpel-Source"];
|
|
693
1006
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
694
1007
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
1008
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
695
1009
|
return cleaned;
|
|
696
1010
|
}
|
|
697
1011
|
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
@@ -719,6 +1033,11 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
719
1033
|
const start = Date.now();
|
|
720
1034
|
const method = req.method ?? "GET";
|
|
721
1035
|
const path4 = req.url ?? "/";
|
|
1036
|
+
const fp = tokenFingerprint(
|
|
1037
|
+
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
1038
|
+
);
|
|
1039
|
+
logger.info(`${source} ${method} ${path4} token=${fp}`);
|
|
1040
|
+
let response = null;
|
|
722
1041
|
try {
|
|
723
1042
|
const body = await collectBody(req);
|
|
724
1043
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
@@ -733,45 +1052,91 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
733
1052
|
}
|
|
734
1053
|
if (isStreaming) {
|
|
735
1054
|
const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
|
|
736
|
-
const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
|
|
1055
|
+
const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
737
1056
|
await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
|
|
738
1057
|
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
739
1058
|
return;
|
|
740
1059
|
}
|
|
741
1060
|
const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
|
|
742
|
-
const directUrl = `${config.anthropicDirectUrl}${path4}`;
|
|
1061
|
+
const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
743
1062
|
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
744
|
-
let response = null;
|
|
745
1063
|
let fetchError = null;
|
|
746
1064
|
let usedFallback = false;
|
|
747
1065
|
if (useSkalpel) {
|
|
748
1066
|
try {
|
|
749
|
-
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
1067
|
+
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
750
1068
|
} catch (err) {
|
|
751
1069
|
fetchError = err;
|
|
752
1070
|
}
|
|
753
|
-
if (
|
|
1071
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
754
1072
|
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
755
1073
|
usedFallback = true;
|
|
756
1074
|
response = null;
|
|
757
1075
|
fetchError = null;
|
|
758
1076
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
759
1077
|
try {
|
|
760
|
-
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
|
|
1078
|
+
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
761
1079
|
} catch (err) {
|
|
762
1080
|
fetchError = err;
|
|
763
1081
|
}
|
|
764
1082
|
}
|
|
765
1083
|
} else {
|
|
766
1084
|
try {
|
|
767
|
-
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
1085
|
+
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
768
1086
|
} catch (err) {
|
|
769
1087
|
fetchError = err;
|
|
770
1088
|
}
|
|
771
1089
|
}
|
|
1090
|
+
const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
1091
|
+
const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
|
|
1092
|
+
if (fetchError) {
|
|
1093
|
+
const code = fetchError.code;
|
|
1094
|
+
if (code && TIMEOUT_CODES3.has(code)) {
|
|
1095
|
+
try {
|
|
1096
|
+
response = await handleTimeoutWithRetry(
|
|
1097
|
+
fetchError,
|
|
1098
|
+
() => fetch(fetchUrl, {
|
|
1099
|
+
method,
|
|
1100
|
+
headers: fetchHeaders,
|
|
1101
|
+
body: fetchBody,
|
|
1102
|
+
dispatcher: skalpelDispatcher
|
|
1103
|
+
}),
|
|
1104
|
+
logger
|
|
1105
|
+
);
|
|
1106
|
+
fetchError = null;
|
|
1107
|
+
} catch (retryErr) {
|
|
1108
|
+
fetchError = retryErr;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
772
1112
|
if (!response || fetchError) {
|
|
1113
|
+
response = null;
|
|
773
1114
|
throw fetchError ?? new Error("no response from upstream");
|
|
774
1115
|
}
|
|
1116
|
+
if (response.status === 429) {
|
|
1117
|
+
response = await handle429WithRetryAfter(
|
|
1118
|
+
response,
|
|
1119
|
+
() => fetch(fetchUrl, {
|
|
1120
|
+
method,
|
|
1121
|
+
headers: fetchHeaders,
|
|
1122
|
+
body: fetchBody,
|
|
1123
|
+
dispatcher: skalpelDispatcher
|
|
1124
|
+
}),
|
|
1125
|
+
logger
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
if (response.status === 401 && (source === "claude-code" || source === "codex")) {
|
|
1129
|
+
const fp2 = tokenFingerprint(
|
|
1130
|
+
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
1131
|
+
);
|
|
1132
|
+
logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
|
|
1133
|
+
const body401 = Buffer.from(await response.arrayBuffer());
|
|
1134
|
+
const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
|
|
1135
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
1136
|
+
res.end(JSON.stringify(envelope));
|
|
1137
|
+
logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
775
1140
|
const responseHeaders = extractResponseHeaders(response);
|
|
776
1141
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
777
1142
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
@@ -781,20 +1146,38 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
781
1146
|
} catch (err) {
|
|
782
1147
|
logger.error(`${method} ${path4} source=${source} error=${err.message}`);
|
|
783
1148
|
if (!res.headersSent) {
|
|
784
|
-
|
|
785
|
-
|
|
1149
|
+
if (response !== null) {
|
|
1150
|
+
const upstreamStatus = response.status;
|
|
1151
|
+
const envelope = buildErrorEnvelope(
|
|
1152
|
+
upstreamStatus,
|
|
1153
|
+
"",
|
|
1154
|
+
"skalpel-proxy",
|
|
1155
|
+
"body read failed after upstream status"
|
|
1156
|
+
);
|
|
1157
|
+
res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
|
|
1158
|
+
res.end(JSON.stringify(envelope));
|
|
1159
|
+
} else {
|
|
1160
|
+
const envelope = buildErrorEnvelope(
|
|
1161
|
+
HTTP_BAD_GATEWAY2,
|
|
1162
|
+
err.message,
|
|
1163
|
+
"skalpel-proxy"
|
|
1164
|
+
);
|
|
1165
|
+
res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
|
|
1166
|
+
res.end(JSON.stringify(envelope));
|
|
1167
|
+
}
|
|
786
1168
|
}
|
|
787
1169
|
}
|
|
788
1170
|
}
|
|
789
1171
|
|
|
790
1172
|
// src/proxy/health.ts
|
|
791
|
-
function handleHealthRequest(res, config, startTime
|
|
1173
|
+
function handleHealthRequest(res, config, startTime) {
|
|
792
1174
|
const body = JSON.stringify({
|
|
793
1175
|
status: "ok",
|
|
794
|
-
mode: passthrough ? "passthrough" : "normal",
|
|
795
1176
|
uptime: Date.now() - startTime,
|
|
796
1177
|
ports: {
|
|
797
|
-
anthropic: config.anthropicPort
|
|
1178
|
+
anthropic: config.anthropicPort,
|
|
1179
|
+
openai: config.openaiPort,
|
|
1180
|
+
cursor: config.cursorPort
|
|
798
1181
|
},
|
|
799
1182
|
version: "proxy-1.0.0"
|
|
800
1183
|
});
|
|
@@ -805,13 +1188,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
|
|
|
805
1188
|
// src/proxy/pid.ts
|
|
806
1189
|
var import_node_fs = __toESM(require("fs"), 1);
|
|
807
1190
|
var import_node_path = __toESM(require("path"), 1);
|
|
1191
|
+
var import_node_child_process = require("child_process");
|
|
808
1192
|
function writePid(pidFile) {
|
|
809
1193
|
import_node_fs.default.mkdirSync(import_node_path.default.dirname(pidFile), { recursive: true });
|
|
810
|
-
|
|
1194
|
+
const record = {
|
|
1195
|
+
pid: process.pid,
|
|
1196
|
+
startTime: getStartTime(process.pid)
|
|
1197
|
+
};
|
|
1198
|
+
import_node_fs.default.writeFileSync(pidFile, JSON.stringify(record));
|
|
811
1199
|
}
|
|
812
1200
|
function readPid(pidFile) {
|
|
813
1201
|
try {
|
|
814
1202
|
const raw = import_node_fs.default.readFileSync(pidFile, "utf-8").trim();
|
|
1203
|
+
try {
|
|
1204
|
+
const parsed = JSON.parse(raw);
|
|
1205
|
+
if (parsed && typeof parsed === "object" && typeof parsed.pid === "number" && !isNaN(parsed.pid)) {
|
|
1206
|
+
const record = parsed;
|
|
1207
|
+
if (record.startTime == null) {
|
|
1208
|
+
return isRunning(record.pid) ? record.pid : null;
|
|
1209
|
+
}
|
|
1210
|
+
return isRunningWithIdentity(record.pid, record.startTime) ? record.pid : null;
|
|
1211
|
+
}
|
|
1212
|
+
} catch {
|
|
1213
|
+
}
|
|
815
1214
|
const pid = parseInt(raw, 10);
|
|
816
1215
|
if (isNaN(pid)) return null;
|
|
817
1216
|
return isRunning(pid) ? pid : null;
|
|
@@ -827,6 +1226,37 @@ function isRunning(pid) {
|
|
|
827
1226
|
return false;
|
|
828
1227
|
}
|
|
829
1228
|
}
|
|
1229
|
+
function getStartTime(pid) {
|
|
1230
|
+
try {
|
|
1231
|
+
if (process.platform === "linux") {
|
|
1232
|
+
const stat = import_node_fs.default.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
1233
|
+
const rparen = stat.lastIndexOf(")");
|
|
1234
|
+
if (rparen < 0) return null;
|
|
1235
|
+
const fields = stat.slice(rparen + 2).split(" ");
|
|
1236
|
+
return fields[19] ?? null;
|
|
1237
|
+
}
|
|
1238
|
+
if (process.platform === "darwin") {
|
|
1239
|
+
const out = (0, import_node_child_process.execSync)(`ps -p ${pid} -o lstart=`, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] });
|
|
1240
|
+
const text = out.toString().trim();
|
|
1241
|
+
return text || null;
|
|
1242
|
+
}
|
|
1243
|
+
return null;
|
|
1244
|
+
} catch {
|
|
1245
|
+
return null;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
function isRunningWithIdentity(pid, expectedStartTime) {
|
|
1249
|
+
try {
|
|
1250
|
+
if (process.platform !== "linux" && process.platform !== "darwin") {
|
|
1251
|
+
return isRunning(pid);
|
|
1252
|
+
}
|
|
1253
|
+
const current = getStartTime(pid);
|
|
1254
|
+
if (current == null) return false;
|
|
1255
|
+
return current === expectedStartTime;
|
|
1256
|
+
} catch {
|
|
1257
|
+
return false;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
830
1260
|
function removePid(pidFile) {
|
|
831
1261
|
try {
|
|
832
1262
|
import_node_fs.default.unlinkSync(pidFile);
|
|
@@ -840,12 +1270,14 @@ var import_node_path2 = __toESM(require("path"), 1);
|
|
|
840
1270
|
var MAX_SIZE = 5 * 1024 * 1024;
|
|
841
1271
|
var MAX_ROTATIONS = 3;
|
|
842
1272
|
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
843
|
-
var Logger = class {
|
|
1273
|
+
var Logger = class _Logger {
|
|
844
1274
|
logFile;
|
|
845
1275
|
level;
|
|
846
|
-
|
|
1276
|
+
prefix;
|
|
1277
|
+
constructor(logFile, level = "info", prefix = "") {
|
|
847
1278
|
this.logFile = logFile;
|
|
848
1279
|
this.level = level;
|
|
1280
|
+
this.prefix = prefix;
|
|
849
1281
|
import_node_fs2.default.mkdirSync(import_node_path2.default.dirname(logFile), { recursive: true });
|
|
850
1282
|
}
|
|
851
1283
|
debug(msg) {
|
|
@@ -860,9 +1292,16 @@ var Logger = class {
|
|
|
860
1292
|
error(msg) {
|
|
861
1293
|
this.log("error", msg);
|
|
862
1294
|
}
|
|
1295
|
+
/** Returns a new Logger that writes to the same file but prefixes every
|
|
1296
|
+
* emitted line with `[conn=<connId>] `. The parent logger continues to
|
|
1297
|
+
* work unchanged. IPv6 colons should already be sanitized by the caller. */
|
|
1298
|
+
child(connId) {
|
|
1299
|
+
const child = new _Logger(this.logFile, this.level, `[conn=${connId}] `);
|
|
1300
|
+
return child;
|
|
1301
|
+
}
|
|
863
1302
|
log(level, msg) {
|
|
864
1303
|
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
865
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
|
|
1304
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
|
|
866
1305
|
`;
|
|
867
1306
|
if (level === "debug" || level === "error") {
|
|
868
1307
|
process.stderr.write(line);
|
|
@@ -893,50 +1332,41 @@ var Logger = class {
|
|
|
893
1332
|
|
|
894
1333
|
// src/proxy/server.ts
|
|
895
1334
|
var proxyStartTime = 0;
|
|
896
|
-
var
|
|
897
|
-
function
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
904
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
905
|
-
req.on("error", reject);
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
function handleAdminMode(req, res, logger) {
|
|
909
|
-
collectAdminBody(req).then((body) => {
|
|
910
|
-
try {
|
|
911
|
-
const { mode } = JSON.parse(body);
|
|
912
|
-
passthroughMode = mode === "passthrough";
|
|
913
|
-
logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
|
|
914
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
915
|
-
res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
|
|
916
|
-
} catch {
|
|
917
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
918
|
-
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
919
|
-
}
|
|
920
|
-
}).catch(() => {
|
|
921
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
922
|
-
res.end(JSON.stringify({ error: "failed to read body" }));
|
|
923
|
-
});
|
|
1335
|
+
var connCounter = 0;
|
|
1336
|
+
function computeConnId(req) {
|
|
1337
|
+
const addr = req.socket.remoteAddress ?? "unknown";
|
|
1338
|
+
const port = req.socket.remotePort ?? 0;
|
|
1339
|
+
const counter = (++connCounter).toString(36);
|
|
1340
|
+
const raw = addr + "|" + port + "|" + Date.now().toString(36) + "|" + counter + "|" + Math.floor(Math.random() * 4096).toString(16);
|
|
1341
|
+
return raw.replace(/:/g, "_");
|
|
924
1342
|
}
|
|
925
1343
|
function startProxy(config) {
|
|
926
1344
|
const logger = new Logger(config.logFile, config.logLevel);
|
|
927
1345
|
const startTime = Date.now();
|
|
928
1346
|
proxyStartTime = Date.now();
|
|
929
|
-
passthroughMode = false;
|
|
930
1347
|
const anthropicServer = import_node_http.default.createServer((req, res) => {
|
|
931
1348
|
if (req.url === "/health" && req.method === "GET") {
|
|
932
|
-
handleHealthRequest(res, config, startTime
|
|
1349
|
+
handleHealthRequest(res, config, startTime);
|
|
933
1350
|
return;
|
|
934
1351
|
}
|
|
935
|
-
|
|
936
|
-
|
|
1352
|
+
const connId = computeConnId(req);
|
|
1353
|
+
handleRequest(req, res, config, "claude-code", logger.child(connId));
|
|
1354
|
+
});
|
|
1355
|
+
const openaiServer = import_node_http.default.createServer((req, res) => {
|
|
1356
|
+
if (req.url === "/health" && req.method === "GET") {
|
|
1357
|
+
handleHealthRequest(res, config, startTime);
|
|
937
1358
|
return;
|
|
938
1359
|
}
|
|
939
|
-
|
|
1360
|
+
const connId = computeConnId(req);
|
|
1361
|
+
handleRequest(req, res, config, "codex", logger.child(connId));
|
|
1362
|
+
});
|
|
1363
|
+
const cursorServer = import_node_http.default.createServer((req, res) => {
|
|
1364
|
+
if (req.url === "/health" && req.method === "GET") {
|
|
1365
|
+
handleHealthRequest(res, config, startTime);
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
const connId = computeConnId(req);
|
|
1369
|
+
handleRequest(req, res, config, "cursor", logger.child(connId));
|
|
940
1370
|
});
|
|
941
1371
|
anthropicServer.on("error", (err) => {
|
|
942
1372
|
if (err.code === "EADDRINUSE") {
|
|
@@ -947,14 +1377,40 @@ function startProxy(config) {
|
|
|
947
1377
|
removePid(config.pidFile);
|
|
948
1378
|
process.exit(1);
|
|
949
1379
|
});
|
|
1380
|
+
openaiServer.on("error", (err) => {
|
|
1381
|
+
if (err.code === "EADDRINUSE") {
|
|
1382
|
+
logger.error(`Port ${config.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
1383
|
+
} else {
|
|
1384
|
+
logger.error(`OpenAI proxy failed to bind port ${config.openaiPort}: ${err.message}`);
|
|
1385
|
+
}
|
|
1386
|
+
removePid(config.pidFile);
|
|
1387
|
+
process.exit(1);
|
|
1388
|
+
});
|
|
1389
|
+
cursorServer.on("error", (err) => {
|
|
1390
|
+
if (err.code === "EADDRINUSE") {
|
|
1391
|
+
logger.error(`Port ${config.cursorPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
1392
|
+
} else {
|
|
1393
|
+
logger.error(`Cursor proxy failed to bind port ${config.cursorPort}: ${err.message}`);
|
|
1394
|
+
}
|
|
1395
|
+
removePid(config.pidFile);
|
|
1396
|
+
process.exit(1);
|
|
1397
|
+
});
|
|
950
1398
|
anthropicServer.listen(config.anthropicPort, () => {
|
|
951
1399
|
logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
|
|
952
1400
|
});
|
|
1401
|
+
openaiServer.listen(config.openaiPort, () => {
|
|
1402
|
+
logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
|
|
1403
|
+
});
|
|
1404
|
+
cursorServer.listen(config.cursorPort, () => {
|
|
1405
|
+
logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
|
|
1406
|
+
});
|
|
953
1407
|
writePid(config.pidFile);
|
|
954
|
-
logger.info(`Proxy started (pid=${process.pid})
|
|
1408
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
955
1409
|
const cleanup = () => {
|
|
956
1410
|
logger.info("Shutting down proxy...");
|
|
957
1411
|
anthropicServer.close();
|
|
1412
|
+
openaiServer.close();
|
|
1413
|
+
cursorServer.close();
|
|
958
1414
|
removePid(config.pidFile);
|
|
959
1415
|
process.exit(0);
|
|
960
1416
|
};
|
|
@@ -970,7 +1426,7 @@ function startProxy(config) {
|
|
|
970
1426
|
removePid(config.pidFile);
|
|
971
1427
|
process.exit(1);
|
|
972
1428
|
});
|
|
973
|
-
return { anthropicServer };
|
|
1429
|
+
return { anthropicServer, openaiServer, cursorServer };
|
|
974
1430
|
}
|
|
975
1431
|
function stopProxy(config) {
|
|
976
1432
|
const pid = readPid(config.pidFile);
|
|
@@ -988,7 +1444,9 @@ function getProxyStatus(config) {
|
|
|
988
1444
|
running: pid !== null,
|
|
989
1445
|
pid,
|
|
990
1446
|
uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
|
|
991
|
-
anthropicPort: config.anthropicPort
|
|
1447
|
+
anthropicPort: config.anthropicPort,
|
|
1448
|
+
openaiPort: config.openaiPort,
|
|
1449
|
+
cursorPort: config.cursorPort
|
|
992
1450
|
};
|
|
993
1451
|
}
|
|
994
1452
|
|
|
@@ -1006,12 +1464,20 @@ var DEFAULTS = {
|
|
|
1006
1464
|
apiKey: "",
|
|
1007
1465
|
remoteBaseUrl: "https://api.skalpel.ai",
|
|
1008
1466
|
anthropicDirectUrl: "https://api.anthropic.com",
|
|
1467
|
+
openaiDirectUrl: "https://api.openai.com",
|
|
1009
1468
|
anthropicPort: 18100,
|
|
1469
|
+
openaiPort: 18101,
|
|
1470
|
+
cursorPort: 18102,
|
|
1471
|
+
cursorDirectUrl: "https://api.openai.com",
|
|
1010
1472
|
logLevel: "info",
|
|
1011
1473
|
logFile: "~/.skalpel/logs/proxy.log",
|
|
1012
1474
|
pidFile: "~/.skalpel/proxy.pid",
|
|
1013
|
-
configFile: "~/.skalpel/config.json"
|
|
1475
|
+
configFile: "~/.skalpel/config.json",
|
|
1476
|
+
mode: "proxy"
|
|
1014
1477
|
};
|
|
1478
|
+
function coerceMode(value) {
|
|
1479
|
+
return value === "direct" ? "direct" : "proxy";
|
|
1480
|
+
}
|
|
1015
1481
|
function loadConfig(configPath) {
|
|
1016
1482
|
const filePath = expandHome(configPath ?? DEFAULTS.configFile);
|
|
1017
1483
|
let fileConfig = {};
|
|
@@ -1024,17 +1490,27 @@ function loadConfig(configPath) {
|
|
|
1024
1490
|
apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
1025
1491
|
remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
|
|
1026
1492
|
anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
|
|
1493
|
+
openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
|
|
1027
1494
|
anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
|
|
1495
|
+
openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
|
|
1496
|
+
cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
|
|
1497
|
+
cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
|
|
1028
1498
|
logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
|
|
1029
1499
|
logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
|
|
1030
1500
|
pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
|
|
1031
|
-
configFile: filePath
|
|
1501
|
+
configFile: filePath,
|
|
1502
|
+
mode: coerceMode(fileConfig.mode)
|
|
1032
1503
|
};
|
|
1033
1504
|
}
|
|
1034
1505
|
function saveConfig(config) {
|
|
1035
1506
|
const dir = import_node_path3.default.dirname(config.configFile);
|
|
1036
1507
|
import_node_fs3.default.mkdirSync(dir, { recursive: true });
|
|
1037
|
-
|
|
1508
|
+
const { mode, ...rest } = config;
|
|
1509
|
+
const serializable = { ...rest };
|
|
1510
|
+
if (mode === "direct") {
|
|
1511
|
+
serializable.mode = mode;
|
|
1512
|
+
}
|
|
1513
|
+
import_node_fs3.default.writeFileSync(config.configFile, JSON.stringify(serializable, null, 2) + "\n");
|
|
1038
1514
|
}
|
|
1039
1515
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1040
1516
|
0 && (module.exports = {
|