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.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}`);
|
|
132
313
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
314
|
+
if (!bodyReadFailed) {
|
|
315
|
+
logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
|
|
316
|
+
}
|
|
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) => {
|
|
@@ -181,38 +388,72 @@ function collectBody(req) {
|
|
|
181
388
|
req.on("error", reject);
|
|
182
389
|
});
|
|
183
390
|
}
|
|
184
|
-
function shouldRouteToSkalpel(path4,
|
|
185
|
-
if (
|
|
391
|
+
function shouldRouteToSkalpel(path4, source) {
|
|
392
|
+
if (source !== "claude-code") return true;
|
|
186
393
|
const pathname = path4.split("?")[0];
|
|
187
394
|
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
188
395
|
}
|
|
189
|
-
function
|
|
396
|
+
async function isSkalpelBackendFailure(response, err, logger) {
|
|
190
397
|
if (err) return true;
|
|
191
398
|
if (!response) return true;
|
|
192
|
-
if (response.status
|
|
193
|
-
|
|
194
|
-
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
|
+
}
|
|
195
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
|
+
]);
|
|
196
437
|
function buildForwardHeaders(req, config, source, useSkalpel) {
|
|
197
438
|
const forwardHeaders = {};
|
|
198
439
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
199
|
-
if (value
|
|
200
|
-
|
|
201
|
-
|
|
440
|
+
if (value === void 0) continue;
|
|
441
|
+
if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
|
|
442
|
+
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
202
443
|
}
|
|
203
|
-
delete forwardHeaders["host"];
|
|
204
|
-
delete forwardHeaders["connection"];
|
|
205
444
|
if (useSkalpel) {
|
|
206
445
|
forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
|
|
207
446
|
forwardHeaders["X-Skalpel-Source"] = source;
|
|
208
447
|
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
209
448
|
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
449
|
+
forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
|
|
210
450
|
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
211
451
|
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
212
452
|
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
213
453
|
const token = authHeader.slice(7).trim();
|
|
214
454
|
if (token.startsWith("sk-ant-")) {
|
|
215
455
|
forwardHeaders["x-api-key"] = token;
|
|
456
|
+
delete forwardHeaders["authorization"];
|
|
216
457
|
}
|
|
217
458
|
}
|
|
218
459
|
}
|
|
@@ -225,6 +466,7 @@ function stripSkalpelHeaders2(headers) {
|
|
|
225
466
|
delete cleaned["X-Skalpel-Source"];
|
|
226
467
|
delete cleaned["X-Skalpel-Agent-Type"];
|
|
227
468
|
delete cleaned["X-Skalpel-SDK-Version"];
|
|
469
|
+
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
228
470
|
return cleaned;
|
|
229
471
|
}
|
|
230
472
|
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
@@ -252,6 +494,11 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
252
494
|
const start = Date.now();
|
|
253
495
|
const method = req.method ?? "GET";
|
|
254
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;
|
|
255
502
|
try {
|
|
256
503
|
const body = await collectBody(req);
|
|
257
504
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
@@ -266,45 +513,91 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
266
513
|
}
|
|
267
514
|
if (isStreaming) {
|
|
268
515
|
const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
|
|
269
|
-
const directUrl2 = `${config.anthropicDirectUrl}${path4}`;
|
|
516
|
+
const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
270
517
|
await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
|
|
271
518
|
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
272
519
|
return;
|
|
273
520
|
}
|
|
274
521
|
const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
|
|
275
|
-
const directUrl = `${config.anthropicDirectUrl}${path4}`;
|
|
522
|
+
const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
276
523
|
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
277
|
-
let response = null;
|
|
278
524
|
let fetchError = null;
|
|
279
525
|
let usedFallback = false;
|
|
280
526
|
if (useSkalpel) {
|
|
281
527
|
try {
|
|
282
|
-
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
528
|
+
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
283
529
|
} catch (err) {
|
|
284
530
|
fetchError = err;
|
|
285
531
|
}
|
|
286
|
-
if (
|
|
532
|
+
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
287
533
|
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
288
534
|
usedFallback = true;
|
|
289
535
|
response = null;
|
|
290
536
|
fetchError = null;
|
|
291
537
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
292
538
|
try {
|
|
293
|
-
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody });
|
|
539
|
+
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
294
540
|
} catch (err) {
|
|
295
541
|
fetchError = err;
|
|
296
542
|
}
|
|
297
543
|
}
|
|
298
544
|
} else {
|
|
299
545
|
try {
|
|
300
|
-
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody });
|
|
546
|
+
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
301
547
|
} catch (err) {
|
|
302
548
|
fetchError = err;
|
|
303
549
|
}
|
|
304
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
|
+
}
|
|
305
573
|
if (!response || fetchError) {
|
|
574
|
+
response = null;
|
|
306
575
|
throw fetchError ?? new Error("no response from upstream");
|
|
307
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
|
+
}
|
|
308
601
|
const responseHeaders = extractResponseHeaders(response);
|
|
309
602
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
310
603
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
@@ -314,20 +607,38 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
314
607
|
} catch (err) {
|
|
315
608
|
logger.error(`${method} ${path4} source=${source} error=${err.message}`);
|
|
316
609
|
if (!res.headersSent) {
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
319
629
|
}
|
|
320
630
|
}
|
|
321
631
|
}
|
|
322
632
|
|
|
323
633
|
// src/proxy/health.ts
|
|
324
|
-
function handleHealthRequest(res, config, startTime
|
|
634
|
+
function handleHealthRequest(res, config, startTime) {
|
|
325
635
|
const body = JSON.stringify({
|
|
326
636
|
status: "ok",
|
|
327
|
-
mode: passthrough ? "passthrough" : "normal",
|
|
328
637
|
uptime: Date.now() - startTime,
|
|
329
638
|
ports: {
|
|
330
|
-
anthropic: config.anthropicPort
|
|
639
|
+
anthropic: config.anthropicPort,
|
|
640
|
+
openai: config.openaiPort,
|
|
641
|
+
cursor: config.cursorPort
|
|
331
642
|
},
|
|
332
643
|
version: "proxy-1.0.0"
|
|
333
644
|
});
|
|
@@ -338,13 +649,29 @@ function handleHealthRequest(res, config, startTime, passthrough = false) {
|
|
|
338
649
|
// src/proxy/pid.ts
|
|
339
650
|
var import_node_fs = __toESM(require("fs"), 1);
|
|
340
651
|
var import_node_path = __toESM(require("path"), 1);
|
|
652
|
+
var import_node_child_process = require("child_process");
|
|
341
653
|
function writePid(pidFile) {
|
|
342
654
|
import_node_fs.default.mkdirSync(import_node_path.default.dirname(pidFile), { recursive: true });
|
|
343
|
-
|
|
655
|
+
const record = {
|
|
656
|
+
pid: process.pid,
|
|
657
|
+
startTime: getStartTime(process.pid)
|
|
658
|
+
};
|
|
659
|
+
import_node_fs.default.writeFileSync(pidFile, JSON.stringify(record));
|
|
344
660
|
}
|
|
345
661
|
function readPid(pidFile) {
|
|
346
662
|
try {
|
|
347
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
|
+
}
|
|
348
675
|
const pid = parseInt(raw, 10);
|
|
349
676
|
if (isNaN(pid)) return null;
|
|
350
677
|
return isRunning(pid) ? pid : null;
|
|
@@ -360,6 +687,37 @@ function isRunning(pid) {
|
|
|
360
687
|
return false;
|
|
361
688
|
}
|
|
362
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
|
+
}
|
|
363
721
|
function removePid(pidFile) {
|
|
364
722
|
try {
|
|
365
723
|
import_node_fs.default.unlinkSync(pidFile);
|
|
@@ -373,12 +731,14 @@ var import_node_path2 = __toESM(require("path"), 1);
|
|
|
373
731
|
var MAX_SIZE = 5 * 1024 * 1024;
|
|
374
732
|
var MAX_ROTATIONS = 3;
|
|
375
733
|
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
376
|
-
var Logger = class {
|
|
734
|
+
var Logger = class _Logger {
|
|
377
735
|
logFile;
|
|
378
736
|
level;
|
|
379
|
-
|
|
737
|
+
prefix;
|
|
738
|
+
constructor(logFile, level = "info", prefix = "") {
|
|
380
739
|
this.logFile = logFile;
|
|
381
740
|
this.level = level;
|
|
741
|
+
this.prefix = prefix;
|
|
382
742
|
import_node_fs2.default.mkdirSync(import_node_path2.default.dirname(logFile), { recursive: true });
|
|
383
743
|
}
|
|
384
744
|
debug(msg) {
|
|
@@ -393,9 +753,16 @@ var Logger = class {
|
|
|
393
753
|
error(msg) {
|
|
394
754
|
this.log("error", msg);
|
|
395
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
|
+
}
|
|
396
763
|
log(level, msg) {
|
|
397
764
|
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
398
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${msg}
|
|
765
|
+
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
|
|
399
766
|
`;
|
|
400
767
|
if (level === "debug" || level === "error") {
|
|
401
768
|
process.stderr.write(line);
|
|
@@ -426,50 +793,41 @@ var Logger = class {
|
|
|
426
793
|
|
|
427
794
|
// src/proxy/server.ts
|
|
428
795
|
var proxyStartTime = 0;
|
|
429
|
-
var
|
|
430
|
-
function
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
437
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
438
|
-
req.on("error", reject);
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
function handleAdminMode(req, res, logger) {
|
|
442
|
-
collectAdminBody(req).then((body) => {
|
|
443
|
-
try {
|
|
444
|
-
const { mode } = JSON.parse(body);
|
|
445
|
-
passthroughMode = mode === "passthrough";
|
|
446
|
-
logger.info(`Proxy mode changed to: ${passthroughMode ? "passthrough" : "normal"}`);
|
|
447
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
448
|
-
res.end(JSON.stringify({ mode: passthroughMode ? "passthrough" : "normal" }));
|
|
449
|
-
} catch {
|
|
450
|
-
res.writeHead(400, { "Content-Type": "application/json" });
|
|
451
|
-
res.end(JSON.stringify({ error: "invalid JSON body" }));
|
|
452
|
-
}
|
|
453
|
-
}).catch(() => {
|
|
454
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
455
|
-
res.end(JSON.stringify({ error: "failed to read body" }));
|
|
456
|
-
});
|
|
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, "_");
|
|
457
803
|
}
|
|
458
804
|
function startProxy(config) {
|
|
459
805
|
const logger = new Logger(config.logFile, config.logLevel);
|
|
460
806
|
const startTime = Date.now();
|
|
461
807
|
proxyStartTime = Date.now();
|
|
462
|
-
passthroughMode = false;
|
|
463
808
|
const anthropicServer = import_node_http.default.createServer((req, res) => {
|
|
464
809
|
if (req.url === "/health" && req.method === "GET") {
|
|
465
|
-
handleHealthRequest(res, config, startTime
|
|
810
|
+
handleHealthRequest(res, config, startTime);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const connId = computeConnId(req);
|
|
814
|
+
handleRequest(req, res, config, "claude-code", logger.child(connId));
|
|
815
|
+
});
|
|
816
|
+
const openaiServer = import_node_http.default.createServer((req, res) => {
|
|
817
|
+
if (req.url === "/health" && req.method === "GET") {
|
|
818
|
+
handleHealthRequest(res, config, startTime);
|
|
466
819
|
return;
|
|
467
820
|
}
|
|
468
|
-
|
|
469
|
-
|
|
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);
|
|
470
827
|
return;
|
|
471
828
|
}
|
|
472
|
-
|
|
829
|
+
const connId = computeConnId(req);
|
|
830
|
+
handleRequest(req, res, config, "cursor", logger.child(connId));
|
|
473
831
|
});
|
|
474
832
|
anthropicServer.on("error", (err) => {
|
|
475
833
|
if (err.code === "EADDRINUSE") {
|
|
@@ -480,14 +838,40 @@ function startProxy(config) {
|
|
|
480
838
|
removePid(config.pidFile);
|
|
481
839
|
process.exit(1);
|
|
482
840
|
});
|
|
841
|
+
openaiServer.on("error", (err) => {
|
|
842
|
+
if (err.code === "EADDRINUSE") {
|
|
843
|
+
logger.error(`Port ${config.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
844
|
+
} else {
|
|
845
|
+
logger.error(`OpenAI proxy failed to bind port ${config.openaiPort}: ${err.message}`);
|
|
846
|
+
}
|
|
847
|
+
removePid(config.pidFile);
|
|
848
|
+
process.exit(1);
|
|
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
|
+
});
|
|
483
859
|
anthropicServer.listen(config.anthropicPort, () => {
|
|
484
860
|
logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
|
|
485
861
|
});
|
|
862
|
+
openaiServer.listen(config.openaiPort, () => {
|
|
863
|
+
logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
|
|
864
|
+
});
|
|
865
|
+
cursorServer.listen(config.cursorPort, () => {
|
|
866
|
+
logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
|
|
867
|
+
});
|
|
486
868
|
writePid(config.pidFile);
|
|
487
|
-
logger.info(`Proxy started (pid=${process.pid})
|
|
869
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
488
870
|
const cleanup = () => {
|
|
489
871
|
logger.info("Shutting down proxy...");
|
|
490
872
|
anthropicServer.close();
|
|
873
|
+
openaiServer.close();
|
|
874
|
+
cursorServer.close();
|
|
491
875
|
removePid(config.pidFile);
|
|
492
876
|
process.exit(0);
|
|
493
877
|
};
|
|
@@ -503,7 +887,7 @@ function startProxy(config) {
|
|
|
503
887
|
removePid(config.pidFile);
|
|
504
888
|
process.exit(1);
|
|
505
889
|
});
|
|
506
|
-
return { anthropicServer };
|
|
890
|
+
return { anthropicServer, openaiServer, cursorServer };
|
|
507
891
|
}
|
|
508
892
|
function stopProxy(config) {
|
|
509
893
|
const pid = readPid(config.pidFile);
|
|
@@ -521,7 +905,9 @@ function getProxyStatus(config) {
|
|
|
521
905
|
running: pid !== null,
|
|
522
906
|
pid,
|
|
523
907
|
uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
|
|
524
|
-
anthropicPort: config.anthropicPort
|
|
908
|
+
anthropicPort: config.anthropicPort,
|
|
909
|
+
openaiPort: config.openaiPort,
|
|
910
|
+
cursorPort: config.cursorPort
|
|
525
911
|
};
|
|
526
912
|
}
|
|
527
913
|
|
|
@@ -539,12 +925,20 @@ var DEFAULTS = {
|
|
|
539
925
|
apiKey: "",
|
|
540
926
|
remoteBaseUrl: "https://api.skalpel.ai",
|
|
541
927
|
anthropicDirectUrl: "https://api.anthropic.com",
|
|
928
|
+
openaiDirectUrl: "https://api.openai.com",
|
|
542
929
|
anthropicPort: 18100,
|
|
930
|
+
openaiPort: 18101,
|
|
931
|
+
cursorPort: 18102,
|
|
932
|
+
cursorDirectUrl: "https://api.openai.com",
|
|
543
933
|
logLevel: "info",
|
|
544
934
|
logFile: "~/.skalpel/logs/proxy.log",
|
|
545
935
|
pidFile: "~/.skalpel/proxy.pid",
|
|
546
|
-
configFile: "~/.skalpel/config.json"
|
|
936
|
+
configFile: "~/.skalpel/config.json",
|
|
937
|
+
mode: "proxy"
|
|
547
938
|
};
|
|
939
|
+
function coerceMode(value) {
|
|
940
|
+
return value === "direct" ? "direct" : "proxy";
|
|
941
|
+
}
|
|
548
942
|
function loadConfig(configPath) {
|
|
549
943
|
const filePath = expandHome(configPath ?? DEFAULTS.configFile);
|
|
550
944
|
let fileConfig = {};
|
|
@@ -557,17 +951,27 @@ function loadConfig(configPath) {
|
|
|
557
951
|
apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
558
952
|
remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
|
|
559
953
|
anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
|
|
954
|
+
openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
|
|
560
955
|
anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
|
|
956
|
+
openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
|
|
957
|
+
cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
|
|
958
|
+
cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
|
|
561
959
|
logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
|
|
562
960
|
logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
|
|
563
961
|
pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
|
|
564
|
-
configFile: filePath
|
|
962
|
+
configFile: filePath,
|
|
963
|
+
mode: coerceMode(fileConfig.mode)
|
|
565
964
|
};
|
|
566
965
|
}
|
|
567
966
|
function saveConfig(config) {
|
|
568
967
|
const dir = import_node_path3.default.dirname(config.configFile);
|
|
569
968
|
import_node_fs3.default.mkdirSync(dir, { recursive: true });
|
|
570
|
-
|
|
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");
|
|
571
975
|
}
|
|
572
976
|
// Annotate the CommonJS export names for ESM import in node:
|
|
573
977
|
0 && (module.exports = {
|