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