skalpel 2.0.23 → 3.0.0
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/INSTALL.md +103 -0
- package/LICENSE +201 -21
- package/README.md +12 -174
- package/design-tokens.json +51 -0
- package/npm-bin/colors.js +125 -0
- package/npm-bin/skalpel.js +200 -0
- package/npm-bin/skalpeld.js +20 -0
- package/package.json +50 -68
- package/postinstall/index.js +294 -0
- package/postinstall/launchd/com.skalpel.skalpeld.plist.tmpl +41 -0
- package/postinstall/lib/detect-prior.js +51 -0
- package/postinstall/lib/env-inject.js +121 -0
- package/postinstall/lib/launch.js +28 -0
- package/postinstall/lib/log.js +31 -0
- package/postinstall/lib/paths.js +186 -0
- package/postinstall/lib/rc-edit.js +167 -0
- package/postinstall/lib/rc-edit.test.js +196 -0
- package/postinstall/lib/service-register.js +293 -0
- package/postinstall/lib/sign-in.js +98 -0
- package/postinstall/lib/template.js +36 -0
- package/postinstall/snippets/bash.sh.tmpl +12 -0
- package/postinstall/snippets/fish.fish.tmpl +11 -0
- package/postinstall/snippets/powershell.ps1.tmpl +12 -0
- package/postinstall/snippets/zsh.sh.tmpl +13 -0
- package/postinstall/systemd/skalpeld.service.tmpl +33 -0
- package/postinstall/windows/Task.xml.tmpl +42 -0
- package/postinstall/windows/register-task.ps1.tmpl +45 -0
- package/dist/cli/index.js +0 -2899
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/proxy-runner.js +0 -1649
- package/dist/cli/proxy-runner.js.map +0 -1
- package/dist/index.cjs +0 -2333
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -165
- package/dist/index.d.ts +0 -165
- package/dist/index.js +0 -2287
- package/dist/index.js.map +0 -1
- package/dist/proxy/index.cjs +0 -1782
- package/dist/proxy/index.cjs.map +0 -1
- package/dist/proxy/index.d.cts +0 -39
- package/dist/proxy/index.d.ts +0 -39
- package/dist/proxy/index.js +0 -1748
- package/dist/proxy/index.js.map +0 -1
package/dist/cli/proxy-runner.js
DELETED
|
@@ -1,1649 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
-
var __esm = (fn, res) => function __init() {
|
|
5
|
-
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
-
};
|
|
7
|
-
var __export = (target, all) => {
|
|
8
|
-
for (var name in all)
|
|
9
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
// src/proxy/dispatcher.ts
|
|
13
|
-
import { Agent } from "undici";
|
|
14
|
-
var skalpelDispatcher;
|
|
15
|
-
var init_dispatcher = __esm({
|
|
16
|
-
"src/proxy/dispatcher.ts"() {
|
|
17
|
-
"use strict";
|
|
18
|
-
skalpelDispatcher = new Agent({
|
|
19
|
-
keepAliveTimeout: 1e4,
|
|
20
|
-
keepAliveMaxTimeout: 6e4,
|
|
21
|
-
connections: 100,
|
|
22
|
-
pipelining: 1,
|
|
23
|
-
allowH2: false
|
|
24
|
-
// Force HTTP/1.1 to prevent GCP LB WebSocket downgrade
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
// src/proxy/envelope.ts
|
|
30
|
-
function isAnthropicShaped(body) {
|
|
31
|
-
if (typeof body !== "object" || body === null) return false;
|
|
32
|
-
const b = body;
|
|
33
|
-
if (b.type !== "error") return false;
|
|
34
|
-
if (typeof b.error !== "object" || b.error === null) return false;
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
function defaultErrorTypeFor(status) {
|
|
38
|
-
if (status === 400) return "invalid_request_error";
|
|
39
|
-
if (status === 401 || status === 403) return "authentication_error";
|
|
40
|
-
if (status === 404) return "not_found_error";
|
|
41
|
-
if (status === 408) return "timeout_error";
|
|
42
|
-
if (status === 429) return "rate_limit_error";
|
|
43
|
-
if (status >= 500) return "api_error";
|
|
44
|
-
if (status >= 400) return "invalid_request_error";
|
|
45
|
-
return "api_error";
|
|
46
|
-
}
|
|
47
|
-
function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
|
|
48
|
-
let parsed = upstreamBody;
|
|
49
|
-
if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
|
|
50
|
-
try {
|
|
51
|
-
parsed = JSON.parse(upstreamBody);
|
|
52
|
-
} catch {
|
|
53
|
-
parsed = upstreamBody;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
let type = defaultErrorTypeFor(status);
|
|
57
|
-
let message;
|
|
58
|
-
if (isAnthropicShaped(parsed)) {
|
|
59
|
-
const inner = parsed.error;
|
|
60
|
-
if (typeof inner.type === "string" && inner.type.length > 0) {
|
|
61
|
-
type = inner.type;
|
|
62
|
-
}
|
|
63
|
-
message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
|
|
64
|
-
} else if (typeof parsed === "string" && parsed.length > 0) {
|
|
65
|
-
message = parsed;
|
|
66
|
-
} else {
|
|
67
|
-
message = defaultMessageForStatus(status);
|
|
68
|
-
}
|
|
69
|
-
const envelope = {
|
|
70
|
-
type: "error",
|
|
71
|
-
error: {
|
|
72
|
-
type,
|
|
73
|
-
message,
|
|
74
|
-
status_code: status,
|
|
75
|
-
origin
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
if (hint !== void 0) envelope.error.hint = hint;
|
|
79
|
-
if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
|
|
80
|
-
return envelope;
|
|
81
|
-
}
|
|
82
|
-
function defaultMessageForStatus(status) {
|
|
83
|
-
if (status === 401) return "Authentication failed";
|
|
84
|
-
if (status === 403) return "Forbidden";
|
|
85
|
-
if (status === 404) return "Not found";
|
|
86
|
-
if (status === 408) return "Request timed out";
|
|
87
|
-
if (status === 429) return "Rate limit exceeded";
|
|
88
|
-
if (status === 502) return "Bad gateway";
|
|
89
|
-
if (status === 503) return "Service unavailable";
|
|
90
|
-
if (status === 504) return "Gateway timeout";
|
|
91
|
-
if (status >= 500) return "Upstream error";
|
|
92
|
-
if (status >= 400) return "Client error";
|
|
93
|
-
return "Error";
|
|
94
|
-
}
|
|
95
|
-
var init_envelope = __esm({
|
|
96
|
-
"src/proxy/envelope.ts"() {
|
|
97
|
-
"use strict";
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
// src/proxy/recovery.ts
|
|
102
|
-
import { createHash } from "crypto";
|
|
103
|
-
function parseRetryAfterHeader(header) {
|
|
104
|
-
if (!header) return void 0;
|
|
105
|
-
const trimmed = header.trim();
|
|
106
|
-
if (!trimmed) return void 0;
|
|
107
|
-
const n = Number(trimmed);
|
|
108
|
-
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
109
|
-
const dateMs = Date.parse(trimmed);
|
|
110
|
-
if (Number.isFinite(dateMs)) {
|
|
111
|
-
return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
|
|
112
|
-
}
|
|
113
|
-
return void 0;
|
|
114
|
-
}
|
|
115
|
-
function sleep(ms) {
|
|
116
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
-
}
|
|
118
|
-
async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
119
|
-
const headerVal = response.headers.get("retry-after");
|
|
120
|
-
const parsed = parseRetryAfterHeader(headerVal);
|
|
121
|
-
logger.debug(`429 recovery retryAfterHeader=${headerVal ?? "none"} parsed=${parsed ?? "none"}`);
|
|
122
|
-
if (parsed === void 0) {
|
|
123
|
-
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
124
|
-
const retried2 = await retryFn();
|
|
125
|
-
logger.info("proxy.recovery.429_retry_count increment");
|
|
126
|
-
return retried2;
|
|
127
|
-
}
|
|
128
|
-
if (parsed > MAX_RETRY_AFTER_SECONDS) {
|
|
129
|
-
logger.warn(`429 recovery capped: retryAfter=${parsed}s exceeds max=${MAX_RETRY_AFTER_SECONDS}s, passing 429 through`);
|
|
130
|
-
return response;
|
|
131
|
-
}
|
|
132
|
-
await sleep(parsed * 1e3);
|
|
133
|
-
const retried = await retryFn();
|
|
134
|
-
logger.info("proxy.recovery.429_retry_count increment");
|
|
135
|
-
return retried;
|
|
136
|
-
}
|
|
137
|
-
async function handleTimeoutWithRetry(err, retryFn, logger) {
|
|
138
|
-
const code = err.code;
|
|
139
|
-
if (!code || !TIMEOUT_CODES.has(code)) {
|
|
140
|
-
throw err;
|
|
141
|
-
}
|
|
142
|
-
logger.warn(`timeout recovery code=${code}`);
|
|
143
|
-
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
144
|
-
const retried = await retryFn();
|
|
145
|
-
logger.info("proxy.recovery.timeout_retry_count increment");
|
|
146
|
-
return retried;
|
|
147
|
-
}
|
|
148
|
-
function tokenFingerprint(authHeader) {
|
|
149
|
-
if (authHeader === void 0) return "none";
|
|
150
|
-
return createHash("sha256").update(authHeader).digest("hex").slice(0, 12);
|
|
151
|
-
}
|
|
152
|
-
var MAX_RETRY_AFTER_SECONDS, DEFAULT_BACKOFF_SECONDS, TIMEOUT_CODES, MUTEX_MAX_ENTRIES, LruMutexMap, refreshMutex;
|
|
153
|
-
var init_recovery = __esm({
|
|
154
|
-
"src/proxy/recovery.ts"() {
|
|
155
|
-
"use strict";
|
|
156
|
-
MAX_RETRY_AFTER_SECONDS = 60;
|
|
157
|
-
DEFAULT_BACKOFF_SECONDS = 2;
|
|
158
|
-
TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
159
|
-
MUTEX_MAX_ENTRIES = 1024;
|
|
160
|
-
LruMutexMap = class extends Map {
|
|
161
|
-
set(key, value) {
|
|
162
|
-
if (this.has(key)) {
|
|
163
|
-
super.delete(key);
|
|
164
|
-
} else if (this.size >= MUTEX_MAX_ENTRIES) {
|
|
165
|
-
const oldest = this.keys().next().value;
|
|
166
|
-
if (oldest !== void 0) super.delete(oldest);
|
|
167
|
-
}
|
|
168
|
-
return super.set(key, value);
|
|
169
|
-
}
|
|
170
|
-
};
|
|
171
|
-
refreshMutex = new LruMutexMap();
|
|
172
|
-
}
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
// src/proxy/fetch-error.ts
|
|
176
|
-
function formatFetchErrorForLog(err, url) {
|
|
177
|
-
const parts = [];
|
|
178
|
-
const top = err;
|
|
179
|
-
if (top && typeof top === "object") {
|
|
180
|
-
if (top.name) parts.push(`name=${top.name}`);
|
|
181
|
-
if (top.code) parts.push(`code=${top.code}`);
|
|
182
|
-
if (top.message) parts.push(`msg=${top.message}`);
|
|
183
|
-
let cause = top.cause;
|
|
184
|
-
let depth = 0;
|
|
185
|
-
while (cause && depth < 4) {
|
|
186
|
-
const c = cause;
|
|
187
|
-
const causeBits = [];
|
|
188
|
-
if (c.name) causeBits.push(`name=${c.name}`);
|
|
189
|
-
if (c.code) causeBits.push(`code=${c.code}`);
|
|
190
|
-
if (c.message) causeBits.push(`msg=${c.message}`);
|
|
191
|
-
if (causeBits.length > 0) parts.push(`cause[${causeBits.join(",")}]`);
|
|
192
|
-
cause = c.cause;
|
|
193
|
-
depth += 1;
|
|
194
|
-
}
|
|
195
|
-
} else if (err !== void 0 && err !== null) {
|
|
196
|
-
parts.push(String(err));
|
|
197
|
-
}
|
|
198
|
-
parts.push(`url=${url}`);
|
|
199
|
-
return parts.join(" ");
|
|
200
|
-
}
|
|
201
|
-
var init_fetch_error = __esm({
|
|
202
|
-
"src/proxy/fetch-error.ts"() {
|
|
203
|
-
"use strict";
|
|
204
|
-
}
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
// src/proxy/trace-context.ts
|
|
208
|
-
import { randomBytes } from "crypto";
|
|
209
|
-
function parseTraceparent(header) {
|
|
210
|
-
if (!header) return null;
|
|
211
|
-
const trimmed = header.trim().toLowerCase();
|
|
212
|
-
const match = trimmed.match(TRACEPARENT_REGEX);
|
|
213
|
-
if (!match) return null;
|
|
214
|
-
const [, traceId, spanId, traceFlags] = match;
|
|
215
|
-
if (traceId === "00000000000000000000000000000000") return null;
|
|
216
|
-
if (spanId === "0000000000000000") return null;
|
|
217
|
-
return { traceId, spanId, traceFlags };
|
|
218
|
-
}
|
|
219
|
-
function generateTraceContext() {
|
|
220
|
-
return {
|
|
221
|
-
traceId: randomBytes(16).toString("hex"),
|
|
222
|
-
spanId: randomBytes(8).toString("hex"),
|
|
223
|
-
traceFlags: "01"
|
|
224
|
-
// sampled
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
function formatTraceparent(ctx) {
|
|
228
|
-
return `00-${ctx.traceId}-${ctx.spanId}-${ctx.traceFlags}`;
|
|
229
|
-
}
|
|
230
|
-
var TRACEPARENT_REGEX;
|
|
231
|
-
var init_trace_context = __esm({
|
|
232
|
-
"src/proxy/trace-context.ts"() {
|
|
233
|
-
"use strict";
|
|
234
|
-
TRACEPARENT_REGEX = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
235
|
-
}
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
// src/proxy/streaming.ts
|
|
239
|
-
function parseRetryAfter(header) {
|
|
240
|
-
if (!header) return void 0;
|
|
241
|
-
const trimmed = header.trim();
|
|
242
|
-
if (!trimmed) return void 0;
|
|
243
|
-
const n = Number(trimmed);
|
|
244
|
-
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
245
|
-
const dateMs = Date.parse(trimmed);
|
|
246
|
-
if (Number.isFinite(dateMs)) {
|
|
247
|
-
const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
|
|
248
|
-
return delta;
|
|
249
|
-
}
|
|
250
|
-
return void 0;
|
|
251
|
-
}
|
|
252
|
-
function stripSkalpelHeaders(headers) {
|
|
253
|
-
const cleaned = { ...headers };
|
|
254
|
-
delete cleaned["X-Skalpel-API-Key"];
|
|
255
|
-
delete cleaned["X-Skalpel-Source"];
|
|
256
|
-
delete cleaned["X-Skalpel-Agent-Type"];
|
|
257
|
-
delete cleaned["X-Skalpel-SDK-Version"];
|
|
258
|
-
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
259
|
-
return cleaned;
|
|
260
|
-
}
|
|
261
|
-
async function doStreamingFetch(url, body, headers) {
|
|
262
|
-
return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
|
|
263
|
-
}
|
|
264
|
-
async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger, traceCtx) {
|
|
265
|
-
let response = null;
|
|
266
|
-
let fetchError = null;
|
|
267
|
-
let usedFallback = false;
|
|
268
|
-
if (useSkalpel) {
|
|
269
|
-
logger.info(`streaming fetch sending url=${skalpelUrl}`);
|
|
270
|
-
try {
|
|
271
|
-
response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
|
|
272
|
-
} catch (err) {
|
|
273
|
-
fetchError = err;
|
|
274
|
-
}
|
|
275
|
-
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${skalpelUrl}`);
|
|
276
|
-
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
277
|
-
logger.warn(`streaming: Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
278
|
-
usedFallback = true;
|
|
279
|
-
response = null;
|
|
280
|
-
fetchError = null;
|
|
281
|
-
const directHeaders = stripSkalpelHeaders(forwardHeaders);
|
|
282
|
-
logger.info(`streaming fetch sending url=${directUrl} fallback=true`);
|
|
283
|
-
try {
|
|
284
|
-
response = await doStreamingFetch(directUrl, body, directHeaders);
|
|
285
|
-
} catch (err) {
|
|
286
|
-
fetchError = err;
|
|
287
|
-
}
|
|
288
|
-
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl} fallback=true`);
|
|
289
|
-
}
|
|
290
|
-
} else {
|
|
291
|
-
logger.info(`streaming fetch sending url=${directUrl}`);
|
|
292
|
-
try {
|
|
293
|
-
response = await doStreamingFetch(directUrl, body, forwardHeaders);
|
|
294
|
-
} catch (err) {
|
|
295
|
-
fetchError = err;
|
|
296
|
-
}
|
|
297
|
-
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl}`);
|
|
298
|
-
}
|
|
299
|
-
const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
300
|
-
const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
|
|
301
|
-
if (fetchError) {
|
|
302
|
-
const code = fetchError.code;
|
|
303
|
-
if (code && TIMEOUT_CODES2.has(code)) {
|
|
304
|
-
try {
|
|
305
|
-
response = await handleTimeoutWithRetry(
|
|
306
|
-
fetchError,
|
|
307
|
-
() => doStreamingFetch(finalUrl, body, finalHeaders),
|
|
308
|
-
logger
|
|
309
|
-
);
|
|
310
|
-
fetchError = null;
|
|
311
|
-
} catch (retryErr) {
|
|
312
|
-
fetchError = retryErr;
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (response && response.status === 429) {
|
|
317
|
-
response = await handle429WithRetryAfter(
|
|
318
|
-
response,
|
|
319
|
-
() => doStreamingFetch(finalUrl, body, finalHeaders),
|
|
320
|
-
logger
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
if (!response || fetchError) {
|
|
324
|
-
const errMsg = fetchError ? formatFetchErrorForLog(fetchError, finalUrl) : "no response from upstream";
|
|
325
|
-
logger.error(`streaming fetch failed: ${errMsg}`);
|
|
326
|
-
res.writeHead(HTTP_BAD_GATEWAY, {
|
|
327
|
-
"Content-Type": "text/event-stream",
|
|
328
|
-
"Cache-Control": "no-cache"
|
|
329
|
-
});
|
|
330
|
-
const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
|
|
331
|
-
res.write(`event: error
|
|
332
|
-
data: ${JSON.stringify(envelope)}
|
|
333
|
-
|
|
334
|
-
`);
|
|
335
|
-
res.end();
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
if (usedFallback) {
|
|
339
|
-
logger.info("streaming: using direct Anthropic API fallback");
|
|
340
|
-
}
|
|
341
|
-
if (response.status >= 300) {
|
|
342
|
-
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
343
|
-
const originHeader = response.headers.get("x-skalpel-origin");
|
|
344
|
-
let origin;
|
|
345
|
-
if (originHeader === "backend") origin = "skalpel-backend";
|
|
346
|
-
else if (originHeader === "provider") origin = "provider";
|
|
347
|
-
else origin = "provider";
|
|
348
|
-
let rawBody = "";
|
|
349
|
-
let bodyReadFailed = false;
|
|
350
|
-
try {
|
|
351
|
-
rawBody = Buffer.from(await response.arrayBuffer()).toString();
|
|
352
|
-
} catch (readErr) {
|
|
353
|
-
bodyReadFailed = true;
|
|
354
|
-
logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
|
|
355
|
-
}
|
|
356
|
-
if (!bodyReadFailed) {
|
|
357
|
-
logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
|
|
358
|
-
}
|
|
359
|
-
const envelope = bodyReadFailed ? buildErrorEnvelope(
|
|
360
|
-
response.status,
|
|
361
|
-
"",
|
|
362
|
-
"skalpel-proxy",
|
|
363
|
-
"mid-stream abort",
|
|
364
|
-
retryAfter
|
|
365
|
-
) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
|
|
366
|
-
res.writeHead(response.status, {
|
|
367
|
-
"Content-Type": "text/event-stream",
|
|
368
|
-
"Cache-Control": "no-cache"
|
|
369
|
-
});
|
|
370
|
-
res.write(`event: error
|
|
371
|
-
data: ${JSON.stringify(envelope)}
|
|
372
|
-
|
|
373
|
-
`);
|
|
374
|
-
res.end();
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
377
|
-
const sseHeaders = {};
|
|
378
|
-
for (const [key, value] of response.headers.entries()) {
|
|
379
|
-
if (!STRIP_HEADERS.has(key) && key !== "content-type") {
|
|
380
|
-
sseHeaders[key] = value;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
sseHeaders["Content-Type"] = "text/event-stream";
|
|
384
|
-
sseHeaders["Cache-Control"] = "no-cache";
|
|
385
|
-
if (traceCtx) {
|
|
386
|
-
sseHeaders["traceparent"] = formatTraceparent(traceCtx);
|
|
387
|
-
}
|
|
388
|
-
res.writeHead(response.status, sseHeaders);
|
|
389
|
-
if (!response.body) {
|
|
390
|
-
res.write(`event: error
|
|
391
|
-
data: ${JSON.stringify({ error: "no response body" })}
|
|
392
|
-
|
|
393
|
-
`);
|
|
394
|
-
res.end();
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
try {
|
|
398
|
-
const reader = response.body.getReader();
|
|
399
|
-
const decoder = new TextDecoder();
|
|
400
|
-
let chunkCount = 0;
|
|
401
|
-
let totalBytes = 0;
|
|
402
|
-
logger.info("streaming started");
|
|
403
|
-
while (true) {
|
|
404
|
-
const { done, value } = await reader.read();
|
|
405
|
-
if (done) break;
|
|
406
|
-
chunkCount++;
|
|
407
|
-
totalBytes += value.byteLength;
|
|
408
|
-
logger.debug(`streaming chunk #${chunkCount} bytes=${value.byteLength} totalBytes=${totalBytes}`);
|
|
409
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
410
|
-
res.write(chunk);
|
|
411
|
-
}
|
|
412
|
-
logger.info(`streaming completed chunks=${chunkCount} totalBytes=${totalBytes}`);
|
|
413
|
-
} catch (err) {
|
|
414
|
-
logger.error(`streaming error: ${err.message}`);
|
|
415
|
-
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
416
|
-
const envelope = buildErrorEnvelope(
|
|
417
|
-
response.status,
|
|
418
|
-
err.message,
|
|
419
|
-
"skalpel-proxy",
|
|
420
|
-
"mid-stream abort",
|
|
421
|
-
retryAfter
|
|
422
|
-
);
|
|
423
|
-
res.write(`event: error
|
|
424
|
-
data: ${JSON.stringify(envelope)}
|
|
425
|
-
|
|
426
|
-
`);
|
|
427
|
-
}
|
|
428
|
-
res.end();
|
|
429
|
-
}
|
|
430
|
-
var TIMEOUT_CODES2, HTTP_BAD_GATEWAY, HOP_BY_HOP, STRIP_HEADERS;
|
|
431
|
-
var init_streaming = __esm({
|
|
432
|
-
"src/proxy/streaming.ts"() {
|
|
433
|
-
"use strict";
|
|
434
|
-
init_dispatcher();
|
|
435
|
-
init_handler();
|
|
436
|
-
init_envelope();
|
|
437
|
-
init_recovery();
|
|
438
|
-
init_fetch_error();
|
|
439
|
-
init_trace_context();
|
|
440
|
-
TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
441
|
-
HTTP_BAD_GATEWAY = 502;
|
|
442
|
-
HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
443
|
-
"connection",
|
|
444
|
-
"keep-alive",
|
|
445
|
-
"proxy-authenticate",
|
|
446
|
-
"proxy-authorization",
|
|
447
|
-
"te",
|
|
448
|
-
"trailer",
|
|
449
|
-
"transfer-encoding",
|
|
450
|
-
"upgrade"
|
|
451
|
-
]);
|
|
452
|
-
STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
453
|
-
...HOP_BY_HOP,
|
|
454
|
-
"content-encoding",
|
|
455
|
-
"content-length"
|
|
456
|
-
]);
|
|
457
|
-
}
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
// src/proxy/codex-oauth.ts
|
|
461
|
-
import { readFileSync } from "fs";
|
|
462
|
-
import { homedir } from "os";
|
|
463
|
-
import { join } from "path";
|
|
464
|
-
function authFilePath() {
|
|
465
|
-
return join(homedir(), ".codex", "auth.json");
|
|
466
|
-
}
|
|
467
|
-
function readCodexAuth() {
|
|
468
|
-
const path4 = authFilePath();
|
|
469
|
-
let raw;
|
|
470
|
-
try {
|
|
471
|
-
raw = readFileSync(path4, "utf-8");
|
|
472
|
-
} catch (err) {
|
|
473
|
-
const code = err?.code;
|
|
474
|
-
if (code !== "ENOENT") {
|
|
475
|
-
process.stderr.write(`skalpel: codex-oauth: cannot read auth file (${code ?? "unknown"})
|
|
476
|
-
`);
|
|
477
|
-
}
|
|
478
|
-
return null;
|
|
479
|
-
}
|
|
480
|
-
let parsed;
|
|
481
|
-
try {
|
|
482
|
-
parsed = JSON.parse(raw);
|
|
483
|
-
} catch {
|
|
484
|
-
process.stderr.write("skalpel: codex-oauth: auth file is not valid JSON\n");
|
|
485
|
-
return null;
|
|
486
|
-
}
|
|
487
|
-
if (parsed === null || typeof parsed !== "object" || typeof parsed.access_token !== "string" || typeof parsed.refresh_token !== "string" || typeof parsed.expires_at !== "string") {
|
|
488
|
-
process.stderr.write("skalpel: codex-oauth: auth file missing required fields\n");
|
|
489
|
-
return null;
|
|
490
|
-
}
|
|
491
|
-
const obj = parsed;
|
|
492
|
-
const auth = {
|
|
493
|
-
access_token: obj.access_token,
|
|
494
|
-
refresh_token: obj.refresh_token,
|
|
495
|
-
expires_at: obj.expires_at
|
|
496
|
-
};
|
|
497
|
-
if (typeof obj.account_id === "string") {
|
|
498
|
-
auth.account_id = obj.account_id;
|
|
499
|
-
}
|
|
500
|
-
return auth;
|
|
501
|
-
}
|
|
502
|
-
function isTokenFresh(auth) {
|
|
503
|
-
const expiresAtMs = Date.parse(auth.expires_at);
|
|
504
|
-
if (!Number.isFinite(expiresAtMs)) return false;
|
|
505
|
-
return expiresAtMs > Date.now() + TOKEN_FRESHNESS_BUFFER_MS;
|
|
506
|
-
}
|
|
507
|
-
function getFreshAccessToken() {
|
|
508
|
-
const auth = readCodexAuth();
|
|
509
|
-
if (auth === null) return null;
|
|
510
|
-
if (!isTokenFresh(auth)) return null;
|
|
511
|
-
return auth.access_token;
|
|
512
|
-
}
|
|
513
|
-
var TOKEN_FRESHNESS_BUFFER_MS;
|
|
514
|
-
var init_codex_oauth = __esm({
|
|
515
|
-
"src/proxy/codex-oauth.ts"() {
|
|
516
|
-
"use strict";
|
|
517
|
-
TOKEN_FRESHNESS_BUFFER_MS = 1e4;
|
|
518
|
-
}
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
// src/proxy/ws-client.ts
|
|
522
|
-
import { EventEmitter } from "events";
|
|
523
|
-
import https from "https";
|
|
524
|
-
import http from "http";
|
|
525
|
-
import WebSocket from "ws";
|
|
526
|
-
function pickAgent(url) {
|
|
527
|
-
return url.startsWith("wss://") ? H1_HTTPS_AGENT : H1_HTTP_AGENT;
|
|
528
|
-
}
|
|
529
|
-
function defaultBackoffBaseMs() {
|
|
530
|
-
const raw = process.env.SKALPEL_WS_BACKOFF_BASE_MS;
|
|
531
|
-
const parsed = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
532
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e3;
|
|
533
|
-
}
|
|
534
|
-
function computeBackoff(attempt, baseMs) {
|
|
535
|
-
const exp = Math.min(MAX_BACKOFF_MS, baseMs * Math.pow(2, attempt));
|
|
536
|
-
const jitter = exp * (0.2 * (Math.random() * 2 - 1));
|
|
537
|
-
return Math.max(0, Math.floor(exp + jitter));
|
|
538
|
-
}
|
|
539
|
-
var WS_SUBPROTOCOL, MAX_RECONNECTS, MAX_BACKOFF_MS, NON_TRANSIENT_CLOSE_CODES, H1_HTTPS_AGENT, H1_HTTP_AGENT, BackendWsClient;
|
|
540
|
-
var init_ws_client = __esm({
|
|
541
|
-
"src/proxy/ws-client.ts"() {
|
|
542
|
-
"use strict";
|
|
543
|
-
init_codex_oauth();
|
|
544
|
-
init_trace_context();
|
|
545
|
-
WS_SUBPROTOCOL = "skalpel-codex-v1";
|
|
546
|
-
MAX_RECONNECTS = 5;
|
|
547
|
-
MAX_BACKOFF_MS = 6e4;
|
|
548
|
-
NON_TRANSIENT_CLOSE_CODES = /* @__PURE__ */ new Set([4e3, 4001, 4002, 4004]);
|
|
549
|
-
H1_HTTPS_AGENT = new https.Agent({
|
|
550
|
-
ALPNProtocols: ["http/1.1"],
|
|
551
|
-
keepAlive: true
|
|
552
|
-
});
|
|
553
|
-
H1_HTTP_AGENT = new http.Agent({ keepAlive: true });
|
|
554
|
-
BackendWsClient = class extends EventEmitter {
|
|
555
|
-
opts;
|
|
556
|
-
ws = null;
|
|
557
|
-
reconnectAttempts = 0;
|
|
558
|
-
closedByUser = false;
|
|
559
|
-
pendingReconnect = null;
|
|
560
|
-
constructor(opts) {
|
|
561
|
-
super();
|
|
562
|
-
this.opts = opts;
|
|
563
|
-
}
|
|
564
|
-
async connect() {
|
|
565
|
-
const freshToken = getFreshAccessToken();
|
|
566
|
-
const bearer = freshToken ?? this.opts.oauthToken;
|
|
567
|
-
return new Promise((resolve, reject) => {
|
|
568
|
-
const headers = {
|
|
569
|
-
"X-Skalpel-API-Key": this.opts.apiKey,
|
|
570
|
-
Authorization: `Bearer ${bearer}`,
|
|
571
|
-
"x-skalpel-source": this.opts.source
|
|
572
|
-
};
|
|
573
|
-
if (this.opts.traceCtx) {
|
|
574
|
-
headers["traceparent"] = formatTraceparent(this.opts.traceCtx);
|
|
575
|
-
}
|
|
576
|
-
const ws = new WebSocket(this.opts.url, [WS_SUBPROTOCOL], {
|
|
577
|
-
agent: pickAgent(this.opts.url),
|
|
578
|
-
headers
|
|
579
|
-
});
|
|
580
|
-
this.ws = ws;
|
|
581
|
-
ws.once("unexpected-response", (_req, res) => {
|
|
582
|
-
const status = res.statusCode ?? 0;
|
|
583
|
-
this.opts.logger.warn(
|
|
584
|
-
`ws-client handshake rejected status=${status} \u2014 no retry, falling back to HTTP`
|
|
585
|
-
);
|
|
586
|
-
this.closedByUser = true;
|
|
587
|
-
this.emit("fallback", `handshake_${status}`);
|
|
588
|
-
try {
|
|
589
|
-
res.destroy?.();
|
|
590
|
-
} catch {
|
|
591
|
-
}
|
|
592
|
-
this.ws = null;
|
|
593
|
-
reject(new Error(`ws handshake status ${status}`));
|
|
594
|
-
});
|
|
595
|
-
ws.once("open", () => {
|
|
596
|
-
this.emit("open");
|
|
597
|
-
resolve();
|
|
598
|
-
});
|
|
599
|
-
ws.on("message", (data) => {
|
|
600
|
-
const text = data.toString("utf-8");
|
|
601
|
-
let parsed = null;
|
|
602
|
-
try {
|
|
603
|
-
parsed = JSON.parse(text);
|
|
604
|
-
} catch {
|
|
605
|
-
this.emit("error", new Error(`invalid frame: ${text.slice(0, 100)}`));
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
this.emit("frame", parsed);
|
|
609
|
-
});
|
|
610
|
-
ws.on("error", (err) => {
|
|
611
|
-
this.opts.logger.debug(`ws-client error: ${err.message}`);
|
|
612
|
-
this.emit("error", err);
|
|
613
|
-
});
|
|
614
|
-
ws.once("close", (code, reasonBuf) => {
|
|
615
|
-
const reason = reasonBuf.toString("utf-8");
|
|
616
|
-
this.opts.logger.info(`ws-client close code=${code} reason=${reason}`);
|
|
617
|
-
this.ws = null;
|
|
618
|
-
if (this.closedByUser || code === 1e3) {
|
|
619
|
-
this.emit("close", code, reason);
|
|
620
|
-
return;
|
|
621
|
-
}
|
|
622
|
-
if (NON_TRANSIENT_CLOSE_CODES.has(code)) {
|
|
623
|
-
this.emit("close", code, reason);
|
|
624
|
-
this.emit("fallback", `close_${code}:${reason}`);
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
this.scheduleReconnect(resolve, reject);
|
|
628
|
-
this.emit("close", code, reason);
|
|
629
|
-
});
|
|
630
|
-
});
|
|
631
|
-
}
|
|
632
|
-
scheduleReconnect(initialResolve, initialReject) {
|
|
633
|
-
if (this.reconnectAttempts >= MAX_RECONNECTS) {
|
|
634
|
-
this.emit("fallback", "reconnect_exhausted");
|
|
635
|
-
return;
|
|
636
|
-
}
|
|
637
|
-
this.reconnectAttempts += 1;
|
|
638
|
-
const delay = computeBackoff(this.reconnectAttempts, defaultBackoffBaseMs());
|
|
639
|
-
this.opts.logger.info(
|
|
640
|
-
`ws-client reconnect attempt=${this.reconnectAttempts} delay=${delay}ms`
|
|
641
|
-
);
|
|
642
|
-
this.pendingReconnect = setTimeout(() => {
|
|
643
|
-
this.pendingReconnect = null;
|
|
644
|
-
this.connect().catch((err) => {
|
|
645
|
-
this.opts.logger.debug(`reconnect failed: ${err.message}`);
|
|
646
|
-
});
|
|
647
|
-
}, delay);
|
|
648
|
-
void initialResolve;
|
|
649
|
-
void initialReject;
|
|
650
|
-
}
|
|
651
|
-
send(frame) {
|
|
652
|
-
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) {
|
|
653
|
-
throw new Error("ws-client send: socket not open");
|
|
654
|
-
}
|
|
655
|
-
this.ws.send(JSON.stringify(frame));
|
|
656
|
-
}
|
|
657
|
-
close(code = 1e3, reason = "client close") {
|
|
658
|
-
this.closedByUser = true;
|
|
659
|
-
if (this.pendingReconnect !== null) {
|
|
660
|
-
clearTimeout(this.pendingReconnect);
|
|
661
|
-
this.pendingReconnect = null;
|
|
662
|
-
}
|
|
663
|
-
if (this.ws !== null) {
|
|
664
|
-
try {
|
|
665
|
-
this.ws.close(code, reason);
|
|
666
|
-
} catch {
|
|
667
|
-
}
|
|
668
|
-
this.ws = null;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
};
|
|
672
|
-
}
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
// src/proxy/handler.ts
|
|
676
|
-
var handler_exports = {};
|
|
677
|
-
__export(handler_exports, {
|
|
678
|
-
buildForwardHeaders: () => buildForwardHeaders,
|
|
679
|
-
handleRequest: () => handleRequest,
|
|
680
|
-
handleWebSocketBridge: () => handleWebSocketBridge,
|
|
681
|
-
isSkalpelBackendFailure: () => isSkalpelBackendFailure,
|
|
682
|
-
shouldRouteToSkalpel: () => shouldRouteToSkalpel
|
|
683
|
-
});
|
|
684
|
-
function validateCodexAuth(oauthToken, inboundAuth) {
|
|
685
|
-
if (!oauthToken && !inboundAuth) {
|
|
686
|
-
return { valid: false, error: "no_credentials" };
|
|
687
|
-
}
|
|
688
|
-
return { valid: true };
|
|
689
|
-
}
|
|
690
|
-
function collectBody(req) {
|
|
691
|
-
return new Promise((resolve, reject) => {
|
|
692
|
-
const chunks = [];
|
|
693
|
-
req.on("data", (chunk) => chunks.push(chunk));
|
|
694
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
695
|
-
req.on("error", reject);
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
function shouldRouteToSkalpel(path4, source) {
|
|
699
|
-
if (source !== "claude-code") return true;
|
|
700
|
-
const pathname = path4.split("?")[0];
|
|
701
|
-
return SKALPEL_EXACT_PATHS.has(pathname);
|
|
702
|
-
}
|
|
703
|
-
async function isSkalpelBackendFailure(response, err, logger) {
|
|
704
|
-
if (err) return true;
|
|
705
|
-
if (!response) return true;
|
|
706
|
-
if (response.status < 500) return false;
|
|
707
|
-
const origin = response.headers?.get("x-skalpel-origin");
|
|
708
|
-
if (origin === "provider") return false;
|
|
709
|
-
if (origin === "backend") return true;
|
|
710
|
-
try {
|
|
711
|
-
const text = await response.clone().text();
|
|
712
|
-
if (!text) {
|
|
713
|
-
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
|
|
714
|
-
return true;
|
|
715
|
-
}
|
|
716
|
-
let shape = "non-anthropic";
|
|
717
|
-
try {
|
|
718
|
-
const parsed = JSON.parse(text);
|
|
719
|
-
if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
|
|
720
|
-
shape = "anthropic";
|
|
721
|
-
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
|
|
722
|
-
return false;
|
|
723
|
-
}
|
|
724
|
-
} catch {
|
|
725
|
-
}
|
|
726
|
-
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
|
|
727
|
-
return true;
|
|
728
|
-
} catch {
|
|
729
|
-
logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
|
|
730
|
-
return true;
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
function buildForwardHeaders(req, config2, source, useSkalpel, traceCtx) {
|
|
734
|
-
const forwardHeaders = {};
|
|
735
|
-
for (const [key, value] of Object.entries(req.headers)) {
|
|
736
|
-
if (value === void 0) continue;
|
|
737
|
-
if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
|
|
738
|
-
forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
|
|
739
|
-
}
|
|
740
|
-
if (traceCtx) {
|
|
741
|
-
forwardHeaders["traceparent"] = formatTraceparent(traceCtx);
|
|
742
|
-
}
|
|
743
|
-
if (useSkalpel) {
|
|
744
|
-
forwardHeaders["X-Skalpel-API-Key"] = config2.apiKey;
|
|
745
|
-
forwardHeaders["X-Skalpel-Source"] = source;
|
|
746
|
-
forwardHeaders["X-Skalpel-Agent-Type"] = source;
|
|
747
|
-
forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
|
|
748
|
-
forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
|
|
749
|
-
if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
|
|
750
|
-
const authHeader = forwardHeaders["authorization"] ?? "";
|
|
751
|
-
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
|
752
|
-
const token = authHeader.slice(7).trim();
|
|
753
|
-
if (token.startsWith("sk-ant-")) {
|
|
754
|
-
forwardHeaders["x-api-key"] = token;
|
|
755
|
-
delete forwardHeaders["authorization"];
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
if (source === "codex") {
|
|
760
|
-
const oauthToken = getFreshAccessToken();
|
|
761
|
-
if (oauthToken !== null) {
|
|
762
|
-
forwardHeaders["authorization"] = `Bearer ${oauthToken}`;
|
|
763
|
-
delete forwardHeaders["Authorization"];
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
return forwardHeaders;
|
|
768
|
-
}
|
|
769
|
-
function stripSkalpelHeaders2(headers) {
|
|
770
|
-
const cleaned = { ...headers };
|
|
771
|
-
delete cleaned["X-Skalpel-API-Key"];
|
|
772
|
-
delete cleaned["X-Skalpel-Source"];
|
|
773
|
-
delete cleaned["X-Skalpel-Agent-Type"];
|
|
774
|
-
delete cleaned["X-Skalpel-SDK-Version"];
|
|
775
|
-
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
776
|
-
return cleaned;
|
|
777
|
-
}
|
|
778
|
-
function extractResponseHeaders(response) {
|
|
779
|
-
const headers = {};
|
|
780
|
-
for (const [key, value] of response.headers.entries()) {
|
|
781
|
-
if (!STRIP_RESPONSE_HEADERS.has(key)) {
|
|
782
|
-
headers[key] = value;
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
return headers;
|
|
786
|
-
}
|
|
787
|
-
async function handleRequest(req, res, config2, source, logger) {
|
|
788
|
-
const start = Date.now();
|
|
789
|
-
const method = req.method ?? "GET";
|
|
790
|
-
const path4 = req.url ?? "/";
|
|
791
|
-
const fp = tokenFingerprint(
|
|
792
|
-
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
793
|
-
);
|
|
794
|
-
logger.info(`${source} ${method} ${path4} token=${fp}`);
|
|
795
|
-
if (source === "codex") {
|
|
796
|
-
const ua = req.headers["user-agent"] ?? "";
|
|
797
|
-
const authScheme = typeof req.headers.authorization === "string" ? req.headers.authorization.split(" ")[0] ?? "none" : "none";
|
|
798
|
-
const upgrade = req.headers.upgrade ?? "";
|
|
799
|
-
const connection = req.headers.connection ?? "";
|
|
800
|
-
const contentType = req.headers["content-type"] ?? "";
|
|
801
|
-
logger.debug(`codex-diag method=${method} path=${path4} ua=${ua} authScheme=${authScheme} upgrade=${upgrade} connection=${connection} contentType=${contentType} hasBody=${method !== "GET" && method !== "HEAD"}`);
|
|
802
|
-
}
|
|
803
|
-
let response = null;
|
|
804
|
-
try {
|
|
805
|
-
const body = await collectBody(req);
|
|
806
|
-
logger.info(`body collected bytes=${body.length}`);
|
|
807
|
-
const inboundTraceparent = req.headers["traceparent"];
|
|
808
|
-
const traceCtx = parseTraceparent(
|
|
809
|
-
typeof inboundTraceparent === "string" ? inboundTraceparent : null
|
|
810
|
-
) ?? generateTraceContext();
|
|
811
|
-
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
812
|
-
logger.info(`routing useSkalpel=${useSkalpel} traceId=${traceCtx.traceId}`);
|
|
813
|
-
const forwardHeaders = buildForwardHeaders(req, config2, source, useSkalpel, traceCtx);
|
|
814
|
-
logger.debug(`headers built skalpelHeaders=${useSkalpel} authConverted=${!forwardHeaders["authorization"] && !!forwardHeaders["x-api-key"]}`);
|
|
815
|
-
let isStreaming = false;
|
|
816
|
-
if (body) {
|
|
817
|
-
try {
|
|
818
|
-
const parsed = JSON.parse(body);
|
|
819
|
-
isStreaming = parsed.stream === true;
|
|
820
|
-
} catch {
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
logger.info(`stream detection isStreaming=${isStreaming}`);
|
|
824
|
-
if (isStreaming) {
|
|
825
|
-
const skalpelUrl2 = `${config2.remoteBaseUrl}${path4}`;
|
|
826
|
-
const directUrl2 = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
|
|
827
|
-
await handleStreamingRequest(req, res, config2, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger, traceCtx);
|
|
828
|
-
logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
|
|
829
|
-
return;
|
|
830
|
-
}
|
|
831
|
-
const skalpelUrl = `${config2.remoteBaseUrl}${path4}`;
|
|
832
|
-
const directUrl = source === "claude-code" ? `${config2.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config2.cursorDirectUrl}${path4}` : `${config2.openaiDirectUrl}${path4}`;
|
|
833
|
-
const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
|
|
834
|
-
let fetchError = null;
|
|
835
|
-
let usedFallback = false;
|
|
836
|
-
if (useSkalpel) {
|
|
837
|
-
logger.info(`fetch sending url=${skalpelUrl} method=${method}`);
|
|
838
|
-
try {
|
|
839
|
-
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
840
|
-
} catch (err) {
|
|
841
|
-
fetchError = err;
|
|
842
|
-
}
|
|
843
|
-
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${skalpelUrl}`);
|
|
844
|
-
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
845
|
-
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
846
|
-
usedFallback = true;
|
|
847
|
-
response = null;
|
|
848
|
-
fetchError = null;
|
|
849
|
-
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
850
|
-
logger.info(`fetch sending url=${directUrl} method=${method} fallback=true`);
|
|
851
|
-
try {
|
|
852
|
-
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
853
|
-
} catch (err) {
|
|
854
|
-
fetchError = err;
|
|
855
|
-
}
|
|
856
|
-
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl} fallback=true`);
|
|
857
|
-
}
|
|
858
|
-
} else {
|
|
859
|
-
logger.info(`fetch sending url=${directUrl} method=${method}`);
|
|
860
|
-
try {
|
|
861
|
-
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
862
|
-
} catch (err) {
|
|
863
|
-
fetchError = err;
|
|
864
|
-
}
|
|
865
|
-
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl}`);
|
|
866
|
-
}
|
|
867
|
-
const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
868
|
-
const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
|
|
869
|
-
if (fetchError) {
|
|
870
|
-
const code = fetchError.code;
|
|
871
|
-
if (code && TIMEOUT_CODES3.has(code)) {
|
|
872
|
-
logger.warn(`timeout detected code=${code} url=${fetchUrl}`);
|
|
873
|
-
try {
|
|
874
|
-
response = await handleTimeoutWithRetry(
|
|
875
|
-
fetchError,
|
|
876
|
-
() => fetch(fetchUrl, {
|
|
877
|
-
method,
|
|
878
|
-
headers: fetchHeaders,
|
|
879
|
-
body: fetchBody,
|
|
880
|
-
dispatcher: skalpelDispatcher
|
|
881
|
-
}),
|
|
882
|
-
logger
|
|
883
|
-
);
|
|
884
|
-
fetchError = null;
|
|
885
|
-
} catch (retryErr) {
|
|
886
|
-
fetchError = retryErr;
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
if (!response || fetchError) {
|
|
891
|
-
response = null;
|
|
892
|
-
throw fetchError ?? new Error("no response from upstream");
|
|
893
|
-
}
|
|
894
|
-
if (response.status === 429) {
|
|
895
|
-
logger.info(`429 received url=${fetchUrl}`);
|
|
896
|
-
response = await handle429WithRetryAfter(
|
|
897
|
-
response,
|
|
898
|
-
() => fetch(fetchUrl, {
|
|
899
|
-
method,
|
|
900
|
-
headers: fetchHeaders,
|
|
901
|
-
body: fetchBody,
|
|
902
|
-
dispatcher: skalpelDispatcher
|
|
903
|
-
}),
|
|
904
|
-
logger
|
|
905
|
-
);
|
|
906
|
-
}
|
|
907
|
-
if (response.status === 401 && (source === "claude-code" || source === "codex")) {
|
|
908
|
-
const fp2 = tokenFingerprint(
|
|
909
|
-
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
910
|
-
);
|
|
911
|
-
logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
|
|
912
|
-
const body401 = Buffer.from(await response.arrayBuffer());
|
|
913
|
-
const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
|
|
914
|
-
res.writeHead(401, { "Content-Type": "application/json" });
|
|
915
|
-
res.end(JSON.stringify(envelope));
|
|
916
|
-
logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
919
|
-
const responseHeaders = extractResponseHeaders(response);
|
|
920
|
-
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
921
|
-
responseHeaders["content-length"] = String(responseBody.length);
|
|
922
|
-
responseHeaders["traceparent"] = formatTraceparent(traceCtx);
|
|
923
|
-
logger.info(`response forwarding status=${response.status} bodyBytes=${responseBody.length}`);
|
|
924
|
-
res.writeHead(response.status, responseHeaders);
|
|
925
|
-
res.end(responseBody);
|
|
926
|
-
logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
|
|
927
|
-
} catch (err) {
|
|
928
|
-
logger.error(`${method} ${path4} source=${source} error=${formatFetchErrorForLog(err, path4)}`);
|
|
929
|
-
if (!res.headersSent) {
|
|
930
|
-
if (response !== null) {
|
|
931
|
-
const upstreamStatus = response.status;
|
|
932
|
-
const envelope = buildErrorEnvelope(
|
|
933
|
-
upstreamStatus,
|
|
934
|
-
"",
|
|
935
|
-
"skalpel-proxy",
|
|
936
|
-
"body read failed after upstream status"
|
|
937
|
-
);
|
|
938
|
-
res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
|
|
939
|
-
res.end(JSON.stringify(envelope));
|
|
940
|
-
} else {
|
|
941
|
-
const envelope = buildErrorEnvelope(
|
|
942
|
-
HTTP_BAD_GATEWAY2,
|
|
943
|
-
err.message,
|
|
944
|
-
"skalpel-proxy"
|
|
945
|
-
);
|
|
946
|
-
res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
|
|
947
|
-
res.end(JSON.stringify(envelope));
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
async function handleWebSocketBridge(clientWs, req, config2, source, logger) {
|
|
953
|
-
const backendUrl = buildBackendWsUrl(config2);
|
|
954
|
-
const freshOauthToken = getFreshAccessToken();
|
|
955
|
-
let oauthToken;
|
|
956
|
-
if (freshOauthToken !== null) {
|
|
957
|
-
oauthToken = freshOauthToken;
|
|
958
|
-
} else {
|
|
959
|
-
const oauthHeader = req.headers["authorization"] ?? "";
|
|
960
|
-
oauthToken = oauthHeader.toLowerCase().startsWith("bearer ") ? oauthHeader.slice(7).trim() : "";
|
|
961
|
-
}
|
|
962
|
-
const authResult = validateCodexAuth(freshOauthToken, oauthToken);
|
|
963
|
-
if (!authResult.valid) {
|
|
964
|
-
clientWs.send(JSON.stringify({
|
|
965
|
-
type: "error",
|
|
966
|
-
error: {
|
|
967
|
-
code: "no_credentials",
|
|
968
|
-
message: "Codex requires OAuth login. Run: codex login"
|
|
969
|
-
}
|
|
970
|
-
}));
|
|
971
|
-
clientWs.close(4e3, "no credentials");
|
|
972
|
-
return;
|
|
973
|
-
}
|
|
974
|
-
const backend = new BackendWsClient({
|
|
975
|
-
url: backendUrl,
|
|
976
|
-
apiKey: config2.apiKey,
|
|
977
|
-
oauthToken,
|
|
978
|
-
source,
|
|
979
|
-
logger
|
|
980
|
-
});
|
|
981
|
-
let fallbackActive = false;
|
|
982
|
-
let backendOpen = false;
|
|
983
|
-
const pendingClientText = [];
|
|
984
|
-
let firstClientFrameBody = null;
|
|
985
|
-
const flushPending = () => {
|
|
986
|
-
if (!backendOpen || fallbackActive) return;
|
|
987
|
-
while (pendingClientText.length > 0) {
|
|
988
|
-
const text = pendingClientText.shift();
|
|
989
|
-
if (text === void 0) break;
|
|
990
|
-
try {
|
|
991
|
-
const parsed = JSON.parse(text);
|
|
992
|
-
backend.send(parsed);
|
|
993
|
-
} catch (err) {
|
|
994
|
-
logger.warn(`bridge: flush backend.send failed: ${err.message}`);
|
|
995
|
-
pendingClientText.unshift(text);
|
|
996
|
-
return;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
};
|
|
1000
|
-
clientWs.on("message", (data) => {
|
|
1001
|
-
const text = data.toString("utf-8");
|
|
1002
|
-
if (firstClientFrameBody === null) {
|
|
1003
|
-
firstClientFrameBody = text;
|
|
1004
|
-
}
|
|
1005
|
-
pendingClientText.push(text);
|
|
1006
|
-
flushPending();
|
|
1007
|
-
});
|
|
1008
|
-
clientWs.on("close", (code, reason) => {
|
|
1009
|
-
logger.info(`bridge: client closed code=${code} reason=${String(reason)}`);
|
|
1010
|
-
backend.close(1e3, "client closed");
|
|
1011
|
-
});
|
|
1012
|
-
backend.on("open", () => {
|
|
1013
|
-
backendOpen = true;
|
|
1014
|
-
flushPending();
|
|
1015
|
-
});
|
|
1016
|
-
backend.on("frame", (frame) => {
|
|
1017
|
-
try {
|
|
1018
|
-
clientWs.send(JSON.stringify(frame));
|
|
1019
|
-
} catch (err) {
|
|
1020
|
-
logger.debug(`bridge: client.send failed: ${err.message}`);
|
|
1021
|
-
}
|
|
1022
|
-
});
|
|
1023
|
-
backend.on("close", (code, reason) => {
|
|
1024
|
-
logger.info(`bridge: backend closed code=${code} reason=${reason}`);
|
|
1025
|
-
backendOpen = false;
|
|
1026
|
-
});
|
|
1027
|
-
backend.on("error", (err) => {
|
|
1028
|
-
logger.debug(`bridge: backend error: ${err.message}`);
|
|
1029
|
-
});
|
|
1030
|
-
backend.on("fallback", (reason) => {
|
|
1031
|
-
if (fallbackActive) return;
|
|
1032
|
-
fallbackActive = true;
|
|
1033
|
-
backendOpen = false;
|
|
1034
|
-
logger.warn(`bridge: backend fallback reason=${reason} \u2014 switching to HTTP POST`);
|
|
1035
|
-
const body = firstClientFrameBody ?? (pendingClientText.length > 0 ? pendingClientText[0] : null);
|
|
1036
|
-
if (body === null) {
|
|
1037
|
-
logger.warn("bridge: no request body cached for HTTP fallback");
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
const inboundAuth = req.headers["authorization"] ?? "";
|
|
1041
|
-
fallbackToHttp(clientWs, config2, source, logger, body, inboundAuth).catch((httpErr) => {
|
|
1042
|
-
logger.error(`bridge HTTP drain failed: ${httpErr.message}`);
|
|
1043
|
-
try {
|
|
1044
|
-
clientWs.close(4003, "fallback drain failed");
|
|
1045
|
-
} catch {
|
|
1046
|
-
}
|
|
1047
|
-
});
|
|
1048
|
-
});
|
|
1049
|
-
try {
|
|
1050
|
-
await backend.connect();
|
|
1051
|
-
} catch (err) {
|
|
1052
|
-
logger.error(`bridge: initial connect failed: ${err.message}`);
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
function buildBackendWsUrl(config2) {
|
|
1056
|
-
const base = config2.remoteBaseUrl.replace(/^http/, "ws");
|
|
1057
|
-
return `${base}/v1/responses`;
|
|
1058
|
-
}
|
|
1059
|
-
function parseSseEvents(buffer) {
|
|
1060
|
-
const events = [];
|
|
1061
|
-
let rest = buffer.replace(/\r\n/g, "\n");
|
|
1062
|
-
while (rest.includes("\n\n")) {
|
|
1063
|
-
const idx = rest.indexOf("\n\n");
|
|
1064
|
-
const block = rest.slice(0, idx);
|
|
1065
|
-
rest = rest.slice(idx + 2);
|
|
1066
|
-
const dataLines = [];
|
|
1067
|
-
for (const line of block.split("\n")) {
|
|
1068
|
-
const trimmed = line.replace(/^\s+/, "");
|
|
1069
|
-
if (trimmed.startsWith("data:")) {
|
|
1070
|
-
dataLines.push(trimmed.slice(5).replace(/^\s+/, ""));
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
if (dataLines.length === 0) continue;
|
|
1074
|
-
const joined = dataLines.join("\n").trim();
|
|
1075
|
-
if (joined.length === 0 || joined === "[DONE]") continue;
|
|
1076
|
-
events.push(joined);
|
|
1077
|
-
}
|
|
1078
|
-
return { events, rest };
|
|
1079
|
-
}
|
|
1080
|
-
async function fallbackToHttp(clientWs, config2, source, logger, requestBody, inboundAuth) {
|
|
1081
|
-
const preflightAuth = validateCodexAuth(getFreshAccessToken(), inboundAuth);
|
|
1082
|
-
if (!preflightAuth.valid) {
|
|
1083
|
-
clientWs.send(JSON.stringify({
|
|
1084
|
-
type: "error",
|
|
1085
|
-
error: {
|
|
1086
|
-
code: "no_credentials",
|
|
1087
|
-
message: "Codex requires OAuth login. Run: codex login"
|
|
1088
|
-
}
|
|
1089
|
-
}));
|
|
1090
|
-
clientWs.close(4e3, "no credentials");
|
|
1091
|
-
return;
|
|
1092
|
-
}
|
|
1093
|
-
try {
|
|
1094
|
-
const freshToken = getFreshAccessToken();
|
|
1095
|
-
const authHeader = freshToken !== null ? `Bearer ${freshToken}` : inboundAuth;
|
|
1096
|
-
if (!authHeader) {
|
|
1097
|
-
logger.error("http fallback aborted: no Authorization available");
|
|
1098
|
-
try {
|
|
1099
|
-
clientWs.send(
|
|
1100
|
-
JSON.stringify({
|
|
1101
|
-
type: "error",
|
|
1102
|
-
error: { code: 401, message: "no credentials available for fallback" }
|
|
1103
|
-
})
|
|
1104
|
-
);
|
|
1105
|
-
clientWs.close(1011, "no credentials");
|
|
1106
|
-
} catch {
|
|
1107
|
-
}
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
const resp = await fetch(`${config2.remoteBaseUrl}/v1/responses`, {
|
|
1111
|
-
method: "POST",
|
|
1112
|
-
headers: {
|
|
1113
|
-
"Content-Type": "application/json",
|
|
1114
|
-
"X-Skalpel-API-Key": config2.apiKey,
|
|
1115
|
-
"X-Skalpel-Source": source,
|
|
1116
|
-
"X-Skalpel-Auth-Mode": "passthrough",
|
|
1117
|
-
Authorization: authHeader,
|
|
1118
|
-
Accept: "text/event-stream"
|
|
1119
|
-
},
|
|
1120
|
-
body: requestBody,
|
|
1121
|
-
dispatcher: skalpelDispatcher
|
|
1122
|
-
});
|
|
1123
|
-
if (!resp.ok) {
|
|
1124
|
-
let errorBody = "";
|
|
1125
|
-
try {
|
|
1126
|
-
const bodyPromise = resp.text();
|
|
1127
|
-
const timeoutPromise = new Promise(
|
|
1128
|
-
(_, reject) => setTimeout(() => reject(new Error("body read timeout")), 5e3)
|
|
1129
|
-
);
|
|
1130
|
-
errorBody = await Promise.race([bodyPromise, timeoutPromise]);
|
|
1131
|
-
} catch (e) {
|
|
1132
|
-
errorBody = `status ${resp.status}`;
|
|
1133
|
-
}
|
|
1134
|
-
let errorMessage = `Backend error: ${resp.status}`;
|
|
1135
|
-
try {
|
|
1136
|
-
const parsed = JSON.parse(errorBody);
|
|
1137
|
-
errorMessage = parsed?.error?.message || parsed?.detail || errorMessage;
|
|
1138
|
-
} catch {
|
|
1139
|
-
if (errorBody.length < 200) errorMessage = errorBody;
|
|
1140
|
-
}
|
|
1141
|
-
clientWs.send(JSON.stringify({
|
|
1142
|
-
type: "error",
|
|
1143
|
-
error: { code: resp.status, message: errorMessage }
|
|
1144
|
-
}));
|
|
1145
|
-
clientWs.close(1011, "backend error");
|
|
1146
|
-
return;
|
|
1147
|
-
}
|
|
1148
|
-
if (resp.body === null) {
|
|
1149
|
-
clientWs.send(JSON.stringify({
|
|
1150
|
-
type: "error",
|
|
1151
|
-
error: { code: resp.status, message: "empty response body" }
|
|
1152
|
-
}));
|
|
1153
|
-
clientWs.close(1011, "empty body");
|
|
1154
|
-
return;
|
|
1155
|
-
}
|
|
1156
|
-
const reader = resp.body.getReader();
|
|
1157
|
-
const decoder = new TextDecoder("utf-8");
|
|
1158
|
-
let buf = "";
|
|
1159
|
-
while (true) {
|
|
1160
|
-
const { done, value } = await reader.read();
|
|
1161
|
-
if (done) break;
|
|
1162
|
-
if (value === void 0) continue;
|
|
1163
|
-
buf += decoder.decode(value, { stream: true });
|
|
1164
|
-
const { events: events2, rest } = parseSseEvents(buf);
|
|
1165
|
-
buf = rest;
|
|
1166
|
-
for (const evt of events2) {
|
|
1167
|
-
try {
|
|
1168
|
-
clientWs.send(evt);
|
|
1169
|
-
} catch {
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
}
|
|
1173
|
-
const { events } = parseSseEvents(buf + "\n\n");
|
|
1174
|
-
for (const evt of events) {
|
|
1175
|
-
try {
|
|
1176
|
-
clientWs.send(evt);
|
|
1177
|
-
} catch {
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
try {
|
|
1181
|
-
clientWs.close(1e3, "fallback complete");
|
|
1182
|
-
} catch {
|
|
1183
|
-
}
|
|
1184
|
-
} catch (err) {
|
|
1185
|
-
logger.warn(`http fallback failed: ${err.message}`);
|
|
1186
|
-
try {
|
|
1187
|
-
clientWs.send(
|
|
1188
|
-
JSON.stringify({
|
|
1189
|
-
type: "error",
|
|
1190
|
-
error: { code: 502, message: err.message }
|
|
1191
|
-
})
|
|
1192
|
-
);
|
|
1193
|
-
clientWs.close(1011, "http fallback error");
|
|
1194
|
-
} catch {
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
var TIMEOUT_CODES3, HTTP_BAD_GATEWAY2, SKALPEL_EXACT_PATHS, FORWARD_HEADER_STRIP, STRIP_RESPONSE_HEADERS;
|
|
1199
|
-
var init_handler = __esm({
|
|
1200
|
-
"src/proxy/handler.ts"() {
|
|
1201
|
-
"use strict";
|
|
1202
|
-
init_streaming();
|
|
1203
|
-
init_dispatcher();
|
|
1204
|
-
init_envelope();
|
|
1205
|
-
init_ws_client();
|
|
1206
|
-
init_codex_oauth();
|
|
1207
|
-
init_recovery();
|
|
1208
|
-
init_fetch_error();
|
|
1209
|
-
init_trace_context();
|
|
1210
|
-
TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
1211
|
-
HTTP_BAD_GATEWAY2 = 502;
|
|
1212
|
-
SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
|
|
1213
|
-
FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
|
|
1214
|
-
"host",
|
|
1215
|
-
"connection",
|
|
1216
|
-
"keep-alive",
|
|
1217
|
-
"proxy-authenticate",
|
|
1218
|
-
"proxy-authorization",
|
|
1219
|
-
"te",
|
|
1220
|
-
"trailer",
|
|
1221
|
-
"transfer-encoding",
|
|
1222
|
-
"upgrade"
|
|
1223
|
-
]);
|
|
1224
|
-
STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
1225
|
-
"connection",
|
|
1226
|
-
"keep-alive",
|
|
1227
|
-
"proxy-authenticate",
|
|
1228
|
-
"proxy-authorization",
|
|
1229
|
-
"te",
|
|
1230
|
-
"trailer",
|
|
1231
|
-
"transfer-encoding",
|
|
1232
|
-
"upgrade",
|
|
1233
|
-
"content-encoding",
|
|
1234
|
-
"content-length"
|
|
1235
|
-
]);
|
|
1236
|
-
}
|
|
1237
|
-
});
|
|
1238
|
-
|
|
1239
|
-
// src/proxy/server.ts
|
|
1240
|
-
init_handler();
|
|
1241
|
-
import http2 from "http";
|
|
1242
|
-
|
|
1243
|
-
// src/proxy/health.ts
|
|
1244
|
-
function handleHealthRequest(res, config2, startTime, logger) {
|
|
1245
|
-
logger?.debug("health check served");
|
|
1246
|
-
const body = JSON.stringify({
|
|
1247
|
-
status: "ok",
|
|
1248
|
-
uptime: Date.now() - startTime,
|
|
1249
|
-
ports: {
|
|
1250
|
-
anthropic: config2.anthropicPort,
|
|
1251
|
-
openai: config2.openaiPort,
|
|
1252
|
-
cursor: config2.cursorPort
|
|
1253
|
-
},
|
|
1254
|
-
version: "proxy-1.0.0"
|
|
1255
|
-
});
|
|
1256
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1257
|
-
res.end(body);
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
// src/proxy/pid.ts
|
|
1261
|
-
import fs from "fs";
|
|
1262
|
-
import path from "path";
|
|
1263
|
-
import { execSync } from "child_process";
|
|
1264
|
-
function writePid(pidFile) {
|
|
1265
|
-
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
|
|
1266
|
-
const record = {
|
|
1267
|
-
pid: process.pid,
|
|
1268
|
-
startTime: getStartTime(process.pid)
|
|
1269
|
-
};
|
|
1270
|
-
fs.writeFileSync(pidFile, JSON.stringify(record));
|
|
1271
|
-
}
|
|
1272
|
-
function getStartTime(pid) {
|
|
1273
|
-
try {
|
|
1274
|
-
if (process.platform === "linux") {
|
|
1275
|
-
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
1276
|
-
const rparen = stat.lastIndexOf(")");
|
|
1277
|
-
if (rparen < 0) return null;
|
|
1278
|
-
const fields = stat.slice(rparen + 2).split(" ");
|
|
1279
|
-
return fields[19] ?? null;
|
|
1280
|
-
}
|
|
1281
|
-
if (process.platform === "darwin") {
|
|
1282
|
-
const out = execSync(`ps -p ${pid} -o lstart=`, { timeout: 2e3, stdio: ["ignore", "pipe", "ignore"] });
|
|
1283
|
-
const text = out.toString().trim();
|
|
1284
|
-
return text || null;
|
|
1285
|
-
}
|
|
1286
|
-
return null;
|
|
1287
|
-
} catch {
|
|
1288
|
-
return null;
|
|
1289
|
-
}
|
|
1290
|
-
}
|
|
1291
|
-
function removePid(pidFile) {
|
|
1292
|
-
try {
|
|
1293
|
-
fs.unlinkSync(pidFile);
|
|
1294
|
-
} catch {
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
// src/proxy/logger.ts
|
|
1299
|
-
import fs2 from "fs";
|
|
1300
|
-
import path2 from "path";
|
|
1301
|
-
var MAX_SIZE = 5 * 1024 * 1024;
|
|
1302
|
-
var MAX_ROTATIONS = 3;
|
|
1303
|
-
var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
1304
|
-
var Logger = class _Logger {
|
|
1305
|
-
logFile;
|
|
1306
|
-
level;
|
|
1307
|
-
prefix;
|
|
1308
|
-
constructor(logFile, level = "info", prefix = "") {
|
|
1309
|
-
this.logFile = logFile;
|
|
1310
|
-
this.level = level;
|
|
1311
|
-
this.prefix = prefix;
|
|
1312
|
-
fs2.mkdirSync(path2.dirname(logFile), { recursive: true });
|
|
1313
|
-
}
|
|
1314
|
-
debug(msg) {
|
|
1315
|
-
this.log("debug", msg);
|
|
1316
|
-
}
|
|
1317
|
-
info(msg) {
|
|
1318
|
-
this.log("info", msg);
|
|
1319
|
-
}
|
|
1320
|
-
warn(msg) {
|
|
1321
|
-
this.log("warn", msg);
|
|
1322
|
-
}
|
|
1323
|
-
error(msg) {
|
|
1324
|
-
this.log("error", msg);
|
|
1325
|
-
}
|
|
1326
|
-
/** Returns a new Logger that writes to the same file but prefixes every
|
|
1327
|
-
* emitted line with `[conn=<connId>] ` or `[conn=<connId> trace=<traceId>] `.
|
|
1328
|
-
* The parent logger continues to work unchanged. IPv6 colons should already
|
|
1329
|
-
* be sanitized by the caller. */
|
|
1330
|
-
child(connId, traceId) {
|
|
1331
|
-
const prefix = traceId ? `[conn=${connId} trace=${traceId}] ` : `[conn=${connId}] `;
|
|
1332
|
-
const child = new _Logger(this.logFile, this.level, prefix);
|
|
1333
|
-
return child;
|
|
1334
|
-
}
|
|
1335
|
-
log(level, msg) {
|
|
1336
|
-
if (LEVELS[level] < LEVELS[this.level]) return;
|
|
1337
|
-
const line = `${(/* @__PURE__ */ new Date()).toISOString()} [${level.toUpperCase()}] ${this.prefix}${msg}
|
|
1338
|
-
`;
|
|
1339
|
-
if (level === "debug" || level === "error") {
|
|
1340
|
-
process.stderr.write(line);
|
|
1341
|
-
}
|
|
1342
|
-
try {
|
|
1343
|
-
this.rotate();
|
|
1344
|
-
fs2.appendFileSync(this.logFile, line);
|
|
1345
|
-
} catch {
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
rotate() {
|
|
1349
|
-
try {
|
|
1350
|
-
const stat = fs2.statSync(this.logFile);
|
|
1351
|
-
if (stat.size < MAX_SIZE) return;
|
|
1352
|
-
} catch {
|
|
1353
|
-
return;
|
|
1354
|
-
}
|
|
1355
|
-
for (let i = MAX_ROTATIONS; i >= 1; i--) {
|
|
1356
|
-
const src = i === 1 ? this.logFile : `${this.logFile}.${i - 1}`;
|
|
1357
|
-
const dst = `${this.logFile}.${i}`;
|
|
1358
|
-
try {
|
|
1359
|
-
fs2.renameSync(src, dst);
|
|
1360
|
-
} catch {
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
};
|
|
1365
|
-
|
|
1366
|
-
// src/proxy/ws-server.ts
|
|
1367
|
-
import { WebSocketServer } from "ws";
|
|
1368
|
-
var WS_SUBPROTOCOL2 = "skalpel-codex-v1";
|
|
1369
|
-
var wss = new WebSocketServer({
|
|
1370
|
-
noServer: true,
|
|
1371
|
-
handleProtocols: (protocols) => protocols.has(WS_SUBPROTOCOL2) ? WS_SUBPROTOCOL2 : false
|
|
1372
|
-
});
|
|
1373
|
-
function reject426(socket, payload) {
|
|
1374
|
-
const body = JSON.stringify(payload);
|
|
1375
|
-
socket.write(
|
|
1376
|
-
`HTTP/1.1 426 Upgrade Required\r
|
|
1377
|
-
Content-Type: application/json\r
|
|
1378
|
-
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1379
|
-
Connection: close\r
|
|
1380
|
-
\r
|
|
1381
|
-
` + body
|
|
1382
|
-
);
|
|
1383
|
-
socket.destroy();
|
|
1384
|
-
}
|
|
1385
|
-
function handleCodexUpgrade(req, socket, head, config2, logger) {
|
|
1386
|
-
const wsFlag = process.env.SKALPEL_CODEX_WS ?? "1";
|
|
1387
|
-
if (wsFlag === "0") {
|
|
1388
|
-
logger.warn("ws-upgrade rejected: feature flag SKALPEL_CODEX_WS=0");
|
|
1389
|
-
reject426(socket, { error: "ws_disabled" });
|
|
1390
|
-
return;
|
|
1391
|
-
}
|
|
1392
|
-
wss.handleUpgrade(req, socket, head, (clientWs) => {
|
|
1393
|
-
logger.info(`ws-upgrade accepted path=${req.url ?? ""} subproto=${WS_SUBPROTOCOL2}`);
|
|
1394
|
-
Promise.resolve().then(() => (init_handler(), handler_exports)).then((mod) => {
|
|
1395
|
-
const bridge = mod.handleWebSocketBridge;
|
|
1396
|
-
if (typeof bridge !== "function") {
|
|
1397
|
-
clientWs.send(
|
|
1398
|
-
JSON.stringify({
|
|
1399
|
-
type: "error",
|
|
1400
|
-
payload: { code: "not_implemented" }
|
|
1401
|
-
})
|
|
1402
|
-
);
|
|
1403
|
-
clientWs.close(4003, "bridge pending");
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
void bridge(clientWs, req, config2, "codex", logger);
|
|
1407
|
-
}).catch((err) => {
|
|
1408
|
-
logger.error(`ws bridge import failed: ${err?.message ?? String(err)}`);
|
|
1409
|
-
try {
|
|
1410
|
-
clientWs.send(
|
|
1411
|
-
JSON.stringify({
|
|
1412
|
-
type: "error",
|
|
1413
|
-
payload: { code: "bridge_import_failed" }
|
|
1414
|
-
})
|
|
1415
|
-
);
|
|
1416
|
-
} catch {
|
|
1417
|
-
}
|
|
1418
|
-
clientWs.close(4003, "bridge import failed");
|
|
1419
|
-
});
|
|
1420
|
-
});
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
// src/proxy/server.ts
|
|
1424
|
-
init_codex_oauth();
|
|
1425
|
-
var proxyStartTime = 0;
|
|
1426
|
-
var connCounter = 0;
|
|
1427
|
-
function computeConnId(req) {
|
|
1428
|
-
const addr = req.socket.remoteAddress ?? "unknown";
|
|
1429
|
-
const port = req.socket.remotePort ?? 0;
|
|
1430
|
-
const counter = (++connCounter).toString(36);
|
|
1431
|
-
const raw = addr + "|" + port + "|" + Date.now().toString(36) + "|" + counter + "|" + Math.floor(Math.random() * 4096).toString(16);
|
|
1432
|
-
return raw.replace(/:/g, "_");
|
|
1433
|
-
}
|
|
1434
|
-
function startProxy(config2) {
|
|
1435
|
-
const logger = new Logger(config2.logFile, config2.logLevel);
|
|
1436
|
-
const startTime = Date.now();
|
|
1437
|
-
proxyStartTime = Date.now();
|
|
1438
|
-
const anthropicServer = http2.createServer((req, res) => {
|
|
1439
|
-
if (req.url === "/health" && req.method === "GET") {
|
|
1440
|
-
handleHealthRequest(res, config2, startTime, logger.child("health"));
|
|
1441
|
-
return;
|
|
1442
|
-
}
|
|
1443
|
-
const connId = computeConnId(req);
|
|
1444
|
-
handleRequest(req, res, config2, "claude-code", logger.child(connId));
|
|
1445
|
-
});
|
|
1446
|
-
const openaiServer = http2.createServer((req, res) => {
|
|
1447
|
-
if (req.url === "/health" && req.method === "GET") {
|
|
1448
|
-
handleHealthRequest(res, config2, startTime, logger.child("health"));
|
|
1449
|
-
return;
|
|
1450
|
-
}
|
|
1451
|
-
const connId = computeConnId(req);
|
|
1452
|
-
handleRequest(req, res, config2, "codex", logger.child(connId));
|
|
1453
|
-
});
|
|
1454
|
-
const cursorServer = http2.createServer((req, res) => {
|
|
1455
|
-
if (req.url === "/health" && req.method === "GET") {
|
|
1456
|
-
handleHealthRequest(res, config2, startTime, logger.child("health"));
|
|
1457
|
-
return;
|
|
1458
|
-
}
|
|
1459
|
-
const connId = computeConnId(req);
|
|
1460
|
-
handleRequest(req, res, config2, "cursor", logger.child(connId));
|
|
1461
|
-
});
|
|
1462
|
-
anthropicServer.on("upgrade", (req, socket, _head) => {
|
|
1463
|
-
const ua = req.headers["user-agent"] ?? "";
|
|
1464
|
-
logger.warn(`upgrade-attempt port=${config2.anthropicPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1465
|
-
const body = JSON.stringify({
|
|
1466
|
-
error: "upgrade_required",
|
|
1467
|
-
message: "Skalpel proxy is HTTP-only",
|
|
1468
|
-
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1469
|
-
});
|
|
1470
|
-
socket.write(
|
|
1471
|
-
`HTTP/1.1 426 Upgrade Required\r
|
|
1472
|
-
Content-Type: application/json\r
|
|
1473
|
-
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1474
|
-
Connection: close\r
|
|
1475
|
-
\r
|
|
1476
|
-
` + body
|
|
1477
|
-
);
|
|
1478
|
-
socket.destroy();
|
|
1479
|
-
});
|
|
1480
|
-
openaiServer.on("upgrade", (req, socket, head) => {
|
|
1481
|
-
const ua = req.headers["user-agent"] ?? "";
|
|
1482
|
-
logger.warn(`upgrade-attempt port=${config2.openaiPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1483
|
-
const pathname = (req.url ?? "").split("?")[0];
|
|
1484
|
-
if (pathname === "/v1/responses") {
|
|
1485
|
-
const oauthPresent = getFreshAccessToken() !== null;
|
|
1486
|
-
logger.info(`codex-upgrade oauth_present=${oauthPresent}`);
|
|
1487
|
-
handleCodexUpgrade(req, socket, head, config2, logger);
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1490
|
-
const body = JSON.stringify({
|
|
1491
|
-
error: "upgrade_required",
|
|
1492
|
-
message: "Skalpel proxy is HTTP-only",
|
|
1493
|
-
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1494
|
-
});
|
|
1495
|
-
socket.write(
|
|
1496
|
-
`HTTP/1.1 426 Upgrade Required\r
|
|
1497
|
-
Content-Type: application/json\r
|
|
1498
|
-
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1499
|
-
Connection: close\r
|
|
1500
|
-
\r
|
|
1501
|
-
` + body
|
|
1502
|
-
);
|
|
1503
|
-
socket.destroy();
|
|
1504
|
-
});
|
|
1505
|
-
cursorServer.on("upgrade", (req, socket, _head) => {
|
|
1506
|
-
const ua = req.headers["user-agent"] ?? "";
|
|
1507
|
-
logger.warn(`upgrade-attempt port=${config2.cursorPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1508
|
-
const body = JSON.stringify({
|
|
1509
|
-
error: "upgrade_required",
|
|
1510
|
-
message: "Skalpel proxy is HTTP-only",
|
|
1511
|
-
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1512
|
-
});
|
|
1513
|
-
socket.write(
|
|
1514
|
-
`HTTP/1.1 426 Upgrade Required\r
|
|
1515
|
-
Content-Type: application/json\r
|
|
1516
|
-
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1517
|
-
Connection: close\r
|
|
1518
|
-
\r
|
|
1519
|
-
` + body
|
|
1520
|
-
);
|
|
1521
|
-
socket.destroy();
|
|
1522
|
-
});
|
|
1523
|
-
anthropicServer.on("error", (err) => {
|
|
1524
|
-
if (err.code === "EADDRINUSE") {
|
|
1525
|
-
logger.error(`Port ${config2.anthropicPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
1526
|
-
} else {
|
|
1527
|
-
logger.error(`Anthropic proxy failed to bind port ${config2.anthropicPort}: ${err.message}`);
|
|
1528
|
-
}
|
|
1529
|
-
removePid(config2.pidFile);
|
|
1530
|
-
process.exit(1);
|
|
1531
|
-
});
|
|
1532
|
-
openaiServer.on("error", (err) => {
|
|
1533
|
-
if (err.code === "EADDRINUSE") {
|
|
1534
|
-
logger.error(`Port ${config2.openaiPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
1535
|
-
} else {
|
|
1536
|
-
logger.error(`OpenAI proxy failed to bind port ${config2.openaiPort}: ${err.message}`);
|
|
1537
|
-
}
|
|
1538
|
-
removePid(config2.pidFile);
|
|
1539
|
-
process.exit(1);
|
|
1540
|
-
});
|
|
1541
|
-
cursorServer.on("error", (err) => {
|
|
1542
|
-
if (err.code === "EADDRINUSE") {
|
|
1543
|
-
logger.error(`Port ${config2.cursorPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
1544
|
-
} else {
|
|
1545
|
-
logger.error(`Cursor proxy failed to bind port ${config2.cursorPort}: ${err.message}`);
|
|
1546
|
-
}
|
|
1547
|
-
removePid(config2.pidFile);
|
|
1548
|
-
process.exit(1);
|
|
1549
|
-
});
|
|
1550
|
-
let bound = 0;
|
|
1551
|
-
const onBound = () => {
|
|
1552
|
-
bound++;
|
|
1553
|
-
if (bound === 3) {
|
|
1554
|
-
writePid(config2.pidFile);
|
|
1555
|
-
logger.info(`Proxy started (pid=${process.pid}) ports=${config2.anthropicPort},${config2.openaiPort},${config2.cursorPort}`);
|
|
1556
|
-
}
|
|
1557
|
-
};
|
|
1558
|
-
anthropicServer.listen(config2.anthropicPort, () => {
|
|
1559
|
-
logger.info(`Anthropic proxy listening on port ${config2.anthropicPort}`);
|
|
1560
|
-
onBound();
|
|
1561
|
-
});
|
|
1562
|
-
openaiServer.listen(config2.openaiPort, () => {
|
|
1563
|
-
logger.info(`OpenAI proxy listening on port ${config2.openaiPort}`);
|
|
1564
|
-
onBound();
|
|
1565
|
-
});
|
|
1566
|
-
cursorServer.listen(config2.cursorPort, () => {
|
|
1567
|
-
logger.info(`Cursor proxy listening on port ${config2.cursorPort}`);
|
|
1568
|
-
onBound();
|
|
1569
|
-
});
|
|
1570
|
-
const cleanup = () => {
|
|
1571
|
-
logger.info("Shutting down proxy...");
|
|
1572
|
-
anthropicServer.close();
|
|
1573
|
-
openaiServer.close();
|
|
1574
|
-
cursorServer.close();
|
|
1575
|
-
removePid(config2.pidFile);
|
|
1576
|
-
process.exit(0);
|
|
1577
|
-
};
|
|
1578
|
-
process.on("SIGTERM", cleanup);
|
|
1579
|
-
process.on("SIGINT", cleanup);
|
|
1580
|
-
process.on("uncaughtException", (err) => {
|
|
1581
|
-
logger.error(`Uncaught exception: ${err.message}`);
|
|
1582
|
-
removePid(config2.pidFile);
|
|
1583
|
-
process.exit(1);
|
|
1584
|
-
});
|
|
1585
|
-
process.on("unhandledRejection", (reason) => {
|
|
1586
|
-
logger.error(`Unhandled rejection: ${reason}`);
|
|
1587
|
-
removePid(config2.pidFile);
|
|
1588
|
-
process.exit(1);
|
|
1589
|
-
});
|
|
1590
|
-
return { anthropicServer, openaiServer, cursorServer };
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
// src/proxy/config.ts
|
|
1594
|
-
import fs3 from "fs";
|
|
1595
|
-
import path3 from "path";
|
|
1596
|
-
import os from "os";
|
|
1597
|
-
function expandHome(filePath) {
|
|
1598
|
-
if (filePath.startsWith("~")) {
|
|
1599
|
-
return path3.join(os.homedir(), filePath.slice(1));
|
|
1600
|
-
}
|
|
1601
|
-
return filePath;
|
|
1602
|
-
}
|
|
1603
|
-
var DEFAULTS = {
|
|
1604
|
-
apiKey: "",
|
|
1605
|
-
remoteBaseUrl: "https://api.skalpel.ai",
|
|
1606
|
-
anthropicDirectUrl: "https://api.anthropic.com",
|
|
1607
|
-
openaiDirectUrl: "https://api.openai.com",
|
|
1608
|
-
anthropicPort: 18100,
|
|
1609
|
-
openaiPort: 18101,
|
|
1610
|
-
cursorPort: 18102,
|
|
1611
|
-
cursorDirectUrl: "https://api.openai.com",
|
|
1612
|
-
logLevel: "info",
|
|
1613
|
-
logFile: "~/.skalpel/logs/proxy.log",
|
|
1614
|
-
pidFile: "~/.skalpel/proxy.pid",
|
|
1615
|
-
configFile: "~/.skalpel/config.json",
|
|
1616
|
-
mode: "proxy"
|
|
1617
|
-
};
|
|
1618
|
-
function coerceMode(value) {
|
|
1619
|
-
return value === "direct" ? "direct" : "proxy";
|
|
1620
|
-
}
|
|
1621
|
-
function loadConfig(configPath) {
|
|
1622
|
-
const filePath = expandHome(configPath ?? DEFAULTS.configFile);
|
|
1623
|
-
let fileConfig = {};
|
|
1624
|
-
try {
|
|
1625
|
-
const raw = fs3.readFileSync(filePath, "utf-8");
|
|
1626
|
-
fileConfig = JSON.parse(raw);
|
|
1627
|
-
} catch {
|
|
1628
|
-
}
|
|
1629
|
-
return {
|
|
1630
|
-
apiKey: fileConfig.apiKey ?? DEFAULTS.apiKey,
|
|
1631
|
-
remoteBaseUrl: fileConfig.remoteBaseUrl ?? DEFAULTS.remoteBaseUrl,
|
|
1632
|
-
anthropicDirectUrl: fileConfig.anthropicDirectUrl ?? DEFAULTS.anthropicDirectUrl,
|
|
1633
|
-
openaiDirectUrl: fileConfig.openaiDirectUrl ?? DEFAULTS.openaiDirectUrl,
|
|
1634
|
-
anthropicPort: fileConfig.anthropicPort ?? DEFAULTS.anthropicPort,
|
|
1635
|
-
openaiPort: fileConfig.openaiPort ?? DEFAULTS.openaiPort,
|
|
1636
|
-
cursorPort: fileConfig.cursorPort ?? DEFAULTS.cursorPort,
|
|
1637
|
-
cursorDirectUrl: fileConfig.cursorDirectUrl ?? DEFAULTS.cursorDirectUrl,
|
|
1638
|
-
logLevel: fileConfig.logLevel ?? DEFAULTS.logLevel,
|
|
1639
|
-
logFile: expandHome(fileConfig.logFile ?? DEFAULTS.logFile),
|
|
1640
|
-
pidFile: expandHome(fileConfig.pidFile ?? DEFAULTS.pidFile),
|
|
1641
|
-
configFile: filePath,
|
|
1642
|
-
mode: coerceMode(fileConfig.mode)
|
|
1643
|
-
};
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
// src/cli/proxy-runner.ts
|
|
1647
|
-
var config = loadConfig();
|
|
1648
|
-
startProxy(config);
|
|
1649
|
-
//# sourceMappingURL=proxy-runner.js.map
|