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