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