skalpel 2.0.12 → 2.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/dist/cli/index.js +634 -314
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/proxy-runner.js +460 -86
- package/dist/cli/proxy-runner.js.map +1 -1
- package/dist/index.cjs +608 -132
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +608 -132
- package/dist/index.js.map +1 -1
- package/dist/proxy/index.cjs +492 -88
- package/dist/proxy/index.cjs.map +1 -1
- package/dist/proxy/index.d.cts +10 -0
- package/dist/proxy/index.d.ts +10 -0
- package/dist/proxy/index.js +492 -88
- package/dist/proxy/index.js.map +1 -1
- package/package.json +6 -13
package/dist/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}`);
|
|
273
|
+
}
|
|
274
|
+
if (!bodyReadFailed) {
|
|
275
|
+
logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
|
|
92
276
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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) => {
|
|
@@ -141,38 +348,72 @@ function collectBody(req) {
|
|
|
141
348
|
req.on("error", reject);
|
|
142
349
|
});
|
|
143
350
|
}
|
|
144
|
-
function shouldRouteToSkalpel(path4,
|
|
145
|
-
if (
|
|
351
|
+
function shouldRouteToSkalpel(path4, source) {
|
|
352
|
+
if (source !== "claude-code") return true;
|
|
146
353
|
const pathname = path4.split("?")[0];
|
|
147
354
|
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
148
355
|
}
|
|
149
|
-
function
|
|
356
|
+
async function isSkalpelBackendFailure(response, err, logger) {
|
|
150
357
|
if (err) return true;
|
|
151
358
|
if (!response) return true;
|
|
152
|
-
if (response.status
|
|
153
|
-
|
|
154
|
-
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
|
+
}
|
|
155
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
|
+
]);
|
|
156
397
|
function buildForwardHeaders(req, config, source, useSkalpel) {
|
|
157
398
|
const forwardHeaders = {};
|
|
158
399
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
159
|
-
if (value
|
|
160
|
-
|
|
161
|
-
|
|
400
|
+
if (value === void 0) continue;
|
|
401
|
+
if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
|
|
402
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
162
403
|
}
|
|
163
|
-
delete forwardHeaders["host"];
|
|
164
|
-
delete forwardHeaders["connection"];
|
|
165
404
|
if (useSkalpel) {
|
|
166
405
|
forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
|
|
167
406
|
forwardHeaders["X-Skalpel-Source"] = source;
|
|
168
407
|
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
169
408
|
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
409
|
+
forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
|
|
170
410
|
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
171
411
|
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
172
412
|
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
173
413
|
const token = authHeader.slice(7).trim();
|
|
174
414
|
if (token.startsWith("sk-ant-")) {
|
|
175
415
|
forwardHeaders["x-api-key"] = token;
|
|
416
|
+
delete forwardHeaders["authorization"];
|
|
176
417
|
}
|
|
177
418
|
}
|
|
178
419
|
}
|
|
@@ -185,6 +426,7 @@ function stripSkalpelHeaders2(headers) {
|
|
|
185
426
|
delete cleaned["X-Skalpel-Source"];
|
|
186
427
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
187
428
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
429
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
188
430
|
return cleaned;
|
|
189
431
|
}
|
|
190
432
|
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
@@ -212,6 +454,11 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
212
454
|
const start = Date.now();
|
|
213
455
|
const method = req.method ?? "GET";
|
|
214
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;
|
|
215
462
|
try {
|
|
216
463
|
const body = await collectBody(req);
|
|
217
464
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
@@ -226,45 +473,91 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
226
473
|
}
|
|
227
474
|
if (isStreaming) {
|
|
228
475
|
const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
|
|
229
|
-
const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
|
|
476
|
+
const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
230
477
|
await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
|
|
231
478
|
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
232
479
|
return;
|
|
233
480
|
}
|
|
234
481
|
const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
|
|
235
|
-
const directUrl = `${config.anthropicDirectUrl}${path4}`;
|
|
482
|
+
const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
236
483
|
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
237
|
-
let response = null;
|
|
238
484
|
let fetchError = null;
|
|
239
485
|
let usedFallback = false;
|
|
240
486
|
if (useSkalpel) {
|
|
241
487
|
try {
|
|
242
|
-
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
488
|
+
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
243
489
|
} catch (err) {
|
|
244
490
|
fetchError = err;
|
|
245
491
|
}
|
|
246
|
-
if (
|
|
492
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
247
493
|
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
248
494
|
usedFallback = true;
|
|
249
495
|
response = null;
|
|
250
496
|
fetchError = null;
|
|
251
497
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
252
498
|
try {
|
|
253
|
-
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
|
|
499
|
+
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
254
500
|
} catch (err) {
|
|
255
501
|
fetchError = err;
|
|
256
502
|
}
|
|
257
503
|
}
|
|
258
504
|
} else {
|
|
259
505
|
try {
|
|
260
|
-
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
506
|
+
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
261
507
|
} catch (err) {
|
|
262
508
|
fetchError = err;
|
|
263
509
|
}
|
|
264
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
|
+
}
|
|
265
533
|
if (!response || fetchError) {
|
|
534
|
+
response = null;
|
|
266
535
|
throw fetchError ?? new Error("no response from upstream");
|
|
267
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
|
+
}
|
|
268
561
|
const responseHeaders = extractResponseHeaders(response);
|
|
269
562
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
270
563
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
@@ -274,20 +567,38 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
274
567
|
} catch (err) {
|
|
275
568
|
logger.error(`${method} ${path4} source=${source} error=${err.message}`);
|
|
276
569
|
if (!res.headersSent) {
|
|
277
|
-
|
|
278
|
-
|
|
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
|
+
}
|
|
279
589
|
}
|
|
280
590
|
}
|
|
281
591
|
}
|
|
282
592
|
|
|
283
593
|
// src/proxy/health.ts
|
|
284
|
-
function handleHealthRequest(res, config, startTime
|
|
594
|
+
function handleHealthRequest(res, config, startTime) {
|
|
285
595
|
const body = JSON.stringify({
|
|
286
596
|
status: "ok",
|
|
287
|
-
mode: passthrough ? "passthrough" : "normal",
|
|
288
597
|
uptime: Date.now() - startTime,
|
|
289
598
|
ports: {
|
|
290
|
-
anthropic: config.anthropicPort
|
|
599
|
+
anthropic: config.anthropicPort,
|
|
600
|
+
openai: config.openaiPort,
|
|
601
|
+
cursor: config.cursorPort
|
|
291
602
|
},
|
|
292
603
|
version: "proxy-1.0.0"
|
|
293
604
|
});
|
|
@@ -298,13 +609,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
|
|
|
298
609
|
// src/proxy/pid.ts
|
|
299
610
|
import fs from "fs";
|
|
300
611
|
import path from "path";
|
|
612
|
+
import { execSync } from "child_process";
|
|
301
613
|
function writePid(pidFile) {
|
|
302
614
|
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
303
|
-
|
|
615
|
+
const record = {
|
|
616
|
+
pid: process.pid,
|
|
617
|
+
startTime: getStartTime(process.pid)
|
|
618
|
+
};
|
|
619
|
+
fs.writeFileSync(pidFile, JSON.stringify(record));
|
|
304
620
|
}
|
|
305
621
|
function readPid(pidFile) {
|
|
306
622
|
try {
|
|
307
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
|
+
}
|
|
308
635
|
const pid = parseInt(raw, 10);
|
|
309
636
|
if (isNaN(pid)) return null;
|
|
310
637
|
return isRunning(pid) ? pid : null;
|
|
@@ -320,6 +647,37 @@ function isRunning(pid) {
|
|
|
320
647
|
return false;
|
|
321
648
|
}
|
|
322
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
|
+
}
|
|
323
681
|
function removePid(pidFile) {
|
|
324
682
|
try {
|
|
325
683
|
fs.unlinkSync(pidFile);
|
|
@@ -333,12 +691,14 @@ import path2 from "path";
|
|
|
333
691
|
var MAX_SIZE = 5 * 1024 * 1024;
|
|
334
692
|
var MAX_ROTATIONS = 3;
|
|
335
693
|
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
336
|
-
var Logger = class {
|
|
694
|
+
var Logger = class _Logger {
|
|
337
695
|
logFile;
|
|
338
696
|
level;
|
|
339
|
-
|
|
697
|
+
prefix;
|
|
698
|
+
constructor(logFile, level = "info", prefix = "") {
|
|
340
699
|
this.logFile = logFile;
|
|
341
700
|
this.level = level;
|
|
701
|
+
this.prefix = prefix;
|
|
342
702
|
fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
|
|
343
703
|
}
|
|
344
704
|
debug(msg) {
|
|
@@ -353,9 +713,16 @@ var Logger = class {
|
|
|
353
713
|
error(msg) {
|
|
354
714
|
this.log("error", msg);
|
|
355
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
|
+
}
|
|
356
723
|
log(level, msg) {
|
|
357
724
|
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
358
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
|
|
725
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
|
|
359
726
|
`;
|
|
360
727
|
if (level === "debug" || level === "error") {
|
|
361
728
|
process.stderr.write(line);
|
|
@@ -386,50 +753,41 @@ var Logger = class {
|
|
|
386
753
|
|
|
387
754
|
// src/proxy/server.ts
|
|
388
755
|
var proxyStartTime = 0;
|
|
389
|
-
var
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
397
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
398
|
-
req.on("error", reject);
|
|
399
|
-
});
|
|
400
|
-
}
|
|
401
|
-
function handleAdminMode(req, res, logger) {
|
|
402
|
-
collectAdminBody(req).then((body) => {
|
|
403
|
-
try {
|
|
404
|
-
const { mode } = JSON.parse(body);
|
|
405
|
-
passthroughMode = mode === "passthrough";
|
|
406
|
-
logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
|
|
407
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
408
|
-
res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
|
|
409
|
-
} catch {
|
|
410
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
411
|
-
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
412
|
-
}
|
|
413
|
-
}).catch(() => {
|
|
414
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
415
|
-
res.end(JSON.stringify({ error: "failed to read body" }));
|
|
416
|
-
});
|
|
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, "_");
|
|
417
763
|
}
|
|
418
764
|
function startProxy(config) {
|
|
419
765
|
const logger = new Logger(config.logFile, config.logLevel);
|
|
420
766
|
const startTime = Date.now();
|
|
421
767
|
proxyStartTime = Date.now();
|
|
422
|
-
passthroughMode = false;
|
|
423
768
|
const anthropicServer = http.createServer((req, res) => {
|
|
424
769
|
if (req.url === "/health" && req.method === "GET") {
|
|
425
|
-
handleHealthRequest(res, config, startTime
|
|
770
|
+
handleHealthRequest(res, config, startTime);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const connId = computeConnId(req);
|
|
774
|
+
handleRequest(req, res, config, "claude-code", logger.child(connId));
|
|
775
|
+
});
|
|
776
|
+
const openaiServer = http.createServer((req, res) => {
|
|
777
|
+
if (req.url === "/health" && req.method === "GET") {
|
|
778
|
+
handleHealthRequest(res, config, startTime);
|
|
426
779
|
return;
|
|
427
780
|
}
|
|
428
|
-
|
|
429
|
-
|
|
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);
|
|
430
787
|
return;
|
|
431
788
|
}
|
|
432
|
-
|
|
789
|
+
const connId = computeConnId(req);
|
|
790
|
+
handleRequest(req, res, config, "cursor", logger.child(connId));
|
|
433
791
|
});
|
|
434
792
|
anthropicServer.on("error", (err) => {
|
|
435
793
|
if (err.code === "EADDRINUSE") {
|
|
@@ -440,14 +798,40 @@ function startProxy(config) {
|
|
|
440
798
|
removePid(config.pidFile);
|
|
441
799
|
process.exit(1);
|
|
442
800
|
});
|
|
801
|
+
openaiServer.on("error", (err) => {
|
|
802
|
+
if (err.code === "EADDRINUSE") {
|
|
803
|
+
logger.error(`Port ${config.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
804
|
+
} else {
|
|
805
|
+
logger.error(`OpenAI proxy failed to bind port ${config.openaiPort}: ${err.message}`);
|
|
806
|
+
}
|
|
807
|
+
removePid(config.pidFile);
|
|
808
|
+
process.exit(1);
|
|
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
|
+
});
|
|
443
819
|
anthropicServer.listen(config.anthropicPort, () => {
|
|
444
820
|
logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
|
|
445
821
|
});
|
|
822
|
+
openaiServer.listen(config.openaiPort, () => {
|
|
823
|
+
logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
|
|
824
|
+
});
|
|
825
|
+
cursorServer.listen(config.cursorPort, () => {
|
|
826
|
+
logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
|
|
827
|
+
});
|
|
446
828
|
writePid(config.pidFile);
|
|
447
|
-
logger.info(`Proxy started (pid=${process.pid})
|
|
829
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
448
830
|
const cleanup = () => {
|
|
449
831
|
logger.info("Shutting down proxy...");
|
|
450
832
|
anthropicServer.close();
|
|
833
|
+
openaiServer.close();
|
|
834
|
+
cursorServer.close();
|
|
451
835
|
removePid(config.pidFile);
|
|
452
836
|
process.exit(0);
|
|
453
837
|
};
|
|
@@ -463,7 +847,7 @@ function startProxy(config) {
|
|
|
463
847
|
removePid(config.pidFile);
|
|
464
848
|
process.exit(1);
|
|
465
849
|
});
|
|
466
|
-
return { anthropicServer };
|
|
850
|
+
return { anthropicServer, openaiServer, cursorServer };
|
|
467
851
|
}
|
|
468
852
|
function stopProxy(config) {
|
|
469
853
|
const pid = readPid(config.pidFile);
|
|
@@ -481,7 +865,9 @@ function getProxyStatus(config) {
|
|
|
481
865
|
running: pid !== null,
|
|
482
866
|
pid,
|
|
483
867
|
uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
|
|
484
|
-
anthropicPort: config.anthropicPort
|
|
868
|
+
anthropicPort: config.anthropicPort,
|
|
869
|
+
openaiPort: config.openaiPort,
|
|
870
|
+
cursorPort: config.cursorPort
|
|
485
871
|
};
|
|
486
872
|
}
|
|
487
873
|
|
|
@@ -499,12 +885,20 @@ var DEFAULTS = {
|
|
|
499
885
|
apiKey: "",
|
|
500
886
|
remoteBaseUrl: "https://api.skalpel.ai",
|
|
501
887
|
anthropicDirectUrl: "https://api.anthropic.com",
|
|
888
|
+
openaiDirectUrl: "https://api.openai.com",
|
|
502
889
|
anthropicPort: 18100,
|
|
890
|
+
openaiPort: 18101,
|
|
891
|
+
cursorPort: 18102,
|
|
892
|
+
cursorDirectUrl: "https://api.openai.com",
|
|
503
893
|
logLevel: "info",
|
|
504
894
|
logFile: "~/.skalpel/logs/proxy.log",
|
|
505
895
|
pidFile: "~/.skalpel/proxy.pid",
|
|
506
|
-
configFile: "~/.skalpel/config.json"
|
|
896
|
+
configFile: "~/.skalpel/config.json",
|
|
897
|
+
mode: "proxy"
|
|
507
898
|
};
|
|
899
|
+
function coerceMode(value) {
|
|
900
|
+
return value === "direct" ? "direct" : "proxy";
|
|
901
|
+
}
|
|
508
902
|
function loadConfig(configPath) {
|
|
509
903
|
const filePath = expandHome(configPath ?? DEFAULTS.configFile);
|
|
510
904
|
let fileConfig = {};
|
|
@@ -517,17 +911,27 @@ function loadConfig(configPath) {
|
|
|
517
911
|
apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
518
912
|
remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
|
|
519
913
|
anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
|
|
914
|
+
openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
|
|
520
915
|
anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
|
|
916
|
+
openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
|
|
917
|
+
cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
|
|
918
|
+
cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
|
|
521
919
|
logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
|
|
522
920
|
logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
|
|
523
921
|
pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
|
|
524
|
-
configFile: filePath
|
|
922
|
+
configFile: filePath,
|
|
923
|
+
mode: coerceMode(fileConfig.mode)
|
|
525
924
|
};
|
|
526
925
|
}
|
|
527
926
|
function saveConfig(config) {
|
|
528
927
|
const dir = path3.dirname(config.configFile);
|
|
529
928
|
fs3.mkdirSync(dir, { recursive: true });
|
|
530
|
-
|
|
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");
|
|
531
935
|
}
|
|
532
936
|
export {
|
|
533
937
|
getProxyStatus,
|