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