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