skalpel 2.0.13 → 2.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +588 -247
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/proxy-runner.js +592 -76
- package/dist/cli/proxy-runner.js.map +1 -1
- package/dist/index.cjs +1580 -1045
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1570 -1028
- package/dist/index.js.map +1 -1
- package/dist/proxy/index.cjs +628 -93
- package/dist/proxy/index.cjs.map +1 -1
- package/dist/proxy/index.d.cts +1 -1
- package/dist/proxy/index.d.ts +1 -1
- package/dist/proxy/index.js +621 -79
- package/dist/proxy/index.js.map +1 -1
- package/package.json +4 -2
package/dist/proxy/index.cjs
CHANGED
|
@@ -5,6 +5,9 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
|
5
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
6
|
var __getProtoOf = Object.getPrototypeOf;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __esm = (fn, res) => function __init() {
|
|
9
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
10
|
+
};
|
|
8
11
|
var __export = (target, all) => {
|
|
9
12
|
for (var name in all)
|
|
10
13
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
@@ -27,27 +30,19 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
27
30
|
));
|
|
28
31
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
32
|
|
|
30
|
-
// src/proxy/index.ts
|
|
31
|
-
var proxy_exports = {};
|
|
32
|
-
__export(proxy_exports, {
|
|
33
|
-
getProxyStatus: () => getProxyStatus,
|
|
34
|
-
loadConfig: () => loadConfig,
|
|
35
|
-
saveConfig: () => saveConfig,
|
|
36
|
-
startProxy: () => startProxy,
|
|
37
|
-
stopProxy: () => stopProxy
|
|
38
|
-
});
|
|
39
|
-
module.exports = __toCommonJS(proxy_exports);
|
|
40
|
-
|
|
41
|
-
// src/proxy/server.ts
|
|
42
|
-
var import_node_http = __toESM(require("http"), 1);
|
|
43
|
-
|
|
44
33
|
// src/proxy/dispatcher.ts
|
|
45
|
-
var import_undici
|
|
46
|
-
var
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
34
|
+
var import_undici, skalpelDispatcher;
|
|
35
|
+
var init_dispatcher = __esm({
|
|
36
|
+
"src/proxy/dispatcher.ts"() {
|
|
37
|
+
"use strict";
|
|
38
|
+
import_undici = require("undici");
|
|
39
|
+
skalpelDispatcher = new import_undici.Agent({
|
|
40
|
+
keepAliveTimeout: 1e4,
|
|
41
|
+
keepAliveMaxTimeout: 6e4,
|
|
42
|
+
connections: 100,
|
|
43
|
+
pipelining: 1
|
|
44
|
+
});
|
|
45
|
+
}
|
|
51
46
|
});
|
|
52
47
|
|
|
53
48
|
// src/proxy/envelope.ts
|
|
@@ -116,9 +111,13 @@ function defaultMessageForStatus(status) {
|
|
|
116
111
|
if (status >= 400) return "Client error";
|
|
117
112
|
return "Error";
|
|
118
113
|
}
|
|
114
|
+
var init_envelope = __esm({
|
|
115
|
+
"src/proxy/envelope.ts"() {
|
|
116
|
+
"use strict";
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
119
|
|
|
120
120
|
// src/proxy/recovery.ts
|
|
121
|
-
var import_node_crypto = require("crypto");
|
|
122
121
|
function parseRetryAfterHeader(header) {
|
|
123
122
|
if (!header) return void 0;
|
|
124
123
|
const trimmed = header.trim();
|
|
@@ -134,11 +133,10 @@ function parseRetryAfterHeader(header) {
|
|
|
134
133
|
function sleep(ms) {
|
|
135
134
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
136
135
|
}
|
|
137
|
-
var MAX_RETRY_AFTER_SECONDS = 60;
|
|
138
|
-
var DEFAULT_BACKOFF_SECONDS = 2;
|
|
139
136
|
async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
140
137
|
const headerVal = response.headers.get("retry-after");
|
|
141
138
|
const parsed = parseRetryAfterHeader(headerVal);
|
|
139
|
+
logger.debug(`429 recovery retryAfterHeader=${headerVal ?? "none"} parsed=${parsed ?? "none"}`);
|
|
142
140
|
if (parsed === void 0) {
|
|
143
141
|
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
144
142
|
const retried2 = await retryFn();
|
|
@@ -146,6 +144,7 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
|
146
144
|
return retried2;
|
|
147
145
|
}
|
|
148
146
|
if (parsed > MAX_RETRY_AFTER_SECONDS) {
|
|
147
|
+
logger.warn(`429 recovery capped: retryAfter=${parsed}s exceeds max=${MAX_RETRY_AFTER_SECONDS}s, passing 429 through`);
|
|
149
148
|
return response;
|
|
150
149
|
}
|
|
151
150
|
await sleep(parsed * 1e3);
|
|
@@ -153,12 +152,12 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
|
153
152
|
logger.info("proxy.recovery.429_retry_count increment");
|
|
154
153
|
return retried;
|
|
155
154
|
}
|
|
156
|
-
var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
157
155
|
async function handleTimeoutWithRetry(err, retryFn, logger) {
|
|
158
156
|
const code = err.code;
|
|
159
157
|
if (!code || !TIMEOUT_CODES.has(code)) {
|
|
160
158
|
throw err;
|
|
161
159
|
}
|
|
160
|
+
logger.warn(`timeout recovery code=${code}`);
|
|
162
161
|
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
163
162
|
const retried = await retryFn();
|
|
164
163
|
logger.info("proxy.recovery.timeout_retry_count increment");
|
|
@@ -168,23 +167,49 @@ function tokenFingerprint(authHeader) {
|
|
|
168
167
|
if (authHeader === void 0) return "none";
|
|
169
168
|
return (0, import_node_crypto.createHash)("sha256").update(authHeader).digest("hex").slice(0, 12);
|
|
170
169
|
}
|
|
171
|
-
var MUTEX_MAX_ENTRIES
|
|
172
|
-
var
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
170
|
+
var import_node_crypto, MAX_RETRY_AFTER_SECONDS, DEFAULT_BACKOFF_SECONDS, TIMEOUT_CODES, MUTEX_MAX_ENTRIES, LruMutexMap, refreshMutex;
|
|
171
|
+
var init_recovery = __esm({
|
|
172
|
+
"src/proxy/recovery.ts"() {
|
|
173
|
+
"use strict";
|
|
174
|
+
import_node_crypto = require("crypto");
|
|
175
|
+
MAX_RETRY_AFTER_SECONDS = 60;
|
|
176
|
+
DEFAULT_BACKOFF_SECONDS = 2;
|
|
177
|
+
TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
178
|
+
MUTEX_MAX_ENTRIES = 1024;
|
|
179
|
+
LruMutexMap = class extends Map {
|
|
180
|
+
set(key, value) {
|
|
181
|
+
if (this.has(key)) {
|
|
182
|
+
super.delete(key);
|
|
183
|
+
} else if (this.size >= MUTEX_MAX_ENTRIES) {
|
|
184
|
+
const oldest = this.keys().next().value;
|
|
185
|
+
if (oldest !== void 0) super.delete(oldest);
|
|
186
|
+
}
|
|
187
|
+
return super.set(key, value);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
refreshMutex = new LruMutexMap();
|
|
181
191
|
}
|
|
182
|
-
};
|
|
183
|
-
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// src/proxy/fetch-error.ts
|
|
195
|
+
function formatFetchErrorForLog(err, url) {
|
|
196
|
+
if (err instanceof Error) {
|
|
197
|
+
const code = err.code;
|
|
198
|
+
const parts = [];
|
|
199
|
+
if (code) parts.push(code);
|
|
200
|
+
parts.push(err.message);
|
|
201
|
+
parts.push(`url=${url}`);
|
|
202
|
+
return parts.join(" ");
|
|
203
|
+
}
|
|
204
|
+
return `${String(err)} url=${url}`;
|
|
205
|
+
}
|
|
206
|
+
var init_fetch_error = __esm({
|
|
207
|
+
"src/proxy/fetch-error.ts"() {
|
|
208
|
+
"use strict";
|
|
209
|
+
}
|
|
210
|
+
});
|
|
184
211
|
|
|
185
212
|
// src/proxy/streaming.ts
|
|
186
|
-
var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
187
|
-
var HTTP_BAD_GATEWAY = 502;
|
|
188
213
|
function parseRetryAfter(header) {
|
|
189
214
|
if (!header) return void 0;
|
|
190
215
|
const trimmed = header.trim();
|
|
@@ -198,21 +223,6 @@ function parseRetryAfter(header) {
|
|
|
198
223
|
}
|
|
199
224
|
return void 0;
|
|
200
225
|
}
|
|
201
|
-
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
202
|
-
"connection",
|
|
203
|
-
"keep-alive",
|
|
204
|
-
"proxy-authenticate",
|
|
205
|
-
"proxy-authorization",
|
|
206
|
-
"te",
|
|
207
|
-
"trailer",
|
|
208
|
-
"transfer-encoding",
|
|
209
|
-
"upgrade"
|
|
210
|
-
]);
|
|
211
|
-
var STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
212
|
-
...HOP_BY_HOP,
|
|
213
|
-
"content-encoding",
|
|
214
|
-
"content-length"
|
|
215
|
-
]);
|
|
216
226
|
function stripSkalpelHeaders(headers) {
|
|
217
227
|
const cleaned = { ...headers };
|
|
218
228
|
delete cleaned["X-Skalpel-API-Key"];
|
|
@@ -230,29 +240,35 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
230
240
|
let fetchError = null;
|
|
231
241
|
let usedFallback = false;
|
|
232
242
|
if (useSkalpel) {
|
|
243
|
+
logger.info(`streaming fetch sending url=${skalpelUrl}`);
|
|
233
244
|
try {
|
|
234
245
|
response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
|
|
235
246
|
} catch (err) {
|
|
236
247
|
fetchError = err;
|
|
237
248
|
}
|
|
249
|
+
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${skalpelUrl}`);
|
|
238
250
|
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
239
|
-
logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError
|
|
251
|
+
logger.warn(`streaming: Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
240
252
|
usedFallback = true;
|
|
241
253
|
response = null;
|
|
242
254
|
fetchError = null;
|
|
243
255
|
const directHeaders = stripSkalpelHeaders(forwardHeaders);
|
|
256
|
+
logger.info(`streaming fetch sending url=${directUrl} fallback=true`);
|
|
244
257
|
try {
|
|
245
258
|
response = await doStreamingFetch(directUrl, body, directHeaders);
|
|
246
259
|
} catch (err) {
|
|
247
260
|
fetchError = err;
|
|
248
261
|
}
|
|
262
|
+
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl} fallback=true`);
|
|
249
263
|
}
|
|
250
264
|
} else {
|
|
265
|
+
logger.info(`streaming fetch sending url=${directUrl}`);
|
|
251
266
|
try {
|
|
252
267
|
response = await doStreamingFetch(directUrl, body, forwardHeaders);
|
|
253
268
|
} catch (err) {
|
|
254
269
|
fetchError = err;
|
|
255
270
|
}
|
|
271
|
+
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl}`);
|
|
256
272
|
}
|
|
257
273
|
const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
258
274
|
const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
|
|
@@ -279,7 +295,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
279
295
|
);
|
|
280
296
|
}
|
|
281
297
|
if (!response || fetchError) {
|
|
282
|
-
const errMsg = fetchError ? fetchError
|
|
298
|
+
const errMsg = fetchError ? formatFetchErrorForLog(fetchError, finalUrl) : "no response from upstream";
|
|
283
299
|
logger.error(`streaming fetch failed: ${errMsg}`);
|
|
284
300
|
res.writeHead(HTTP_BAD_GATEWAY, {
|
|
285
301
|
"Content-Type": "text/event-stream",
|
|
@@ -352,12 +368,19 @@ data: ${JSON.stringify({ error: "no response body" })}
|
|
|
352
368
|
try {
|
|
353
369
|
const reader = response.body.getReader();
|
|
354
370
|
const decoder = new TextDecoder();
|
|
371
|
+
let chunkCount = 0;
|
|
372
|
+
let totalBytes = 0;
|
|
373
|
+
logger.info("streaming started");
|
|
355
374
|
while (true) {
|
|
356
375
|
const { done, value } = await reader.read();
|
|
357
376
|
if (done) break;
|
|
377
|
+
chunkCount++;
|
|
378
|
+
totalBytes += value.byteLength;
|
|
379
|
+
logger.debug(`streaming chunk #${chunkCount} bytes=${value.byteLength} totalBytes=${totalBytes}`);
|
|
358
380
|
const chunk = decoder.decode(value, { stream: true });
|
|
359
381
|
res.write(chunk);
|
|
360
382
|
}
|
|
383
|
+
logger.info(`streaming completed chunks=${chunkCount} totalBytes=${totalBytes}`);
|
|
361
384
|
} catch (err) {
|
|
362
385
|
logger.error(`streaming error: ${err.message}`);
|
|
363
386
|
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
@@ -375,11 +398,165 @@ data: ${JSON.stringify(envelope)}
|
|
|
375
398
|
}
|
|
376
399
|
res.end();
|
|
377
400
|
}
|
|
401
|
+
var TIMEOUT_CODES2, HTTP_BAD_GATEWAY, HOP_BY_HOP, STRIP_HEADERS;
|
|
402
|
+
var init_streaming = __esm({
|
|
403
|
+
"src/proxy/streaming.ts"() {
|
|
404
|
+
"use strict";
|
|
405
|
+
init_dispatcher();
|
|
406
|
+
init_handler();
|
|
407
|
+
init_envelope();
|
|
408
|
+
init_recovery();
|
|
409
|
+
init_fetch_error();
|
|
410
|
+
TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
411
|
+
HTTP_BAD_GATEWAY = 502;
|
|
412
|
+
HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
413
|
+
"connection",
|
|
414
|
+
"keep-alive",
|
|
415
|
+
"proxy-authenticate",
|
|
416
|
+
"proxy-authorization",
|
|
417
|
+
"te",
|
|
418
|
+
"trailer",
|
|
419
|
+
"transfer-encoding",
|
|
420
|
+
"upgrade"
|
|
421
|
+
]);
|
|
422
|
+
STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
423
|
+
...HOP_BY_HOP,
|
|
424
|
+
"content-encoding",
|
|
425
|
+
"content-length"
|
|
426
|
+
]);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// src/proxy/ws-client.ts
|
|
431
|
+
function defaultBackoffBaseMs() {
|
|
432
|
+
const raw = process.env.SKALPEL_WS_BACKOFF_BASE_MS;
|
|
433
|
+
const parsed = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
434
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e3;
|
|
435
|
+
}
|
|
436
|
+
function computeBackoff(attempt, baseMs) {
|
|
437
|
+
const exp = Math.min(MAX_BACKOFF_MS, baseMs * Math.pow(2, attempt));
|
|
438
|
+
const jitter = exp * (0.2 * (Math.random() * 2 - 1));
|
|
439
|
+
return Math.max(0, Math.floor(exp + jitter));
|
|
440
|
+
}
|
|
441
|
+
var import_node_events, import_ws, WS_SUBPROTOCOL, MAX_RECONNECTS, MAX_BACKOFF_MS, NON_TRANSIENT_CLOSE_CODES, BackendWsClient;
|
|
442
|
+
var init_ws_client = __esm({
|
|
443
|
+
"src/proxy/ws-client.ts"() {
|
|
444
|
+
"use strict";
|
|
445
|
+
import_node_events = require("events");
|
|
446
|
+
import_ws = __toESM(require("ws"), 1);
|
|
447
|
+
WS_SUBPROTOCOL = "skalpel-codex-v1";
|
|
448
|
+
MAX_RECONNECTS = 5;
|
|
449
|
+
MAX_BACKOFF_MS = 6e4;
|
|
450
|
+
NON_TRANSIENT_CLOSE_CODES = /* @__PURE__ */ new Set([4e3, 4001, 4002, 4004]);
|
|
451
|
+
BackendWsClient = class extends import_node_events.EventEmitter {
|
|
452
|
+
opts;
|
|
453
|
+
ws = null;
|
|
454
|
+
reconnectAttempts = 0;
|
|
455
|
+
closedByUser = false;
|
|
456
|
+
pendingReconnect = null;
|
|
457
|
+
constructor(opts) {
|
|
458
|
+
super();
|
|
459
|
+
this.opts = opts;
|
|
460
|
+
}
|
|
461
|
+
async connect() {
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
const ws = new import_ws.default(this.opts.url, [WS_SUBPROTOCOL], {
|
|
464
|
+
headers: {
|
|
465
|
+
"X-Skalpel-API-Key": this.opts.apiKey,
|
|
466
|
+
Authorization: `Bearer ${this.opts.oauthToken}`,
|
|
467
|
+
"x-skalpel-source": this.opts.source
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
this.ws = ws;
|
|
471
|
+
ws.once("open", () => {
|
|
472
|
+
this.emit("open");
|
|
473
|
+
resolve();
|
|
474
|
+
});
|
|
475
|
+
ws.on("message", (data) => {
|
|
476
|
+
const text = data.toString("utf-8");
|
|
477
|
+
let parsed = null;
|
|
478
|
+
try {
|
|
479
|
+
parsed = JSON.parse(text);
|
|
480
|
+
} catch {
|
|
481
|
+
this.emit("error", new Error(`invalid frame: ${text.slice(0, 100)}`));
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
this.emit("frame", parsed);
|
|
485
|
+
});
|
|
486
|
+
ws.on("error", (err) => {
|
|
487
|
+
this.opts.logger.debug(`ws-client error: ${err.message}`);
|
|
488
|
+
this.emit("error", err);
|
|
489
|
+
});
|
|
490
|
+
ws.once("close", (code, reasonBuf) => {
|
|
491
|
+
const reason = reasonBuf.toString("utf-8");
|
|
492
|
+
this.opts.logger.info(`ws-client close code=${code} reason=${reason}`);
|
|
493
|
+
this.ws = null;
|
|
494
|
+
if (this.closedByUser || code === 1e3) {
|
|
495
|
+
this.emit("close", code, reason);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (NON_TRANSIENT_CLOSE_CODES.has(code)) {
|
|
499
|
+
this.emit("close", code, reason);
|
|
500
|
+
this.emit("fallback", `close_${code}:${reason}`);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
this.scheduleReconnect(resolve, reject);
|
|
504
|
+
this.emit("close", code, reason);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
scheduleReconnect(initialResolve, initialReject) {
|
|
509
|
+
if (this.reconnectAttempts >= MAX_RECONNECTS) {
|
|
510
|
+
this.emit("fallback", "reconnect_exhausted");
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.reconnectAttempts += 1;
|
|
514
|
+
const delay = computeBackoff(this.reconnectAttempts, defaultBackoffBaseMs());
|
|
515
|
+
this.opts.logger.info(
|
|
516
|
+
`ws-client reconnect attempt=${this.reconnectAttempts} delay=${delay}ms`
|
|
517
|
+
);
|
|
518
|
+
this.pendingReconnect = setTimeout(() => {
|
|
519
|
+
this.pendingReconnect = null;
|
|
520
|
+
this.connect().catch((err) => {
|
|
521
|
+
this.opts.logger.debug(`reconnect failed: ${err.message}`);
|
|
522
|
+
});
|
|
523
|
+
}, delay);
|
|
524
|
+
void initialResolve;
|
|
525
|
+
void initialReject;
|
|
526
|
+
}
|
|
527
|
+
send(frame) {
|
|
528
|
+
if (this.ws === null || this.ws.readyState !== import_ws.default.OPEN) {
|
|
529
|
+
throw new Error("ws-client send: socket not open");
|
|
530
|
+
}
|
|
531
|
+
this.ws.send(JSON.stringify(frame));
|
|
532
|
+
}
|
|
533
|
+
close(code = 1e3, reason = "client close") {
|
|
534
|
+
this.closedByUser = true;
|
|
535
|
+
if (this.pendingReconnect !== null) {
|
|
536
|
+
clearTimeout(this.pendingReconnect);
|
|
537
|
+
this.pendingReconnect = null;
|
|
538
|
+
}
|
|
539
|
+
if (this.ws !== null) {
|
|
540
|
+
try {
|
|
541
|
+
this.ws.close(code, reason);
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
this.ws = null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
});
|
|
378
550
|
|
|
379
551
|
// src/proxy/handler.ts
|
|
380
|
-
var
|
|
381
|
-
|
|
382
|
-
|
|
552
|
+
var handler_exports = {};
|
|
553
|
+
__export(handler_exports, {
|
|
554
|
+
buildForwardHeaders: () => buildForwardHeaders,
|
|
555
|
+
handleRequest: () => handleRequest,
|
|
556
|
+
handleWebSocketBridge: () => handleWebSocketBridge,
|
|
557
|
+
isSkalpelBackendFailure: () => isSkalpelBackendFailure,
|
|
558
|
+
shouldRouteToSkalpel: () => shouldRouteToSkalpel
|
|
559
|
+
});
|
|
383
560
|
function collectBody(req) {
|
|
384
561
|
return new Promise((resolve, reject) => {
|
|
385
562
|
const chunks = [];
|
|
@@ -423,17 +600,6 @@ async function isSkalpelBackendFailure(response, err, logger) {
|
|
|
423
600
|
return true;
|
|
424
601
|
}
|
|
425
602
|
}
|
|
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
|
-
]);
|
|
437
603
|
function buildForwardHeaders(req, config, source, useSkalpel) {
|
|
438
604
|
const forwardHeaders = {};
|
|
439
605
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
@@ -469,18 +635,6 @@ function stripSkalpelHeaders2(headers) {
|
|
|
469
635
|
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
470
636
|
return cleaned;
|
|
471
637
|
}
|
|
472
|
-
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
473
|
-
"connection",
|
|
474
|
-
"keep-alive",
|
|
475
|
-
"proxy-authenticate",
|
|
476
|
-
"proxy-authorization",
|
|
477
|
-
"te",
|
|
478
|
-
"trailer",
|
|
479
|
-
"transfer-encoding",
|
|
480
|
-
"upgrade",
|
|
481
|
-
"content-encoding",
|
|
482
|
-
"content-length"
|
|
483
|
-
]);
|
|
484
638
|
function extractResponseHeaders(response) {
|
|
485
639
|
const headers = {};
|
|
486
640
|
for (const [key, value] of response.headers.entries()) {
|
|
@@ -498,11 +652,22 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
498
652
|
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
499
653
|
);
|
|
500
654
|
logger.info(`${source} ${method} ${path4} token=${fp}`);
|
|
655
|
+
if (source === "codex") {
|
|
656
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
657
|
+
const authScheme = typeof req.headers.authorization === "string" ? req.headers.authorization.split(" ")[0] ?? "none" : "none";
|
|
658
|
+
const upgrade = req.headers.upgrade ?? "";
|
|
659
|
+
const connection = req.headers.connection ?? "";
|
|
660
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
661
|
+
logger.debug(`codex-diag method=${method} path=${path4} ua=${ua} authScheme=${authScheme} upgrade=${upgrade} connection=${connection} contentType=${contentType} hasBody=${method !== "GET" && method !== "HEAD"}`);
|
|
662
|
+
}
|
|
501
663
|
let response = null;
|
|
502
664
|
try {
|
|
503
665
|
const body = await collectBody(req);
|
|
666
|
+
logger.info(`body collected bytes=${body.length}`);
|
|
504
667
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
668
|
+
logger.info(`routing useSkalpel=${useSkalpel}`);
|
|
505
669
|
const forwardHeaders = buildForwardHeaders(req, config, source, useSkalpel);
|
|
670
|
+
logger.debug(`headers built skalpelHeaders=${useSkalpel} authConverted=${!forwardHeaders["authorization"] && !!forwardHeaders["x-api-key"]}`);
|
|
506
671
|
let isStreaming = false;
|
|
507
672
|
if (body) {
|
|
508
673
|
try {
|
|
@@ -511,6 +676,7 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
511
676
|
} catch {
|
|
512
677
|
}
|
|
513
678
|
}
|
|
679
|
+
logger.info(`stream detection isStreaming=${isStreaming}`);
|
|
514
680
|
if (isStreaming) {
|
|
515
681
|
const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
|
|
516
682
|
const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
@@ -524,35 +690,42 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
524
690
|
let fetchError = null;
|
|
525
691
|
let usedFallback = false;
|
|
526
692
|
if (useSkalpel) {
|
|
693
|
+
logger.info(`fetch sending url=${skalpelUrl} method=${method}`);
|
|
527
694
|
try {
|
|
528
695
|
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
529
696
|
} catch (err) {
|
|
530
697
|
fetchError = err;
|
|
531
698
|
}
|
|
699
|
+
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${skalpelUrl}`);
|
|
532
700
|
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
533
|
-
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError
|
|
701
|
+
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
534
702
|
usedFallback = true;
|
|
535
703
|
response = null;
|
|
536
704
|
fetchError = null;
|
|
537
705
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
706
|
+
logger.info(`fetch sending url=${directUrl} method=${method} fallback=true`);
|
|
538
707
|
try {
|
|
539
708
|
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
540
709
|
} catch (err) {
|
|
541
710
|
fetchError = err;
|
|
542
711
|
}
|
|
712
|
+
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl} fallback=true`);
|
|
543
713
|
}
|
|
544
714
|
} else {
|
|
715
|
+
logger.info(`fetch sending url=${directUrl} method=${method}`);
|
|
545
716
|
try {
|
|
546
717
|
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
547
718
|
} catch (err) {
|
|
548
719
|
fetchError = err;
|
|
549
720
|
}
|
|
721
|
+
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl}`);
|
|
550
722
|
}
|
|
551
723
|
const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
552
724
|
const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
|
|
553
725
|
if (fetchError) {
|
|
554
726
|
const code = fetchError.code;
|
|
555
727
|
if (code && TIMEOUT_CODES3.has(code)) {
|
|
728
|
+
logger.warn(`timeout detected code=${code} url=${fetchUrl}`);
|
|
556
729
|
try {
|
|
557
730
|
response = await handleTimeoutWithRetry(
|
|
558
731
|
fetchError,
|
|
@@ -575,6 +748,7 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
575
748
|
throw fetchError ?? new Error("no response from upstream");
|
|
576
749
|
}
|
|
577
750
|
if (response.status === 429) {
|
|
751
|
+
logger.info(`429 received url=${fetchUrl}`);
|
|
578
752
|
response = await handle429WithRetryAfter(
|
|
579
753
|
response,
|
|
580
754
|
() => fetch(fetchUrl, {
|
|
@@ -601,11 +775,12 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
601
775
|
const responseHeaders = extractResponseHeaders(response);
|
|
602
776
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
603
777
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
778
|
+
logger.info(`response forwarding status=${response.status} bodyBytes=${responseBody.length}`);
|
|
604
779
|
res.writeHead(response.status, responseHeaders);
|
|
605
780
|
res.end(responseBody);
|
|
606
781
|
logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
|
|
607
782
|
} catch (err) {
|
|
608
|
-
logger.error(`${method} ${path4} source=${source} error=${err
|
|
783
|
+
logger.error(`${method} ${path4} source=${source} error=${formatFetchErrorForLog(err, path4)}`);
|
|
609
784
|
if (!res.headersSent) {
|
|
610
785
|
if (response !== null) {
|
|
611
786
|
const upstreamStatus = response.status;
|
|
@@ -629,9 +804,215 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
629
804
|
}
|
|
630
805
|
}
|
|
631
806
|
}
|
|
807
|
+
async function handleWebSocketBridge(clientWs, req, config, source, logger) {
|
|
808
|
+
const backendUrl = buildBackendWsUrl(config);
|
|
809
|
+
const oauthHeader = req.headers["authorization"] ?? "";
|
|
810
|
+
const oauthToken = oauthHeader.toLowerCase().startsWith("bearer ") ? oauthHeader.slice(7).trim() : "";
|
|
811
|
+
const backend = new BackendWsClient({
|
|
812
|
+
url: backendUrl,
|
|
813
|
+
apiKey: config.apiKey,
|
|
814
|
+
oauthToken,
|
|
815
|
+
source,
|
|
816
|
+
logger
|
|
817
|
+
});
|
|
818
|
+
let fallbackActive = false;
|
|
819
|
+
let backendOpen = false;
|
|
820
|
+
const pendingClientFrames = [];
|
|
821
|
+
const inflightRequests = /* @__PURE__ */ new Map();
|
|
822
|
+
const flushPending = () => {
|
|
823
|
+
if (!backendOpen || fallbackActive) return;
|
|
824
|
+
while (pendingClientFrames.length > 0) {
|
|
825
|
+
const frame = pendingClientFrames.shift();
|
|
826
|
+
if (frame === void 0) break;
|
|
827
|
+
try {
|
|
828
|
+
backend.send(frame);
|
|
829
|
+
if (frame.type === "request") {
|
|
830
|
+
const id = String(frame.id ?? "");
|
|
831
|
+
if (id) inflightRequests.set(id, frame);
|
|
832
|
+
}
|
|
833
|
+
} catch (err) {
|
|
834
|
+
logger.warn(`bridge: flush backend.send failed: ${err.message}`);
|
|
835
|
+
pendingClientFrames.unshift(frame);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
clientWs.on("message", (data) => {
|
|
841
|
+
const text = data.toString("utf-8");
|
|
842
|
+
let parsed;
|
|
843
|
+
try {
|
|
844
|
+
parsed = JSON.parse(text);
|
|
845
|
+
} catch {
|
|
846
|
+
logger.warn("bridge: invalid client frame (dropped)");
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
pendingClientFrames.push(parsed);
|
|
850
|
+
flushPending();
|
|
851
|
+
});
|
|
852
|
+
clientWs.on("close", (code, reason) => {
|
|
853
|
+
logger.info(`bridge: client closed code=${code} reason=${String(reason)}`);
|
|
854
|
+
backend.close(1e3, "client closed");
|
|
855
|
+
});
|
|
856
|
+
backend.on("open", () => {
|
|
857
|
+
backendOpen = true;
|
|
858
|
+
flushPending();
|
|
859
|
+
});
|
|
860
|
+
backend.on("frame", (frame) => {
|
|
861
|
+
try {
|
|
862
|
+
clientWs.send(JSON.stringify(frame));
|
|
863
|
+
} catch (err) {
|
|
864
|
+
logger.debug(`bridge: client.send failed: ${err.message}`);
|
|
865
|
+
}
|
|
866
|
+
if (typeof frame === "object" && frame !== null) {
|
|
867
|
+
const fr = frame;
|
|
868
|
+
const t = fr.type;
|
|
869
|
+
if (t === "done" || t === "error") {
|
|
870
|
+
const id = String(fr.id ?? "");
|
|
871
|
+
if (id) inflightRequests.delete(id);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
backend.on("close", (code, reason) => {
|
|
876
|
+
logger.info(`bridge: backend closed code=${code} reason=${reason}`);
|
|
877
|
+
backendOpen = false;
|
|
878
|
+
});
|
|
879
|
+
backend.on("error", (err) => {
|
|
880
|
+
logger.debug(`bridge: backend error: ${err.message}`);
|
|
881
|
+
});
|
|
882
|
+
backend.on("fallback", (reason) => {
|
|
883
|
+
fallbackActive = true;
|
|
884
|
+
backendOpen = false;
|
|
885
|
+
logger.warn(`bridge: backend fallback reason=${reason} \u2014 switching to HTTP POST`);
|
|
886
|
+
const replay = [
|
|
887
|
+
...inflightRequests.values(),
|
|
888
|
+
...pendingClientFrames.splice(0)
|
|
889
|
+
];
|
|
890
|
+
inflightRequests.clear();
|
|
891
|
+
drainPendingToHttp(clientWs, config, source, logger, replay).catch((httpErr) => {
|
|
892
|
+
logger.error(`bridge HTTP drain failed: ${httpErr.message}`);
|
|
893
|
+
try {
|
|
894
|
+
clientWs.close(4003, "fallback drain failed");
|
|
895
|
+
} catch {
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
try {
|
|
900
|
+
await backend.connect();
|
|
901
|
+
} catch (err) {
|
|
902
|
+
logger.error(`bridge: initial connect failed: ${err.message}`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
function buildBackendWsUrl(config) {
|
|
906
|
+
const base = config.remoteBaseUrl.replace(/^http/, "ws");
|
|
907
|
+
return `${base}/v1/responses`;
|
|
908
|
+
}
|
|
909
|
+
async function drainPendingToHttp(clientWs, config, source, logger, frames) {
|
|
910
|
+
for (const frame of frames) {
|
|
911
|
+
if (frame.type !== "request") continue;
|
|
912
|
+
const payload = frame.payload ?? {};
|
|
913
|
+
const id = String(frame.id ?? "");
|
|
914
|
+
try {
|
|
915
|
+
const resp = await fetch(`${config.remoteBaseUrl}/v1/responses`, {
|
|
916
|
+
method: "POST",
|
|
917
|
+
headers: {
|
|
918
|
+
"Content-Type": "application/json",
|
|
919
|
+
"X-Skalpel-API-Key": config.apiKey,
|
|
920
|
+
"x-skalpel-source": source,
|
|
921
|
+
Accept: "text/event-stream"
|
|
922
|
+
},
|
|
923
|
+
body: JSON.stringify(payload)
|
|
924
|
+
});
|
|
925
|
+
if (!resp.ok || resp.body === null) {
|
|
926
|
+
clientWs.send(
|
|
927
|
+
JSON.stringify({
|
|
928
|
+
type: "error",
|
|
929
|
+
id,
|
|
930
|
+
payload: { code: resp.status, detail: `http fallback status ${resp.status}` }
|
|
931
|
+
})
|
|
932
|
+
);
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
const reader = resp.body.getReader();
|
|
936
|
+
const decoder = new TextDecoder("utf-8");
|
|
937
|
+
while (true) {
|
|
938
|
+
const { done, value } = await reader.read();
|
|
939
|
+
if (done) break;
|
|
940
|
+
if (value === void 0) continue;
|
|
941
|
+
const chunkText = decoder.decode(value, { stream: true });
|
|
942
|
+
clientWs.send(
|
|
943
|
+
JSON.stringify({ type: "chunk", id, payload: { data: chunkText } })
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
clientWs.send(JSON.stringify({ type: "done", id, payload: {} }));
|
|
947
|
+
} catch (err) {
|
|
948
|
+
logger.warn(`http fallback frame failed: ${err.message}`);
|
|
949
|
+
clientWs.send(
|
|
950
|
+
JSON.stringify({
|
|
951
|
+
type: "error",
|
|
952
|
+
id,
|
|
953
|
+
payload: { code: 502, detail: err.message }
|
|
954
|
+
})
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
var TIMEOUT_CODES3, HTTP_BAD_GATEWAY2, SKALPEL_EXACT_PATHS, FORWARD_HEADER_STRIP, STRIP_RESPONSE_HEADERS;
|
|
960
|
+
var init_handler = __esm({
|
|
961
|
+
"src/proxy/handler.ts"() {
|
|
962
|
+
"use strict";
|
|
963
|
+
init_streaming();
|
|
964
|
+
init_dispatcher();
|
|
965
|
+
init_envelope();
|
|
966
|
+
init_ws_client();
|
|
967
|
+
init_recovery();
|
|
968
|
+
init_fetch_error();
|
|
969
|
+
TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
970
|
+
HTTP_BAD_GATEWAY2 = 502;
|
|
971
|
+
SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
|
|
972
|
+
FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
|
|
973
|
+
"host",
|
|
974
|
+
"connection",
|
|
975
|
+
"keep-alive",
|
|
976
|
+
"proxy-authenticate",
|
|
977
|
+
"proxy-authorization",
|
|
978
|
+
"te",
|
|
979
|
+
"trailer",
|
|
980
|
+
"transfer-encoding",
|
|
981
|
+
"upgrade"
|
|
982
|
+
]);
|
|
983
|
+
STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
984
|
+
"connection",
|
|
985
|
+
"keep-alive",
|
|
986
|
+
"proxy-authenticate",
|
|
987
|
+
"proxy-authorization",
|
|
988
|
+
"te",
|
|
989
|
+
"trailer",
|
|
990
|
+
"transfer-encoding",
|
|
991
|
+
"upgrade",
|
|
992
|
+
"content-encoding",
|
|
993
|
+
"content-length"
|
|
994
|
+
]);
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
// src/proxy/index.ts
|
|
999
|
+
var proxy_exports = {};
|
|
1000
|
+
__export(proxy_exports, {
|
|
1001
|
+
getProxyStatus: () => getProxyStatus,
|
|
1002
|
+
loadConfig: () => loadConfig,
|
|
1003
|
+
saveConfig: () => saveConfig,
|
|
1004
|
+
startProxy: () => startProxy,
|
|
1005
|
+
stopProxy: () => stopProxy
|
|
1006
|
+
});
|
|
1007
|
+
module.exports = __toCommonJS(proxy_exports);
|
|
1008
|
+
|
|
1009
|
+
// src/proxy/server.ts
|
|
1010
|
+
var import_node_http = __toESM(require("http"), 1);
|
|
1011
|
+
init_handler();
|
|
632
1012
|
|
|
633
1013
|
// src/proxy/health.ts
|
|
634
|
-
function handleHealthRequest(res, config, startTime) {
|
|
1014
|
+
function handleHealthRequest(res, config, startTime, logger) {
|
|
1015
|
+
logger?.debug("health check served");
|
|
635
1016
|
const body = JSON.stringify({
|
|
636
1017
|
status: "ok",
|
|
637
1018
|
uptime: Date.now() - startTime,
|
|
@@ -725,6 +1106,20 @@ function removePid(pidFile) {
|
|
|
725
1106
|
}
|
|
726
1107
|
}
|
|
727
1108
|
|
|
1109
|
+
// src/proxy/health-check.ts
|
|
1110
|
+
async function isProxyAlive(port, timeoutMs = 2e3) {
|
|
1111
|
+
const controller = new AbortController();
|
|
1112
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1113
|
+
try {
|
|
1114
|
+
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
|
|
1115
|
+
return res.ok;
|
|
1116
|
+
} catch {
|
|
1117
|
+
return false;
|
|
1118
|
+
} finally {
|
|
1119
|
+
clearTimeout(timer);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
728
1123
|
// src/proxy/logger.ts
|
|
729
1124
|
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
730
1125
|
var import_node_path2 = __toESM(require("path"), 1);
|
|
@@ -791,6 +1186,67 @@ var Logger = class _Logger {
|
|
|
791
1186
|
}
|
|
792
1187
|
};
|
|
793
1188
|
|
|
1189
|
+
// src/proxy/ws-server.ts
|
|
1190
|
+
var import_ws2 = require("ws");
|
|
1191
|
+
var WS_SUBPROTOCOL2 = "skalpel-codex-v1";
|
|
1192
|
+
var wss = new import_ws2.WebSocketServer({ noServer: true });
|
|
1193
|
+
function reject426(socket, payload) {
|
|
1194
|
+
const body = JSON.stringify(payload);
|
|
1195
|
+
socket.write(
|
|
1196
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1197
|
+
Content-Type: application/json\r
|
|
1198
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1199
|
+
Connection: close\r
|
|
1200
|
+
\r
|
|
1201
|
+
` + body
|
|
1202
|
+
);
|
|
1203
|
+
socket.destroy();
|
|
1204
|
+
}
|
|
1205
|
+
function handleCodexUpgrade(req, socket, head, config, logger) {
|
|
1206
|
+
const wsFlag = process.env.SKALPEL_CODEX_WS ?? "1";
|
|
1207
|
+
if (wsFlag === "0") {
|
|
1208
|
+
logger.warn("ws-upgrade rejected: feature flag SKALPEL_CODEX_WS=0");
|
|
1209
|
+
reject426(socket, { error: "ws_disabled" });
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const offered = req.headers["sec-websocket-protocol"] ?? "";
|
|
1213
|
+
const tokens = offered.split(",").map((t) => t.trim()).filter(Boolean);
|
|
1214
|
+
if (!tokens.includes(WS_SUBPROTOCOL2)) {
|
|
1215
|
+
logger.warn(`ws-upgrade rejected: unsupported subprotocol offered="${offered}"`);
|
|
1216
|
+
reject426(socket, { error: "unsupported_subprotocol" });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
wss.handleUpgrade(req, socket, head, (clientWs) => {
|
|
1220
|
+
logger.info(`ws-upgrade accepted path=${req.url ?? ""} subproto=${WS_SUBPROTOCOL2}`);
|
|
1221
|
+
Promise.resolve().then(() => (init_handler(), handler_exports)).then((mod) => {
|
|
1222
|
+
const bridge = mod.handleWebSocketBridge;
|
|
1223
|
+
if (typeof bridge !== "function") {
|
|
1224
|
+
clientWs.send(
|
|
1225
|
+
JSON.stringify({
|
|
1226
|
+
type: "error",
|
|
1227
|
+
payload: { code: "not_implemented" }
|
|
1228
|
+
})
|
|
1229
|
+
);
|
|
1230
|
+
clientWs.close(4003, "bridge pending");
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
void bridge(clientWs, req, config, "codex", logger);
|
|
1234
|
+
}).catch((err) => {
|
|
1235
|
+
logger.error(`ws bridge import failed: ${err?.message ?? String(err)}`);
|
|
1236
|
+
try {
|
|
1237
|
+
clientWs.send(
|
|
1238
|
+
JSON.stringify({
|
|
1239
|
+
type: "error",
|
|
1240
|
+
payload: { code: "bridge_import_failed" }
|
|
1241
|
+
})
|
|
1242
|
+
);
|
|
1243
|
+
} catch {
|
|
1244
|
+
}
|
|
1245
|
+
clientWs.close(4003, "bridge import failed");
|
|
1246
|
+
});
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
|
|
794
1250
|
// src/proxy/server.ts
|
|
795
1251
|
var proxyStartTime = 0;
|
|
796
1252
|
var connCounter = 0;
|
|
@@ -807,7 +1263,7 @@ function startProxy(config) {
|
|
|
807
1263
|
proxyStartTime = Date.now();
|
|
808
1264
|
const anthropicServer = import_node_http.default.createServer((req, res) => {
|
|
809
1265
|
if (req.url === "/health" && req.method === "GET") {
|
|
810
|
-
handleHealthRequest(res, config, startTime);
|
|
1266
|
+
handleHealthRequest(res, config, startTime, logger.child("health"));
|
|
811
1267
|
return;
|
|
812
1268
|
}
|
|
813
1269
|
const connId = computeConnId(req);
|
|
@@ -815,7 +1271,7 @@ function startProxy(config) {
|
|
|
815
1271
|
});
|
|
816
1272
|
const openaiServer = import_node_http.default.createServer((req, res) => {
|
|
817
1273
|
if (req.url === "/health" && req.method === "GET") {
|
|
818
|
-
handleHealthRequest(res, config, startTime);
|
|
1274
|
+
handleHealthRequest(res, config, startTime, logger.child("health"));
|
|
819
1275
|
return;
|
|
820
1276
|
}
|
|
821
1277
|
const connId = computeConnId(req);
|
|
@@ -823,12 +1279,71 @@ function startProxy(config) {
|
|
|
823
1279
|
});
|
|
824
1280
|
const cursorServer = import_node_http.default.createServer((req, res) => {
|
|
825
1281
|
if (req.url === "/health" && req.method === "GET") {
|
|
826
|
-
handleHealthRequest(res, config, startTime);
|
|
1282
|
+
handleHealthRequest(res, config, startTime, logger.child("health"));
|
|
827
1283
|
return;
|
|
828
1284
|
}
|
|
829
1285
|
const connId = computeConnId(req);
|
|
830
1286
|
handleRequest(req, res, config, "cursor", logger.child(connId));
|
|
831
1287
|
});
|
|
1288
|
+
anthropicServer.on("upgrade", (req, socket, _head) => {
|
|
1289
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
1290
|
+
logger.warn(`upgrade-attempt port=${config.anthropicPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1291
|
+
const body = JSON.stringify({
|
|
1292
|
+
error: "upgrade_required",
|
|
1293
|
+
message: "Skalpel proxy is HTTP-only",
|
|
1294
|
+
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1295
|
+
});
|
|
1296
|
+
socket.write(
|
|
1297
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1298
|
+
Content-Type: application/json\r
|
|
1299
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1300
|
+
Connection: close\r
|
|
1301
|
+
\r
|
|
1302
|
+
` + body
|
|
1303
|
+
);
|
|
1304
|
+
socket.destroy();
|
|
1305
|
+
});
|
|
1306
|
+
openaiServer.on("upgrade", (req, socket, head) => {
|
|
1307
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
1308
|
+
logger.warn(`upgrade-attempt port=${config.openaiPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1309
|
+
const pathname = (req.url ?? "").split("?")[0];
|
|
1310
|
+
if (pathname === "/v1/responses") {
|
|
1311
|
+
handleCodexUpgrade(req, socket, head, config, logger);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
const body = JSON.stringify({
|
|
1315
|
+
error: "upgrade_required",
|
|
1316
|
+
message: "Skalpel proxy is HTTP-only",
|
|
1317
|
+
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1318
|
+
});
|
|
1319
|
+
socket.write(
|
|
1320
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1321
|
+
Content-Type: application/json\r
|
|
1322
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1323
|
+
Connection: close\r
|
|
1324
|
+
\r
|
|
1325
|
+
` + body
|
|
1326
|
+
);
|
|
1327
|
+
socket.destroy();
|
|
1328
|
+
});
|
|
1329
|
+
cursorServer.on("upgrade", (req, socket, _head) => {
|
|
1330
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
1331
|
+
logger.warn(`upgrade-attempt port=${config.cursorPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1332
|
+
const body = JSON.stringify({
|
|
1333
|
+
error: "upgrade_required",
|
|
1334
|
+
message: "Skalpel proxy is HTTP-only",
|
|
1335
|
+
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1336
|
+
});
|
|
1337
|
+
socket.write(
|
|
1338
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1339
|
+
Content-Type: application/json\r
|
|
1340
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1341
|
+
Connection: close\r
|
|
1342
|
+
\r
|
|
1343
|
+
` + body
|
|
1344
|
+
);
|
|
1345
|
+
socket.destroy();
|
|
1346
|
+
});
|
|
832
1347
|
anthropicServer.on("error", (err) => {
|
|
833
1348
|
if (err.code === "EADDRINUSE") {
|
|
834
1349
|
logger.error(`Port ${config.anthropicPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
@@ -856,17 +1371,26 @@ function startProxy(config) {
|
|
|
856
1371
|
removePid(config.pidFile);
|
|
857
1372
|
process.exit(1);
|
|
858
1373
|
});
|
|
1374
|
+
let bound = 0;
|
|
1375
|
+
const onBound = () => {
|
|
1376
|
+
bound++;
|
|
1377
|
+
if (bound === 3) {
|
|
1378
|
+
writePid(config.pidFile);
|
|
1379
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
859
1382
|
anthropicServer.listen(config.anthropicPort, () => {
|
|
860
1383
|
logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
|
|
1384
|
+
onBound();
|
|
861
1385
|
});
|
|
862
1386
|
openaiServer.listen(config.openaiPort, () => {
|
|
863
1387
|
logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
|
|
1388
|
+
onBound();
|
|
864
1389
|
});
|
|
865
1390
|
cursorServer.listen(config.cursorPort, () => {
|
|
866
1391
|
logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
|
|
1392
|
+
onBound();
|
|
867
1393
|
});
|
|
868
|
-
writePid(config.pidFile);
|
|
869
|
-
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
870
1394
|
const cleanup = () => {
|
|
871
1395
|
logger.info("Shutting down proxy...");
|
|
872
1396
|
anthropicServer.close();
|
|
@@ -899,12 +1423,23 @@ function stopProxy(config) {
|
|
|
899
1423
|
removePid(config.pidFile);
|
|
900
1424
|
return true;
|
|
901
1425
|
}
|
|
902
|
-
function getProxyStatus(config) {
|
|
1426
|
+
async function getProxyStatus(config) {
|
|
903
1427
|
const pid = readPid(config.pidFile);
|
|
1428
|
+
if (pid !== null) {
|
|
1429
|
+
return {
|
|
1430
|
+
running: true,
|
|
1431
|
+
pid,
|
|
1432
|
+
uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
|
|
1433
|
+
anthropicPort: config.anthropicPort,
|
|
1434
|
+
openaiPort: config.openaiPort,
|
|
1435
|
+
cursorPort: config.cursorPort
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
const alive = await isProxyAlive(config.anthropicPort);
|
|
904
1439
|
return {
|
|
905
|
-
running:
|
|
906
|
-
pid,
|
|
907
|
-
uptime:
|
|
1440
|
+
running: alive,
|
|
1441
|
+
pid: null,
|
|
1442
|
+
uptime: 0,
|
|
908
1443
|
anthropicPort: config.anthropicPort,
|
|
909
1444
|
openaiPort: config.openaiPort,
|
|
910
1445
|
cursorPort: config.cursorPort
|