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/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,1150 +30,1528 @@ 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/index.ts
31
- var index_exports = {};
32
- __export(index_exports, {
33
- SkalpelAuthError: () => SkalpelAuthError,
34
- SkalpelError: () => SkalpelError,
35
- SkalpelRateLimitError: () => SkalpelRateLimitError,
36
- SkalpelTimeoutError: () => SkalpelTimeoutError,
37
- SkalpelUnavailableError: () => SkalpelUnavailableError,
38
- createSkalpelAnthropic: () => createSkalpelAnthropic,
39
- createSkalpelClient: () => createSkalpelClient,
40
- createSkalpelOpenAI: () => createSkalpelOpenAI,
41
- createSnapshot: () => createSnapshot,
42
- extractMetadata: () => extractMetadata,
43
- getProxyStatus: () => getProxyStatus,
44
- loadConfig: () => loadConfig,
45
- resolveContext: () => resolveContext,
46
- saveConfig: () => saveConfig,
47
- startProxy: () => startProxy,
48
- stopProxy: () => stopProxy,
49
- uploadChunks: () => uploadChunks
33
+ // src/proxy/dispatcher.ts
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
+ }
50
46
  });
51
- module.exports = __toCommonJS(index_exports);
52
47
 
53
- // src/metadata.ts
54
- function extractMetadata(headers) {
55
- const get = (name) => {
56
- if (headers instanceof Headers) {
57
- return headers.get(name);
58
- }
59
- return headers[name] ?? null;
60
- };
61
- const requestId = get("x-skalpel-request-id");
62
- if (!requestId) return null;
63
- return {
64
- requestId,
65
- optimization: get("x-skalpel-optimization") ?? "none",
66
- originalModel: get("x-skalpel-original-model") ?? "",
67
- actualModel: get("x-skalpel-actual-model") ?? "",
68
- savingsUsd: parseFloat(get("x-skalpel-savings-usd") ?? "0"),
69
- cacheHit: get("x-skalpel-cache-hit") === "true",
70
- latencyMs: parseInt(get("x-skalpel-latency-ms") ?? "0", 10)
71
- };
48
+ // src/proxy/envelope.ts
49
+ function isAnthropicShaped(body) {
50
+ if (typeof body !== "object" || body === null) return false;
51
+ const b = body;
52
+ if (b.type !== "error") return false;
53
+ if (typeof b.error !== "object" || b.error === null) return false;
54
+ return true;
72
55
  }
73
-
74
- // src/errors.ts
75
- var SkalpelError = class extends Error {
76
- code;
77
- statusCode;
78
- retryAfter;
79
- constructor(message, code, statusCode, retryAfter) {
80
- super(message);
81
- this.name = "SkalpelError";
82
- this.code = code;
83
- this.statusCode = statusCode;
84
- this.retryAfter = retryAfter;
85
- }
86
- };
87
- var SkalpelAuthError = class extends SkalpelError {
88
- constructor(message = "Skalpel authentication failed") {
89
- super(message, "SKALPEL_AUTH_FAILED", 401);
90
- this.name = "SkalpelAuthError";
91
- }
92
- };
93
- var SkalpelTimeoutError = class extends SkalpelError {
94
- constructor(message = "Skalpel request timed out") {
95
- super(message, "SKALPEL_TIMEOUT");
96
- this.name = "SkalpelTimeoutError";
97
- }
98
- };
99
- var SkalpelRateLimitError = class extends SkalpelError {
100
- constructor(message = "Skalpel rate limit exceeded", retryAfter) {
101
- super(message, "SKALPEL_RATE_LIMITED", 429, retryAfter);
102
- this.name = "SkalpelRateLimitError";
56
+ function defaultErrorTypeFor(status) {
57
+ if (status === 400) return "invalid_request_error";
58
+ if (status === 401 || status === 403) return "authentication_error";
59
+ if (status === 404) return "not_found_error";
60
+ if (status === 408) return "timeout_error";
61
+ if (status === 429) return "rate_limit_error";
62
+ if (status >= 500) return "api_error";
63
+ if (status >= 400) return "invalid_request_error";
64
+ return "api_error";
65
+ }
66
+ function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
67
+ let parsed = upstreamBody;
68
+ if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
69
+ try {
70
+ parsed = JSON.parse(upstreamBody);
71
+ } catch {
72
+ parsed = upstreamBody;
73
+ }
103
74
  }
104
- };
105
- var SkalpelUnavailableError = class extends SkalpelError {
106
- constructor(message = "Skalpel service unavailable", statusCode) {
107
- super(message, "SKALPEL_UNAVAILABLE", statusCode);
108
- this.name = "SkalpelUnavailableError";
75
+ let type = defaultErrorTypeFor(status);
76
+ let message;
77
+ if (isAnthropicShaped(parsed)) {
78
+ const inner = parsed.error;
79
+ if (typeof inner.type === "string" && inner.type.length > 0) {
80
+ type = inner.type;
81
+ }
82
+ message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
83
+ } else if (typeof parsed === "string" && parsed.length > 0) {
84
+ message = parsed;
85
+ } else {
86
+ message = defaultMessageForStatus(status);
109
87
  }
110
- };
111
- var SkalpelClientRequestError = class extends SkalpelError {
112
- constructor(message, statusCode) {
113
- super(message, "SKALPEL_CLIENT_REQUEST", statusCode);
114
- this.name = "SkalpelClientRequestError";
88
+ const envelope = {
89
+ type: "error",
90
+ error: {
91
+ type,
92
+ message,
93
+ status_code: status,
94
+ origin
95
+ }
96
+ };
97
+ if (hint !== void 0) envelope.error.hint = hint;
98
+ if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
99
+ return envelope;
100
+ }
101
+ function defaultMessageForStatus(status) {
102
+ if (status === 401) return "Authentication failed";
103
+ if (status === 403) return "Forbidden";
104
+ if (status === 404) return "Not found";
105
+ if (status === 408) return "Request timed out";
106
+ if (status === 429) return "Rate limit exceeded";
107
+ if (status === 502) return "Bad gateway";
108
+ if (status === 503) return "Service unavailable";
109
+ if (status === 504) return "Gateway timeout";
110
+ if (status >= 500) return "Upstream error";
111
+ if (status >= 400) return "Client error";
112
+ return "Error";
113
+ }
114
+ var init_envelope = __esm({
115
+ "src/proxy/envelope.ts"() {
116
+ "use strict";
115
117
  }
116
- };
118
+ });
117
119
 
118
- // src/fallback.ts
119
- var TRULY_CLIENT_4XX = /* @__PURE__ */ new Set([
120
- 400,
121
- 403,
122
- 404,
123
- 405,
124
- 409,
125
- 410,
126
- 411,
127
- 413,
128
- 415,
129
- 417,
130
- 418,
131
- 421,
132
- 422,
133
- 423,
134
- 424,
135
- 425,
136
- 426,
137
- 428,
138
- 431,
139
- 451
140
- ]);
141
- function sleep(ms) {
120
+ // src/proxy/recovery.ts
121
+ function parseRetryAfterHeader(header) {
122
+ if (!header) return void 0;
123
+ const trimmed = header.trim();
124
+ if (!trimmed) return void 0;
125
+ const n = Number(trimmed);
126
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
127
+ const dateMs = Date.parse(trimmed);
128
+ if (Number.isFinite(dateMs)) {
129
+ return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
130
+ }
131
+ return void 0;
132
+ }
133
+ function sleep2(ms) {
142
134
  return new Promise((resolve) => setTimeout(resolve, ms));
143
135
  }
144
- function classifyError(err) {
145
- if (err instanceof SkalpelError) return err;
146
- const error = err;
147
- const status = error.status ?? error.statusCode;
148
- const message = error.message ?? "Unknown error";
149
- if (status === 401) {
150
- return new SkalpelAuthError(message);
151
- }
152
- if (status === 429) {
153
- const retryAfter = error.headers?.["retry-after"] ? parseInt(error.headers["retry-after"], 10) : void 0;
154
- return new SkalpelRateLimitError(message, retryAfter);
136
+ async function handle429WithRetryAfter(response, retryFn, logger) {
137
+ const headerVal = response.headers.get("retry-after");
138
+ const parsed = parseRetryAfterHeader(headerVal);
139
+ logger.debug(`429 recovery retryAfterHeader=${headerVal ?? "none"} parsed=${parsed ?? "none"}`);
140
+ if (parsed === void 0) {
141
+ await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
142
+ const retried2 = await retryFn();
143
+ logger.info("proxy.recovery.429_retry_count increment");
144
+ return retried2;
155
145
  }
156
- if (status === 408) {
157
- return new SkalpelTimeoutError(message);
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`);
148
+ return response;
158
149
  }
159
- if (error.code === "ETIMEDOUT" || error.code === "TIMEOUT" || error.code === "UND_ERR_HEADERS_TIMEOUT") {
160
- return new SkalpelTimeoutError(message);
150
+ await sleep2(parsed * 1e3);
151
+ const retried = await retryFn();
152
+ logger.info("proxy.recovery.429_retry_count increment");
153
+ return retried;
154
+ }
155
+ async function handleTimeoutWithRetry(err, retryFn, logger) {
156
+ const code = err.code;
157
+ if (!code || !TIMEOUT_CODES.has(code)) {
158
+ throw err;
161
159
  }
162
- if (status && status >= 500) {
163
- return new SkalpelUnavailableError(message, status);
160
+ logger.warn(`timeout recovery code=${code}`);
161
+ await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
162
+ const retried = await retryFn();
163
+ logger.info("proxy.recovery.timeout_retry_count increment");
164
+ return retried;
165
+ }
166
+ function tokenFingerprint(authHeader) {
167
+ if (authHeader === void 0) return "none";
168
+ return (0, import_node_crypto.createHash)("sha256").update(authHeader).digest("hex").slice(0, 12);
169
+ }
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();
164
191
  }
165
- if (status && TRULY_CLIENT_4XX.has(status)) {
166
- return new SkalpelClientRequestError(message, status);
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";
167
209
  }
168
- if (status && status >= 400 && status < 500) {
169
- return new SkalpelClientRequestError(message, status);
210
+ });
211
+
212
+ // src/proxy/streaming.ts
213
+ function parseRetryAfter(header) {
214
+ if (!header) return void 0;
215
+ const trimmed = header.trim();
216
+ if (!trimmed) return void 0;
217
+ const n = Number(trimmed);
218
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
219
+ const dateMs = Date.parse(trimmed);
220
+ if (Number.isFinite(dateMs)) {
221
+ const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
222
+ return delta;
170
223
  }
171
- return new SkalpelUnavailableError(message, status);
224
+ return void 0;
172
225
  }
173
- async function withFallback(primaryFn, fallbackFn, options = {}) {
174
- const { retries = 2, verbose = false, onFallback, provider = "unknown", fallbackOnError = true } = options;
175
- let lastError;
176
- for (let attempt = 0; attempt <= retries; attempt++) {
226
+ function stripSkalpelHeaders(headers) {
227
+ const cleaned = { ...headers };
228
+ delete cleaned["X-Skalpel-API-Key"];
229
+ delete cleaned["X-Skalpel-Source"];
230
+ delete cleaned["X-Skalpel-Agent-Type"];
231
+ delete cleaned["X-Skalpel-SDK-Version"];
232
+ delete cleaned["X-Skalpel-Auth-Mode"];
233
+ return cleaned;
234
+ }
235
+ async function doStreamingFetch(url, body, headers) {
236
+ return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
237
+ }
238
+ async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
239
+ let response = null;
240
+ let fetchError = null;
241
+ let usedFallback = false;
242
+ if (useSkalpel) {
243
+ logger.info(`streaming fetch sending url=${skalpelUrl}`);
177
244
  try {
178
- return await primaryFn();
245
+ response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
179
246
  } catch (err) {
180
- lastError = classifyError(err);
181
- if (lastError instanceof SkalpelAuthError) {
182
- throw lastError;
183
- }
184
- if (lastError instanceof SkalpelClientRequestError) {
185
- throw lastError;
186
- }
187
- if (lastError instanceof SkalpelRateLimitError && lastError.retryAfter && attempt < retries) {
188
- await sleep(lastError.retryAfter * 1e3);
189
- continue;
247
+ fetchError = err;
248
+ }
249
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${skalpelUrl}`);
250
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
251
+ logger.warn(`streaming: Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
252
+ usedFallback = true;
253
+ response = null;
254
+ fetchError = null;
255
+ const directHeaders = stripSkalpelHeaders(forwardHeaders);
256
+ logger.info(`streaming fetch sending url=${directUrl} fallback=true`);
257
+ try {
258
+ response = await doStreamingFetch(directUrl, body, directHeaders);
259
+ } catch (err) {
260
+ fetchError = err;
190
261
  }
191
- if (attempt < retries) {
192
- await sleep(Math.pow(2, attempt) * 1e3);
193
- continue;
262
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl} fallback=true`);
263
+ }
264
+ } else {
265
+ logger.info(`streaming fetch sending url=${directUrl}`);
266
+ try {
267
+ response = await doStreamingFetch(directUrl, body, forwardHeaders);
268
+ } catch (err) {
269
+ fetchError = err;
270
+ }
271
+ if (response && !fetchError) logger.info(`streaming fetch received status=${response.status} url=${directUrl}`);
272
+ }
273
+ const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
274
+ const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
275
+ if (fetchError) {
276
+ const code = fetchError.code;
277
+ if (code && TIMEOUT_CODES2.has(code)) {
278
+ try {
279
+ response = await handleTimeoutWithRetry(
280
+ fetchError,
281
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
282
+ logger
283
+ );
284
+ fetchError = null;
285
+ } catch (retryErr) {
286
+ fetchError = retryErr;
194
287
  }
195
288
  }
196
289
  }
197
- if (fallbackOnError) {
198
- if (verbose && lastError) {
199
- console.warn(`[skalpel] Falling back to direct ${provider} call: ${lastError.message}`);
290
+ if (response && response.status === 429) {
291
+ response = await handle429WithRetryAfter(
292
+ response,
293
+ () => doStreamingFetch(finalUrl, body, finalHeaders),
294
+ logger
295
+ );
296
+ }
297
+ if (!response || fetchError) {
298
+ const errMsg = fetchError ? formatFetchErrorForLog(fetchError, finalUrl) : "no response from upstream";
299
+ logger.error(`streaming fetch failed: ${errMsg}`);
300
+ res.writeHead(HTTP_BAD_GATEWAY, {
301
+ "Content-Type": "text/event-stream",
302
+ "Cache-Control": "no-cache"
303
+ });
304
+ const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
305
+ res.write(`event: error
306
+ data: ${JSON.stringify(envelope)}
307
+
308
+ `);
309
+ res.end();
310
+ return;
311
+ }
312
+ if (usedFallback) {
313
+ logger.info("streaming: using direct Anthropic API fallback");
314
+ }
315
+ if (response.status >= 300) {
316
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
317
+ const originHeader = response.headers.get("x-skalpel-origin");
318
+ let origin;
319
+ if (originHeader === "backend") origin = "skalpel-backend";
320
+ else if (originHeader === "provider") origin = "provider";
321
+ else origin = "provider";
322
+ let rawBody = "";
323
+ let bodyReadFailed = false;
324
+ try {
325
+ rawBody = Buffer.from(await response.arrayBuffer()).toString();
326
+ } catch (readErr) {
327
+ bodyReadFailed = true;
328
+ logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
200
329
  }
201
- if (onFallback && lastError) {
202
- onFallback(lastError, provider);
330
+ if (!bodyReadFailed) {
331
+ logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
203
332
  }
204
- return fallbackFn();
205
- }
206
- throw lastError;
207
- }
333
+ const envelope = bodyReadFailed ? buildErrorEnvelope(
334
+ response.status,
335
+ "",
336
+ "skalpel-proxy",
337
+ "mid-stream abort",
338
+ retryAfter
339
+ ) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
340
+ res.writeHead(response.status, {
341
+ "Content-Type": "text/event-stream",
342
+ "Cache-Control": "no-cache"
343
+ });
344
+ res.write(`event: error
345
+ data: ${JSON.stringify(envelope)}
208
346
 
209
- // src/version.ts
210
- var VERSION = "1.0.5";
347
+ `);
348
+ res.end();
349
+ return;
350
+ }
351
+ const sseHeaders = {};
352
+ for (const [key, value] of response.headers.entries()) {
353
+ if (!STRIP_HEADERS.has(key) && key !== "content-type") {
354
+ sseHeaders[key] = value;
355
+ }
356
+ }
357
+ sseHeaders["Content-Type"] = "text/event-stream";
358
+ sseHeaders["Cache-Control"] = "no-cache";
359
+ res.writeHead(response.status, sseHeaders);
360
+ if (!response.body) {
361
+ res.write(`event: error
362
+ data: ${JSON.stringify({ error: "no response body" })}
211
363
 
212
- // src/client.ts
213
- var AsyncMutex = class {
214
- locked = false;
215
- queue = [];
216
- async acquire() {
217
- if (this.locked) {
218
- await new Promise((resolve) => this.queue.push(resolve));
364
+ `);
365
+ res.end();
366
+ return;
367
+ }
368
+ try {
369
+ const reader = response.body.getReader();
370
+ const decoder = new TextDecoder();
371
+ let chunkCount = 0;
372
+ let totalBytes = 0;
373
+ logger.info("streaming started");
374
+ while (true) {
375
+ const { done, value } = await reader.read();
376
+ if (done) break;
377
+ chunkCount++;
378
+ totalBytes += value.byteLength;
379
+ logger.debug(`streaming chunk #${chunkCount} bytes=${value.byteLength} totalBytes=${totalBytes}`);
380
+ const chunk = decoder.decode(value, { stream: true });
381
+ res.write(chunk);
219
382
  }
220
- this.locked = true;
221
- return () => {
222
- this.locked = false;
223
- const next = this.queue.shift();
224
- if (next) next();
225
- };
383
+ logger.info(`streaming completed chunks=${chunkCount} totalBytes=${totalBytes}`);
384
+ } catch (err) {
385
+ logger.error(`streaming error: ${err.message}`);
386
+ const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
387
+ const envelope = buildErrorEnvelope(
388
+ response.status,
389
+ err.message,
390
+ "skalpel-proxy",
391
+ "mid-stream abort",
392
+ retryAfter
393
+ );
394
+ res.write(`event: error
395
+ data: ${JSON.stringify(envelope)}
396
+
397
+ `);
226
398
  }
227
- };
228
- function resolveConfig(options) {
229
- return {
230
- apiKey: options.apiKey,
231
- baseURL: options.baseURL ?? "https://api.skalpel.ai",
232
- workspace: options.workspace,
233
- fallbackOnError: options.fallbackOnError ?? true,
234
- timeout: options.timeout ?? 3e4,
235
- retries: options.retries ?? 2,
236
- verbose: options.verbose ?? false,
237
- headers: options.headers ?? {},
238
- onFallback: options.onFallback,
239
- onMetadata: options.onMetadata
240
- };
399
+ res.end();
241
400
  }
242
- function buildSkalpelHeaders(config) {
243
- const headers = {
244
- "Authorization": `Bearer ${config.apiKey}`,
245
- "X-Skalpel-SDK-Version": VERSION,
246
- ...config.headers
247
- };
248
- if (config.workspace) {
249
- headers["X-Skalpel-Workspace"] = config.workspace;
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
+ ]);
250
427
  }
251
- return headers;
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;
252
435
  }
253
- function extractMetadataFromResponse(response, config) {
254
- if (!response || typeof response !== "object") return;
255
- const resp = response;
256
- if (resp._response && typeof resp._response === "object") {
257
- const raw = resp._response;
258
- if (raw.headers) {
259
- const metadata = extractMetadata(raw.headers);
260
- if (metadata) {
261
- config.onMetadata?.(metadata);
262
- return;
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
+ }
263
546
  }
264
- }
265
- }
266
- if (resp.x_skalpel && typeof resp.x_skalpel === "object") {
267
- const sk = resp.x_skalpel;
268
- const metadata = {
269
- requestId: sk.request_id ?? "",
270
- optimization: sk.optimization ?? "none",
271
- originalModel: sk.original_model ?? "",
272
- actualModel: sk.actual_model ?? "",
273
- savingsUsd: Number(sk.savings_usd ?? 0),
274
- cacheHit: Boolean(sk.cache_hit),
275
- latencyMs: Number(sk.latency_ms ?? 0)
276
547
  };
277
- config.onMetadata?.(metadata);
278
548
  }
549
+ });
550
+
551
+ // src/proxy/handler.ts
552
+ var handler_exports = {};
553
+ __export(handler_exports, {
554
+ buildForwardHeaders: () => buildForwardHeaders,
555
+ handleRequest: () => handleRequest,
556
+ handleWebSocketBridge: () => handleWebSocketBridge,
557
+ isSkalpelBackendFailure: () => isSkalpelBackendFailure,
558
+ shouldRouteToSkalpel: () => shouldRouteToSkalpel
559
+ });
560
+ function collectBody(req) {
561
+ return new Promise((resolve, reject) => {
562
+ const chunks = [];
563
+ req.on("data", (chunk) => chunks.push(chunk));
564
+ req.on("end", () => resolve(Buffer.concat(chunks).toString()));
565
+ req.on("error", reject);
566
+ });
279
567
  }
280
- function isOpenAIClient(client) {
281
- if (!client || typeof client !== "object") return false;
282
- const c = client;
283
- return c.chat !== void 0 && typeof c.chat === "object" && c.chat !== null && "completions" in c.chat;
284
- }
285
- function isAnthropicClient(client) {
286
- if (!client || typeof client !== "object") return false;
287
- const c = client;
288
- return c.messages !== void 0 && typeof c.messages === "object" && c.messages !== null && typeof c.messages.create === "function";
568
+ function shouldRouteToSkalpel(path4, source) {
569
+ if (source !== "claude-code") return true;
570
+ const pathname = path4.split("?")[0];
571
+ return SKALPEL_EXACT_PATHS.has(pathname);
289
572
  }
290
- function wrapOpenAI(client, config) {
291
- const c = client;
292
- const skalpelHeaders = buildSkalpelHeaders(config);
293
- const originalBaseURL = c.baseURL;
294
- const mutex = new AsyncMutex();
295
- function createMethodProxy(target, methodName) {
296
- const originalMethod = target[methodName];
297
- return async function(...args) {
298
- const primaryFn = async () => {
299
- const release = await mutex.acquire();
300
- try {
301
- c.baseURL = `${config.baseURL}/v1`;
302
- const requestArgs = args[0];
303
- const extraHeaders = skalpelHeaders;
304
- let callArgs;
305
- if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
306
- const opts = args[1];
307
- opts.headers = { ...extraHeaders, ...opts.headers };
308
- callArgs = [requestArgs, opts, ...args.slice(2)];
309
- } else {
310
- callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
311
- }
312
- try {
313
- const result = await originalMethod.apply(target, callArgs);
314
- extractMetadataFromResponse(result, config);
315
- return result;
316
- } finally {
317
- c.baseURL = originalBaseURL;
318
- }
319
- } finally {
320
- release();
321
- }
322
- };
323
- const fallbackFn = async () => {
324
- c.baseURL = originalBaseURL;
325
- return originalMethod.apply(target, args);
326
- };
327
- return withFallback(primaryFn, fallbackFn, {
328
- retries: config.retries,
329
- verbose: config.verbose,
330
- onFallback: config.onFallback,
331
- provider: "openai",
332
- fallbackOnError: config.fallbackOnError
333
- });
334
- };
335
- }
336
- function createNamespaceProxy(namespace) {
337
- return new Proxy(namespace, {
338
- get(target, prop, receiver) {
339
- const value = Reflect.get(target, prop, receiver);
340
- if (typeof value === "function" && (prop === "create" || prop === "stream")) {
341
- return createMethodProxy(target, prop);
342
- }
343
- if (value && typeof value === "object" && !Array.isArray(value)) {
344
- return createNamespaceProxy(value);
345
- }
346
- return value;
347
- }
348
- });
349
- }
350
- return new Proxy(client, {
351
- get(target, prop, receiver) {
352
- const value = Reflect.get(target, prop, receiver);
353
- if (typeof value === "object" && value !== null && (prop === "chat" || prop === "completions" || prop === "embeddings")) {
354
- return createNamespaceProxy(value);
573
+ async function isSkalpelBackendFailure(response, err, logger) {
574
+ if (err) return true;
575
+ if (!response) return true;
576
+ if (response.status < 500) return false;
577
+ const origin = response.headers?.get("x-skalpel-origin");
578
+ if (origin === "provider") return false;
579
+ if (origin === "backend") return true;
580
+ try {
581
+ const text = await response.clone().text();
582
+ if (!text) {
583
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
584
+ return true;
585
+ }
586
+ let shape = "non-anthropic";
587
+ try {
588
+ const parsed = JSON.parse(text);
589
+ if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
590
+ shape = "anthropic";
591
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
592
+ return false;
355
593
  }
356
- return value;
594
+ } catch {
357
595
  }
358
- });
596
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
597
+ return true;
598
+ } catch {
599
+ logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
600
+ return true;
601
+ }
359
602
  }
360
- function wrapAnthropic(client, config) {
361
- const c = client;
362
- const skalpelHeaders = buildSkalpelHeaders(config);
363
- const originalBaseURL = c.baseURL ?? c._client?.baseURL;
364
- const mutex = new AsyncMutex();
365
- function createMethodProxy(target, methodName) {
366
- const originalMethod = target[methodName];
367
- return async function(...args) {
368
- const primaryFn = async () => {
369
- const release = await mutex.acquire();
370
- try {
371
- const proxyURL = config.baseURL;
372
- if ("baseURL" in c) c.baseURL = proxyURL;
373
- if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
374
- const requestArgs = args[0];
375
- const extraHeaders = skalpelHeaders;
376
- let callArgs;
377
- if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
378
- const opts = args[1];
379
- opts.headers = { ...extraHeaders, ...opts.headers };
380
- callArgs = [requestArgs, opts, ...args.slice(2)];
381
- } else {
382
- callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
383
- }
384
- try {
385
- const result = await originalMethod.apply(target, callArgs);
386
- extractMetadataFromResponse(result, config);
387
- return result;
388
- } finally {
389
- if ("baseURL" in c) c.baseURL = originalBaseURL;
390
- if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
391
- }
392
- } finally {
393
- release();
394
- }
395
- };
396
- const fallbackFn = async () => {
397
- if ("baseURL" in c) c.baseURL = originalBaseURL;
398
- if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
399
- return originalMethod.apply(target, args);
400
- };
401
- return withFallback(primaryFn, fallbackFn, {
402
- retries: config.retries,
403
- verbose: config.verbose,
404
- onFallback: config.onFallback,
405
- provider: "anthropic",
406
- fallbackOnError: config.fallbackOnError
407
- });
408
- };
603
+ function buildForwardHeaders(req, config, source, useSkalpel) {
604
+ const forwardHeaders = {};
605
+ for (const [key, value] of Object.entries(req.headers)) {
606
+ if (value === void 0) continue;
607
+ if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
608
+ forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
409
609
  }
410
- return new Proxy(client, {
411
- get(target, prop, receiver) {
412
- const value = Reflect.get(target, prop, receiver);
413
- if (prop === "messages" && typeof value === "object" && value !== null) {
414
- return new Proxy(value, {
415
- get(msgTarget, msgProp, msgReceiver) {
416
- const msgValue = Reflect.get(msgTarget, msgProp, msgReceiver);
417
- if (typeof msgValue === "function" && (msgProp === "create" || msgProp === "stream")) {
418
- return createMethodProxy(msgTarget, msgProp);
419
- }
420
- return msgValue;
421
- }
422
- });
610
+ if (useSkalpel) {
611
+ forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
612
+ forwardHeaders["X-Skalpel-Source"] = source;
613
+ forwardHeaders["X-Skalpel-Agent-Type"] = source;
614
+ forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
615
+ forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
616
+ if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
617
+ const authHeader = forwardHeaders["authorization"] ?? "";
618
+ if (authHeader.toLowerCase().startsWith("bearer ")) {
619
+ const token = authHeader.slice(7).trim();
620
+ if (token.startsWith("sk-ant-")) {
621
+ forwardHeaders["x-api-key"] = token;
622
+ delete forwardHeaders["authorization"];
623
+ }
423
624
  }
424
- return value;
425
625
  }
426
- });
427
- }
428
- function createSkalpelClient(client, options) {
429
- const config = resolveConfig(options);
430
- if (isOpenAIClient(client)) {
431
- return wrapOpenAI(client, config);
432
- }
433
- if (isAnthropicClient(client)) {
434
- return wrapAnthropic(client, config);
435
626
  }
436
- throw new Error(
437
- "Unsupported client. createSkalpelClient supports OpenAI and Anthropic SDK clients."
438
- );
627
+ return forwardHeaders;
439
628
  }
440
- async function contextRequest(config, path4, body) {
441
- const url = `${config.baseURL}/api/workspaces${path4}`;
442
- const headers = buildSkalpelHeaders(config);
443
- headers["Content-Type"] = "application/json";
444
- const controller = new AbortController();
445
- const timeoutId = setTimeout(() => controller.abort(), config.timeout);
446
- try {
447
- const response = await fetch(url, {
448
- method: "POST",
449
- headers,
450
- body: JSON.stringify(body),
451
- signal: controller.signal
452
- });
453
- if (!response.ok) {
454
- const text = await response.text().catch(() => "");
455
- const message = `Skalpel context API error ${response.status}: ${text}`;
456
- if (response.status >= 400 && response.status < 500) {
457
- throw new SkalpelClientRequestError(message, response.status);
458
- }
459
- throw new SkalpelUnavailableError(message, response.status);
629
+ function stripSkalpelHeaders2(headers) {
630
+ const cleaned = { ...headers };
631
+ delete cleaned["X-Skalpel-API-Key"];
632
+ delete cleaned["X-Skalpel-Source"];
633
+ delete cleaned["X-Skalpel-Agent-Type"];
634
+ delete cleaned["X-Skalpel-SDK-Version"];
635
+ delete cleaned["X-Skalpel-Auth-Mode"];
636
+ return cleaned;
637
+ }
638
+ function extractResponseHeaders(response) {
639
+ const headers = {};
640
+ for (const [key, value] of response.headers.entries()) {
641
+ if (!STRIP_RESPONSE_HEADERS.has(key)) {
642
+ headers[key] = value;
460
643
  }
461
- return await response.json();
462
- } finally {
463
- clearTimeout(timeoutId);
464
644
  }
645
+ return headers;
465
646
  }
466
- async function createSnapshot(options, params) {
467
- const config = resolveConfig(options);
468
- const raw = await contextRequest(config, "/snapshots", {
469
- workspace_id: params.workspaceId,
470
- snapshot_hash: params.snapshotHash,
471
- manifest: params.manifest ?? {},
472
- metadata: params.metadata ?? null
647
+ async function handleRequest(req, res, config, source, logger) {
648
+ const start = Date.now();
649
+ const method = req.method ?? "GET";
650
+ const path4 = req.url ?? "/";
651
+ const fp = tokenFingerprint(
652
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
653
+ );
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
+ }
663
+ let response = null;
664
+ try {
665
+ const body = await collectBody(req);
666
+ logger.info(`body collected bytes=${body.length}`);
667
+ const useSkalpel = shouldRouteToSkalpel(path4, source);
668
+ logger.info(`routing useSkalpel=${useSkalpel}`);
669
+ const forwardHeaders = buildForwardHeaders(req, config, source, useSkalpel);
670
+ logger.debug(`headers built skalpelHeaders=${useSkalpel} authConverted=${!forwardHeaders["authorization"] && !!forwardHeaders["x-api-key"]}`);
671
+ let isStreaming = false;
672
+ if (body) {
673
+ try {
674
+ const parsed = JSON.parse(body);
675
+ isStreaming = parsed.stream === true;
676
+ } catch {
677
+ }
678
+ }
679
+ logger.info(`stream detection isStreaming=${isStreaming}`);
680
+ if (isStreaming) {
681
+ const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
682
+ const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
683
+ await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
684
+ logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
685
+ return;
686
+ }
687
+ const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
688
+ const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
689
+ const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
690
+ let fetchError = null;
691
+ let usedFallback = false;
692
+ if (useSkalpel) {
693
+ logger.info(`fetch sending url=${skalpelUrl} method=${method}`);
694
+ try {
695
+ response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
696
+ } catch (err) {
697
+ fetchError = err;
698
+ }
699
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${skalpelUrl}`);
700
+ if (await isSkalpelBackendFailure(response, fetchError, logger)) {
701
+ logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? formatFetchErrorForLog(fetchError, skalpelUrl) : `status ${response?.status}`}), falling back to direct Anthropic API`);
702
+ usedFallback = true;
703
+ response = null;
704
+ fetchError = null;
705
+ const directHeaders = stripSkalpelHeaders2(forwardHeaders);
706
+ logger.info(`fetch sending url=${directUrl} method=${method} fallback=true`);
707
+ try {
708
+ response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
709
+ } catch (err) {
710
+ fetchError = err;
711
+ }
712
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl} fallback=true`);
713
+ }
714
+ } else {
715
+ logger.info(`fetch sending url=${directUrl} method=${method}`);
716
+ try {
717
+ response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
718
+ } catch (err) {
719
+ fetchError = err;
720
+ }
721
+ if (response && !fetchError) logger.info(`fetch received status=${response.status} url=${directUrl}`);
722
+ }
723
+ const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
724
+ const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
725
+ if (fetchError) {
726
+ const code = fetchError.code;
727
+ if (code && TIMEOUT_CODES3.has(code)) {
728
+ logger.warn(`timeout detected code=${code} url=${fetchUrl}`);
729
+ try {
730
+ response = await handleTimeoutWithRetry(
731
+ fetchError,
732
+ () => fetch(fetchUrl, {
733
+ method,
734
+ headers: fetchHeaders,
735
+ body: fetchBody,
736
+ dispatcher: skalpelDispatcher
737
+ }),
738
+ logger
739
+ );
740
+ fetchError = null;
741
+ } catch (retryErr) {
742
+ fetchError = retryErr;
743
+ }
744
+ }
745
+ }
746
+ if (!response || fetchError) {
747
+ response = null;
748
+ throw fetchError ?? new Error("no response from upstream");
749
+ }
750
+ if (response.status === 429) {
751
+ logger.info(`429 received url=${fetchUrl}`);
752
+ response = await handle429WithRetryAfter(
753
+ response,
754
+ () => fetch(fetchUrl, {
755
+ method,
756
+ headers: fetchHeaders,
757
+ body: fetchBody,
758
+ dispatcher: skalpelDispatcher
759
+ }),
760
+ logger
761
+ );
762
+ }
763
+ if (response.status === 401 && (source === "claude-code" || source === "codex")) {
764
+ const fp2 = tokenFingerprint(
765
+ typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
766
+ );
767
+ logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
768
+ const body401 = Buffer.from(await response.arrayBuffer());
769
+ const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
770
+ res.writeHead(401, { "Content-Type": "application/json" });
771
+ res.end(JSON.stringify(envelope));
772
+ logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
773
+ return;
774
+ }
775
+ const responseHeaders = extractResponseHeaders(response);
776
+ const responseBody = Buffer.from(await response.arrayBuffer());
777
+ responseHeaders["content-length"] = String(responseBody.length);
778
+ logger.info(`response forwarding status=${response.status} bodyBytes=${responseBody.length}`);
779
+ res.writeHead(response.status, responseHeaders);
780
+ res.end(responseBody);
781
+ logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
782
+ } catch (err) {
783
+ logger.error(`${method} ${path4} source=${source} error=${formatFetchErrorForLog(err, path4)}`);
784
+ if (!res.headersSent) {
785
+ if (response !== null) {
786
+ const upstreamStatus = response.status;
787
+ const envelope = buildErrorEnvelope(
788
+ upstreamStatus,
789
+ "",
790
+ "skalpel-proxy",
791
+ "body read failed after upstream status"
792
+ );
793
+ res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
794
+ res.end(JSON.stringify(envelope));
795
+ } else {
796
+ const envelope = buildErrorEnvelope(
797
+ HTTP_BAD_GATEWAY2,
798
+ err.message,
799
+ "skalpel-proxy"
800
+ );
801
+ res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
802
+ res.end(JSON.stringify(envelope));
803
+ }
804
+ }
805
+ }
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
473
817
  });
474
- return {
475
- id: String(raw.id),
476
- workspaceId: String(raw.workspace_id),
477
- snapshotHash: String(raw.snapshot_hash),
478
- manifest: raw.manifest ?? {},
479
- metadata: raw.metadata ?? null,
480
- createdAt: String(raw.created_at)
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
+ }
481
839
  };
482
- }
483
- async function uploadChunks(options, params) {
484
- const config = resolveConfig(options);
485
- const raw = await contextRequest(config, "/chunks", {
486
- workspace_id: params.workspaceId,
487
- snapshot_id: params.snapshotId,
488
- chunks: params.chunks.map((c) => ({
489
- path: c.path,
490
- chunk_hash: c.chunkHash,
491
- chunk_text: c.chunkText,
492
- symbol: c.symbol ?? null,
493
- language: c.language ?? null,
494
- token_count: c.tokenCount ?? 0,
495
- metadata: c.metadata ?? null
496
- }))
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();
497
851
  });
498
- return (raw.items ?? []).map((item) => ({
499
- id: String(item.id),
500
- workspaceId: String(item.workspace_id),
501
- snapshotId: String(item.snapshot_id),
502
- path: String(item.path),
503
- chunkHash: String(item.chunk_hash),
504
- symbol: item.symbol != null ? String(item.symbol) : null,
505
- language: item.language != null ? String(item.language) : null,
506
- tokenCount: Number(item.token_count ?? 0),
507
- metadata: item.metadata ?? null,
508
- createdAt: String(item.created_at)
509
- }));
510
- }
511
- async function resolveContext(options, params) {
512
- const config = resolveConfig(options);
513
- const raw = await contextRequest(config, "/context/resolve", {
514
- workspace_id: params.workspaceId,
515
- snapshot_id: params.snapshotId ?? null,
516
- query: params.query,
517
- changed_paths: params.changedPaths ?? [],
518
- limit: params.limit ?? 8
852
+ clientWs.on("close", (code, reason) => {
853
+ logger.info(`bridge: client closed code=${code} reason=${String(reason)}`);
854
+ backend.close(1e3, "client closed");
519
855
  });
520
- const items = raw.items ?? [];
521
- return {
522
- workspaceId: String(raw.workspace_id),
523
- snapshotId: raw.snapshot_id != null ? String(raw.snapshot_id) : null,
524
- items: items.map((item) => ({
525
- path: String(item.path),
526
- symbol: item.symbol != null ? String(item.symbol) : null,
527
- language: item.language != null ? String(item.language) : null,
528
- tokenCount: Number(item.token_count ?? 0),
529
- score: Number(item.score ?? 0),
530
- reason: String(item.reason ?? "semantic"),
531
- metadata: item.metadata ?? null,
532
- preview: String(item.preview ?? "")
533
- }))
534
- };
535
- }
536
-
537
- // src/url-swap.ts
538
- var SKALPEL_MANAGED_SENTINEL = "skalpel-managed";
539
- function buildHeaders(options) {
540
- const headers = {
541
- "X-Skalpel-SDK-Version": VERSION,
542
- ...options.headers
543
- };
544
- if (options.workspace) {
545
- headers["X-Skalpel-Workspace"] = options.workspace;
546
- }
547
- return headers;
548
- }
549
- async function createSkalpelOpenAI(options) {
550
- const { default: OpenAI } = await import("openai");
551
- const baseURL = `${options.baseURL ?? "https://api.skalpel.ai"}/v1`;
552
- const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
553
- return new OpenAI({
554
- baseURL,
555
- apiKey: sdkApiKey,
556
- defaultHeaders: {
557
- ...buildHeaders(options),
558
- "Authorization": `Bearer ${options.apiKey}`
559
- },
560
- timeout: options.timeout ?? 3e4,
561
- maxRetries: options.retries ?? 2
856
+ backend.on("open", () => {
857
+ backendOpen = true;
858
+ flushPending();
562
859
  });
563
- }
564
- async function createSkalpelAnthropic(options) {
565
- const { default: Anthropic } = await import("@anthropic-ai/sdk");
566
- const baseURL = options.baseURL ?? "https://api.skalpel.ai";
567
- const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
568
- return new Anthropic({
569
- baseURL,
570
- apiKey: sdkApiKey,
571
- defaultHeaders: {
572
- ...buildHeaders(options),
573
- "Authorization": `Bearer ${options.apiKey}`
574
- },
575
- timeout: options.timeout ?? 3e4,
576
- maxRetries: options.retries ?? 2
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
+ }
577
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
+ }
578
904
  }
579
-
580
- // src/proxy/server.ts
581
- var import_node_http = __toESM(require("http"), 1);
582
-
583
- // src/proxy/dispatcher.ts
584
- var import_undici = require("undici");
585
- var skalpelDispatcher = new import_undici.Agent({
586
- keepAliveTimeout: 1e4,
587
- keepAliveMaxTimeout: 6e4,
588
- connections: 100,
589
- pipelining: 1
590
- });
591
-
592
- // src/proxy/envelope.ts
593
- function isAnthropicShaped(body) {
594
- if (typeof body !== "object" || body === null) return false;
595
- const b = body;
596
- if (b.type !== "error") return false;
597
- if (typeof b.error !== "object" || b.error === null) return false;
598
- return true;
599
- }
600
- function defaultErrorTypeFor(status) {
601
- if (status === 400) return "invalid_request_error";
602
- if (status === 401 || status === 403) return "authentication_error";
603
- if (status === 404) return "not_found_error";
604
- if (status === 408) return "timeout_error";
605
- if (status === 429) return "rate_limit_error";
606
- if (status >= 500) return "api_error";
607
- if (status >= 400) return "invalid_request_error";
608
- return "api_error";
905
+ function buildBackendWsUrl(config) {
906
+ const base = config.remoteBaseUrl.replace(/^http/, "ws");
907
+ return `${base}/v1/responses`;
609
908
  }
610
- function buildErrorEnvelope(status, upstreamBody, origin, hint, retryAfter) {
611
- let parsed = upstreamBody;
612
- if (typeof upstreamBody === "string" && upstreamBody.length > 0) {
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 ?? "");
613
914
  try {
614
- parsed = JSON.parse(upstreamBody);
615
- } catch {
616
- parsed = upstreamBody;
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
+ );
617
956
  }
618
957
  }
619
- let type = defaultErrorTypeFor(status);
620
- let message;
621
- if (isAnthropicShaped(parsed)) {
622
- const inner = parsed.error;
623
- if (typeof inner.type === "string" && inner.type.length > 0) {
624
- type = inner.type;
625
- }
626
- message = typeof inner.message === "string" && inner.message.length > 0 ? inner.message : defaultMessageForStatus(status);
627
- } else if (typeof parsed === "string" && parsed.length > 0) {
628
- message = parsed;
629
- } else {
630
- message = defaultMessageForStatus(status);
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
+ ]);
631
995
  }
632
- const envelope = {
633
- type: "error",
634
- error: {
635
- type,
636
- message,
637
- status_code: status,
638
- origin
996
+ });
997
+
998
+ // src/index.ts
999
+ var index_exports = {};
1000
+ __export(index_exports, {
1001
+ SkalpelAuthError: () => SkalpelAuthError,
1002
+ SkalpelError: () => SkalpelError,
1003
+ SkalpelRateLimitError: () => SkalpelRateLimitError,
1004
+ SkalpelTimeoutError: () => SkalpelTimeoutError,
1005
+ SkalpelUnavailableError: () => SkalpelUnavailableError,
1006
+ createSkalpelAnthropic: () => createSkalpelAnthropic,
1007
+ createSkalpelClient: () => createSkalpelClient,
1008
+ createSkalpelOpenAI: () => createSkalpelOpenAI,
1009
+ createSnapshot: () => createSnapshot,
1010
+ extractMetadata: () => extractMetadata,
1011
+ getProxyStatus: () => getProxyStatus,
1012
+ loadConfig: () => loadConfig,
1013
+ resolveContext: () => resolveContext,
1014
+ saveConfig: () => saveConfig,
1015
+ startProxy: () => startProxy,
1016
+ stopProxy: () => stopProxy,
1017
+ uploadChunks: () => uploadChunks
1018
+ });
1019
+ module.exports = __toCommonJS(index_exports);
1020
+
1021
+ // src/metadata.ts
1022
+ function extractMetadata(headers) {
1023
+ const get = (name) => {
1024
+ if (headers instanceof Headers) {
1025
+ return headers.get(name);
639
1026
  }
1027
+ return headers[name] ?? null;
1028
+ };
1029
+ const requestId = get("x-skalpel-request-id");
1030
+ if (!requestId) return null;
1031
+ return {
1032
+ requestId,
1033
+ optimization: get("x-skalpel-optimization") ?? "none",
1034
+ originalModel: get("x-skalpel-original-model") ?? "",
1035
+ actualModel: get("x-skalpel-actual-model") ?? "",
1036
+ savingsUsd: parseFloat(get("x-skalpel-savings-usd") ?? "0"),
1037
+ cacheHit: get("x-skalpel-cache-hit") === "true",
1038
+ latencyMs: parseInt(get("x-skalpel-latency-ms") ?? "0", 10)
640
1039
  };
641
- if (hint !== void 0) envelope.error.hint = hint;
642
- if (retryAfter !== void 0) envelope.error.retry_after = retryAfter;
643
- return envelope;
644
- }
645
- function defaultMessageForStatus(status) {
646
- if (status === 401) return "Authentication failed";
647
- if (status === 403) return "Forbidden";
648
- if (status === 404) return "Not found";
649
- if (status === 408) return "Request timed out";
650
- if (status === 429) return "Rate limit exceeded";
651
- if (status === 502) return "Bad gateway";
652
- if (status === 503) return "Service unavailable";
653
- if (status === 504) return "Gateway timeout";
654
- if (status >= 500) return "Upstream error";
655
- if (status >= 400) return "Client error";
656
- return "Error";
657
1040
  }
658
1041
 
659
- // src/proxy/recovery.ts
660
- var import_node_crypto = require("crypto");
661
- function parseRetryAfterHeader(header) {
662
- if (!header) return void 0;
663
- const trimmed = header.trim();
664
- if (!trimmed) return void 0;
665
- const n = Number(trimmed);
666
- if (Number.isFinite(n) && n >= 0) return Math.floor(n);
667
- const dateMs = Date.parse(trimmed);
668
- if (Number.isFinite(dateMs)) {
669
- return Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
1042
+ // src/errors.ts
1043
+ var SkalpelError = class extends Error {
1044
+ code;
1045
+ statusCode;
1046
+ retryAfter;
1047
+ constructor(message, code, statusCode, retryAfter) {
1048
+ super(message);
1049
+ this.name = "SkalpelError";
1050
+ this.code = code;
1051
+ this.statusCode = statusCode;
1052
+ this.retryAfter = retryAfter;
670
1053
  }
671
- return void 0;
672
- }
673
- function sleep2(ms) {
674
- return new Promise((resolve) => setTimeout(resolve, ms));
675
- }
676
- var MAX_RETRY_AFTER_SECONDS = 60;
677
- var DEFAULT_BACKOFF_SECONDS = 2;
678
- async function handle429WithRetryAfter(response, retryFn, logger) {
679
- const headerVal = response.headers.get("retry-after");
680
- const parsed = parseRetryAfterHeader(headerVal);
681
- if (parsed === void 0) {
682
- await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
683
- const retried2 = await retryFn();
684
- logger.info("proxy.recovery.429_retry_count increment");
685
- return retried2;
1054
+ };
1055
+ var SkalpelAuthError = class extends SkalpelError {
1056
+ constructor(message = "Skalpel authentication failed") {
1057
+ super(message, "SKALPEL_AUTH_FAILED", 401);
1058
+ this.name = "SkalpelAuthError";
686
1059
  }
687
- if (parsed > MAX_RETRY_AFTER_SECONDS) {
688
- return response;
1060
+ };
1061
+ var SkalpelTimeoutError = class extends SkalpelError {
1062
+ constructor(message = "Skalpel request timed out") {
1063
+ super(message, "SKALPEL_TIMEOUT");
1064
+ this.name = "SkalpelTimeoutError";
689
1065
  }
690
- await sleep2(parsed * 1e3);
691
- const retried = await retryFn();
692
- logger.info("proxy.recovery.429_retry_count increment");
693
- return retried;
694
- }
695
- var TIMEOUT_CODES = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
696
- async function handleTimeoutWithRetry(err, retryFn, logger) {
697
- const code = err.code;
698
- if (!code || !TIMEOUT_CODES.has(code)) {
699
- throw err;
1066
+ };
1067
+ var SkalpelRateLimitError = class extends SkalpelError {
1068
+ constructor(message = "Skalpel rate limit exceeded", retryAfter) {
1069
+ super(message, "SKALPEL_RATE_LIMITED", 429, retryAfter);
1070
+ this.name = "SkalpelRateLimitError";
700
1071
  }
701
- await sleep2(DEFAULT_BACKOFF_SECONDS * 1e3);
702
- const retried = await retryFn();
703
- logger.info("proxy.recovery.timeout_retry_count increment");
704
- return retried;
705
- }
706
- function tokenFingerprint(authHeader) {
707
- if (authHeader === void 0) return "none";
708
- return (0, import_node_crypto.createHash)("sha256").update(authHeader).digest("hex").slice(0, 12);
709
- }
710
- var MUTEX_MAX_ENTRIES = 1024;
711
- var LruMutexMap = class extends Map {
712
- set(key, value) {
713
- if (this.has(key)) {
714
- super.delete(key);
715
- } else if (this.size >= MUTEX_MAX_ENTRIES) {
716
- const oldest = this.keys().next().value;
717
- if (oldest !== void 0) super.delete(oldest);
718
- }
719
- return super.set(key, value);
1072
+ };
1073
+ var SkalpelUnavailableError = class extends SkalpelError {
1074
+ constructor(message = "Skalpel service unavailable", statusCode) {
1075
+ super(message, "SKALPEL_UNAVAILABLE", statusCode);
1076
+ this.name = "SkalpelUnavailableError";
720
1077
  }
721
1078
  };
722
- var refreshMutex = new LruMutexMap();
723
-
724
- // src/proxy/streaming.ts
725
- var TIMEOUT_CODES2 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
726
- var HTTP_BAD_GATEWAY = 502;
727
- function parseRetryAfter(header) {
728
- if (!header) return void 0;
729
- const trimmed = header.trim();
730
- if (!trimmed) return void 0;
731
- const n = Number(trimmed);
732
- if (Number.isFinite(n) && n >= 0) return Math.floor(n);
733
- const dateMs = Date.parse(trimmed);
734
- if (Number.isFinite(dateMs)) {
735
- const delta = Math.max(0, Math.ceil((dateMs - Date.now()) / 1e3));
736
- return delta;
1079
+ var SkalpelClientRequestError = class extends SkalpelError {
1080
+ constructor(message, statusCode) {
1081
+ super(message, "SKALPEL_CLIENT_REQUEST", statusCode);
1082
+ this.name = "SkalpelClientRequestError";
737
1083
  }
738
- return void 0;
739
- }
740
- var HOP_BY_HOP = /* @__PURE__ */ new Set([
741
- "connection",
742
- "keep-alive",
743
- "proxy-authenticate",
744
- "proxy-authorization",
745
- "te",
746
- "trailer",
747
- "transfer-encoding",
748
- "upgrade"
749
- ]);
750
- var STRIP_HEADERS = /* @__PURE__ */ new Set([
751
- ...HOP_BY_HOP,
752
- "content-encoding",
753
- "content-length"
754
- ]);
755
- function stripSkalpelHeaders(headers) {
756
- const cleaned = { ...headers };
757
- delete cleaned["X-Skalpel-API-Key"];
758
- delete cleaned["X-Skalpel-Source"];
759
- delete cleaned["X-Skalpel-Agent-Type"];
760
- delete cleaned["X-Skalpel-SDK-Version"];
761
- delete cleaned["X-Skalpel-Auth-Mode"];
762
- return cleaned;
763
- }
764
- async function doStreamingFetch(url, body, headers) {
765
- return fetch(url, { method: "POST", headers, body, dispatcher: skalpelDispatcher });
766
- }
767
- async function handleStreamingRequest(_req, res, _config, _source, body, skalpelUrl, directUrl, useSkalpel, forwardHeaders, logger) {
768
- let response = null;
769
- let fetchError = null;
770
- let usedFallback = false;
771
- if (useSkalpel) {
772
- try {
773
- response = await doStreamingFetch(skalpelUrl, body, forwardHeaders);
774
- } catch (err) {
775
- fetchError = err;
776
- }
777
- if (await isSkalpelBackendFailure(response, fetchError, logger)) {
778
- logger.warn(`streaming: Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
779
- usedFallback = true;
780
- response = null;
781
- fetchError = null;
782
- const directHeaders = stripSkalpelHeaders(forwardHeaders);
783
- try {
784
- response = await doStreamingFetch(directUrl, body, directHeaders);
785
- } catch (err) {
786
- fetchError = err;
787
- }
788
- }
789
- } else {
790
- try {
791
- response = await doStreamingFetch(directUrl, body, forwardHeaders);
792
- } catch (err) {
793
- fetchError = err;
794
- }
1084
+ };
1085
+
1086
+ // src/fallback.ts
1087
+ var TRULY_CLIENT_4XX = /* @__PURE__ */ new Set([
1088
+ 400,
1089
+ 403,
1090
+ 404,
1091
+ 405,
1092
+ 409,
1093
+ 410,
1094
+ 411,
1095
+ 413,
1096
+ 415,
1097
+ 417,
1098
+ 418,
1099
+ 421,
1100
+ 422,
1101
+ 423,
1102
+ 424,
1103
+ 425,
1104
+ 426,
1105
+ 428,
1106
+ 431,
1107
+ 451
1108
+ ]);
1109
+ function sleep(ms) {
1110
+ return new Promise((resolve) => setTimeout(resolve, ms));
1111
+ }
1112
+ function classifyError(err) {
1113
+ if (err instanceof SkalpelError) return err;
1114
+ const error = err;
1115
+ const status = error.status ?? error.statusCode;
1116
+ const message = error.message ?? "Unknown error";
1117
+ if (status === 401) {
1118
+ return new SkalpelAuthError(message);
795
1119
  }
796
- const finalUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
797
- const finalHeaders = usedFallback ? stripSkalpelHeaders(forwardHeaders) : forwardHeaders;
798
- if (fetchError) {
799
- const code = fetchError.code;
800
- if (code && TIMEOUT_CODES2.has(code)) {
801
- try {
802
- response = await handleTimeoutWithRetry(
803
- fetchError,
804
- () => doStreamingFetch(finalUrl, body, finalHeaders),
805
- logger
806
- );
807
- fetchError = null;
808
- } catch (retryErr) {
809
- fetchError = retryErr;
810
- }
811
- }
1120
+ if (status === 429) {
1121
+ const retryAfter = error.headers?.["retry-after"] ? parseInt(error.headers["retry-after"], 10) : void 0;
1122
+ return new SkalpelRateLimitError(message, retryAfter);
812
1123
  }
813
- if (response && response.status === 429) {
814
- response = await handle429WithRetryAfter(
815
- response,
816
- () => doStreamingFetch(finalUrl, body, finalHeaders),
817
- logger
818
- );
1124
+ if (status === 408) {
1125
+ return new SkalpelTimeoutError(message);
819
1126
  }
820
- if (!response || fetchError) {
821
- const errMsg = fetchError ? fetchError.message : "no response from upstream";
822
- logger.error(`streaming fetch failed: ${errMsg}`);
823
- res.writeHead(HTTP_BAD_GATEWAY, {
824
- "Content-Type": "text/event-stream",
825
- "Cache-Control": "no-cache"
826
- });
827
- const envelope = buildErrorEnvelope(HTTP_BAD_GATEWAY, errMsg, "skalpel-proxy");
828
- res.write(`event: error
829
- data: ${JSON.stringify(envelope)}
830
-
831
- `);
832
- res.end();
833
- return;
1127
+ if (error.code === "ETIMEDOUT" || error.code === "TIMEOUT" || error.code === "UND_ERR_HEADERS_TIMEOUT") {
1128
+ return new SkalpelTimeoutError(message);
834
1129
  }
835
- if (usedFallback) {
836
- logger.info("streaming: using direct Anthropic API fallback");
1130
+ if (status && status >= 500) {
1131
+ return new SkalpelUnavailableError(message, status);
837
1132
  }
838
- if (response.status >= 300) {
839
- const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
840
- const originHeader = response.headers.get("x-skalpel-origin");
841
- let origin;
842
- if (originHeader === "backend") origin = "skalpel-backend";
843
- else if (originHeader === "provider") origin = "provider";
844
- else origin = "provider";
845
- let rawBody = "";
846
- let bodyReadFailed = false;
1133
+ if (status && TRULY_CLIENT_4XX.has(status)) {
1134
+ return new SkalpelClientRequestError(message, status);
1135
+ }
1136
+ if (status && status >= 400 && status < 500) {
1137
+ return new SkalpelClientRequestError(message, status);
1138
+ }
1139
+ return new SkalpelUnavailableError(message, status);
1140
+ }
1141
+ async function withFallback(primaryFn, fallbackFn, options = {}) {
1142
+ const { retries = 2, verbose = false, onFallback, provider = "unknown", fallbackOnError = true } = options;
1143
+ let lastError;
1144
+ for (let attempt = 0; attempt <= retries; attempt++) {
847
1145
  try {
848
- rawBody = Buffer.from(await response.arrayBuffer()).toString();
849
- } catch (readErr) {
850
- bodyReadFailed = true;
851
- logger.error(`streaming body-read failed after upstream status: ${readErr.message}`);
852
- }
853
- if (!bodyReadFailed) {
854
- logger.error(`streaming upstream error: status=${response.status} body=${rawBody.slice(0, 500)}`);
1146
+ return await primaryFn();
1147
+ } catch (err) {
1148
+ lastError = classifyError(err);
1149
+ if (lastError instanceof SkalpelAuthError) {
1150
+ throw lastError;
1151
+ }
1152
+ if (lastError instanceof SkalpelClientRequestError) {
1153
+ throw lastError;
1154
+ }
1155
+ if (lastError instanceof SkalpelRateLimitError && lastError.retryAfter && attempt < retries) {
1156
+ await sleep(lastError.retryAfter * 1e3);
1157
+ continue;
1158
+ }
1159
+ if (attempt < retries) {
1160
+ await sleep(Math.pow(2, attempt) * 1e3);
1161
+ continue;
1162
+ }
855
1163
  }
856
- const envelope = bodyReadFailed ? buildErrorEnvelope(
857
- response.status,
858
- "",
859
- "skalpel-proxy",
860
- "mid-stream abort",
861
- retryAfter
862
- ) : buildErrorEnvelope(response.status, rawBody, origin, void 0, retryAfter);
863
- res.writeHead(response.status, {
864
- "Content-Type": "text/event-stream",
865
- "Cache-Control": "no-cache"
866
- });
867
- res.write(`event: error
868
- data: ${JSON.stringify(envelope)}
869
-
870
- `);
871
- res.end();
872
- return;
873
1164
  }
874
- const sseHeaders = {};
875
- for (const [key, value] of response.headers.entries()) {
876
- if (!STRIP_HEADERS.has(key) && key !== "content-type") {
877
- sseHeaders[key] = value;
1165
+ if (fallbackOnError) {
1166
+ if (verbose && lastError) {
1167
+ console.warn(`[skalpel] Falling back to direct ${provider} call: ${lastError.message}`);
878
1168
  }
879
- }
880
- sseHeaders["Content-Type"] = "text/event-stream";
881
- sseHeaders["Cache-Control"] = "no-cache";
882
- res.writeHead(response.status, sseHeaders);
883
- if (!response.body) {
884
- res.write(`event: error
885
- data: ${JSON.stringify({ error: "no response body" })}
886
-
887
- `);
888
- res.end();
889
- return;
890
- }
891
- try {
892
- const reader = response.body.getReader();
893
- const decoder = new TextDecoder();
894
- while (true) {
895
- const { done, value } = await reader.read();
896
- if (done) break;
897
- const chunk = decoder.decode(value, { stream: true });
898
- res.write(chunk);
1169
+ if (onFallback && lastError) {
1170
+ onFallback(lastError, provider);
899
1171
  }
900
- } catch (err) {
901
- logger.error(`streaming error: ${err.message}`);
902
- const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
903
- const envelope = buildErrorEnvelope(
904
- response.status,
905
- err.message,
906
- "skalpel-proxy",
907
- "mid-stream abort",
908
- retryAfter
909
- );
910
- res.write(`event: error
911
- data: ${JSON.stringify(envelope)}
912
-
913
- `);
1172
+ return fallbackFn();
914
1173
  }
915
- res.end();
1174
+ throw lastError;
916
1175
  }
917
1176
 
918
- // src/proxy/handler.ts
919
- var TIMEOUT_CODES3 = /* @__PURE__ */ new Set(["ETIMEDOUT", "TIMEOUT", "UND_ERR_HEADERS_TIMEOUT"]);
920
- var HTTP_BAD_GATEWAY2 = 502;
921
- var SKALPEL_EXACT_PATHS = /* @__PURE__ */ new Set(["/v1/messages"]);
922
- function collectBody(req) {
923
- return new Promise((resolve, reject) => {
924
- const chunks = [];
925
- req.on("data", (chunk) => chunks.push(chunk));
926
- req.on("end", () => resolve(Buffer.concat(chunks).toString()));
927
- req.on("error", reject);
928
- });
929
- }
930
- function shouldRouteToSkalpel(path4, source) {
931
- if (source !== "claude-code") return true;
932
- const pathname = path4.split("?")[0];
933
- return SKALPEL_EXACT_PATHS.has(pathname);
934
- }
935
- async function isSkalpelBackendFailure(response, err, logger) {
936
- if (err) return true;
937
- if (!response) return true;
938
- if (response.status < 500) return false;
939
- const origin = response.headers?.get("x-skalpel-origin");
940
- if (origin === "provider") return false;
941
- if (origin === "backend") return true;
942
- try {
943
- const text = await response.clone().text();
944
- if (!text) {
945
- logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=non-anthropic`);
946
- return true;
947
- }
948
- let shape = "non-anthropic";
949
- try {
950
- const parsed = JSON.parse(text);
951
- if (parsed && typeof parsed === "object" && parsed.type === "error" && parsed.error && typeof parsed.error === "object" && typeof parsed.error.type === "string" && typeof parsed.error.message === "string") {
952
- shape = "anthropic";
953
- logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=false shape=${shape}`);
954
- return false;
955
- }
956
- } catch {
1177
+ // src/version.ts
1178
+ var VERSION = "1.0.5";
1179
+
1180
+ // src/client.ts
1181
+ var AsyncMutex = class {
1182
+ locked = false;
1183
+ queue = [];
1184
+ async acquire() {
1185
+ if (this.locked) {
1186
+ await new Promise((resolve) => this.queue.push(resolve));
957
1187
  }
958
- logger?.debug(`isSkalpelBackendFailure heuristic: status=${response.status} result=true shape=${shape}`);
959
- return true;
960
- } catch {
961
- logger?.debug(`isSkalpelBackendFailure heuristic: status=${response?.status ?? "null"} result=true shape=non-anthropic`);
962
- return true;
1188
+ this.locked = true;
1189
+ return () => {
1190
+ this.locked = false;
1191
+ const next = this.queue.shift();
1192
+ if (next) next();
1193
+ };
963
1194
  }
1195
+ };
1196
+ function resolveConfig(options) {
1197
+ return {
1198
+ apiKey: options.apiKey,
1199
+ baseURL: options.baseURL ?? "https://api.skalpel.ai",
1200
+ workspace: options.workspace,
1201
+ fallbackOnError: options.fallbackOnError ?? true,
1202
+ timeout: options.timeout ?? 3e4,
1203
+ retries: options.retries ?? 2,
1204
+ verbose: options.verbose ?? false,
1205
+ headers: options.headers ?? {},
1206
+ onFallback: options.onFallback,
1207
+ onMetadata: options.onMetadata
1208
+ };
964
1209
  }
965
- var FORWARD_HEADER_STRIP = /* @__PURE__ */ new Set([
966
- "host",
967
- "connection",
968
- "keep-alive",
969
- "proxy-authenticate",
970
- "proxy-authorization",
971
- "te",
972
- "trailer",
973
- "transfer-encoding",
974
- "upgrade"
975
- ]);
976
- function buildForwardHeaders(req, config, source, useSkalpel) {
977
- const forwardHeaders = {};
978
- for (const [key, value] of Object.entries(req.headers)) {
979
- if (value === void 0) continue;
980
- if (FORWARD_HEADER_STRIP.has(key.toLowerCase())) continue;
981
- forwardHeaders[key] = Array.isArray(value) ? value.join(", ") : value;
1210
+ function buildSkalpelHeaders(config) {
1211
+ const headers = {
1212
+ "Authorization": `Bearer ${config.apiKey}`,
1213
+ "X-Skalpel-SDK-Version": VERSION,
1214
+ ...config.headers
1215
+ };
1216
+ if (config.workspace) {
1217
+ headers["X-Skalpel-Workspace"] = config.workspace;
982
1218
  }
983
- if (useSkalpel) {
984
- forwardHeaders["X-Skalpel-API-Key"] = config.apiKey;
985
- forwardHeaders["X-Skalpel-Source"] = source;
986
- forwardHeaders["X-Skalpel-Agent-Type"] = source;
987
- forwardHeaders["X-Skalpel-SDK-Version"] = "proxy-1.0.0";
988
- forwardHeaders["X-Skalpel-Auth-Mode"] = "passthrough";
989
- if (source === "claude-code" && !forwardHeaders["x-api-key"]) {
990
- const authHeader = forwardHeaders["authorization"] ?? "";
991
- if (authHeader.toLowerCase().startsWith("bearer ")) {
992
- const token = authHeader.slice(7).trim();
993
- if (token.startsWith("sk-ant-")) {
994
- forwardHeaders["x-api-key"] = token;
995
- delete forwardHeaders["authorization"];
996
- }
1219
+ return headers;
1220
+ }
1221
+ function extractMetadataFromResponse(response, config) {
1222
+ if (!response || typeof response !== "object") return;
1223
+ const resp = response;
1224
+ if (resp._response && typeof resp._response === "object") {
1225
+ const raw = resp._response;
1226
+ if (raw.headers) {
1227
+ const metadata = extractMetadata(raw.headers);
1228
+ if (metadata) {
1229
+ config.onMetadata?.(metadata);
1230
+ return;
997
1231
  }
998
1232
  }
999
1233
  }
1000
- return forwardHeaders;
1234
+ if (resp.x_skalpel && typeof resp.x_skalpel === "object") {
1235
+ const sk = resp.x_skalpel;
1236
+ const metadata = {
1237
+ requestId: sk.request_id ?? "",
1238
+ optimization: sk.optimization ?? "none",
1239
+ originalModel: sk.original_model ?? "",
1240
+ actualModel: sk.actual_model ?? "",
1241
+ savingsUsd: Number(sk.savings_usd ?? 0),
1242
+ cacheHit: Boolean(sk.cache_hit),
1243
+ latencyMs: Number(sk.latency_ms ?? 0)
1244
+ };
1245
+ config.onMetadata?.(metadata);
1246
+ }
1001
1247
  }
1002
- function stripSkalpelHeaders2(headers) {
1003
- const cleaned = { ...headers };
1004
- delete cleaned["X-Skalpel-API-Key"];
1005
- delete cleaned["X-Skalpel-Source"];
1006
- delete cleaned["X-Skalpel-Agent-Type"];
1007
- delete cleaned["X-Skalpel-SDK-Version"];
1008
- delete cleaned["X-Skalpel-Auth-Mode"];
1009
- return cleaned;
1248
+ function isOpenAIClient(client) {
1249
+ if (!client || typeof client !== "object") return false;
1250
+ const c = client;
1251
+ return c.chat !== void 0 && typeof c.chat === "object" && c.chat !== null && "completions" in c.chat;
1010
1252
  }
1011
- var STRIP_RESPONSE_HEADERS = /* @__PURE__ */ new Set([
1012
- "connection",
1013
- "keep-alive",
1014
- "proxy-authenticate",
1015
- "proxy-authorization",
1016
- "te",
1017
- "trailer",
1018
- "transfer-encoding",
1019
- "upgrade",
1020
- "content-encoding",
1021
- "content-length"
1022
- ]);
1023
- function extractResponseHeaders(response) {
1024
- const headers = {};
1025
- for (const [key, value] of response.headers.entries()) {
1026
- if (!STRIP_RESPONSE_HEADERS.has(key)) {
1027
- headers[key] = value;
1028
- }
1029
- }
1030
- return headers;
1253
+ function isAnthropicClient(client) {
1254
+ if (!client || typeof client !== "object") return false;
1255
+ const c = client;
1256
+ return c.messages !== void 0 && typeof c.messages === "object" && c.messages !== null && typeof c.messages.create === "function";
1031
1257
  }
1032
- async function handleRequest(req, res, config, source, logger) {
1033
- const start = Date.now();
1034
- const method = req.method ?? "GET";
1035
- const path4 = req.url ?? "/";
1036
- const fp = tokenFingerprint(
1037
- typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
1038
- );
1039
- logger.info(`${source} ${method} ${path4} token=${fp}`);
1040
- let response = null;
1041
- try {
1042
- const body = await collectBody(req);
1043
- const useSkalpel = shouldRouteToSkalpel(path4, source);
1044
- const forwardHeaders = buildForwardHeaders(req, config, source, useSkalpel);
1045
- let isStreaming = false;
1046
- if (body) {
1047
- try {
1048
- const parsed = JSON.parse(body);
1049
- isStreaming = parsed.stream === true;
1050
- } catch {
1051
- }
1052
- }
1053
- if (isStreaming) {
1054
- const skalpelUrl2 = `${config.remoteBaseUrl}${path4}`;
1055
- const directUrl2 = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
1056
- await handleStreamingRequest(req, res, config, source, body, skalpelUrl2, directUrl2, useSkalpel, forwardHeaders, logger);
1057
- logger.info(`${method} ${path4} source=${source} streaming latency=${Date.now() - start}ms`);
1058
- return;
1059
- }
1060
- const skalpelUrl = `${config.remoteBaseUrl}${path4}`;
1061
- const directUrl = source === "claude-code" ? `${config.anthropicDirectUrl}${path4}` : source === "cursor" ? `${config.cursorDirectUrl}${path4}` : `${config.openaiDirectUrl}${path4}`;
1062
- const fetchBody = method !== "GET" && method !== "HEAD" ? body : void 0;
1063
- let fetchError = null;
1064
- let usedFallback = false;
1065
- if (useSkalpel) {
1066
- try {
1067
- response = await fetch(skalpelUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
1068
- } catch (err) {
1069
- fetchError = err;
1070
- }
1071
- if (await isSkalpelBackendFailure(response, fetchError, logger)) {
1072
- logger.warn(`${method} ${path4} Skalpel backend failed (${fetchError ? fetchError.message : `status ${response?.status}`}), falling back to direct Anthropic API`);
1073
- usedFallback = true;
1074
- response = null;
1075
- fetchError = null;
1076
- const directHeaders = stripSkalpelHeaders2(forwardHeaders);
1258
+ function wrapOpenAI(client, config) {
1259
+ const c = client;
1260
+ const skalpelHeaders = buildSkalpelHeaders(config);
1261
+ const originalBaseURL = c.baseURL;
1262
+ const mutex = new AsyncMutex();
1263
+ function createMethodProxy(target, methodName) {
1264
+ const originalMethod = target[methodName];
1265
+ return async function(...args) {
1266
+ const primaryFn = async () => {
1267
+ const release = await mutex.acquire();
1077
1268
  try {
1078
- response = await fetch(directUrl, { method, headers: directHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
1079
- } catch (err) {
1080
- fetchError = err;
1269
+ c.baseURL = `${config.baseURL}/v1`;
1270
+ const requestArgs = args[0];
1271
+ const extraHeaders = skalpelHeaders;
1272
+ let callArgs;
1273
+ if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
1274
+ const opts = args[1];
1275
+ opts.headers = { ...extraHeaders, ...opts.headers };
1276
+ callArgs = [requestArgs, opts, ...args.slice(2)];
1277
+ } else {
1278
+ callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
1279
+ }
1280
+ try {
1281
+ const result = await originalMethod.apply(target, callArgs);
1282
+ extractMetadataFromResponse(result, config);
1283
+ return result;
1284
+ } finally {
1285
+ c.baseURL = originalBaseURL;
1286
+ }
1287
+ } finally {
1288
+ release();
1289
+ }
1290
+ };
1291
+ const fallbackFn = async () => {
1292
+ c.baseURL = originalBaseURL;
1293
+ return originalMethod.apply(target, args);
1294
+ };
1295
+ return withFallback(primaryFn, fallbackFn, {
1296
+ retries: config.retries,
1297
+ verbose: config.verbose,
1298
+ onFallback: config.onFallback,
1299
+ provider: "openai",
1300
+ fallbackOnError: config.fallbackOnError
1301
+ });
1302
+ };
1303
+ }
1304
+ function createNamespaceProxy(namespace) {
1305
+ return new Proxy(namespace, {
1306
+ get(target, prop, receiver) {
1307
+ const value = Reflect.get(target, prop, receiver);
1308
+ if (typeof value === "function" && (prop === "create" || prop === "stream")) {
1309
+ return createMethodProxy(target, prop);
1310
+ }
1311
+ if (value && typeof value === "object" && !Array.isArray(value)) {
1312
+ return createNamespaceProxy(value);
1081
1313
  }
1314
+ return value;
1082
1315
  }
1083
- } else {
1084
- try {
1085
- response = await fetch(directUrl, { method, headers: forwardHeaders, body: fetchBody, dispatcher: skalpelDispatcher });
1086
- } catch (err) {
1087
- fetchError = err;
1316
+ });
1317
+ }
1318
+ return new Proxy(client, {
1319
+ get(target, prop, receiver) {
1320
+ const value = Reflect.get(target, prop, receiver);
1321
+ if (typeof value === "object" && value !== null && (prop === "chat" || prop === "completions" || prop === "embeddings")) {
1322
+ return createNamespaceProxy(value);
1088
1323
  }
1324
+ return value;
1089
1325
  }
1090
- const fetchUrl = usedFallback || !useSkalpel ? directUrl : skalpelUrl;
1091
- const fetchHeaders = usedFallback ? stripSkalpelHeaders2(forwardHeaders) : forwardHeaders;
1092
- if (fetchError) {
1093
- const code = fetchError.code;
1094
- if (code && TIMEOUT_CODES3.has(code)) {
1326
+ });
1327
+ }
1328
+ function wrapAnthropic(client, config) {
1329
+ const c = client;
1330
+ const skalpelHeaders = buildSkalpelHeaders(config);
1331
+ const originalBaseURL = c.baseURL ?? c._client?.baseURL;
1332
+ const mutex = new AsyncMutex();
1333
+ function createMethodProxy(target, methodName) {
1334
+ const originalMethod = target[methodName];
1335
+ return async function(...args) {
1336
+ const primaryFn = async () => {
1337
+ const release = await mutex.acquire();
1095
1338
  try {
1096
- response = await handleTimeoutWithRetry(
1097
- fetchError,
1098
- () => fetch(fetchUrl, {
1099
- method,
1100
- headers: fetchHeaders,
1101
- body: fetchBody,
1102
- dispatcher: skalpelDispatcher
1103
- }),
1104
- logger
1105
- );
1106
- fetchError = null;
1107
- } catch (retryErr) {
1108
- fetchError = retryErr;
1339
+ const proxyURL = config.baseURL;
1340
+ if ("baseURL" in c) c.baseURL = proxyURL;
1341
+ if (c._client && "baseURL" in c._client) c._client.baseURL = proxyURL;
1342
+ const requestArgs = args[0];
1343
+ const extraHeaders = skalpelHeaders;
1344
+ let callArgs;
1345
+ if (args.length >= 2 && typeof args[1] === "object" && args[1] !== null) {
1346
+ const opts = args[1];
1347
+ opts.headers = { ...extraHeaders, ...opts.headers };
1348
+ callArgs = [requestArgs, opts, ...args.slice(2)];
1349
+ } else {
1350
+ callArgs = [requestArgs, { headers: extraHeaders }, ...args.slice(2)];
1351
+ }
1352
+ try {
1353
+ const result = await originalMethod.apply(target, callArgs);
1354
+ extractMetadataFromResponse(result, config);
1355
+ return result;
1356
+ } finally {
1357
+ if ("baseURL" in c) c.baseURL = originalBaseURL;
1358
+ if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
1359
+ }
1360
+ } finally {
1361
+ release();
1109
1362
  }
1363
+ };
1364
+ const fallbackFn = async () => {
1365
+ if ("baseURL" in c) c.baseURL = originalBaseURL;
1366
+ if (c._client && "baseURL" in c._client) c._client.baseURL = originalBaseURL;
1367
+ return originalMethod.apply(target, args);
1368
+ };
1369
+ return withFallback(primaryFn, fallbackFn, {
1370
+ retries: config.retries,
1371
+ verbose: config.verbose,
1372
+ onFallback: config.onFallback,
1373
+ provider: "anthropic",
1374
+ fallbackOnError: config.fallbackOnError
1375
+ });
1376
+ };
1377
+ }
1378
+ return new Proxy(client, {
1379
+ get(target, prop, receiver) {
1380
+ const value = Reflect.get(target, prop, receiver);
1381
+ if (prop === "messages" && typeof value === "object" && value !== null) {
1382
+ return new Proxy(value, {
1383
+ get(msgTarget, msgProp, msgReceiver) {
1384
+ const msgValue = Reflect.get(msgTarget, msgProp, msgReceiver);
1385
+ if (typeof msgValue === "function" && (msgProp === "create" || msgProp === "stream")) {
1386
+ return createMethodProxy(msgTarget, msgProp);
1387
+ }
1388
+ return msgValue;
1389
+ }
1390
+ });
1110
1391
  }
1392
+ return value;
1111
1393
  }
1112
- if (!response || fetchError) {
1113
- response = null;
1114
- throw fetchError ?? new Error("no response from upstream");
1115
- }
1116
- if (response.status === 429) {
1117
- response = await handle429WithRetryAfter(
1118
- response,
1119
- () => fetch(fetchUrl, {
1120
- method,
1121
- headers: fetchHeaders,
1122
- body: fetchBody,
1123
- dispatcher: skalpelDispatcher
1124
- }),
1125
- logger
1126
- );
1127
- }
1128
- if (response.status === 401 && (source === "claude-code" || source === "codex")) {
1129
- const fp2 = tokenFingerprint(
1130
- typeof req.headers.authorization === "string" ? req.headers.authorization : void 0
1131
- );
1132
- logger.debug(`handler: upstream 401 origin=provider token=${fp2} passthrough`);
1133
- const body401 = Buffer.from(await response.arrayBuffer());
1134
- const envelope = buildErrorEnvelope(401, body401.toString(), "provider");
1135
- res.writeHead(401, { "Content-Type": "application/json" });
1136
- res.end(JSON.stringify(envelope));
1137
- logger.info(`${method} ${path4} source=${source} status=401 (passthrough) latency=${Date.now() - start}ms`);
1138
- return;
1139
- }
1140
- const responseHeaders = extractResponseHeaders(response);
1141
- const responseBody = Buffer.from(await response.arrayBuffer());
1142
- responseHeaders["content-length"] = String(responseBody.length);
1143
- res.writeHead(response.status, responseHeaders);
1144
- res.end(responseBody);
1145
- logger.info(`${method} ${path4} source=${source} status=${response.status}${usedFallback ? " (fallback)" : ""} latency=${Date.now() - start}ms`);
1146
- } catch (err) {
1147
- logger.error(`${method} ${path4} source=${source} error=${err.message}`);
1148
- if (!res.headersSent) {
1149
- if (response !== null) {
1150
- const upstreamStatus = response.status;
1151
- const envelope = buildErrorEnvelope(
1152
- upstreamStatus,
1153
- "",
1154
- "skalpel-proxy",
1155
- "body read failed after upstream status"
1156
- );
1157
- res.writeHead(upstreamStatus, { "Content-Type": "application/json" });
1158
- res.end(JSON.stringify(envelope));
1159
- } else {
1160
- const envelope = buildErrorEnvelope(
1161
- HTTP_BAD_GATEWAY2,
1162
- err.message,
1163
- "skalpel-proxy"
1164
- );
1165
- res.writeHead(HTTP_BAD_GATEWAY2, { "Content-Type": "application/json" });
1166
- res.end(JSON.stringify(envelope));
1394
+ });
1395
+ }
1396
+ function createSkalpelClient(client, options) {
1397
+ const config = resolveConfig(options);
1398
+ if (isOpenAIClient(client)) {
1399
+ return wrapOpenAI(client, config);
1400
+ }
1401
+ if (isAnthropicClient(client)) {
1402
+ return wrapAnthropic(client, config);
1403
+ }
1404
+ throw new Error(
1405
+ "Unsupported client. createSkalpelClient supports OpenAI and Anthropic SDK clients."
1406
+ );
1407
+ }
1408
+ async function contextRequest(config, path4, body) {
1409
+ const url = `${config.baseURL}/api/workspaces${path4}`;
1410
+ const headers = buildSkalpelHeaders(config);
1411
+ headers["Content-Type"] = "application/json";
1412
+ const controller = new AbortController();
1413
+ const timeoutId = setTimeout(() => controller.abort(), config.timeout);
1414
+ try {
1415
+ const response = await fetch(url, {
1416
+ method: "POST",
1417
+ headers,
1418
+ body: JSON.stringify(body),
1419
+ signal: controller.signal
1420
+ });
1421
+ if (!response.ok) {
1422
+ const text = await response.text().catch(() => "");
1423
+ const message = `Skalpel context API error ${response.status}: ${text}`;
1424
+ if (response.status >= 400 && response.status < 500) {
1425
+ throw new SkalpelClientRequestError(message, response.status);
1167
1426
  }
1427
+ throw new SkalpelUnavailableError(message, response.status);
1168
1428
  }
1429
+ return await response.json();
1430
+ } finally {
1431
+ clearTimeout(timeoutId);
1432
+ }
1433
+ }
1434
+ async function createSnapshot(options, params) {
1435
+ const config = resolveConfig(options);
1436
+ const raw = await contextRequest(config, "/snapshots", {
1437
+ workspace_id: params.workspaceId,
1438
+ snapshot_hash: params.snapshotHash,
1439
+ manifest: params.manifest ?? {},
1440
+ metadata: params.metadata ?? null
1441
+ });
1442
+ return {
1443
+ id: String(raw.id),
1444
+ workspaceId: String(raw.workspace_id),
1445
+ snapshotHash: String(raw.snapshot_hash),
1446
+ manifest: raw.manifest ?? {},
1447
+ metadata: raw.metadata ?? null,
1448
+ createdAt: String(raw.created_at)
1449
+ };
1450
+ }
1451
+ async function uploadChunks(options, params) {
1452
+ const config = resolveConfig(options);
1453
+ const raw = await contextRequest(config, "/chunks", {
1454
+ workspace_id: params.workspaceId,
1455
+ snapshot_id: params.snapshotId,
1456
+ chunks: params.chunks.map((c) => ({
1457
+ path: c.path,
1458
+ chunk_hash: c.chunkHash,
1459
+ chunk_text: c.chunkText,
1460
+ symbol: c.symbol ?? null,
1461
+ language: c.language ?? null,
1462
+ token_count: c.tokenCount ?? 0,
1463
+ metadata: c.metadata ?? null
1464
+ }))
1465
+ });
1466
+ return (raw.items ?? []).map((item) => ({
1467
+ id: String(item.id),
1468
+ workspaceId: String(item.workspace_id),
1469
+ snapshotId: String(item.snapshot_id),
1470
+ path: String(item.path),
1471
+ chunkHash: String(item.chunk_hash),
1472
+ symbol: item.symbol != null ? String(item.symbol) : null,
1473
+ language: item.language != null ? String(item.language) : null,
1474
+ tokenCount: Number(item.token_count ?? 0),
1475
+ metadata: item.metadata ?? null,
1476
+ createdAt: String(item.created_at)
1477
+ }));
1478
+ }
1479
+ async function resolveContext(options, params) {
1480
+ const config = resolveConfig(options);
1481
+ const raw = await contextRequest(config, "/context/resolve", {
1482
+ workspace_id: params.workspaceId,
1483
+ snapshot_id: params.snapshotId ?? null,
1484
+ query: params.query,
1485
+ changed_paths: params.changedPaths ?? [],
1486
+ limit: params.limit ?? 8
1487
+ });
1488
+ const items = raw.items ?? [];
1489
+ return {
1490
+ workspaceId: String(raw.workspace_id),
1491
+ snapshotId: raw.snapshot_id != null ? String(raw.snapshot_id) : null,
1492
+ items: items.map((item) => ({
1493
+ path: String(item.path),
1494
+ symbol: item.symbol != null ? String(item.symbol) : null,
1495
+ language: item.language != null ? String(item.language) : null,
1496
+ tokenCount: Number(item.token_count ?? 0),
1497
+ score: Number(item.score ?? 0),
1498
+ reason: String(item.reason ?? "semantic"),
1499
+ metadata: item.metadata ?? null,
1500
+ preview: String(item.preview ?? "")
1501
+ }))
1502
+ };
1503
+ }
1504
+
1505
+ // src/url-swap.ts
1506
+ var SKALPEL_MANAGED_SENTINEL = "skalpel-managed";
1507
+ function buildHeaders(options) {
1508
+ const headers = {
1509
+ "X-Skalpel-SDK-Version": VERSION,
1510
+ ...options.headers
1511
+ };
1512
+ if (options.workspace) {
1513
+ headers["X-Skalpel-Workspace"] = options.workspace;
1169
1514
  }
1515
+ return headers;
1516
+ }
1517
+ async function createSkalpelOpenAI(options) {
1518
+ const { default: OpenAI } = await import("openai");
1519
+ const baseURL = `${options.baseURL ?? "https://api.skalpel.ai"}/v1`;
1520
+ const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
1521
+ return new OpenAI({
1522
+ baseURL,
1523
+ apiKey: sdkApiKey,
1524
+ defaultHeaders: {
1525
+ ...buildHeaders(options),
1526
+ "Authorization": `Bearer ${options.apiKey}`
1527
+ },
1528
+ timeout: options.timeout ?? 3e4,
1529
+ maxRetries: options.retries ?? 2
1530
+ });
1531
+ }
1532
+ async function createSkalpelAnthropic(options) {
1533
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
1534
+ const baseURL = options.baseURL ?? "https://api.skalpel.ai";
1535
+ const sdkApiKey = options.providerApiKey ? options.providerApiKey : SKALPEL_MANAGED_SENTINEL;
1536
+ return new Anthropic({
1537
+ baseURL,
1538
+ apiKey: sdkApiKey,
1539
+ defaultHeaders: {
1540
+ ...buildHeaders(options),
1541
+ "Authorization": `Bearer ${options.apiKey}`
1542
+ },
1543
+ timeout: options.timeout ?? 3e4,
1544
+ maxRetries: options.retries ?? 2
1545
+ });
1170
1546
  }
1171
1547
 
1548
+ // src/proxy/server.ts
1549
+ var import_node_http = __toESM(require("http"), 1);
1550
+ init_handler();
1551
+
1172
1552
  // src/proxy/health.ts
1173
- function handleHealthRequest(res, config, startTime) {
1553
+ function handleHealthRequest(res, config, startTime, logger) {
1554
+ logger?.debug("health check served");
1174
1555
  const body = JSON.stringify({
1175
1556
  status: "ok",
1176
1557
  uptime: Date.now() - startTime,
@@ -1264,6 +1645,20 @@ function removePid(pidFile) {
1264
1645
  }
1265
1646
  }
1266
1647
 
1648
+ // src/proxy/health-check.ts
1649
+ async function isProxyAlive(port, timeoutMs = 2e3) {
1650
+ const controller = new AbortController();
1651
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1652
+ try {
1653
+ const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
1654
+ return res.ok;
1655
+ } catch {
1656
+ return false;
1657
+ } finally {
1658
+ clearTimeout(timer);
1659
+ }
1660
+ }
1661
+
1267
1662
  // src/proxy/logger.ts
1268
1663
  var import_node_fs2 = __toESM(require("fs"), 1);
1269
1664
  var import_node_path2 = __toESM(require("path"), 1);
@@ -1330,6 +1725,67 @@ var Logger = class _Logger {
1330
1725
  }
1331
1726
  };
1332
1727
 
1728
+ // src/proxy/ws-server.ts
1729
+ var import_ws2 = require("ws");
1730
+ var WS_SUBPROTOCOL2 = "skalpel-codex-v1";
1731
+ var wss = new import_ws2.WebSocketServer({ noServer: true });
1732
+ function reject426(socket, payload) {
1733
+ const body = JSON.stringify(payload);
1734
+ socket.write(
1735
+ `HTTP/1.1 426 Upgrade Required\r
1736
+ Content-Type: application/json\r
1737
+ Content-Length: ${Buffer.byteLength(body)}\r
1738
+ Connection: close\r
1739
+ \r
1740
+ ` + body
1741
+ );
1742
+ socket.destroy();
1743
+ }
1744
+ function handleCodexUpgrade(req, socket, head, config, logger) {
1745
+ const wsFlag = process.env.SKALPEL_CODEX_WS ?? "1";
1746
+ if (wsFlag === "0") {
1747
+ logger.warn("ws-upgrade rejected: feature flag SKALPEL_CODEX_WS=0");
1748
+ reject426(socket, { error: "ws_disabled" });
1749
+ return;
1750
+ }
1751
+ const offered = req.headers["sec-websocket-protocol"] ?? "";
1752
+ const tokens = offered.split(",").map((t) => t.trim()).filter(Boolean);
1753
+ if (!tokens.includes(WS_SUBPROTOCOL2)) {
1754
+ logger.warn(`ws-upgrade rejected: unsupported subprotocol offered="${offered}"`);
1755
+ reject426(socket, { error: "unsupported_subprotocol" });
1756
+ return;
1757
+ }
1758
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
1759
+ logger.info(`ws-upgrade accepted path=${req.url ?? ""} subproto=${WS_SUBPROTOCOL2}`);
1760
+ Promise.resolve().then(() => (init_handler(), handler_exports)).then((mod) => {
1761
+ const bridge = mod.handleWebSocketBridge;
1762
+ if (typeof bridge !== "function") {
1763
+ clientWs.send(
1764
+ JSON.stringify({
1765
+ type: "error",
1766
+ payload: { code: "not_implemented" }
1767
+ })
1768
+ );
1769
+ clientWs.close(4003, "bridge pending");
1770
+ return;
1771
+ }
1772
+ void bridge(clientWs, req, config, "codex", logger);
1773
+ }).catch((err) => {
1774
+ logger.error(`ws bridge import failed: ${err?.message ?? String(err)}`);
1775
+ try {
1776
+ clientWs.send(
1777
+ JSON.stringify({
1778
+ type: "error",
1779
+ payload: { code: "bridge_import_failed" }
1780
+ })
1781
+ );
1782
+ } catch {
1783
+ }
1784
+ clientWs.close(4003, "bridge import failed");
1785
+ });
1786
+ });
1787
+ }
1788
+
1333
1789
  // src/proxy/server.ts
1334
1790
  var proxyStartTime = 0;
1335
1791
  var connCounter = 0;
@@ -1346,7 +1802,7 @@ function startProxy(config) {
1346
1802
  proxyStartTime = Date.now();
1347
1803
  const anthropicServer = import_node_http.default.createServer((req, res) => {
1348
1804
  if (req.url === "/health" && req.method === "GET") {
1349
- handleHealthRequest(res, config, startTime);
1805
+ handleHealthRequest(res, config, startTime, logger.child("health"));
1350
1806
  return;
1351
1807
  }
1352
1808
  const connId = computeConnId(req);
@@ -1354,7 +1810,7 @@ function startProxy(config) {
1354
1810
  });
1355
1811
  const openaiServer = import_node_http.default.createServer((req, res) => {
1356
1812
  if (req.url === "/health" && req.method === "GET") {
1357
- handleHealthRequest(res, config, startTime);
1813
+ handleHealthRequest(res, config, startTime, logger.child("health"));
1358
1814
  return;
1359
1815
  }
1360
1816
  const connId = computeConnId(req);
@@ -1362,12 +1818,71 @@ function startProxy(config) {
1362
1818
  });
1363
1819
  const cursorServer = import_node_http.default.createServer((req, res) => {
1364
1820
  if (req.url === "/health" && req.method === "GET") {
1365
- handleHealthRequest(res, config, startTime);
1821
+ handleHealthRequest(res, config, startTime, logger.child("health"));
1366
1822
  return;
1367
1823
  }
1368
1824
  const connId = computeConnId(req);
1369
1825
  handleRequest(req, res, config, "cursor", logger.child(connId));
1370
1826
  });
1827
+ anthropicServer.on("upgrade", (req, socket, _head) => {
1828
+ const ua = req.headers["user-agent"] ?? "";
1829
+ logger.warn(`upgrade-attempt port=${config.anthropicPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1830
+ const body = JSON.stringify({
1831
+ error: "upgrade_required",
1832
+ message: "Skalpel proxy is HTTP-only",
1833
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1834
+ });
1835
+ socket.write(
1836
+ `HTTP/1.1 426 Upgrade Required\r
1837
+ Content-Type: application/json\r
1838
+ Content-Length: ${Buffer.byteLength(body)}\r
1839
+ Connection: close\r
1840
+ \r
1841
+ ` + body
1842
+ );
1843
+ socket.destroy();
1844
+ });
1845
+ openaiServer.on("upgrade", (req, socket, head) => {
1846
+ const ua = req.headers["user-agent"] ?? "";
1847
+ logger.warn(`upgrade-attempt port=${config.openaiPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1848
+ const pathname = (req.url ?? "").split("?")[0];
1849
+ if (pathname === "/v1/responses") {
1850
+ handleCodexUpgrade(req, socket, head, config, logger);
1851
+ return;
1852
+ }
1853
+ const body = JSON.stringify({
1854
+ error: "upgrade_required",
1855
+ message: "Skalpel proxy is HTTP-only",
1856
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1857
+ });
1858
+ socket.write(
1859
+ `HTTP/1.1 426 Upgrade Required\r
1860
+ Content-Type: application/json\r
1861
+ Content-Length: ${Buffer.byteLength(body)}\r
1862
+ Connection: close\r
1863
+ \r
1864
+ ` + body
1865
+ );
1866
+ socket.destroy();
1867
+ });
1868
+ cursorServer.on("upgrade", (req, socket, _head) => {
1869
+ const ua = req.headers["user-agent"] ?? "";
1870
+ logger.warn(`upgrade-attempt port=${config.cursorPort} method=${req.method} url=${req.url} ua=${ua} origin=${req.headers.origin ?? ""}`);
1871
+ const body = JSON.stringify({
1872
+ error: "upgrade_required",
1873
+ message: "Skalpel proxy is HTTP-only",
1874
+ hint: 'Use wire_api="responses" over HTTP in your Codex config. See docs/codex-integration-fix.md.'
1875
+ });
1876
+ socket.write(
1877
+ `HTTP/1.1 426 Upgrade Required\r
1878
+ Content-Type: application/json\r
1879
+ Content-Length: ${Buffer.byteLength(body)}\r
1880
+ Connection: close\r
1881
+ \r
1882
+ ` + body
1883
+ );
1884
+ socket.destroy();
1885
+ });
1371
1886
  anthropicServer.on("error", (err) => {
1372
1887
  if (err.code === "EADDRINUSE") {
1373
1888
  logger.error(`Port ${config.anthropicPort} is already in use. Another Skalpel proxy or process may be running.`);
@@ -1395,17 +1910,26 @@ function startProxy(config) {
1395
1910
  removePid(config.pidFile);
1396
1911
  process.exit(1);
1397
1912
  });
1913
+ let bound = 0;
1914
+ const onBound = () => {
1915
+ bound++;
1916
+ if (bound === 3) {
1917
+ writePid(config.pidFile);
1918
+ logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
1919
+ }
1920
+ };
1398
1921
  anthropicServer.listen(config.anthropicPort, () => {
1399
1922
  logger.info(`Anthropic proxy listening on port ${config.anthropicPort}`);
1923
+ onBound();
1400
1924
  });
1401
1925
  openaiServer.listen(config.openaiPort, () => {
1402
1926
  logger.info(`OpenAI proxy listening on port ${config.openaiPort}`);
1927
+ onBound();
1403
1928
  });
1404
1929
  cursorServer.listen(config.cursorPort, () => {
1405
1930
  logger.info(`Cursor proxy listening on port ${config.cursorPort}`);
1931
+ onBound();
1406
1932
  });
1407
- writePid(config.pidFile);
1408
- logger.info(`Proxy started (pid=${process.pid}) ports=${config.anthropicPort},${config.openaiPort},${config.cursorPort}`);
1409
1933
  const cleanup = () => {
1410
1934
  logger.info("Shutting down proxy...");
1411
1935
  anthropicServer.close();
@@ -1438,12 +1962,23 @@ function stopProxy(config) {
1438
1962
  removePid(config.pidFile);
1439
1963
  return true;
1440
1964
  }
1441
- function getProxyStatus(config) {
1965
+ async function getProxyStatus(config) {
1442
1966
  const pid = readPid(config.pidFile);
1967
+ if (pid !== null) {
1968
+ return {
1969
+ running: true,
1970
+ pid,
1971
+ uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
1972
+ anthropicPort: config.anthropicPort,
1973
+ openaiPort: config.openaiPort,
1974
+ cursorPort: config.cursorPort
1975
+ };
1976
+ }
1977
+ const alive = await isProxyAlive(config.anthropicPort);
1443
1978
  return {
1444
- running: pid !== null,
1445
- pid,
1446
- uptime: proxyStartTime > 0 ? Date.now() - proxyStartTime : 0,
1979
+ running: alive,
1980
+ pid: null,
1981
+ uptime: 0,
1447
1982
  anthropicPort: config.anthropicPort,
1448
1983
  openaiPort: config.openaiPort,
1449
1984
  cursorPort: config.cursorPort