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/cli/proxy-runner.js
CHANGED
|
@@ -3,7 +3,163 @@
|
|
|
3
3
|
// src/proxy/server.ts
|
|
4
4
|
import http from "http";
|
|
5
5
|
|
|
6
|
+
// src/proxy/dispatcher.ts
|
|
7
|
+
import { Agent } from "undici";
|
|
8
|
+
var skalpelDispatcher = new Agent({
|
|
9
|
+
keepAliveTimeout: 1e4,
|
|
10
|
+
keepAliveMaxTimeout: 6e4,
|
|
11
|
+
connections: 100,
|
|
12
|
+
pipelining: 1
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// src/proxy/envelope.ts
|
|
16
|
+
function isAnthropicShaped(body) {
|
|
17
|
+
if (typeof body !== "object" || body === null) return false;
|
|
18
|
+
const b = body;
|
|
19
|
+
if (b.type !== "error") return false;
|
|
20
|
+
if (typeof b.error !== "object" || b.error === null) return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
function defaultErrorTypeFor(status) {
|
|
24
|
+
if (status === 400) return "invalid_request_error";
|
|
25
|
+
if (status === 401 || status === 403) return "authentication_error";
|
|
26
|
+
if (status === 404) return "not_found_error";
|
|
27
|
+
if (status === 408) return "timeout_error";
|
|
28
|
+
if (status === 429) return "rate_limit_error";
|
|
29
|
+
if (status >= 500) return "api_error";
|
|
30
|
+
if (status >= 400) return "invalid_request_error";
|
|
31
|
+
return "api_error";
|
|
32
|
+
}
|
|
33
|
+
function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
|
|
34
|
+
let parsed = upstreamBody;
|
|
35
|
+
if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(upstreamBody);
|
|
38
|
+
} catch {
|
|
39
|
+
parsed = upstreamBody;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
let type = defaultErrorTypeFor(status);
|
|
43
|
+
let message;
|
|
44
|
+
if (isAnthropicShaped(parsed)) {
|
|
45
|
+
const inner = parsed.error;
|
|
46
|
+
if (typeof inner.type === "string" && inner.type.length > 0) {
|
|
47
|
+
type = inner.type;
|
|
48
|
+
}
|
|
49
|
+
message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
|
|
50
|
+
} else if (typeof parsed === "string" && parsed.length > 0) {
|
|
51
|
+
message = parsed;
|
|
52
|
+
} else {
|
|
53
|
+
message = defaultMessageForStatus(status);
|
|
54
|
+
}
|
|
55
|
+
const envelope = {
|
|
56
|
+
type: "error",
|
|
57
|
+
error: {
|
|
58
|
+
type,
|
|
59
|
+
message,
|
|
60
|
+
status_code: status,
|
|
61
|
+
origin
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
if (hint !== void 0) envelope.error.hint = hint;
|
|
65
|
+
if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
|
|
66
|
+
return envelope;
|
|
67
|
+
}
|
|
68
|
+
function defaultMessageForStatus(status) {
|
|
69
|
+
if (status === 401) return "Authentication failed";
|
|
70
|
+
if (status === 403) return "Forbidden";
|
|
71
|
+
if (status === 404) return "Not found";
|
|
72
|
+
if (status === 408) return "Request timed out";
|
|
73
|
+
if (status === 429) return "Rate limit exceeded";
|
|
74
|
+
if (status === 502) return "Bad gateway";
|
|
75
|
+
if (status === 503) return "Service unavailable";
|
|
76
|
+
if (status === 504) return "Gateway timeout";
|
|
77
|
+
if (status >= 500) return "Upstream error";
|
|
78
|
+
if (status >= 400) return "Client error";
|
|
79
|
+
return "Error";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/proxy/recovery.ts
|
|
83
|
+
import { createHash } from "crypto";
|
|
84
|
+
function parseRetryAfterHeader(header) {
|
|
85
|
+
if (!header) return void 0;
|
|
86
|
+
const trimmed = header.trim();
|
|
87
|
+
if (!trimmed) return void 0;
|
|
88
|
+
const n = Number(trimmed);
|
|
89
|
+
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
90
|
+
const dateMs = Date.parse(trimmed);
|
|
91
|
+
if (Number.isFinite(dateMs)) {
|
|
92
|
+
return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
|
|
93
|
+
}
|
|
94
|
+
return void 0;
|
|
95
|
+
}
|
|
96
|
+
function sleep(ms) {
|
|
97
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
98
|
+
}
|
|
99
|
+
var MAX_RETRY_AFTER_SECONDS = 60;
|
|
100
|
+
var DEFAULT_BACKOFF_SECONDS = 2;
|
|
101
|
+
async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
102
|
+
const headerVal = response.headers.get("retry-after");
|
|
103
|
+
const parsed = parseRetryAfterHeader(headerVal);
|
|
104
|
+
if (parsed === void 0) {
|
|
105
|
+
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
106
|
+
const retried2 = await retryFn();
|
|
107
|
+
logger.info("proxy.recovery.429_retry_count increment");
|
|
108
|
+
return retried2;
|
|
109
|
+
}
|
|
110
|
+
if (parsed > MAX_RETRY_AFTER_SECONDS) {
|
|
111
|
+
return response;
|
|
112
|
+
}
|
|
113
|
+
await sleep(parsed * 1e3);
|
|
114
|
+
const retried = await retryFn();
|
|
115
|
+
logger.info("proxy.recovery.429_retry_count increment");
|
|
116
|
+
return retried;
|
|
117
|
+
}
|
|
118
|
+
var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
119
|
+
async function handleTimeoutWithRetry(err, retryFn, logger) {
|
|
120
|
+
const code = err.code;
|
|
121
|
+
if (!code || !TIMEOUT_CODES.has(code)) {
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
125
|
+
const retried = await retryFn();
|
|
126
|
+
logger.info("proxy.recovery.timeout_retry_count increment");
|
|
127
|
+
return retried;
|
|
128
|
+
}
|
|
129
|
+
function tokenFingerprint(authHeader) {
|
|
130
|
+
if (authHeader === void 0) return "none";
|
|
131
|
+
return createHash("sha256").update(authHeader).digest("hex").slice(0, 12);
|
|
132
|
+
}
|
|
133
|
+
var MUTEX_MAX_ENTRIES = 1024;
|
|
134
|
+
var LruMutexMap = class extends Map {
|
|
135
|
+
set(key, value) {
|
|
136
|
+
if (this.has(key)) {
|
|
137
|
+
super.delete(key);
|
|
138
|
+
} else if (this.size >= MUTEX_MAX_ENTRIES) {
|
|
139
|
+
const oldest = this.keys().next().value;
|
|
140
|
+
if (oldest !== void 0) super.delete(oldest);
|
|
141
|
+
}
|
|
142
|
+
return super.set(key, value);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
var refreshMutex = new LruMutexMap();
|
|
146
|
+
|
|
6
147
|
// src/proxy/streaming.ts
|
|
148
|
+
var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
149
|
+
var HTTP_BAD_GATEWAY = 502;
|
|
150
|
+
function parseRetryAfter(header) {
|
|
151
|
+
if (!header) return void 0;
|
|
152
|
+
const trimmed = header.trim();
|
|
153
|
+
if (!trimmed) return void 0;
|
|
154
|
+
const n = Number(trimmed);
|
|
155
|
+
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
156
|
+
const dateMs = Date.parse(trimmed);
|
|
157
|
+
if (Number.isFinite(dateMs)) {
|
|
158
|
+
const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
|
|
159
|
+
return delta;
|
|
160
|
+
}
|
|
161
|
+
return void 0;
|
|
162
|
+
}
|
|
7
163
|
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
8
164
|
"connection",
|
|
9
165
|
"keep-alive",
|
|
@@ -25,17 +181,11 @@ function stripSkalpelHeaders(headers) {
|
|
|
25
181
|
delete cleaned["X-Skalpel-Source"];
|
|
26
182
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
27
183
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
184
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
28
185
|
return cleaned;
|
|
29
186
|
}
|
|
30
|
-
function isSkalpelBackendFailure(response, err) {
|
|
31
|
-
if (err) return true;
|
|
32
|
-
if (!response) return true;
|
|
33
|
-
if (response.status >= 500) return true;
|
|
34
|
-
if (response.status === 403) return true;
|
|
35
|
-
return false;
|
|
36
|
-
}
|
|
37
187
|
async function doStreamingFetch(url, body, headers) {
|
|
38
|
-
return fetch(url, { method: "POST", headers, body });
|
|
188
|
+
return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
|
|
39
189
|
}
|
|
40
190
|
async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
|
|
41
191
|
let response = null;
|
|
@@ -47,7 +197,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
47
197
|
} catch (err) {
|
|
48
198
|
fetchError = err;
|
|
49
199
|
}
|
|
50
|
-
if (isSkalpelBackendFailure(response, fetchError)) {
|
|
200
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
51
201
|
logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
52
202
|
usedFallback = true;
|
|
53
203
|
response = null;
|
|
@@ -66,15 +216,40 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
66
216
|
fetchError = err;
|
|
67
217
|
}
|
|
68
218
|
}
|
|
219
|
+
const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
220
|
+
const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
|
|
221
|
+
if (fetchError) {
|
|
222
|
+
const code = fetchError.code;
|
|
223
|
+
if (code && TIMEOUT_CODES2.has(code)) {
|
|
224
|
+
try {
|
|
225
|
+
response = await handleTimeoutWithRetry(
|
|
226
|
+
fetchError,
|
|
227
|
+
() => doStreamingFetch(finalUrl, body, finalHeaders),
|
|
228
|
+
logger
|
|
229
|
+
);
|
|
230
|
+
fetchError = null;
|
|
231
|
+
} catch (retryErr) {
|
|
232
|
+
fetchError = retryErr;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (response && response.status === 429) {
|
|
237
|
+
response = await handle429WithRetryAfter(
|
|
238
|
+
response,
|
|
239
|
+
() => doStreamingFetch(finalUrl, body, finalHeaders),
|
|
240
|
+
logger
|
|
241
|
+
);
|
|
242
|
+
}
|
|
69
243
|
if (!response || fetchError) {
|
|
70
244
|
const errMsg = fetchError ? fetchError.message : "no response from upstream";
|
|
71
245
|
logger.error(`streaming fetch failed: ${errMsg}`);
|
|
72
|
-
res.writeHead(
|
|
246
|
+
res.writeHead(HTTP_BAD_GATEWAY, {
|
|
73
247
|
"Content-Type": "text/event-stream",
|
|
74
248
|
"Cache-Control": "no-cache"
|
|
75
249
|
});
|
|
250
|
+
const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
|
|
76
251
|
res.write(`event: error
|
|
77
|
-
data: ${JSON.stringify(
|
|
252
|
+
data: ${JSON.stringify(envelope)}
|
|
78
253
|
|
|
79
254
|
`);
|
|
80
255
|
res.end();
|
|
@@ -84,17 +259,39 @@ data: ${JSON.stringify({ error: errMsg })}
|
|
|
84
259
|
logger.info("streaming: using direct Anthropic API fallback");
|
|
85
260
|
}
|
|
86
261
|
if (response.status >= 300) {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
262
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
263
|
+
const originHeader = response.headers.get("x-skalpel-origin");
|
|
264
|
+
let origin;
|
|
265
|
+
if (originHeader === "backend") origin = "skalpel-backend";
|
|
266
|
+
else if (originHeader === "provider") origin = "provider";
|
|
267
|
+
else origin = "provider";
|
|
268
|
+
let rawBody = "";
|
|
269
|
+
let bodyReadFailed = false;
|
|
270
|
+
try {
|
|
271
|
+
rawBody = Buffer.from(await response.arrayBuffer()).toString();
|
|
272
|
+
} catch (readErr) {
|
|
273
|
+
bodyReadFailed = true;
|
|
274
|
+
logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
|
|
275
|
+
}
|
|
276
|
+
if (!bodyReadFailed) {
|
|
277
|
+
logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
|
|
94
278
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
279
|
+
const envelope = bodyReadFailed ? buildErrorEnvelope(
|
|
280
|
+
response.status,
|
|
281
|
+
"",
|
|
282
|
+
"skalpel-proxy",
|
|
283
|
+
"mid-stream abort",
|
|
284
|
+
retryAfter
|
|
285
|
+
) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
|
|
286
|
+
res.writeHead(response.status, {
|
|
287
|
+
"Content-Type": "text/event-stream",
|
|
288
|
+
"Cache-Control": "no-cache"
|
|
289
|
+
});
|
|
290
|
+
res.write(`event: error
|
|
291
|
+
data: ${JSON.stringify(envelope)}
|
|
292
|
+
|
|
293
|
+
`);
|
|
294
|
+
res.end();
|
|
98
295
|
return;
|
|
99
296
|
}
|
|
100
297
|
const sseHeaders = {};
|
|
@@ -125,8 +322,16 @@ data: ${JSON.stringify({ error: "no response body" })}
|
|
|
125
322
|
}
|
|
126
323
|
} catch (err) {
|
|
127
324
|
logger.error(`streaming error: ${err.message}`);
|
|
325
|
+
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
326
|
+
const envelope = buildErrorEnvelope(
|
|
327
|
+
response.status,
|
|
328
|
+
err.message,
|
|
329
|
+
"skalpel-proxy",
|
|
330
|
+
"mid-stream abort",
|
|
331
|
+
retryAfter
|
|
332
|
+
);
|
|
128
333
|
res.write(`event: error
|
|
129
|
-
data: ${JSON.stringify(
|
|
334
|
+
data: ${JSON.stringify(envelope)}
|
|
130
335
|
|
|
131
336
|
`);
|
|
132
337
|
}
|
|
@@ -134,6 +339,8 @@ data: ${JSON.stringify({ error: err.message })}
|
|
|
134
339
|
}
|
|
135
340
|
|
|
136
341
|
// src/proxy/handler.ts
|
|
342
|
+
var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
343
|
+
var HTTP_BAD_GATEWAY2 = 502;
|
|
137
344
|
var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
|
|
138
345
|
function collectBody(req) {
|
|
139
346
|
return new Promise((resolve, reject) => {
|
|
@@ -143,38 +350,72 @@ function collectBody(req) {
|
|
|
143
350
|
req.on("error", reject);
|
|
144
351
|
});
|
|
145
352
|
}
|
|
146
|
-
function shouldRouteToSkalpel(path4,
|
|
147
|
-
if (
|
|
353
|
+
function shouldRouteToSkalpel(path4, source) {
|
|
354
|
+
if (source !== "claude-code") return true;
|
|
148
355
|
const pathname = path4.split("?")[0];
|
|
149
356
|
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
150
357
|
}
|
|
151
|
-
function
|
|
358
|
+
async function isSkalpelBackendFailure(response, err, logger) {
|
|
152
359
|
if (err) return true;
|
|
153
360
|
if (!response) return true;
|
|
154
|
-
if (response.status
|
|
155
|
-
|
|
156
|
-
return false;
|
|
361
|
+
if (response.status < 500) return false;
|
|
362
|
+
const origin = response.headers?.get("x-skalpel-origin");
|
|
363
|
+
if (origin === "provider") return false;
|
|
364
|
+
if (origin === "backend") return true;
|
|
365
|
+
try {
|
|
366
|
+
const text = await response.clone().text();
|
|
367
|
+
if (!text) {
|
|
368
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
let shape = "non-anthropic";
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(text);
|
|
374
|
+
if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
|
|
375
|
+
shape = "anthropic";
|
|
376
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
}
|
|
381
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
|
|
382
|
+
return true;
|
|
383
|
+
} catch {
|
|
384
|
+
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
157
387
|
}
|
|
388
|
+
var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
|
|
389
|
+
"host",
|
|
390
|
+
"connection",
|
|
391
|
+
"keep-alive",
|
|
392
|
+
"proxy-authenticate",
|
|
393
|
+
"proxy-authorization",
|
|
394
|
+
"te",
|
|
395
|
+
"trailer",
|
|
396
|
+
"transfer-encoding",
|
|
397
|
+
"upgrade"
|
|
398
|
+
]);
|
|
158
399
|
function buildForwardHeaders(req, config2, source, useSkalpel) {
|
|
159
400
|
const forwardHeaders = {};
|
|
160
401
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
161
|
-
if (value
|
|
162
|
-
|
|
163
|
-
|
|
402
|
+
if (value === void 0) continue;
|
|
403
|
+
if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
|
|
404
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
164
405
|
}
|
|
165
|
-
delete forwardHeaders["host"];
|
|
166
|
-
delete forwardHeaders["connection"];
|
|
167
406
|
if (useSkalpel) {
|
|
168
407
|
forwardHeaders["X-Skalpel-API-Key"] = config2.apiKey;
|
|
169
408
|
forwardHeaders["X-Skalpel-Source"] = source;
|
|
170
409
|
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
171
410
|
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
411
|
+
forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
|
|
172
412
|
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
173
413
|
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
174
414
|
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
175
415
|
const token = authHeader.slice(7).trim();
|
|
176
416
|
if (token.startsWith("sk-ant-")) {
|
|
177
417
|
forwardHeaders["x-api-key"] = token;
|
|
418
|
+
delete forwardHeaders["authorization"];
|
|
178
419
|
}
|
|
179
420
|
}
|
|
180
421
|
}
|
|
@@ -187,6 +428,7 @@ function stripSkalpelHeaders2(headers) {
|
|
|
187
428
|
delete cleaned["X-Skalpel-Source"];
|
|
188
429
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
189
430
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
431
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
190
432
|
return cleaned;
|
|
191
433
|
}
|
|
192
434
|
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
@@ -214,6 +456,11 @@ async function handleRequest(req, res, config2, source, logger) {
|
|
|
214
456
|
const start = Date.now();
|
|
215
457
|
const method = req.method ?? "GET";
|
|
216
458
|
const path4 = req.url ?? "/";
|
|
459
|
+
const fp = tokenFingerprint(
|
|
460
|
+
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
461
|
+
);
|
|
462
|
+
logger.info(`${source} ${method} ${path4} token=${fp}`);
|
|
463
|
+
let response = null;
|
|
217
464
|
try {
|
|
218
465
|
const body = await collectBody(req);
|
|
219
466
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
@@ -228,45 +475,91 @@ async function handleRequest(req, res, config2, source, logger) {
|
|
|
228
475
|
}
|
|
229
476
|
if (isStreaming) {
|
|
230
477
|
const skalpelUrl2 = `${config2.remoteBaseUrl}${path4}`;
|
|
231
|
-
const directUrl2 = `${config2.anthropicDirectUrl}${path4}`;
|
|
478
|
+
const directUrl2 = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
|
|
232
479
|
await handleStreamingRequest(req, res, config2, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
|
|
233
480
|
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
234
481
|
return;
|
|
235
482
|
}
|
|
236
483
|
const skalpelUrl = `${config2.remoteBaseUrl}${path4}`;
|
|
237
|
-
const directUrl = `${config2.anthropicDirectUrl}${path4}`;
|
|
484
|
+
const directUrl = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
|
|
238
485
|
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
239
|
-
let response = null;
|
|
240
486
|
let fetchError = null;
|
|
241
487
|
let usedFallback = false;
|
|
242
488
|
if (useSkalpel) {
|
|
243
489
|
try {
|
|
244
|
-
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
490
|
+
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
245
491
|
} catch (err) {
|
|
246
492
|
fetchError = err;
|
|
247
493
|
}
|
|
248
|
-
if (
|
|
494
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
249
495
|
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
250
496
|
usedFallback = true;
|
|
251
497
|
response = null;
|
|
252
498
|
fetchError = null;
|
|
253
499
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
254
500
|
try {
|
|
255
|
-
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
|
|
501
|
+
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
256
502
|
} catch (err) {
|
|
257
503
|
fetchError = err;
|
|
258
504
|
}
|
|
259
505
|
}
|
|
260
506
|
} else {
|
|
261
507
|
try {
|
|
262
|
-
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
508
|
+
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
263
509
|
} catch (err) {
|
|
264
510
|
fetchError = err;
|
|
265
511
|
}
|
|
266
512
|
}
|
|
513
|
+
const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
514
|
+
const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
|
|
515
|
+
if (fetchError) {
|
|
516
|
+
const code = fetchError.code;
|
|
517
|
+
if (code && TIMEOUT_CODES3.has(code)) {
|
|
518
|
+
try {
|
|
519
|
+
response = await handleTimeoutWithRetry(
|
|
520
|
+
fetchError,
|
|
521
|
+
() => fetch(fetchUrl, {
|
|
522
|
+
method,
|
|
523
|
+
headers: fetchHeaders,
|
|
524
|
+
body: fetchBody,
|
|
525
|
+
dispatcher: skalpelDispatcher
|
|
526
|
+
}),
|
|
527
|
+
logger
|
|
528
|
+
);
|
|
529
|
+
fetchError = null;
|
|
530
|
+
} catch (retryErr) {
|
|
531
|
+
fetchError = retryErr;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
267
535
|
if (!response || fetchError) {
|
|
536
|
+
response = null;
|
|
268
537
|
throw fetchError ?? new Error("no response from upstream");
|
|
269
538
|
}
|
|
539
|
+
if (response.status === 429) {
|
|
540
|
+
response = await handle429WithRetryAfter(
|
|
541
|
+
response,
|
|
542
|
+
() => fetch(fetchUrl, {
|
|
543
|
+
method,
|
|
544
|
+
headers: fetchHeaders,
|
|
545
|
+
body: fetchBody,
|
|
546
|
+
dispatcher: skalpelDispatcher
|
|
547
|
+
}),
|
|
548
|
+
logger
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
if (response.status === 401 && (source === "claude-code" || source === "codex")) {
|
|
552
|
+
const fp2 = tokenFingerprint(
|
|
553
|
+
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
554
|
+
);
|
|
555
|
+
logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
|
|
556
|
+
const body401 = Buffer.from(await response.arrayBuffer());
|
|
557
|
+
const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
|
|
558
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
559
|
+
res.end(JSON.stringify(envelope));
|
|
560
|
+
logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
270
563
|
const responseHeaders = extractResponseHeaders(response);
|
|
271
564
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
272
565
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
@@ -276,20 +569,38 @@ async function handleRequest(req, res, config2, source, logger) {
|
|
|
276
569
|
} catch (err) {
|
|
277
570
|
logger.error(`${method} ${path4} source=${source} error=${err.message}`);
|
|
278
571
|
if (!res.headersSent) {
|
|
279
|
-
|
|
280
|
-
|
|
572
|
+
if (response !== null) {
|
|
573
|
+
const upstreamStatus = response.status;
|
|
574
|
+
const envelope = buildErrorEnvelope(
|
|
575
|
+
upstreamStatus,
|
|
576
|
+
"",
|
|
577
|
+
"skalpel-proxy",
|
|
578
|
+
"body read failed after upstream status"
|
|
579
|
+
);
|
|
580
|
+
res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
|
|
581
|
+
res.end(JSON.stringify(envelope));
|
|
582
|
+
} else {
|
|
583
|
+
const envelope = buildErrorEnvelope(
|
|
584
|
+
HTTP_BAD_GATEWAY2,
|
|
585
|
+
err.message,
|
|
586
|
+
"skalpel-proxy"
|
|
587
|
+
);
|
|
588
|
+
res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
|
|
589
|
+
res.end(JSON.stringify(envelope));
|
|
590
|
+
}
|
|
281
591
|
}
|
|
282
592
|
}
|
|
283
593
|
}
|
|
284
594
|
|
|
285
595
|
// src/proxy/health.ts
|
|
286
|
-
function handleHealthRequest(res, config2, startTime
|
|
596
|
+
function handleHealthRequest(res, config2, startTime) {
|
|
287
597
|
const body = JSON.stringify({
|
|
288
598
|
status: "ok",
|
|
289
|
-
mode: passthrough ? "passthrough" : "normal",
|
|
290
599
|
uptime: Date.now() - startTime,
|
|
291
600
|
ports: {
|
|
292
|
-
anthropic: config2.anthropicPort
|
|
601
|
+
anthropic: config2.anthropicPort,
|
|
602
|
+
openai: config2.openaiPort,
|
|
603
|
+
cursor: config2.cursorPort
|
|
293
604
|
},
|
|
294
605
|
version: "proxy-1.0.0"
|
|
295
606
|
});
|
|
@@ -300,9 +611,33 @@ function handleHealthRequest(res, config2, startTime, passthrough = false) {
|
|
|
300
611
|
// src/proxy/pid.ts
|
|
301
612
|
import fs from "fs";
|
|
302
613
|
import path from "path";
|
|
614
|
+
import { execSync } from "child_process";
|
|
303
615
|
function writePid(pidFile) {
|
|
304
616
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
305
|
-
|
|
617
|
+
const record = {
|
|
618
|
+
pid: process.pid,
|
|
619
|
+
startTime: getStartTime(process.pid)
|
|
620
|
+
};
|
|
621
|
+
fs.writeFileSync(pidFile, JSON.stringify(record));
|
|
622
|
+
}
|
|
623
|
+
function getStartTime(pid) {
|
|
624
|
+
try {
|
|
625
|
+
if (process.platform === "linux") {
|
|
626
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
627
|
+
const rparen = stat.lastIndexOf(")");
|
|
628
|
+
if (rparen < 0) return null;
|
|
629
|
+
const fields = stat.slice(rparen + 2).split(" ");
|
|
630
|
+
return fields[19] ?? null;
|
|
631
|
+
}
|
|
632
|
+
if (process.platform === "darwin") {
|
|
633
|
+
const out = execSync(`ps -p ${pid} -o lstart=`, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] });
|
|
634
|
+
const text = out.toString().trim();
|
|
635
|
+
return text || null;
|
|
636
|
+
}
|
|
637
|
+
return null;
|
|
638
|
+
} catch {
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
306
641
|
}
|
|
307
642
|
function removePid(pidFile) {
|
|
308
643
|
try {
|
|
@@ -317,12 +652,14 @@ import path2 from "path";
|
|
|
317
652
|
var MAX_SIZE = 5 * 1024 * 1024;
|
|
318
653
|
var MAX_ROTATIONS = 3;
|
|
319
654
|
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
320
|
-
var Logger = class {
|
|
655
|
+
var Logger = class _Logger {
|
|
321
656
|
logFile;
|
|
322
657
|
level;
|
|
323
|
-
|
|
658
|
+
prefix;
|
|
659
|
+
constructor(logFile, level = "info", prefix = "") {
|
|
324
660
|
this.logFile = logFile;
|
|
325
661
|
this.level = level;
|
|
662
|
+
this.prefix = prefix;
|
|
326
663
|
fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
|
|
327
664
|
}
|
|
328
665
|
debug(msg) {
|
|
@@ -337,9 +674,16 @@ var Logger = class {
|
|
|
337
674
|
error(msg) {
|
|
338
675
|
this.log("error", msg);
|
|
339
676
|
}
|
|
677
|
+
/** Returns a new Logger that writes to the same file but prefixes every
|
|
678
|
+
* emitted line with `[conn=<connId>] `. The parent logger continues to
|
|
679
|
+
* work unchanged. IPv6 colons should already be sanitized by the caller. */
|
|
680
|
+
child(connId) {
|
|
681
|
+
const child = new _Logger(this.logFile, this.level, `[conn=${connId}] `);
|
|
682
|
+
return child;
|
|
683
|
+
}
|
|
340
684
|
log(level, msg) {
|
|
341
685
|
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
342
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
|
|
686
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
|
|
343
687
|
`;
|
|
344
688
|
if (level === "debug" || level === "error") {
|
|
345
689
|
process.stderr.write(line);
|
|
@@ -370,50 +714,41 @@ var Logger = class {
|
|
|
370
714
|
|
|
371
715
|
// src/proxy/server.ts
|
|
372
716
|
var proxyStartTime = 0;
|
|
373
|
-
var
|
|
374
|
-
function
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
381
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
382
|
-
req.on("error", reject);
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
function handleAdminMode(req, res, logger) {
|
|
386
|
-
collectAdminBody(req).then((body) => {
|
|
387
|
-
try {
|
|
388
|
-
const { mode } = JSON.parse(body);
|
|
389
|
-
passthroughMode = mode === "passthrough";
|
|
390
|
-
logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
|
|
391
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
392
|
-
res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
|
|
393
|
-
} catch {
|
|
394
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
395
|
-
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
396
|
-
}
|
|
397
|
-
}).catch(() => {
|
|
398
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
399
|
-
res.end(JSON.stringify({ error: "failed to read body" }));
|
|
400
|
-
});
|
|
717
|
+
var connCounter = 0;
|
|
718
|
+
function computeConnId(req) {
|
|
719
|
+
const addr = req.socket.remoteAddress ?? "unknown";
|
|
720
|
+
const port = req.socket.remotePort ?? 0;
|
|
721
|
+
const counter = (++connCounter).toString(36);
|
|
722
|
+
const raw = addr + "|" + port + "|" + Date.now().toString(36) + "|" + counter + "|" + Math.floor(Math.random() * 4096).toString(16);
|
|
723
|
+
return raw.replace(/:/g, "_");
|
|
401
724
|
}
|
|
402
725
|
function startProxy(config2) {
|
|
403
726
|
const logger = new Logger(config2.logFile, config2.logLevel);
|
|
404
727
|
const startTime = Date.now();
|
|
405
728
|
proxyStartTime = Date.now();
|
|
406
|
-
passthroughMode = false;
|
|
407
729
|
const anthropicServer = http.createServer((req, res) => {
|
|
408
730
|
if (req.url === "/health" && req.method === "GET") {
|
|
409
|
-
handleHealthRequest(res, config2, startTime
|
|
731
|
+
handleHealthRequest(res, config2, startTime);
|
|
410
732
|
return;
|
|
411
733
|
}
|
|
412
|
-
|
|
413
|
-
|
|
734
|
+
const connId = computeConnId(req);
|
|
735
|
+
handleRequest(req, res, config2, "claude-code", logger.child(connId));
|
|
736
|
+
});
|
|
737
|
+
const openaiServer = http.createServer((req, res) => {
|
|
738
|
+
if (req.url === "/health" && req.method === "GET") {
|
|
739
|
+
handleHealthRequest(res, config2, startTime);
|
|
414
740
|
return;
|
|
415
741
|
}
|
|
416
|
-
|
|
742
|
+
const connId = computeConnId(req);
|
|
743
|
+
handleRequest(req, res, config2, "codex", logger.child(connId));
|
|
744
|
+
});
|
|
745
|
+
const cursorServer = http.createServer((req, res) => {
|
|
746
|
+
if (req.url === "/health" && req.method === "GET") {
|
|
747
|
+
handleHealthRequest(res, config2, startTime);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const connId = computeConnId(req);
|
|
751
|
+
handleRequest(req, res, config2, "cursor", logger.child(connId));
|
|
417
752
|
});
|
|
418
753
|
anthropicServer.on("error", (err) => {
|
|
419
754
|
if (err.code === "EADDRINUSE") {
|
|
@@ -424,14 +759,40 @@ function startProxy(config2) {
|
|
|
424
759
|
removePid(config2.pidFile);
|
|
425
760
|
process.exit(1);
|
|
426
761
|
});
|
|
762
|
+
openaiServer.on("error", (err) => {
|
|
763
|
+
if (err.code === "EADDRINUSE") {
|
|
764
|
+
logger.error(`Port ${config2.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
765
|
+
} else {
|
|
766
|
+
logger.error(`OpenAI proxy failed to bind port ${config2.openaiPort}: ${err.message}`);
|
|
767
|
+
}
|
|
768
|
+
removePid(config2.pidFile);
|
|
769
|
+
process.exit(1);
|
|
770
|
+
});
|
|
771
|
+
cursorServer.on("error", (err) => {
|
|
772
|
+
if (err.code === "EADDRINUSE") {
|
|
773
|
+
logger.error(`Port ${config2.cursorPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
774
|
+
} else {
|
|
775
|
+
logger.error(`Cursor proxy failed to bind port ${config2.cursorPort}: ${err.message}`);
|
|
776
|
+
}
|
|
777
|
+
removePid(config2.pidFile);
|
|
778
|
+
process.exit(1);
|
|
779
|
+
});
|
|
427
780
|
anthropicServer.listen(config2.anthropicPort, () => {
|
|
428
781
|
logger.info(`Anthropic proxy listening on port ${config2.anthropicPort}`);
|
|
429
782
|
});
|
|
783
|
+
openaiServer.listen(config2.openaiPort, () => {
|
|
784
|
+
logger.info(`OpenAI proxy listening on port ${config2.openaiPort}`);
|
|
785
|
+
});
|
|
786
|
+
cursorServer.listen(config2.cursorPort, () => {
|
|
787
|
+
logger.info(`Cursor proxy listening on port ${config2.cursorPort}`);
|
|
788
|
+
});
|
|
430
789
|
writePid(config2.pidFile);
|
|
431
|
-
logger.info(`Proxy started (pid=${process.pid})
|
|
790
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config2.anthropicPort},${config2.openaiPort},${config2.cursorPort}`);
|
|
432
791
|
const cleanup = () => {
|
|
433
792
|
logger.info("Shutting down proxy...");
|
|
434
793
|
anthropicServer.close();
|
|
794
|
+
openaiServer.close();
|
|
795
|
+
cursorServer.close();
|
|
435
796
|
removePid(config2.pidFile);
|
|
436
797
|
process.exit(0);
|
|
437
798
|
};
|
|
@@ -447,7 +808,7 @@ function startProxy(config2) {
|
|
|
447
808
|
removePid(config2.pidFile);
|
|
448
809
|
process.exit(1);
|
|
449
810
|
});
|
|
450
|
-
return { anthropicServer };
|
|
811
|
+
return { anthropicServer, openaiServer, cursorServer };
|
|
451
812
|
}
|
|
452
813
|
|
|
453
814
|
// src/proxy/config.ts
|
|
@@ -464,12 +825,20 @@ var DEFAULTS = {
|
|
|
464
825
|
apiKey: "",
|
|
465
826
|
remoteBaseUrl: "https://api.skalpel.ai",
|
|
466
827
|
anthropicDirectUrl: "https://api.anthropic.com",
|
|
828
|
+
openaiDirectUrl: "https://api.openai.com",
|
|
467
829
|
anthropicPort: 18100,
|
|
830
|
+
openaiPort: 18101,
|
|
831
|
+
cursorPort: 18102,
|
|
832
|
+
cursorDirectUrl: "https://api.openai.com",
|
|
468
833
|
logLevel: "info",
|
|
469
834
|
logFile: "~/.skalpel/logs/proxy.log",
|
|
470
835
|
pidFile: "~/.skalpel/proxy.pid",
|
|
471
|
-
configFile: "~/.skalpel/config.json"
|
|
836
|
+
configFile: "~/.skalpel/config.json",
|
|
837
|
+
mode: "proxy"
|
|
472
838
|
};
|
|
839
|
+
function coerceMode(value) {
|
|
840
|
+
return value === "direct" ? "direct" : "proxy";
|
|
841
|
+
}
|
|
473
842
|
function loadConfig(configPath) {
|
|
474
843
|
const filePath = expandHome(configPath ?? DEFAULTS.configFile);
|
|
475
844
|
let fileConfig = {};
|
|
@@ -482,11 +851,16 @@ function loadConfig(configPath) {
|
|
|
482
851
|
apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
483
852
|
remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
|
|
484
853
|
anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
|
|
854
|
+
openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
|
|
485
855
|
anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
|
|
856
|
+
openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
|
|
857
|
+
cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
|
|
858
|
+
cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
|
|
486
859
|
logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
|
|
487
860
|
logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
|
|
488
861
|
pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
|
|
489
|
-
configFile: filePath
|
|
862
|
+
configFile: filePath,
|
|
863
|
+
mode: coerceMode(fileConfig.mode)
|
|
490
864
|
};
|
|
491
865
|
}
|
|
492
866
|
|