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