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.js
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
3
10
|
|
|
4
11
|
// src/proxy/dispatcher.ts
|
|
5
12
|
import { Agent } from "undici";
|
|
6
|
-
var skalpelDispatcher
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
var skalpelDispatcher;
|
|
14
|
+
var init_dispatcher = __esm({
|
|
15
|
+
"src/proxy/dispatcher.ts"() {
|
|
16
|
+
"use strict";
|
|
17
|
+
skalpelDispatcher = new Agent({
|
|
18
|
+
keepAliveTimeout: 1e4,
|
|
19
|
+
keepAliveMaxTimeout: 6e4,
|
|
20
|
+
connections: 100,
|
|
21
|
+
pipelining: 1
|
|
22
|
+
});
|
|
23
|
+
}
|
|
11
24
|
});
|
|
12
25
|
|
|
13
26
|
// src/proxy/envelope.ts
|
|
@@ -76,6 +89,11 @@ function defaultMessageForStatus(status) {
|
|
|
76
89
|
if (status >= 400) return "Client error";
|
|
77
90
|
return "Error";
|
|
78
91
|
}
|
|
92
|
+
var init_envelope = __esm({
|
|
93
|
+
"src/proxy/envelope.ts"() {
|
|
94
|
+
"use strict";
|
|
95
|
+
}
|
|
96
|
+
});
|
|
79
97
|
|
|
80
98
|
// src/proxy/recovery.ts
|
|
81
99
|
import { createHash } from "crypto";
|
|
@@ -94,11 +112,10 @@ function parseRetryAfterHeader(header) {
|
|
|
94
112
|
function sleep(ms) {
|
|
95
113
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
114
|
}
|
|
97
|
-
var MAX_RETRY_AFTER_SECONDS = 60;
|
|
98
|
-
var DEFAULT_BACKOFF_SECONDS = 2;
|
|
99
115
|
async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
100
116
|
const headerVal = response.headers.get("retry-after");
|
|
101
117
|
const parsed = parseRetryAfterHeader(headerVal);
|
|
118
|
+
logger.debug(`429 recovery retryAfterHeader=${headerVal ?? "none"} parsed=${parsed ?? "none"}`);
|
|
102
119
|
if (parsed === void 0) {
|
|
103
120
|
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
104
121
|
const retried2 = await retryFn();
|
|
@@ -106,6 +123,7 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
|
106
123
|
return retried2;
|
|
107
124
|
}
|
|
108
125
|
if (parsed > MAX_RETRY_AFTER_SECONDS) {
|
|
126
|
+
logger.warn(`429 recovery capped: retryAfter=${parsed}s exceeds max=${MAX_RETRY_AFTER_SECONDS}s, passing 429 through`);
|
|
109
127
|
return response;
|
|
110
128
|
}
|
|
111
129
|
await sleep(parsed * 1e3);
|
|
@@ -113,12 +131,12 @@ async function handle429WithRetryAfter(response, retryFn, logger) {
|
|
|
113
131
|
logger.info("proxy.recovery.429_retry_count increment");
|
|
114
132
|
return retried;
|
|
115
133
|
}
|
|
116
|
-
var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
117
134
|
async function handleTimeoutWithRetry(err, retryFn, logger) {
|
|
118
135
|
const code = err.code;
|
|
119
136
|
if (!code || !TIMEOUT_CODES.has(code)) {
|
|
120
137
|
throw err;
|
|
121
138
|
}
|
|
139
|
+
logger.warn(`timeout recovery code=${code}`);
|
|
122
140
|
await sleep(DEFAULT_BACKOFF_SECONDS * 1e3);
|
|
123
141
|
const retried = await retryFn();
|
|
124
142
|
logger.info("proxy.recovery.timeout_retry_count increment");
|
|
@@ -128,23 +146,48 @@ function tokenFingerprint(authHeader) {
|
|
|
128
146
|
if (authHeader === void 0) return "none";
|
|
129
147
|
return createHash("sha256").update(authHeader).digest("hex").slice(0, 12);
|
|
130
148
|
}
|
|
131
|
-
var MUTEX_MAX_ENTRIES
|
|
132
|
-
var
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
149
|
+
var MAX_RETRY_AFTER_SECONDS, DEFAULT_BACKOFF_SECONDS, TIMEOUT_CODES, MUTEX_MAX_ENTRIES, LruMutexMap, refreshMutex;
|
|
150
|
+
var init_recovery = __esm({
|
|
151
|
+
"src/proxy/recovery.ts"() {
|
|
152
|
+
"use strict";
|
|
153
|
+
MAX_RETRY_AFTER_SECONDS = 60;
|
|
154
|
+
DEFAULT_BACKOFF_SECONDS = 2;
|
|
155
|
+
TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
156
|
+
MUTEX_MAX_ENTRIES = 1024;
|
|
157
|
+
LruMutexMap = class extends Map {
|
|
158
|
+
set(key, value) {
|
|
159
|
+
if (this.has(key)) {
|
|
160
|
+
super.delete(key);
|
|
161
|
+
} else if (this.size >= MUTEX_MAX_ENTRIES) {
|
|
162
|
+
const oldest = this.keys().next().value;
|
|
163
|
+
if (oldest !== void 0) super.delete(oldest);
|
|
164
|
+
}
|
|
165
|
+
return super.set(key, value);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
refreshMutex = new LruMutexMap();
|
|
141
169
|
}
|
|
142
|
-
};
|
|
143
|
-
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// src/proxy/fetch-error.ts
|
|
173
|
+
function formatFetchErrorForLog(err, url) {
|
|
174
|
+
if (err instanceof Error) {
|
|
175
|
+
const code = err.code;
|
|
176
|
+
const parts = [];
|
|
177
|
+
if (code) parts.push(code);
|
|
178
|
+
parts.push(err.message);
|
|
179
|
+
parts.push(`url=${url}`);
|
|
180
|
+
return parts.join(" ");
|
|
181
|
+
}
|
|
182
|
+
return `${String(err)} url=${url}`;
|
|
183
|
+
}
|
|
184
|
+
var init_fetch_error = __esm({
|
|
185
|
+
"src/proxy/fetch-error.ts"() {
|
|
186
|
+
"use strict";
|
|
187
|
+
}
|
|
188
|
+
});
|
|
144
189
|
|
|
145
190
|
// src/proxy/streaming.ts
|
|
146
|
-
var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
147
|
-
var HTTP_BAD_GATEWAY = 502;
|
|
148
191
|
function parseRetryAfter(header) {
|
|
149
192
|
if (!header) return void 0;
|
|
150
193
|
const trimmed = header.trim();
|
|
@@ -158,21 +201,6 @@ function parseRetryAfter(header) {
|
|
|
158
201
|
}
|
|
159
202
|
return void 0;
|
|
160
203
|
}
|
|
161
|
-
var HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
162
|
-
"connection",
|
|
163
|
-
"keep-alive",
|
|
164
|
-
"proxy-authenticate",
|
|
165
|
-
"proxy-authorization",
|
|
166
|
-
"te",
|
|
167
|
-
"trailer",
|
|
168
|
-
"transfer-encoding",
|
|
169
|
-
"upgrade"
|
|
170
|
-
]);
|
|
171
|
-
var STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
172
|
-
...HOP_BY_HOP,
|
|
173
|
-
"content-encoding",
|
|
174
|
-
"content-length"
|
|
175
|
-
]);
|
|
176
204
|
function stripSkalpelHeaders(headers) {
|
|
177
205
|
const cleaned = { ...headers };
|
|
178
206
|
delete cleaned["X-Skalpel-API-Key"];
|
|
@@ -190,29 +218,35 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
190
218
|
let fetchError = null;
|
|
191
219
|
let usedFallback = false;
|
|
192
220
|
if (useSkalpel) {
|
|
221
|
+
logger.info(`streaming fetch sending url=${skalpelUrl}`);
|
|
193
222
|
try {
|
|
194
223
|
response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
|
|
195
224
|
} catch (err) {
|
|
196
225
|
fetchError = err;
|
|
197
226
|
}
|
|
227
|
+
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${skalpelUrl}`);
|
|
198
228
|
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
199
|
-
logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError
|
|
229
|
+
logger.warn(`streaming: Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
200
230
|
usedFallback = true;
|
|
201
231
|
response = null;
|
|
202
232
|
fetchError = null;
|
|
203
233
|
const directHeaders = stripSkalpelHeaders(forwardHeaders);
|
|
234
|
+
logger.info(`streaming fetch sending url=${directUrl} fallback=true`);
|
|
204
235
|
try {
|
|
205
236
|
response = await doStreamingFetch(directUrl, body, directHeaders);
|
|
206
237
|
} catch (err) {
|
|
207
238
|
fetchError = err;
|
|
208
239
|
}
|
|
240
|
+
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl} fallback=true`);
|
|
209
241
|
}
|
|
210
242
|
} else {
|
|
243
|
+
logger.info(`streaming fetch sending url=${directUrl}`);
|
|
211
244
|
try {
|
|
212
245
|
response = await doStreamingFetch(directUrl, body, forwardHeaders);
|
|
213
246
|
} catch (err) {
|
|
214
247
|
fetchError = err;
|
|
215
248
|
}
|
|
249
|
+
if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl}`);
|
|
216
250
|
}
|
|
217
251
|
const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
218
252
|
const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
|
|
@@ -239,7 +273,7 @@ async function handleStreamingRequest(_req, res, _config, _source, body, skalpel
|
|
|
239
273
|
);
|
|
240
274
|
}
|
|
241
275
|
if (!response || fetchError) {
|
|
242
|
-
const errMsg = fetchError ? fetchError
|
|
276
|
+
const errMsg = fetchError ? formatFetchErrorForLog(fetchError, finalUrl) : "no response from upstream";
|
|
243
277
|
logger.error(`streaming fetch failed: ${errMsg}`);
|
|
244
278
|
res.writeHead(HTTP_BAD_GATEWAY, {
|
|
245
279
|
"Content-Type": "text/event-stream",
|
|
@@ -312,12 +346,19 @@ data: ${JSON.stringify({ error: "no response body" })}
|
|
|
312
346
|
try {
|
|
313
347
|
const reader = response.body.getReader();
|
|
314
348
|
const decoder = new TextDecoder();
|
|
349
|
+
let chunkCount = 0;
|
|
350
|
+
let totalBytes = 0;
|
|
351
|
+
logger.info("streaming started");
|
|
315
352
|
while (true) {
|
|
316
353
|
const { done, value } = await reader.read();
|
|
317
354
|
if (done) break;
|
|
355
|
+
chunkCount++;
|
|
356
|
+
totalBytes += value.byteLength;
|
|
357
|
+
logger.debug(`streaming chunk #${chunkCount} bytes=${value.byteLength} totalBytes=${totalBytes}`);
|
|
318
358
|
const chunk = decoder.decode(value, { stream: true });
|
|
319
359
|
res.write(chunk);
|
|
320
360
|
}
|
|
361
|
+
logger.info(`streaming completed chunks=${chunkCount} totalBytes=${totalBytes}`);
|
|
321
362
|
} catch (err) {
|
|
322
363
|
logger.error(`streaming error: ${err.message}`);
|
|
323
364
|
const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
|
|
@@ -335,11 +376,165 @@ data: ${JSON.stringify(envelope)}
|
|
|
335
376
|
}
|
|
336
377
|
res.end();
|
|
337
378
|
}
|
|
379
|
+
var TIMEOUT_CODES2, HTTP_BAD_GATEWAY, HOP_BY_HOP, STRIP_HEADERS;
|
|
380
|
+
var init_streaming = __esm({
|
|
381
|
+
"src/proxy/streaming.ts"() {
|
|
382
|
+
"use strict";
|
|
383
|
+
init_dispatcher();
|
|
384
|
+
init_handler();
|
|
385
|
+
init_envelope();
|
|
386
|
+
init_recovery();
|
|
387
|
+
init_fetch_error();
|
|
388
|
+
TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
389
|
+
HTTP_BAD_GATEWAY = 502;
|
|
390
|
+
HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
391
|
+
"connection",
|
|
392
|
+
"keep-alive",
|
|
393
|
+
"proxy-authenticate",
|
|
394
|
+
"proxy-authorization",
|
|
395
|
+
"te",
|
|
396
|
+
"trailer",
|
|
397
|
+
"transfer-encoding",
|
|
398
|
+
"upgrade"
|
|
399
|
+
]);
|
|
400
|
+
STRIP_HEADERS = /* @__PURE__ */ new Set([
|
|
401
|
+
...HOP_BY_HOP,
|
|
402
|
+
"content-encoding",
|
|
403
|
+
"content-length"
|
|
404
|
+
]);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// src/proxy/ws-client.ts
|
|
409
|
+
import { EventEmitter } from "events";
|
|
410
|
+
import WebSocket from "ws";
|
|
411
|
+
function defaultBackoffBaseMs() {
|
|
412
|
+
const raw = process.env.SKALPEL_WS_BACKOFF_BASE_MS;
|
|
413
|
+
const parsed = raw === void 0 ? NaN : parseInt(raw, 10);
|
|
414
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1e3;
|
|
415
|
+
}
|
|
416
|
+
function computeBackoff(attempt, baseMs) {
|
|
417
|
+
const exp = Math.min(MAX_BACKOFF_MS, baseMs * Math.pow(2, attempt));
|
|
418
|
+
const jitter = exp * (0.2 * (Math.random() * 2 - 1));
|
|
419
|
+
return Math.max(0, Math.floor(exp + jitter));
|
|
420
|
+
}
|
|
421
|
+
var WS_SUBPROTOCOL, MAX_RECONNECTS, MAX_BACKOFF_MS, NON_TRANSIENT_CLOSE_CODES, BackendWsClient;
|
|
422
|
+
var init_ws_client = __esm({
|
|
423
|
+
"src/proxy/ws-client.ts"() {
|
|
424
|
+
"use strict";
|
|
425
|
+
WS_SUBPROTOCOL = "skalpel-codex-v1";
|
|
426
|
+
MAX_RECONNECTS = 5;
|
|
427
|
+
MAX_BACKOFF_MS = 6e4;
|
|
428
|
+
NON_TRANSIENT_CLOSE_CODES = /* @__PURE__ */ new Set([4e3, 4001, 4002, 4004]);
|
|
429
|
+
BackendWsClient = class extends EventEmitter {
|
|
430
|
+
opts;
|
|
431
|
+
ws = null;
|
|
432
|
+
reconnectAttempts = 0;
|
|
433
|
+
closedByUser = false;
|
|
434
|
+
pendingReconnect = null;
|
|
435
|
+
constructor(opts) {
|
|
436
|
+
super();
|
|
437
|
+
this.opts = opts;
|
|
438
|
+
}
|
|
439
|
+
async connect() {
|
|
440
|
+
return new Promise((resolve, reject) => {
|
|
441
|
+
const ws = new WebSocket(this.opts.url, [WS_SUBPROTOCOL], {
|
|
442
|
+
headers: {
|
|
443
|
+
"X-Skalpel-API-Key": this.opts.apiKey,
|
|
444
|
+
Authorization: `Bearer ${this.opts.oauthToken}`,
|
|
445
|
+
"x-skalpel-source": this.opts.source
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
this.ws = ws;
|
|
449
|
+
ws.once("open", () => {
|
|
450
|
+
this.emit("open");
|
|
451
|
+
resolve();
|
|
452
|
+
});
|
|
453
|
+
ws.on("message", (data) => {
|
|
454
|
+
const text = data.toString("utf-8");
|
|
455
|
+
let parsed = null;
|
|
456
|
+
try {
|
|
457
|
+
parsed = JSON.parse(text);
|
|
458
|
+
} catch {
|
|
459
|
+
this.emit("error", new Error(`invalid frame: ${text.slice(0, 100)}`));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
this.emit("frame", parsed);
|
|
463
|
+
});
|
|
464
|
+
ws.on("error", (err) => {
|
|
465
|
+
this.opts.logger.debug(`ws-client error: ${err.message}`);
|
|
466
|
+
this.emit("error", err);
|
|
467
|
+
});
|
|
468
|
+
ws.once("close", (code, reasonBuf) => {
|
|
469
|
+
const reason = reasonBuf.toString("utf-8");
|
|
470
|
+
this.opts.logger.info(`ws-client close code=${code} reason=${reason}`);
|
|
471
|
+
this.ws = null;
|
|
472
|
+
if (this.closedByUser || code === 1e3) {
|
|
473
|
+
this.emit("close", code, reason);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (NON_TRANSIENT_CLOSE_CODES.has(code)) {
|
|
477
|
+
this.emit("close", code, reason);
|
|
478
|
+
this.emit("fallback", `close_${code}:${reason}`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
this.scheduleReconnect(resolve, reject);
|
|
482
|
+
this.emit("close", code, reason);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
scheduleReconnect(initialResolve, initialReject) {
|
|
487
|
+
if (this.reconnectAttempts >= MAX_RECONNECTS) {
|
|
488
|
+
this.emit("fallback", "reconnect_exhausted");
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
this.reconnectAttempts += 1;
|
|
492
|
+
const delay = computeBackoff(this.reconnectAttempts, defaultBackoffBaseMs());
|
|
493
|
+
this.opts.logger.info(
|
|
494
|
+
`ws-client reconnect attempt=${this.reconnectAttempts} delay=${delay}ms`
|
|
495
|
+
);
|
|
496
|
+
this.pendingReconnect = setTimeout(() => {
|
|
497
|
+
this.pendingReconnect = null;
|
|
498
|
+
this.connect().catch((err) => {
|
|
499
|
+
this.opts.logger.debug(`reconnect failed: ${err.message}`);
|
|
500
|
+
});
|
|
501
|
+
}, delay);
|
|
502
|
+
void initialResolve;
|
|
503
|
+
void initialReject;
|
|
504
|
+
}
|
|
505
|
+
send(frame) {
|
|
506
|
+
if (this.ws === null || this.ws.readyState !== WebSocket.OPEN) {
|
|
507
|
+
throw new Error("ws-client send: socket not open");
|
|
508
|
+
}
|
|
509
|
+
this.ws.send(JSON.stringify(frame));
|
|
510
|
+
}
|
|
511
|
+
close(code = 1e3, reason = "client close") {
|
|
512
|
+
this.closedByUser = true;
|
|
513
|
+
if (this.pendingReconnect !== null) {
|
|
514
|
+
clearTimeout(this.pendingReconnect);
|
|
515
|
+
this.pendingReconnect = null;
|
|
516
|
+
}
|
|
517
|
+
if (this.ws !== null) {
|
|
518
|
+
try {
|
|
519
|
+
this.ws.close(code, reason);
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
this.ws = null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
});
|
|
338
528
|
|
|
339
529
|
// src/proxy/handler.ts
|
|
340
|
-
var
|
|
341
|
-
|
|
342
|
-
|
|
530
|
+
var handler_exports = {};
|
|
531
|
+
__export(handler_exports, {
|
|
532
|
+
buildForwardHeaders: () => buildForwardHeaders,
|
|
533
|
+
handleRequest: () => handleRequest,
|
|
534
|
+
handleWebSocketBridge: () => handleWebSocketBridge,
|
|
535
|
+
isSkalpelBackendFailure: () => isSkalpelBackendFailure,
|
|
536
|
+
shouldRouteToSkalpel: () => shouldRouteToSkalpel
|
|
537
|
+
});
|
|
343
538
|
function collectBody(req) {
|
|
344
539
|
return new Promise((resolve, reject) => {
|
|
345
540
|
const chunks = [];
|
|
@@ -383,17 +578,6 @@ async function isSkalpelBackendFailure(response, err, logger) {
|
|
|
383
578
|
return true;
|
|
384
579
|
}
|
|
385
580
|
}
|
|
386
|
-
var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
|
|
387
|
-
"host",
|
|
388
|
-
"connection",
|
|
389
|
-
"keep-alive",
|
|
390
|
-
"proxy-authenticate",
|
|
391
|
-
"proxy-authorization",
|
|
392
|
-
"te",
|
|
393
|
-
"trailer",
|
|
394
|
-
"transfer-encoding",
|
|
395
|
-
"upgrade"
|
|
396
|
-
]);
|
|
397
581
|
function buildForwardHeaders(req, config, source, useSkalpel) {
|
|
398
582
|
const forwardHeaders = {};
|
|
399
583
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
@@ -429,18 +613,6 @@ function stripSkalpelHeaders2(headers) {
|
|
|
429
613
|
delete cleaned["X-Skalpel-Auth-Mode"];
|
|
430
614
|
return cleaned;
|
|
431
615
|
}
|
|
432
|
-
var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
433
|
-
"connection",
|
|
434
|
-
"keep-alive",
|
|
435
|
-
"proxy-authenticate",
|
|
436
|
-
"proxy-authorization",
|
|
437
|
-
"te",
|
|
438
|
-
"trailer",
|
|
439
|
-
"transfer-encoding",
|
|
440
|
-
"upgrade",
|
|
441
|
-
"content-encoding",
|
|
442
|
-
"content-length"
|
|
443
|
-
]);
|
|
444
616
|
function extractResponseHeaders(response) {
|
|
445
617
|
const headers = {};
|
|
446
618
|
for (const [key, value] of response.headers.entries()) {
|
|
@@ -458,11 +630,22 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
458
630
|
typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
|
|
459
631
|
);
|
|
460
632
|
logger.info(`${source} ${method} ${path4} token=${fp}`);
|
|
633
|
+
if (source === "codex") {
|
|
634
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
635
|
+
const authScheme = typeof req.headers.authorization === "string" ? req.headers.authorization.split(" ")[0] ?? "none" : "none";
|
|
636
|
+
const upgrade = req.headers.upgrade ?? "";
|
|
637
|
+
const connection = req.headers.connection ?? "";
|
|
638
|
+
const contentType = req.headers["content-type"] ?? "";
|
|
639
|
+
logger.debug(`codex-diag method=${method} path=${path4} ua=${ua} authScheme=${authScheme} upgrade=${upgrade} connection=${connection} contentType=${contentType} hasBody=${method !== "GET" && method !== "HEAD"}`);
|
|
640
|
+
}
|
|
461
641
|
let response = null;
|
|
462
642
|
try {
|
|
463
643
|
const body = await collectBody(req);
|
|
644
|
+
logger.info(`body collected bytes=${body.length}`);
|
|
464
645
|
const useSkalpel = shouldRouteToSkalpel(path4, source);
|
|
646
|
+
logger.info(`routing useSkalpel=${useSkalpel}`);
|
|
465
647
|
const forwardHeaders = buildForwardHeaders(req, config, source, useSkalpel);
|
|
648
|
+
logger.debug(`headers built skalpelHeaders=${useSkalpel} authConverted=${!forwardHeaders["authorization"] && !!forwardHeaders["x-api-key"]}`);
|
|
466
649
|
let isStreaming = false;
|
|
467
650
|
if (body) {
|
|
468
651
|
try {
|
|
@@ -471,6 +654,7 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
471
654
|
} catch {
|
|
472
655
|
}
|
|
473
656
|
}
|
|
657
|
+
logger.info(`stream detection isStreaming=${isStreaming}`);
|
|
474
658
|
if (isStreaming) {
|
|
475
659
|
const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
|
|
476
660
|
const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
|
|
@@ -484,35 +668,42 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
484
668
|
let fetchError = null;
|
|
485
669
|
let usedFallback = false;
|
|
486
670
|
if (useSkalpel) {
|
|
671
|
+
logger.info(`fetch sending url=${skalpelUrl} method=${method}`);
|
|
487
672
|
try {
|
|
488
673
|
response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
489
674
|
} catch (err) {
|
|
490
675
|
fetchError = err;
|
|
491
676
|
}
|
|
677
|
+
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${skalpelUrl}`);
|
|
492
678
|
if (await isSkalpelBackendFailure(response, fetchError, logger)) {
|
|
493
|
-
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError
|
|
679
|
+
logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
|
|
494
680
|
usedFallback = true;
|
|
495
681
|
response = null;
|
|
496
682
|
fetchError = null;
|
|
497
683
|
const directHeaders = stripSkalpelHeaders2(forwardHeaders);
|
|
684
|
+
logger.info(`fetch sending url=${directUrl} method=${method} fallback=true`);
|
|
498
685
|
try {
|
|
499
686
|
response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
500
687
|
} catch (err) {
|
|
501
688
|
fetchError = err;
|
|
502
689
|
}
|
|
690
|
+
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl} fallback=true`);
|
|
503
691
|
}
|
|
504
692
|
} else {
|
|
693
|
+
logger.info(`fetch sending url=${directUrl} method=${method}`);
|
|
505
694
|
try {
|
|
506
695
|
response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
|
|
507
696
|
} catch (err) {
|
|
508
697
|
fetchError = err;
|
|
509
698
|
}
|
|
699
|
+
if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl}`);
|
|
510
700
|
}
|
|
511
701
|
const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
|
|
512
702
|
const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
|
|
513
703
|
if (fetchError) {
|
|
514
704
|
const code = fetchError.code;
|
|
515
705
|
if (code && TIMEOUT_CODES3.has(code)) {
|
|
706
|
+
logger.warn(`timeout detected code=${code} url=${fetchUrl}`);
|
|
516
707
|
try {
|
|
517
708
|
response = await handleTimeoutWithRetry(
|
|
518
709
|
fetchError,
|
|
@@ -535,6 +726,7 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
535
726
|
throw fetchError ?? new Error("no response from upstream");
|
|
536
727
|
}
|
|
537
728
|
if (response.status === 429) {
|
|
729
|
+
logger.info(`429 received url=${fetchUrl}`);
|
|
538
730
|
response = await handle429WithRetryAfter(
|
|
539
731
|
response,
|
|
540
732
|
() => fetch(fetchUrl, {
|
|
@@ -561,11 +753,12 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
561
753
|
const responseHeaders = extractResponseHeaders(response);
|
|
562
754
|
const responseBody = Buffer.from(await response.arrayBuffer());
|
|
563
755
|
responseHeaders["content-length"] = String(responseBody.length);
|
|
756
|
+
logger.info(`response forwarding status=${response.status} bodyBytes=${responseBody.length}`);
|
|
564
757
|
res.writeHead(response.status, responseHeaders);
|
|
565
758
|
res.end(responseBody);
|
|
566
759
|
logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
|
|
567
760
|
} catch (err) {
|
|
568
|
-
logger.error(`${method} ${path4} source=${source} error=${err
|
|
761
|
+
logger.error(`${method} ${path4} source=${source} error=${formatFetchErrorForLog(err, path4)}`);
|
|
569
762
|
if (!res.headersSent) {
|
|
570
763
|
if (response !== null) {
|
|
571
764
|
const upstreamStatus = response.status;
|
|
@@ -589,9 +782,204 @@ async function handleRequest(req, res, config, source, logger) {
|
|
|
589
782
|
}
|
|
590
783
|
}
|
|
591
784
|
}
|
|
785
|
+
async function handleWebSocketBridge(clientWs, req, config, source, logger) {
|
|
786
|
+
const backendUrl = buildBackendWsUrl(config);
|
|
787
|
+
const oauthHeader = req.headers["authorization"] ?? "";
|
|
788
|
+
const oauthToken = oauthHeader.toLowerCase().startsWith("bearer ") ? oauthHeader.slice(7).trim() : "";
|
|
789
|
+
const backend = new BackendWsClient({
|
|
790
|
+
url: backendUrl,
|
|
791
|
+
apiKey: config.apiKey,
|
|
792
|
+
oauthToken,
|
|
793
|
+
source,
|
|
794
|
+
logger
|
|
795
|
+
});
|
|
796
|
+
let fallbackActive = false;
|
|
797
|
+
let backendOpen = false;
|
|
798
|
+
const pendingClientFrames = [];
|
|
799
|
+
const inflightRequests = /* @__PURE__ */ new Map();
|
|
800
|
+
const flushPending = () => {
|
|
801
|
+
if (!backendOpen || fallbackActive) return;
|
|
802
|
+
while (pendingClientFrames.length > 0) {
|
|
803
|
+
const frame = pendingClientFrames.shift();
|
|
804
|
+
if (frame === void 0) break;
|
|
805
|
+
try {
|
|
806
|
+
backend.send(frame);
|
|
807
|
+
if (frame.type === "request") {
|
|
808
|
+
const id = String(frame.id ?? "");
|
|
809
|
+
if (id) inflightRequests.set(id, frame);
|
|
810
|
+
}
|
|
811
|
+
} catch (err) {
|
|
812
|
+
logger.warn(`bridge: flush backend.send failed: ${err.message}`);
|
|
813
|
+
pendingClientFrames.unshift(frame);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
clientWs.on("message", (data) => {
|
|
819
|
+
const text = data.toString("utf-8");
|
|
820
|
+
let parsed;
|
|
821
|
+
try {
|
|
822
|
+
parsed = JSON.parse(text);
|
|
823
|
+
} catch {
|
|
824
|
+
logger.warn("bridge: invalid client frame (dropped)");
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
pendingClientFrames.push(parsed);
|
|
828
|
+
flushPending();
|
|
829
|
+
});
|
|
830
|
+
clientWs.on("close", (code, reason) => {
|
|
831
|
+
logger.info(`bridge: client closed code=${code} reason=${String(reason)}`);
|
|
832
|
+
backend.close(1e3, "client closed");
|
|
833
|
+
});
|
|
834
|
+
backend.on("open", () => {
|
|
835
|
+
backendOpen = true;
|
|
836
|
+
flushPending();
|
|
837
|
+
});
|
|
838
|
+
backend.on("frame", (frame) => {
|
|
839
|
+
try {
|
|
840
|
+
clientWs.send(JSON.stringify(frame));
|
|
841
|
+
} catch (err) {
|
|
842
|
+
logger.debug(`bridge: client.send failed: ${err.message}`);
|
|
843
|
+
}
|
|
844
|
+
if (typeof frame === "object" && frame !== null) {
|
|
845
|
+
const fr = frame;
|
|
846
|
+
const t = fr.type;
|
|
847
|
+
if (t === "done" || t === "error") {
|
|
848
|
+
const id = String(fr.id ?? "");
|
|
849
|
+
if (id) inflightRequests.delete(id);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
backend.on("close", (code, reason) => {
|
|
854
|
+
logger.info(`bridge: backend closed code=${code} reason=${reason}`);
|
|
855
|
+
backendOpen = false;
|
|
856
|
+
});
|
|
857
|
+
backend.on("error", (err) => {
|
|
858
|
+
logger.debug(`bridge: backend error: ${err.message}`);
|
|
859
|
+
});
|
|
860
|
+
backend.on("fallback", (reason) => {
|
|
861
|
+
fallbackActive = true;
|
|
862
|
+
backendOpen = false;
|
|
863
|
+
logger.warn(`bridge: backend fallback reason=${reason} \u2014 switching to HTTP POST`);
|
|
864
|
+
const replay = [
|
|
865
|
+
...inflightRequests.values(),
|
|
866
|
+
...pendingClientFrames.splice(0)
|
|
867
|
+
];
|
|
868
|
+
inflightRequests.clear();
|
|
869
|
+
drainPendingToHttp(clientWs, config, source, logger, replay).catch((httpErr) => {
|
|
870
|
+
logger.error(`bridge HTTP drain failed: ${httpErr.message}`);
|
|
871
|
+
try {
|
|
872
|
+
clientWs.close(4003, "fallback drain failed");
|
|
873
|
+
} catch {
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
try {
|
|
878
|
+
await backend.connect();
|
|
879
|
+
} catch (err) {
|
|
880
|
+
logger.error(`bridge: initial connect failed: ${err.message}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
function buildBackendWsUrl(config) {
|
|
884
|
+
const base = config.remoteBaseUrl.replace(/^http/, "ws");
|
|
885
|
+
return `${base}/v1/responses`;
|
|
886
|
+
}
|
|
887
|
+
async function drainPendingToHttp(clientWs, config, source, logger, frames) {
|
|
888
|
+
for (const frame of frames) {
|
|
889
|
+
if (frame.type !== "request") continue;
|
|
890
|
+
const payload = frame.payload ?? {};
|
|
891
|
+
const id = String(frame.id ?? "");
|
|
892
|
+
try {
|
|
893
|
+
const resp = await fetch(`${config.remoteBaseUrl}/v1/responses`, {
|
|
894
|
+
method: "POST",
|
|
895
|
+
headers: {
|
|
896
|
+
"Content-Type": "application/json",
|
|
897
|
+
"X-Skalpel-API-Key": config.apiKey,
|
|
898
|
+
"x-skalpel-source": source,
|
|
899
|
+
Accept: "text/event-stream"
|
|
900
|
+
},
|
|
901
|
+
body: JSON.stringify(payload)
|
|
902
|
+
});
|
|
903
|
+
if (!resp.ok || resp.body === null) {
|
|
904
|
+
clientWs.send(
|
|
905
|
+
JSON.stringify({
|
|
906
|
+
type: "error",
|
|
907
|
+
id,
|
|
908
|
+
payload: { code: resp.status, detail: `http fallback status ${resp.status}` }
|
|
909
|
+
})
|
|
910
|
+
);
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
const reader = resp.body.getReader();
|
|
914
|
+
const decoder = new TextDecoder("utf-8");
|
|
915
|
+
while (true) {
|
|
916
|
+
const { done, value } = await reader.read();
|
|
917
|
+
if (done) break;
|
|
918
|
+
if (value === void 0) continue;
|
|
919
|
+
const chunkText = decoder.decode(value, { stream: true });
|
|
920
|
+
clientWs.send(
|
|
921
|
+
JSON.stringify({ type: "chunk", id, payload: { data: chunkText } })
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
clientWs.send(JSON.stringify({ type: "done", id, payload: {} }));
|
|
925
|
+
} catch (err) {
|
|
926
|
+
logger.warn(`http fallback frame failed: ${err.message}`);
|
|
927
|
+
clientWs.send(
|
|
928
|
+
JSON.stringify({
|
|
929
|
+
type: "error",
|
|
930
|
+
id,
|
|
931
|
+
payload: { code: 502, detail: err.message }
|
|
932
|
+
})
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
var TIMEOUT_CODES3, HTTP_BAD_GATEWAY2, SKALPEL_EXACT_PATHS, FORWARD_HEADER_STRIP, STRIP_RESPONSE_HEADERS;
|
|
938
|
+
var init_handler = __esm({
|
|
939
|
+
"src/proxy/handler.ts"() {
|
|
940
|
+
"use strict";
|
|
941
|
+
init_streaming();
|
|
942
|
+
init_dispatcher();
|
|
943
|
+
init_envelope();
|
|
944
|
+
init_ws_client();
|
|
945
|
+
init_recovery();
|
|
946
|
+
init_fetch_error();
|
|
947
|
+
TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
|
|
948
|
+
HTTP_BAD_GATEWAY2 = 502;
|
|
949
|
+
SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
|
|
950
|
+
FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
|
|
951
|
+
"host",
|
|
952
|
+
"connection",
|
|
953
|
+
"keep-alive",
|
|
954
|
+
"proxy-authenticate",
|
|
955
|
+
"proxy-authorization",
|
|
956
|
+
"te",
|
|
957
|
+
"trailer",
|
|
958
|
+
"transfer-encoding",
|
|
959
|
+
"upgrade"
|
|
960
|
+
]);
|
|
961
|
+
STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
|
|
962
|
+
"connection",
|
|
963
|
+
"keep-alive",
|
|
964
|
+
"proxy-authenticate",
|
|
965
|
+
"proxy-authorization",
|
|
966
|
+
"te",
|
|
967
|
+
"trailer",
|
|
968
|
+
"transfer-encoding",
|
|
969
|
+
"upgrade",
|
|
970
|
+
"content-encoding",
|
|
971
|
+
"content-length"
|
|
972
|
+
]);
|
|
973
|
+
}
|
|
974
|
+
});
|
|
975
|
+
|
|
976
|
+
// src/proxy/server.ts
|
|
977
|
+
init_handler();
|
|
978
|
+
import http from "http";
|
|
592
979
|
|
|
593
980
|
// src/proxy/health.ts
|
|
594
|
-
function handleHealthRequest(res, config, startTime) {
|
|
981
|
+
function handleHealthRequest(res, config, startTime, logger) {
|
|
982
|
+
logger?.debug("health check served");
|
|
595
983
|
const body = JSON.stringify({
|
|
596
984
|
status: "ok",
|
|
597
985
|
uptime: Date.now() - startTime,
|
|
@@ -685,6 +1073,20 @@ function removePid(pidFile) {
|
|
|
685
1073
|
}
|
|
686
1074
|
}
|
|
687
1075
|
|
|
1076
|
+
// src/proxy/health-check.ts
|
|
1077
|
+
async function isProxyAlive(port, timeoutMs = 2e3) {
|
|
1078
|
+
const controller = new AbortController();
|
|
1079
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1080
|
+
try {
|
|
1081
|
+
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
|
|
1082
|
+
return res.ok;
|
|
1083
|
+
} catch {
|
|
1084
|
+
return false;
|
|
1085
|
+
} finally {
|
|
1086
|
+
clearTimeout(timer);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
688
1090
|
// src/proxy/logger.ts
|
|
689
1091
|
import fs2 from "fs";
|
|
690
1092
|
import path2 from "path";
|
|
@@ -751,6 +1153,67 @@ var Logger = class _Logger {
|
|
|
751
1153
|
}
|
|
752
1154
|
};
|
|
753
1155
|
|
|
1156
|
+
// src/proxy/ws-server.ts
|
|
1157
|
+
import { WebSocketServer } from "ws";
|
|
1158
|
+
var WS_SUBPROTOCOL2 = "skalpel-codex-v1";
|
|
1159
|
+
var wss = new WebSocketServer({ noServer: true });
|
|
1160
|
+
function reject426(socket, payload) {
|
|
1161
|
+
const body = JSON.stringify(payload);
|
|
1162
|
+
socket.write(
|
|
1163
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1164
|
+
Content-Type: application/json\r
|
|
1165
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1166
|
+
Connection: close\r
|
|
1167
|
+
\r
|
|
1168
|
+
` + body
|
|
1169
|
+
);
|
|
1170
|
+
socket.destroy();
|
|
1171
|
+
}
|
|
1172
|
+
function handleCodexUpgrade(req, socket, head, config, logger) {
|
|
1173
|
+
const wsFlag = process.env.SKALPEL_CODEX_WS ?? "1";
|
|
1174
|
+
if (wsFlag === "0") {
|
|
1175
|
+
logger.warn("ws-upgrade rejected: feature flag SKALPEL_CODEX_WS=0");
|
|
1176
|
+
reject426(socket, { error: "ws_disabled" });
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
const offered = req.headers["sec-websocket-protocol"] ?? "";
|
|
1180
|
+
const tokens = offered.split(",").map((t) => t.trim()).filter(Boolean);
|
|
1181
|
+
if (!tokens.includes(WS_SUBPROTOCOL2)) {
|
|
1182
|
+
logger.warn(`ws-upgrade rejected: unsupported subprotocol offered="${offered}"`);
|
|
1183
|
+
reject426(socket, { error: "unsupported_subprotocol" });
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
wss.handleUpgrade(req, socket, head, (clientWs) => {
|
|
1187
|
+
logger.info(`ws-upgrade accepted path=${req.url ?? ""} subproto=${WS_SUBPROTOCOL2}`);
|
|
1188
|
+
Promise.resolve().then(() => (init_handler(), handler_exports)).then((mod) => {
|
|
1189
|
+
const bridge = mod.handleWebSocketBridge;
|
|
1190
|
+
if (typeof bridge !== "function") {
|
|
1191
|
+
clientWs.send(
|
|
1192
|
+
JSON.stringify({
|
|
1193
|
+
type: "error",
|
|
1194
|
+
payload: { code: "not_implemented" }
|
|
1195
|
+
})
|
|
1196
|
+
);
|
|
1197
|
+
clientWs.close(4003, "bridge pending");
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
void bridge(clientWs, req, config, "codex", logger);
|
|
1201
|
+
}).catch((err) => {
|
|
1202
|
+
logger.error(`ws bridge import failed: ${err?.message ?? String(err)}`);
|
|
1203
|
+
try {
|
|
1204
|
+
clientWs.send(
|
|
1205
|
+
JSON.stringify({
|
|
1206
|
+
type: "error",
|
|
1207
|
+
payload: { code: "bridge_import_failed" }
|
|
1208
|
+
})
|
|
1209
|
+
);
|
|
1210
|
+
} catch {
|
|
1211
|
+
}
|
|
1212
|
+
clientWs.close(4003, "bridge import failed");
|
|
1213
|
+
});
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
754
1217
|
// src/proxy/server.ts
|
|
755
1218
|
var proxyStartTime = 0;
|
|
756
1219
|
var connCounter = 0;
|
|
@@ -767,7 +1230,7 @@ function startProxy(config) {
|
|
|
767
1230
|
proxyStartTime = Date.now();
|
|
768
1231
|
const anthropicServer = http.createServer((req, res) => {
|
|
769
1232
|
if (req.url === "/health" && req.method === "GET") {
|
|
770
|
-
handleHealthRequest(res, config, startTime);
|
|
1233
|
+
handleHealthRequest(res, config, startTime, logger.child("health"));
|
|
771
1234
|
return;
|
|
772
1235
|
}
|
|
773
1236
|
const connId = computeConnId(req);
|
|
@@ -775,7 +1238,7 @@ function startProxy(config) {
|
|
|
775
1238
|
});
|
|
776
1239
|
const openaiServer = http.createServer((req, res) => {
|
|
777
1240
|
if (req.url === "/health" && req.method === "GET") {
|
|
778
|
-
handleHealthRequest(res, config, startTime);
|
|
1241
|
+
handleHealthRequest(res, config, startTime, logger.child("health"));
|
|
779
1242
|
return;
|
|
780
1243
|
}
|
|
781
1244
|
const connId = computeConnId(req);
|
|
@@ -783,12 +1246,71 @@ function startProxy(config) {
|
|
|
783
1246
|
});
|
|
784
1247
|
const cursorServer = http.createServer((req, res) => {
|
|
785
1248
|
if (req.url === "/health" && req.method === "GET") {
|
|
786
|
-
handleHealthRequest(res, config, startTime);
|
|
1249
|
+
handleHealthRequest(res, config, startTime, logger.child("health"));
|
|
787
1250
|
return;
|
|
788
1251
|
}
|
|
789
1252
|
const connId = computeConnId(req);
|
|
790
1253
|
handleRequest(req, res, config, "cursor", logger.child(connId));
|
|
791
1254
|
});
|
|
1255
|
+
anthropicServer.on("upgrade", (req, socket, _head) => {
|
|
1256
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
1257
|
+
logger.warn(`upgrade-attempt port=${config.anthropicPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1258
|
+
const body = JSON.stringify({
|
|
1259
|
+
error: "upgrade_required",
|
|
1260
|
+
message: "Skalpel proxy is HTTP-only",
|
|
1261
|
+
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1262
|
+
});
|
|
1263
|
+
socket.write(
|
|
1264
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1265
|
+
Content-Type: application/json\r
|
|
1266
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1267
|
+
Connection: close\r
|
|
1268
|
+
\r
|
|
1269
|
+
` + body
|
|
1270
|
+
);
|
|
1271
|
+
socket.destroy();
|
|
1272
|
+
});
|
|
1273
|
+
openaiServer.on("upgrade", (req, socket, head) => {
|
|
1274
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
1275
|
+
logger.warn(`upgrade-attempt port=${config.openaiPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1276
|
+
const pathname = (req.url ?? "").split("?")[0];
|
|
1277
|
+
if (pathname === "/v1/responses") {
|
|
1278
|
+
handleCodexUpgrade(req, socket, head, config, logger);
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
const body = JSON.stringify({
|
|
1282
|
+
error: "upgrade_required",
|
|
1283
|
+
message: "Skalpel proxy is HTTP-only",
|
|
1284
|
+
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1285
|
+
});
|
|
1286
|
+
socket.write(
|
|
1287
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1288
|
+
Content-Type: application/json\r
|
|
1289
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1290
|
+
Connection: close\r
|
|
1291
|
+
\r
|
|
1292
|
+
` + body
|
|
1293
|
+
);
|
|
1294
|
+
socket.destroy();
|
|
1295
|
+
});
|
|
1296
|
+
cursorServer.on("upgrade", (req, socket, _head) => {
|
|
1297
|
+
const ua = req.headers["user-agent"] ?? "";
|
|
1298
|
+
logger.warn(`upgrade-attempt port=${config.cursorPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
|
|
1299
|
+
const body = JSON.stringify({
|
|
1300
|
+
error: "upgrade_required",
|
|
1301
|
+
message: "Skalpel proxy is HTTP-only",
|
|
1302
|
+
hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
|
|
1303
|
+
});
|
|
1304
|
+
socket.write(
|
|
1305
|
+
`HTTP/1.1 426 Upgrade Required\r
|
|
1306
|
+
Content-Type: application/json\r
|
|
1307
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1308
|
+
Connection: close\r
|
|
1309
|
+
\r
|
|
1310
|
+
` + body
|
|
1311
|
+
);
|
|
1312
|
+
socket.destroy();
|
|
1313
|
+
});
|
|
792
1314
|
anthropicServer.on("error", (err) => {
|
|
793
1315
|
if (err.code === "EADDRINUSE") {
|
|
794
1316
|
logger.error(`Port ${config.anthropicPort} is already in use. Another Skalpel proxy or process may be running.`);
|
|
@@ -816,17 +1338,26 @@ function startProxy(config) {
|
|
|
816
1338
|
removePid(config.pidFile);
|
|
817
1339
|
process.exit(1);
|
|
818
1340
|
});
|
|
1341
|
+
let bound = 0;
|
|
1342
|
+
const onBound = () => {
|
|
1343
|
+
bound++;
|
|
1344
|
+
if (bound === 3) {
|
|
1345
|
+
writePid(config.pidFile);
|
|
1346
|
+
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
1347
|
+
}
|
|
1348
|
+
};
|
|
819
1349
|
anthropicServer.listen(config.anthropicPort, () => {
|
|
820
1350
|
logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
|
|
1351
|
+
onBound();
|
|
821
1352
|
});
|
|
822
1353
|
openaiServer.listen(config.openaiPort, () => {
|
|
823
1354
|
logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
|
|
1355
|
+
onBound();
|
|
824
1356
|
});
|
|
825
1357
|
cursorServer.listen(config.cursorPort, () => {
|
|
826
1358
|
logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
|
|
1359
|
+
onBound();
|
|
827
1360
|
});
|
|
828
|
-
writePid(config.pidFile);
|
|
829
|
-
logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
|
|
830
1361
|
const cleanup = () => {
|
|
831
1362
|
logger.info("Shutting down proxy...");
|
|
832
1363
|
anthropicServer.close();
|
|
@@ -859,12 +1390,23 @@ function stopProxy(config) {
|
|
|
859
1390
|
removePid(config.pidFile);
|
|
860
1391
|
return true;
|
|
861
1392
|
}
|
|
862
|
-
function getProxyStatus(config) {
|
|
1393
|
+
async function getProxyStatus(config) {
|
|
863
1394
|
const pid = readPid(config.pidFile);
|
|
1395
|
+
if (pid !== null) {
|
|
1396
|
+
return {
|
|
1397
|
+
running: true,
|
|
1398
|
+
pid,
|
|
1399
|
+
uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
|
|
1400
|
+
anthropicPort: config.anthropicPort,
|
|
1401
|
+
openaiPort: config.openaiPort,
|
|
1402
|
+
cursorPort: config.cursorPort
|
|
1403
|
+
};
|
|
1404
|
+
}
|
|
1405
|
+
const alive = await isProxyAlive(config.anthropicPort);
|
|
864
1406
|
return {
|
|
865
|
-
running:
|
|
866
|
-
pid,
|
|
867
|
-
uptime:
|
|
1407
|
+
running: alive,
|
|
1408
|
+
pid: null,
|
|
1409
|
+
uptime: 0,
|
|
868
1410
|
anthropicPort: config.anthropicPort,
|
|
869
1411
|
openaiPort: config.openaiPort,
|
|
870
1412
|
cursorPort: config.cursorPort
|