skalpel 2.0.11 → 2.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +547 -330
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/proxy-runner.js +437 -91
- package/dist/cli/proxy-runner.js.map +1 -1
- package/dist/index.cjs +584 -137
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +584 -137
- package/dist/index.js.map +1 -1
- package/dist/proxy/index.cjs +468 -93
- package/dist/proxy/index.cjs.map +1 -1
- package/dist/proxy/index.d.cts +7 -0
- package/dist/proxy/index.d.ts +7 -0
- package/dist/proxy/index.js +468 -93
- package/dist/proxy/index.js.map +1 -1
- package/package.json +6 -13
package/dist/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) => {
|
|
@@ -144,38 +351,71 @@ function collectBody(req) {
|
|
|
144
351
|
});
|
|
145
352
|
}
|
|
146
353
|
function shouldRouteToSkalpel(path4, source) {
|
|
147
|
-
if (isPassthroughMode()) return false;
|
|
148
354
|
if (source !== "claude-code") return true;
|
|
149
355
|
const pathname = path4.split("?")[0];
|
|
150
356
|
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
151
357
|
}
|
|
152
|
-
function
|
|
358
|
+
async function isSkalpelBackendFailure(response, err, logger) {
|
|
153
359
|
if (err) return true;
|
|
154
360
|
if (!response) return true;
|
|
155
|
-
if (response.status
|
|
156
|
-
|
|
157
|
-
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
|
+
}
|
|
158
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
|
+
]);
|
|
159
399
|
function buildForwardHeaders(req, config2, source, useSkalpel) {
|
|
160
400
|
const forwardHeaders = {};
|
|
161
401
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
162
|
-
if (value
|
|
163
|
-
|
|
164
|
-
|
|
402
|
+
if (value === void 0) continue;
|
|
403
|
+
if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
|
|
404
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
165
405
|
}
|
|
166
|
-
delete forwardHeaders["host"];
|
|
167
|
-
delete forwardHeaders["connection"];
|
|
168
406
|
if (useSkalpel) {
|
|
169
407
|
forwardHeaders["X-Skalpel-API-Key"] = config2.apiKey;
|
|
170
408
|
forwardHeaders["X-Skalpel-Source"] = source;
|
|
171
409
|
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
172
410
|
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
411
|
+
forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
|
|
173
412
|
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
174
413
|
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
175
414
|
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
176
415
|
const token = authHeader.slice(7).trim();
|
|
177
416
|
if (token.startsWith("sk-ant-")) {
|
|
178
417
|
forwardHeaders["x-api-key"] = token;
|
|
418
|
+
delete forwardHeaders["authorization"];
|
|
179
419
|
}
|
|
180
420
|
}
|
|
181
421
|
}
|
|
@@ -188,6 +428,7 @@ function stripSkalpelHeaders2(headers) {
|
|
|
188
428
|
delete cleaned["X-Skalpel-Source"];
|
|
189
429
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
190
430
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
431
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
191
432
|
return cleaned;
|
|
192
433
|
}
|
|
193
434
|
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
@@ -215,6 +456,11 @@ async function handleRequest(req, res, config2, source, logger) {
|
|
|
215
456
|
const start = Date.now();
|
|
216
457
|
const method = req.method ?? "GET";
|
|
217
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;
|
|
218
464
|
try {
|
|
219
465
|
const body = await collectBody(req);
|
|
220
466
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
@@ -229,45 +475,91 @@ async function handleRequest(req, res, config2, source, logger) {
|
|
|
229
475
|
}
|
|
230
476
|
if (isStreaming) {
|
|
231
477
|
const skalpelUrl2 = `${config2.remoteBaseUrl}${path4}`;
|
|
232
|
-
const directUrl2 = `${config2.anthropicDirectUrl}${path4}`;
|
|
478
|
+
const directUrl2 = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
|
|
233
479
|
await handleStreamingRequest(req, res, config2, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
|
|
234
480
|
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
235
481
|
return;
|
|
236
482
|
}
|
|
237
483
|
const skalpelUrl = `${config2.remoteBaseUrl}${path4}`;
|
|
238
|
-
const directUrl = `${config2.anthropicDirectUrl}${path4}`;
|
|
484
|
+
const directUrl = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
|
|
239
485
|
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
240
|
-
let response = null;
|
|
241
486
|
let fetchError = null;
|
|
242
487
|
let usedFallback = false;
|
|
243
488
|
if (useSkalpel) {
|
|
244
489
|
try {
|
|
245
|
-
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
490
|
+
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
246
491
|
} catch (err) {
|
|
247
492
|
fetchError = err;
|
|
248
493
|
}
|
|
249
|
-
if (
|
|
494
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
250
495
|
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
251
496
|
usedFallback = true;
|
|
252
497
|
response = null;
|
|
253
498
|
fetchError = null;
|
|
254
499
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
255
500
|
try {
|
|
256
|
-
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
|
|
501
|
+
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
257
502
|
} catch (err) {
|
|
258
503
|
fetchError = err;
|
|
259
504
|
}
|
|
260
505
|
}
|
|
261
506
|
} else {
|
|
262
507
|
try {
|
|
263
|
-
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
508
|
+
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
264
509
|
} catch (err) {
|
|
265
510
|
fetchError = err;
|
|
266
511
|
}
|
|
267
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
|
+
}
|
|
268
535
|
if (!response || fetchError) {
|
|
536
|
+
response = null;
|
|
269
537
|
throw fetchError ?? new Error("no response from upstream");
|
|
270
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
|
+
}
|
|
271
563
|
const responseHeaders = extractResponseHeaders(response);
|
|
272
564
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
273
565
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
@@ -277,21 +569,38 @@ async function handleRequest(req, res, config2, source, logger) {
|
|
|
277
569
|
} catch (err) {
|
|
278
570
|
logger.error(`${method} ${path4} source=${source} error=${err.message}`);
|
|
279
571
|
if (!res.headersSent) {
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
}
|
|
282
591
|
}
|
|
283
592
|
}
|
|
284
593
|
}
|
|
285
594
|
|
|
286
595
|
// src/proxy/health.ts
|
|
287
|
-
function handleHealthRequest(res, config2, startTime
|
|
596
|
+
function handleHealthRequest(res, config2, startTime) {
|
|
288
597
|
const body = JSON.stringify({
|
|
289
598
|
status: "ok",
|
|
290
|
-
mode: passthrough ? "passthrough" : "normal",
|
|
291
599
|
uptime: Date.now() - startTime,
|
|
292
600
|
ports: {
|
|
293
601
|
anthropic: config2.anthropicPort,
|
|
294
|
-
openai: config2.openaiPort
|
|
602
|
+
openai: config2.openaiPort,
|
|
603
|
+
cursor: config2.cursorPort
|
|
295
604
|
},
|
|
296
605
|
version: "proxy-1.0.0"
|
|
297
606
|
});
|
|
@@ -302,9 +611,33 @@ function handleHealthRequest(res, config2, startTime, passthrough = false) {
|
|
|
302
611
|
// src/proxy/pid.ts
|
|
303
612
|
import fs from "fs";
|
|
304
613
|
import path from "path";
|
|
614
|
+
import { execSync } from "child_process";
|
|
305
615
|
function writePid(pidFile) {
|
|
306
616
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
307
|
-
|
|
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
|
+
}
|
|
308
641
|
}
|
|
309
642
|
function removePid(pidFile) {
|
|
310
643
|
try {
|
|
@@ -319,12 +652,14 @@ import path2 from "path";
|
|
|
319
652
|
var MAX_SIZE = 5 * 1024 * 1024;
|
|
320
653
|
var MAX_ROTATIONS = 3;
|
|
321
654
|
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
322
|
-
var Logger = class {
|
|
655
|
+
var Logger = class _Logger {
|
|
323
656
|
logFile;
|
|
324
657
|
level;
|
|
325
|
-
|
|
658
|
+
prefix;
|
|
659
|
+
constructor(logFile, level = "info", prefix = "") {
|
|
326
660
|
this.logFile = logFile;
|
|
327
661
|
this.level = level;
|
|
662
|
+
this.prefix = prefix;
|
|
328
663
|
fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
|
|
329
664
|
}
|
|
330
665
|
debug(msg) {
|
|
@@ -339,9 +674,16 @@ var Logger = class {
|
|
|
339
674
|
error(msg) {
|
|
340
675
|
this.log("error", msg);
|
|
341
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
|
+
}
|
|
342
684
|
log(level, msg) {
|
|
343
685
|
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
344
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
|
|
686
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
|
|
345
687
|
`;
|
|
346
688
|
if (level === "debug" || level === "error") {
|
|
347
689
|
process.stderr.write(line);
|
|
@@ -372,61 +714,41 @@ var Logger = class {
|
|
|
372
714
|
|
|
373
715
|
// src/proxy/server.ts
|
|
374
716
|
var proxyStartTime = 0;
|
|
375
|
-
var
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
383
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
384
|
-
req.on("error", reject);
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
function handleAdminMode(req, res, logger) {
|
|
388
|
-
collectAdminBody(req).then((body) => {
|
|
389
|
-
try {
|
|
390
|
-
const { mode } = JSON.parse(body);
|
|
391
|
-
passthroughMode = mode === "passthrough";
|
|
392
|
-
logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
|
|
393
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
394
|
-
res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
|
|
395
|
-
} catch {
|
|
396
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
397
|
-
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
398
|
-
}
|
|
399
|
-
}).catch(() => {
|
|
400
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
401
|
-
res.end(JSON.stringify({ error: "failed to read body" }));
|
|
402
|
-
});
|
|
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, "_");
|
|
403
724
|
}
|
|
404
725
|
function startProxy(config2) {
|
|
405
726
|
const logger = new Logger(config2.logFile, config2.logLevel);
|
|
406
727
|
const startTime = Date.now();
|
|
407
728
|
proxyStartTime = Date.now();
|
|
408
|
-
passthroughMode = false;
|
|
409
729
|
const anthropicServer = http.createServer((req, res) => {
|
|
410
730
|
if (req.url === "/health" && req.method === "GET") {
|
|
411
|
-
handleHealthRequest(res, config2, startTime
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
if (req.url === "/admin/mode" && req.method === "POST") {
|
|
415
|
-
handleAdminMode(req, res, logger);
|
|
731
|
+
handleHealthRequest(res, config2, startTime);
|
|
416
732
|
return;
|
|
417
733
|
}
|
|
418
|
-
|
|
734
|
+
const connId = computeConnId(req);
|
|
735
|
+
handleRequest(req, res, config2, "claude-code", logger.child(connId));
|
|
419
736
|
});
|
|
420
737
|
const openaiServer = http.createServer((req, res) => {
|
|
421
738
|
if (req.url === "/health" && req.method === "GET") {
|
|
422
|
-
handleHealthRequest(res, config2, startTime
|
|
739
|
+
handleHealthRequest(res, config2, startTime);
|
|
423
740
|
return;
|
|
424
741
|
}
|
|
425
|
-
|
|
426
|
-
|
|
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);
|
|
427
748
|
return;
|
|
428
749
|
}
|
|
429
|
-
|
|
750
|
+
const connId = computeConnId(req);
|
|
751
|
+
handleRequest(req, res, config2, "cursor", logger.child(connId));
|
|
430
752
|
});
|
|
431
753
|
anthropicServer.on("error", (err) => {
|
|
432
754
|
if (err.code === "EADDRINUSE") {
|
|
@@ -446,18 +768,31 @@ function startProxy(config2) {
|
|
|
446
768
|
removePid(config2.pidFile);
|
|
447
769
|
process.exit(1);
|
|
448
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
|
+
});
|
|
449
780
|
anthropicServer.listen(config2.anthropicPort, () => {
|
|
450
781
|
logger.info(`Anthropic proxy listening on port ${config2.anthropicPort}`);
|
|
451
782
|
});
|
|
452
783
|
openaiServer.listen(config2.openaiPort, () => {
|
|
453
784
|
logger.info(`OpenAI proxy listening on port ${config2.openaiPort}`);
|
|
454
785
|
});
|
|
786
|
+
cursorServer.listen(config2.cursorPort, () => {
|
|
787
|
+
logger.info(`Cursor proxy listening on port ${config2.cursorPort}`);
|
|
788
|
+
});
|
|
455
789
|
writePid(config2.pidFile);
|
|
456
|
-
logger.info(`Proxy started (pid=${process.pid}) ports=${config2.anthropicPort},${config2.openaiPort}`);
|
|
790
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config2.anthropicPort},${config2.openaiPort},${config2.cursorPort}`);
|
|
457
791
|
const cleanup = () => {
|
|
458
792
|
logger.info("Shutting down proxy...");
|
|
459
793
|
anthropicServer.close();
|
|
460
794
|
openaiServer.close();
|
|
795
|
+
cursorServer.close();
|
|
461
796
|
removePid(config2.pidFile);
|
|
462
797
|
process.exit(0);
|
|
463
798
|
};
|
|
@@ -473,7 +808,7 @@ function startProxy(config2) {
|
|
|
473
808
|
removePid(config2.pidFile);
|
|
474
809
|
process.exit(1);
|
|
475
810
|
});
|
|
476
|
-
return { anthropicServer, openaiServer };
|
|
811
|
+
return { anthropicServer, openaiServer, cursorServer };
|
|
477
812
|
}
|
|
478
813
|
|
|
479
814
|
// src/proxy/config.ts
|
|
@@ -490,13 +825,20 @@ var DEFAULTS = {
|
|
|
490
825
|
apiKey: "",
|
|
491
826
|
remoteBaseUrl: "https://api.skalpel.ai",
|
|
492
827
|
anthropicDirectUrl: "https://api.anthropic.com",
|
|
828
|
+
openaiDirectUrl: "https://api.openai.com",
|
|
493
829
|
anthropicPort: 18100,
|
|
494
830
|
openaiPort: 18101,
|
|
831
|
+
cursorPort: 18102,
|
|
832
|
+
cursorDirectUrl: "https://api.openai.com",
|
|
495
833
|
logLevel: "info",
|
|
496
834
|
logFile: "~/.skalpel/logs/proxy.log",
|
|
497
835
|
pidFile: "~/.skalpel/proxy.pid",
|
|
498
|
-
configFile: "~/.skalpel/config.json"
|
|
836
|
+
configFile: "~/.skalpel/config.json",
|
|
837
|
+
mode: "proxy"
|
|
499
838
|
};
|
|
839
|
+
function coerceMode(value) {
|
|
840
|
+
return value === "direct" ? "direct" : "proxy";
|
|
841
|
+
}
|
|
500
842
|
function loadConfig(configPath) {
|
|
501
843
|
const filePath = expandHome(configPath ?? DEFAULTS.configFile);
|
|
502
844
|
let fileConfig = {};
|
|
@@ -509,12 +851,16 @@ function loadConfig(configPath) {
|
|
|
509
851
|
apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
510
852
|
remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
|
|
511
853
|
anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
|
|
854
|
+
openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
|
|
512
855
|
anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
|
|
513
856
|
openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
|
|
857
|
+
cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
|
|
858
|
+
cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
|
|
514
859
|
logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
|
|
515
860
|
logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
|
|
516
861
|
pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
|
|
517
|
-
configFile: filePath
|
|
862
|
+
configFile: filePath,
|
|
863
|
+
mode: coerceMode(fileConfig.mode)
|
|
518
864
|
};
|
|
519
865
|
}
|
|
520
866
|
|