claude-scionos 4.3.4 → 4.4.0
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/README.fr.md +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/proxy.js +562 -281
- package/src/routerlab.js +3 -3
package/README.fr.md
CHANGED
|
@@ -78,7 +78,7 @@ npx claude-scionos --strategy aws --no-prompt -p "Résume ce dépôt"
|
|
|
78
78
|
- `claude-gpt` : mappe les requêtes Claude vers la famille `claude-gpt`
|
|
79
79
|
`claude-gpt-5.5 ==> claude-opus-4.7`, `claude-gpt-5.4 ==> claude-sonnet-4.6`, `claude-gpt-5.4-mini ==> claude-gpt-5.4-mini`
|
|
80
80
|
- `claude-gpt-special` : sur `--service llm`, force toutes les requêtes vers `claude-gpt-5.4-sp`
|
|
81
|
-
- `deepseek-v4-beta` : sur `--service llm`, mappe les requêtes Claude vers `deepseek-v4-pro` pour opus et `deepseek-v4-flash` pour
|
|
81
|
+
- `deepseek-v4-beta` : sur `--service llm`, mappe les requêtes Claude vers `claude-deepseek-v4-pro` pour opus ou sonnet et `claude-deepseek-v4-flash` pour haiku
|
|
82
82
|
- `claude-qwen3.6-plus` : force toutes les requêtes vers `claude-qwen3.6-plus`
|
|
83
83
|
- `claude-minimax-m2.7` : force toutes les requêtes vers `claude-minimax-m2.7`
|
|
84
84
|
- `claude-glm-5.1` : force toutes les requêtes vers `claude-glm-5.1`
|
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ npx claude-scionos --strategy aws --no-prompt -p "Summarize this repo"
|
|
|
78
78
|
- `claude-gpt`: map Claude requests to the `claude-gpt` family
|
|
79
79
|
`claude-gpt-5.5 ==> claude-opus-4.7`, `claude-gpt-5.4 ==> claude-sonnet-4.6`, `claude-gpt-5.4-mini ==> claude-gpt-5.4-mini`
|
|
80
80
|
- `claude-gpt-special`: on `--service llm`, force all requests to `claude-gpt-5.4-sp`
|
|
81
|
-
- `deepseek-v4-beta`: on `--service llm`, map Claude requests to `deepseek-v4-pro` for opus and `deepseek-v4-flash` for
|
|
81
|
+
- `deepseek-v4-beta`: on `--service llm`, map Claude requests to `claude-deepseek-v4-pro` for opus or sonnet and `claude-deepseek-v4-flash` for haiku
|
|
82
82
|
- `claude-qwen3.6-plus`: force all requests to `claude-qwen3.6-plus`
|
|
83
83
|
- `claude-minimax-m2.7`: force all requests to `claude-minimax-m2.7`
|
|
84
84
|
- `claude-glm-5.1`: force all requests to `claude-glm-5.1`
|
package/package.json
CHANGED
package/src/proxy.js
CHANGED
|
@@ -1,368 +1,649 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import {Transform} from 'node:stream';
|
|
2
3
|
import {BASE_URL, DEFAULT_ANTHROPIC_VERSION} from './routerlab.js';
|
|
3
|
-
|
|
4
|
-
const HOP_BY_HOP_HEADERS = new Set([
|
|
5
|
-
'connection',
|
|
6
|
-
'content-length',
|
|
7
|
-
'content-encoding',
|
|
8
|
-
'host',
|
|
9
|
-
'keep-alive',
|
|
10
|
-
'proxy-authenticate',
|
|
11
|
-
'proxy-authorization',
|
|
12
|
-
'te',
|
|
13
|
-
'trailer',
|
|
14
|
-
'transfer-encoding',
|
|
15
|
-
'upgrade',
|
|
16
|
-
]);
|
|
4
|
+
|
|
5
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
6
|
+
'connection',
|
|
7
|
+
'content-length',
|
|
8
|
+
'content-encoding',
|
|
9
|
+
'host',
|
|
10
|
+
'keep-alive',
|
|
11
|
+
'proxy-authenticate',
|
|
12
|
+
'proxy-authorization',
|
|
13
|
+
'te',
|
|
14
|
+
'trailer',
|
|
15
|
+
'transfer-encoding',
|
|
16
|
+
'upgrade',
|
|
17
|
+
]);
|
|
17
18
|
const PROXY_AUTH_HEADER = 'x-scionos-proxy-secret';
|
|
18
19
|
const MESSAGES_PATH = '/v1/messages';
|
|
20
|
+
const REASONING_CONTENT_BLOCK_TYPES = new Set(['thinking', 'redacted_thinking']);
|
|
21
|
+
const REASONING_DELTA_TYPES = new Set(['thinking_delta', 'signature_delta']);
|
|
22
|
+
|
|
23
|
+
function normalizeProxyHeaders(headers) {
|
|
24
|
+
const normalizedHeaders = {};
|
|
25
|
+
|
|
26
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
27
|
+
if (value === undefined || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
normalizedHeaders[key] = value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return normalizedHeaders;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildProxyRequestOptions(url, method, upstreamHeaders, validToken, bodyLength, timeout) {
|
|
38
|
+
const headers = normalizeProxyHeaders(upstreamHeaders);
|
|
39
|
+
deleteHeader(headers, 'authorization');
|
|
40
|
+
deleteHeader(headers, PROXY_AUTH_HEADER);
|
|
41
|
+
deleteHeader(headers, 'accept-encoding');
|
|
42
|
+
headers['x-api-key'] = validToken;
|
|
43
|
+
headers['anthropic-version'] ??= DEFAULT_ANTHROPIC_VERSION;
|
|
44
|
+
|
|
45
|
+
if (bodyLength !== undefined) {
|
|
46
|
+
headers['Content-Length'] = String(bodyLength);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
hostname: url.hostname,
|
|
51
|
+
port: url.port || 443,
|
|
52
|
+
path: url.pathname + url.search,
|
|
53
|
+
method,
|
|
54
|
+
headers,
|
|
55
|
+
timeout,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
19
58
|
|
|
20
|
-
function
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (value === undefined || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
|
|
25
|
-
continue;
|
|
59
|
+
function deleteHeader(headers, headerName) {
|
|
60
|
+
for (const key of Object.keys(headers)) {
|
|
61
|
+
if (key.toLowerCase() === headerName.toLowerCase()) {
|
|
62
|
+
delete headers[key];
|
|
26
63
|
}
|
|
27
|
-
|
|
28
|
-
normalizedHeaders[key] = value;
|
|
29
64
|
}
|
|
30
|
-
|
|
31
|
-
return normalizedHeaders;
|
|
32
65
|
}
|
|
33
66
|
|
|
34
|
-
function
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
delete headers[PROXY_AUTH_HEADER];
|
|
38
|
-
headers['x-api-key'] = validToken;
|
|
39
|
-
headers['anthropic-version'] ??= DEFAULT_ANTHROPIC_VERSION;
|
|
67
|
+
function isReasoningContentBlock(block) {
|
|
68
|
+
return REASONING_CONTENT_BLOCK_TYPES.has(block?.type);
|
|
69
|
+
}
|
|
40
70
|
|
|
41
|
-
|
|
42
|
-
|
|
71
|
+
function sanitizeContentBlocks(content) {
|
|
72
|
+
if (!Array.isArray(content)) {
|
|
73
|
+
return {content, changed: false};
|
|
43
74
|
}
|
|
44
75
|
|
|
76
|
+
const sanitized = content.filter((block) => !isReasoningContentBlock(block));
|
|
45
77
|
return {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
path: url.pathname + url.search,
|
|
49
|
-
method,
|
|
50
|
-
headers,
|
|
51
|
-
timeout,
|
|
78
|
+
content: sanitized,
|
|
79
|
+
changed: sanitized.length !== content.length,
|
|
52
80
|
};
|
|
53
81
|
}
|
|
54
82
|
|
|
55
|
-
function
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
83
|
+
function sanitizeAnthropicCompatiblePayload(payload) {
|
|
84
|
+
if (!payload || typeof payload !== 'object') {
|
|
85
|
+
return {payload, changed: false};
|
|
58
86
|
}
|
|
59
87
|
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
let changed = false;
|
|
89
|
+
const sanitizedPayload = {...payload};
|
|
90
|
+
|
|
91
|
+
if (Array.isArray(payload.content)) {
|
|
92
|
+
const sanitizedContent = sanitizeContentBlocks(payload.content);
|
|
93
|
+
sanitizedPayload.content = sanitizedContent.content;
|
|
94
|
+
changed ||= sanitizedContent.changed;
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
|
|
65
|
-
|
|
97
|
+
if (Array.isArray(payload.messages)) {
|
|
98
|
+
sanitizedPayload.messages = payload.messages.map((message) => {
|
|
99
|
+
if (!message || typeof message !== 'object') {
|
|
100
|
+
return message;
|
|
101
|
+
}
|
|
66
102
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
const sanitizedContent = sanitizeContentBlocks(message.content);
|
|
104
|
+
if (!sanitizedContent.changed) {
|
|
105
|
+
return message;
|
|
106
|
+
}
|
|
71
107
|
|
|
72
|
-
|
|
73
|
-
|
|
108
|
+
changed = true;
|
|
109
|
+
return {
|
|
110
|
+
...message,
|
|
111
|
+
content: sanitizedContent.content,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
return
|
|
116
|
+
return {payload: changed ? sanitizedPayload : payload, changed};
|
|
77
117
|
}
|
|
78
|
-
|
|
118
|
+
|
|
119
|
+
function getPreferredClaudeGptModel(requestedModel = '') {
|
|
120
|
+
if (requestedModel.includes('haiku') || requestedModel.includes('mini')) {
|
|
121
|
+
return 'claude-gpt-5.4-mini';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (requestedModel.includes('opus')) {
|
|
125
|
+
return 'claude-gpt-5.5';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return 'claude-gpt-5.4';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getPreferredClaudeModel(requestedModel = '') {
|
|
132
|
+
if (requestedModel.includes('haiku') || requestedModel.includes('mini')) {
|
|
133
|
+
return 'claude-haiku-4-5-20251001';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (requestedModel.includes('opus')) {
|
|
137
|
+
return 'claude-opus-4-6';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return 'claude-sonnet-4-6';
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
function getPreferredDeepseekV4Model(requestedModel = '') {
|
|
80
|
-
if (requestedModel.includes('opus')) {
|
|
81
|
-
return 'deepseek-v4-pro';
|
|
144
|
+
if (requestedModel.includes('opus') || requestedModel.includes('sonnet')) {
|
|
145
|
+
return 'claude-deepseek-v4-pro';
|
|
82
146
|
}
|
|
83
147
|
|
|
84
|
-
return 'deepseek-v4-flash';
|
|
85
|
-
}
|
|
148
|
+
return 'claude-deepseek-v4-flash';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveMappedModel(targetModel, requestedModel = '', availableModels = []) {
|
|
152
|
+
if (targetModel === 'claude') {
|
|
153
|
+
const preferredModel = getPreferredClaudeModel(requestedModel);
|
|
154
|
+
const availableClaudeModels = Array.isArray(availableModels)
|
|
155
|
+
? availableModels.filter((model) => model.startsWith('claude-') && !model.startsWith('claude-gpt-'))
|
|
156
|
+
: [];
|
|
157
|
+
|
|
158
|
+
if (availableClaudeModels.length === 0) {
|
|
159
|
+
return preferredModel;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (availableClaudeModels.includes(preferredModel)) {
|
|
163
|
+
return preferredModel;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
availableClaudeModels.find((model) => model === 'claude-sonnet-4-6')
|
|
168
|
+
?? availableClaudeModels[0]
|
|
169
|
+
?? preferredModel
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (targetModel === 'claude-gpt-special') {
|
|
174
|
+
return 'claude-gpt-5.4-sp';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (targetModel === 'deepseek-v4-beta') {
|
|
178
|
+
const preferredModel = getPreferredDeepseekV4Model(requestedModel);
|
|
179
|
+
const availableDeepseekModels = Array.isArray(availableModels)
|
|
180
|
+
? availableModels.filter((model) => model === 'claude-deepseek-v4-pro' || model === 'claude-deepseek-v4-flash')
|
|
181
|
+
: [];
|
|
182
|
+
|
|
183
|
+
if (availableDeepseekModels.length === 0) {
|
|
184
|
+
return preferredModel;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (availableDeepseekModels.includes(preferredModel)) {
|
|
188
|
+
return preferredModel;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
availableDeepseekModels.find((model) => model === 'claude-deepseek-v4-flash')
|
|
193
|
+
?? availableDeepseekModels[0]
|
|
194
|
+
?? preferredModel
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (targetModel !== 'aws') {
|
|
199
|
+
if (targetModel !== 'claude-gpt') {
|
|
200
|
+
return targetModel;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const preferredModel = getPreferredClaudeGptModel(requestedModel);
|
|
204
|
+
const availableClaudeGptModels = Array.isArray(availableModels)
|
|
205
|
+
? availableModels.filter((model) => model.startsWith('claude-gpt-'))
|
|
206
|
+
: [];
|
|
207
|
+
|
|
208
|
+
if (availableClaudeGptModels.length === 0) {
|
|
209
|
+
return preferredModel;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (availableClaudeGptModels.includes(preferredModel)) {
|
|
213
|
+
return preferredModel;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
availableClaudeGptModels.find((model) => model === 'claude-gpt-5.4')
|
|
218
|
+
?? availableClaudeGptModels[0]
|
|
219
|
+
?? preferredModel
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (requestedModel.includes('haiku')) {
|
|
224
|
+
return 'aws-claude-haiku-4-5-20251001';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (requestedModel.includes('opus')) {
|
|
228
|
+
return 'aws-claude-opus-4-6';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return 'aws-claude-sonnet-4-6';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function writeJsonError(res, statusCode, payload) {
|
|
235
|
+
if (res.headersSent) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
res.writeHead(statusCode);
|
|
240
|
+
res.end(JSON.stringify(payload));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function getRequestPath(req) {
|
|
244
|
+
return new URL(req.url, 'http://127.0.0.1').pathname;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isAuthorizedProxyRequest(req, proxySecret) {
|
|
248
|
+
if (!proxySecret) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return req.headers[PROXY_AUTH_HEADER] === proxySecret;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isAllowedProxyRoute(req) {
|
|
256
|
+
return req.method === 'POST' && getRequestPath(req) === MESSAGES_PATH;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function handleMessageRequest(req, res, options) {
|
|
260
|
+
const {availableModels = [], baseUrl, debug, onDebug, onError, targetModel, validToken} = options;
|
|
261
|
+
const chunks = [];
|
|
262
|
+
const maxSize = 100 * 1024 * 1024;
|
|
263
|
+
let totalSize = 0;
|
|
264
|
+
|
|
265
|
+
req.on('data', (chunk) => {
|
|
266
|
+
totalSize += chunk.length;
|
|
267
|
+
if (totalSize > maxSize) {
|
|
268
|
+
writeJsonError(res, 413, {error: {message: 'Request too large'}});
|
|
269
|
+
req.destroy();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
chunks.push(chunk);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
req.on('end', async () => {
|
|
277
|
+
try {
|
|
278
|
+
const bodyBuffer = Buffer.concat(chunks);
|
|
279
|
+
let bodyJson;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
bodyJson = JSON.parse(bodyBuffer.toString());
|
|
283
|
+
} catch {
|
|
284
|
+
bodyJson = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (bodyJson?.model) {
|
|
288
|
+
const preferredModel = resolveMappedModel(targetModel, bodyJson.model);
|
|
289
|
+
const newModel = resolveMappedModel(targetModel, bodyJson.model, availableModels);
|
|
290
|
+
if (debug) {
|
|
291
|
+
onDebug(`[Proxy] Swapping model ${bodyJson.model} -> ${newModel}`);
|
|
292
|
+
if (preferredModel !== newModel) {
|
|
293
|
+
onDebug(`[Proxy] Fallback applied because ${preferredModel} is not available for this token`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
86
296
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const preferredModel = getPreferredClaudeModel(requestedModel);
|
|
90
|
-
const availableClaudeModels = Array.isArray(availableModels)
|
|
91
|
-
? availableModels.filter((model) => model.startsWith('claude-') && !model.startsWith('claude-gpt-'))
|
|
92
|
-
: [];
|
|
297
|
+
bodyJson.model = newModel;
|
|
298
|
+
}
|
|
93
299
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
300
|
+
if (bodyJson) {
|
|
301
|
+
bodyJson = sanitizeAnthropicCompatiblePayload(bodyJson).payload;
|
|
302
|
+
}
|
|
97
303
|
|
|
98
|
-
|
|
99
|
-
|
|
304
|
+
const payload = bodyJson ? JSON.stringify(bodyJson) : bodyBuffer;
|
|
305
|
+
await forwardRequest(req, res, {
|
|
306
|
+
baseUrl,
|
|
307
|
+
bodyLength: typeof payload === 'string' ? Buffer.byteLength(payload) : payload.length,
|
|
308
|
+
debug,
|
|
309
|
+
onDebug,
|
|
310
|
+
onError,
|
|
311
|
+
payload,
|
|
312
|
+
timeout: 120000,
|
|
313
|
+
validToken,
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
onError(`[Proxy Error] POST ${MESSAGES_PATH}: ${error.message}`);
|
|
317
|
+
writeJsonError(res, 500, {
|
|
318
|
+
error: {
|
|
319
|
+
message: 'Scionos Proxy Error',
|
|
320
|
+
details: error.message,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function forwardRequest(req, res, options) {
|
|
328
|
+
const {baseUrl, bodyLength, debug, onDebug, onError, payload, timeout, validToken} = options;
|
|
329
|
+
const https = await import('node:https');
|
|
330
|
+
const url = new URL(`${baseUrl}${req.url}`);
|
|
331
|
+
const requestOptions = buildProxyRequestOptions(
|
|
332
|
+
url,
|
|
333
|
+
req.method,
|
|
334
|
+
req.headers,
|
|
335
|
+
validToken,
|
|
336
|
+
bodyLength,
|
|
337
|
+
timeout,
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const proxyReq = https.request(requestOptions, (proxyRes) => {
|
|
341
|
+
if (debug) {
|
|
342
|
+
onDebug(`[Proxy] Upstream response status: ${proxyRes.statusCode}`);
|
|
100
343
|
}
|
|
101
344
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
345
|
+
handleProxyResponse(proxyRes, res, {debug, onDebug, onError});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
proxyReq.on('error', (error) => {
|
|
349
|
+
onError(`[Proxy Error] ${req.method} ${req.url}: ${error.message}`);
|
|
350
|
+
if (debug && error.code) {
|
|
351
|
+
onError(`[Proxy Error] Code: ${error.code}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
writeJsonError(res, req.method === 'POST' && getRequestPath(req) === MESSAGES_PATH ? 500 : 502, {
|
|
355
|
+
error: {
|
|
356
|
+
message: req.method === 'POST' && getRequestPath(req) === MESSAGES_PATH
|
|
357
|
+
? 'Proxy Error'
|
|
358
|
+
: 'Scionos Proxy Error: Failed to connect to upstream',
|
|
359
|
+
details: error.message,
|
|
360
|
+
...(error.code ? {code: error.code} : {}),
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
proxyReq.on('timeout', () => {
|
|
366
|
+
onError('[Proxy] Request timeout');
|
|
367
|
+
proxyReq.destroy();
|
|
368
|
+
writeJsonError(res, 504, {error: {message: 'Gateway Timeout'}});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
if (payload !== undefined) {
|
|
372
|
+
proxyReq.write(payload);
|
|
373
|
+
proxyReq.end();
|
|
374
|
+
if (debug) {
|
|
375
|
+
onDebug('[Proxy] Request sent to upstream');
|
|
376
|
+
}
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (req.method === 'POST' || req.method === 'PUT') {
|
|
381
|
+
req.pipe(proxyReq);
|
|
382
|
+
} else {
|
|
383
|
+
proxyReq.end();
|
|
111
384
|
}
|
|
385
|
+
}
|
|
112
386
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
? availableModels.filter((model) => model === 'deepseek-v4-pro' || model === 'deepseek-v4-flash')
|
|
117
|
-
: [];
|
|
387
|
+
function handleProxyResponse(proxyRes, res, options = {}) {
|
|
388
|
+
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
|
389
|
+
const contentEncoding = String(proxyRes.headers['content-encoding'] ?? '');
|
|
118
390
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return preferredModel;
|
|
125
|
-
}
|
|
391
|
+
if (contentEncoding && contentEncoding !== 'identity') {
|
|
392
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
393
|
+
proxyRes.pipe(res);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
126
396
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
);
|
|
397
|
+
if (contentType.includes('text/event-stream')) {
|
|
398
|
+
const headers = normalizeProxyHeaders(proxyRes.headers);
|
|
399
|
+
deleteHeader(headers, 'content-length');
|
|
400
|
+
deleteHeader(headers, 'content-encoding');
|
|
401
|
+
res.writeHead(proxyRes.statusCode, headers);
|
|
402
|
+
proxyRes.pipe(createAnthropicSseSanitizer(options)).pipe(res);
|
|
403
|
+
return;
|
|
132
404
|
}
|
|
133
405
|
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
406
|
+
if (!contentType.includes('application/json')) {
|
|
407
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
408
|
+
proxyRes.pipe(res);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
138
411
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
412
|
+
const chunks = [];
|
|
413
|
+
proxyRes.on('data', (chunk) => chunks.push(chunk));
|
|
414
|
+
proxyRes.on('end', () => {
|
|
415
|
+
const bodyBuffer = Buffer.concat(chunks);
|
|
416
|
+
let bodyText = bodyBuffer.toString('utf8');
|
|
143
417
|
|
|
144
|
-
|
|
145
|
-
|
|
418
|
+
try {
|
|
419
|
+
const parsed = JSON.parse(bodyText);
|
|
420
|
+
const sanitized = sanitizeAnthropicCompatiblePayload(parsed);
|
|
421
|
+
if (sanitized.changed) {
|
|
422
|
+
bodyText = JSON.stringify(sanitized.payload);
|
|
423
|
+
if (options.debug) {
|
|
424
|
+
options.onDebug('[Proxy] Removed upstream reasoning content blocks from JSON response');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
bodyText = bodyBuffer.toString('utf8');
|
|
146
429
|
}
|
|
147
430
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
431
|
+
const headers = normalizeProxyHeaders(proxyRes.headers);
|
|
432
|
+
deleteHeader(headers, 'content-length');
|
|
433
|
+
deleteHeader(headers, 'content-encoding');
|
|
434
|
+
headers['content-length'] = String(Buffer.byteLength(bodyText));
|
|
435
|
+
res.writeHead(proxyRes.statusCode, headers);
|
|
436
|
+
res.end(bodyText);
|
|
437
|
+
});
|
|
151
438
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}
|
|
439
|
+
proxyRes.on('error', (error) => {
|
|
440
|
+
options.onError?.(`[Proxy Error] Upstream response read failed: ${error.message}`);
|
|
441
|
+
res.destroy(error);
|
|
442
|
+
});
|
|
443
|
+
}
|
|
158
444
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
445
|
+
function createAnthropicSseSanitizer(options = {}) {
|
|
446
|
+
const state = {
|
|
447
|
+
droppedIndexes: new Set(),
|
|
448
|
+
indexMap: new Map(),
|
|
449
|
+
loggedReasoningRemoval: false,
|
|
450
|
+
nextIndex: 0,
|
|
451
|
+
};
|
|
452
|
+
let pending = '';
|
|
453
|
+
|
|
454
|
+
return new Transform({
|
|
455
|
+
transform(chunk, _encoding, callback) {
|
|
456
|
+
pending += chunk.toString('utf8');
|
|
457
|
+
const events = pending.split(/\r?\n\r?\n/);
|
|
458
|
+
pending = events.pop() ?? '';
|
|
459
|
+
|
|
460
|
+
for (const eventText of events) {
|
|
461
|
+
const sanitized = sanitizeSseEvent(eventText, state, options);
|
|
462
|
+
if (sanitized) {
|
|
463
|
+
this.push(`${sanitized}\n\n`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
162
466
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
467
|
+
callback();
|
|
468
|
+
},
|
|
469
|
+
flush(callback) {
|
|
470
|
+
const sanitized = sanitizeSseEvent(pending, state, options);
|
|
471
|
+
if (sanitized) {
|
|
472
|
+
this.push(`${sanitized}\n\n`);
|
|
473
|
+
}
|
|
166
474
|
|
|
167
|
-
|
|
475
|
+
callback();
|
|
476
|
+
},
|
|
477
|
+
});
|
|
168
478
|
}
|
|
169
479
|
|
|
170
|
-
function
|
|
171
|
-
if (
|
|
172
|
-
return;
|
|
480
|
+
function sanitizeSseEvent(eventText, state, options = {}) {
|
|
481
|
+
if (!eventText.trim()) {
|
|
482
|
+
return '';
|
|
173
483
|
}
|
|
174
484
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
485
|
+
const lines = eventText.split(/\r?\n/);
|
|
486
|
+
const dataLines = lines.filter((line) => line.startsWith('data:'));
|
|
178
487
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
488
|
+
if (dataLines.length === 0) {
|
|
489
|
+
return eventText;
|
|
490
|
+
}
|
|
182
491
|
|
|
183
|
-
|
|
184
|
-
if (
|
|
185
|
-
return
|
|
492
|
+
const data = dataLines.map((line) => line.slice(5).trimStart()).join('\n');
|
|
493
|
+
if (data === '[DONE]') {
|
|
494
|
+
return eventText;
|
|
186
495
|
}
|
|
187
496
|
|
|
188
|
-
|
|
189
|
-
|
|
497
|
+
let parsed;
|
|
498
|
+
try {
|
|
499
|
+
parsed = JSON.parse(data);
|
|
500
|
+
} catch {
|
|
501
|
+
return eventText;
|
|
502
|
+
}
|
|
190
503
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
504
|
+
const sanitized = sanitizeAnthropicStreamEvent(parsed, state);
|
|
505
|
+
if (!sanitized) {
|
|
506
|
+
if (options.debug && !state.loggedReasoningRemoval) {
|
|
507
|
+
options.onDebug('[Proxy] Removed upstream reasoning content block from stream response');
|
|
508
|
+
state.loggedReasoningRemoval = true;
|
|
509
|
+
}
|
|
510
|
+
return '';
|
|
511
|
+
}
|
|
194
512
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
writeJsonError(res, 413, {error: {message: 'Request too large'}});
|
|
205
|
-
req.destroy();
|
|
206
|
-
return;
|
|
513
|
+
const outputLines = [];
|
|
514
|
+
let wroteData = false;
|
|
515
|
+
for (const line of lines) {
|
|
516
|
+
if (line.startsWith('data:')) {
|
|
517
|
+
if (!wroteData) {
|
|
518
|
+
outputLines.push(`data: ${JSON.stringify(sanitized)}`);
|
|
519
|
+
wroteData = true;
|
|
520
|
+
}
|
|
521
|
+
continue;
|
|
207
522
|
}
|
|
208
523
|
|
|
209
|
-
|
|
210
|
-
}
|
|
524
|
+
outputLines.push(line);
|
|
525
|
+
}
|
|
211
526
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const bodyBuffer = Buffer.concat(chunks);
|
|
215
|
-
let bodyJson;
|
|
527
|
+
return outputLines.join('\n');
|
|
528
|
+
}
|
|
216
529
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
530
|
+
function sanitizeAnthropicStreamEvent(event, state) {
|
|
531
|
+
if (!event || typeof event !== 'object') {
|
|
532
|
+
return event;
|
|
533
|
+
}
|
|
222
534
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (preferredModel !== newModel) {
|
|
229
|
-
onDebug(`[Proxy] Fallback applied because ${preferredModel} is not available for this token`);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
535
|
+
if (event.type === 'message_start' && Array.isArray(event.message?.content)) {
|
|
536
|
+
initializeStreamContentIndexMap(event.message.content, state);
|
|
537
|
+
const sanitized = sanitizeAnthropicCompatiblePayload(event.message);
|
|
538
|
+
return sanitized.changed ? {...event, message: sanitized.payload} : event;
|
|
539
|
+
}
|
|
232
540
|
|
|
233
|
-
|
|
234
|
-
|
|
541
|
+
if (event.type === 'content_block_start') {
|
|
542
|
+
const originalIndex = event.index;
|
|
235
543
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
bodyLength: typeof payload === 'string' ? Buffer.byteLength(payload) : payload.length,
|
|
240
|
-
debug,
|
|
241
|
-
onDebug,
|
|
242
|
-
onError,
|
|
243
|
-
payload,
|
|
244
|
-
timeout: 120000,
|
|
245
|
-
validToken,
|
|
246
|
-
});
|
|
247
|
-
} catch (error) {
|
|
248
|
-
onError(`[Proxy Error] POST ${MESSAGES_PATH}: ${error.message}`);
|
|
249
|
-
writeJsonError(res, 500, {
|
|
250
|
-
error: {
|
|
251
|
-
message: 'Scionos Proxy Error',
|
|
252
|
-
details: error.message,
|
|
253
|
-
},
|
|
254
|
-
});
|
|
544
|
+
if (isReasoningContentBlock(event.content_block)) {
|
|
545
|
+
state.droppedIndexes.add(originalIndex);
|
|
546
|
+
return null;
|
|
255
547
|
}
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
548
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const url = new URL(`${baseUrl}${req.url}`);
|
|
263
|
-
const requestOptions = buildProxyRequestOptions(
|
|
264
|
-
url,
|
|
265
|
-
req.method,
|
|
266
|
-
req.headers,
|
|
267
|
-
validToken,
|
|
268
|
-
bodyLength,
|
|
269
|
-
timeout,
|
|
270
|
-
);
|
|
549
|
+
const mappedIndex = getOrCreateMappedContentIndex(originalIndex, state);
|
|
550
|
+
return {...event, index: mappedIndex};
|
|
551
|
+
}
|
|
271
552
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
553
|
+
if (event.type === 'content_block_delta' || event.type === 'content_block_stop') {
|
|
554
|
+
const originalIndex = event.index;
|
|
555
|
+
if (state.droppedIndexes.has(originalIndex)) {
|
|
556
|
+
return null;
|
|
275
557
|
}
|
|
276
558
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
proxyReq.on('error', (error) => {
|
|
282
|
-
onError(`[Proxy Error] ${req.method} ${req.url}: ${error.message}`);
|
|
283
|
-
if (debug && error.code) {
|
|
284
|
-
onError(`[Proxy Error] Code: ${error.code}`);
|
|
559
|
+
if (REASONING_DELTA_TYPES.has(event.delta?.type)) {
|
|
560
|
+
state.droppedIndexes.add(originalIndex);
|
|
561
|
+
return null;
|
|
285
562
|
}
|
|
286
563
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
message: req.method === 'POST' && getRequestPath(req) === MESSAGES_PATH
|
|
290
|
-
? 'Proxy Error'
|
|
291
|
-
: 'Scionos Proxy Error: Failed to connect to upstream',
|
|
292
|
-
details: error.message,
|
|
293
|
-
...(error.code ? {code: error.code} : {}),
|
|
294
|
-
},
|
|
295
|
-
});
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
proxyReq.on('timeout', () => {
|
|
299
|
-
onError('[Proxy] Request timeout');
|
|
300
|
-
proxyReq.destroy();
|
|
301
|
-
writeJsonError(res, 504, {error: {message: 'Gateway Timeout'}});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
if (payload !== undefined) {
|
|
305
|
-
proxyReq.write(payload);
|
|
306
|
-
proxyReq.end();
|
|
307
|
-
if (debug) {
|
|
308
|
-
onDebug('[Proxy] Request sent to upstream');
|
|
564
|
+
if (!state.indexMap.has(originalIndex)) {
|
|
565
|
+
return event;
|
|
309
566
|
}
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
567
|
|
|
313
|
-
|
|
314
|
-
req.pipe(proxyReq);
|
|
315
|
-
} else {
|
|
316
|
-
proxyReq.end();
|
|
568
|
+
return {...event, index: state.indexMap.get(originalIndex)};
|
|
317
569
|
}
|
|
570
|
+
|
|
571
|
+
return event;
|
|
318
572
|
}
|
|
319
573
|
|
|
320
|
-
function
|
|
321
|
-
const {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
onDebug = () => {},
|
|
326
|
-
onError = () => {},
|
|
327
|
-
proxySecret = null,
|
|
328
|
-
} = options;
|
|
329
|
-
|
|
330
|
-
return new Promise((resolve, reject) => {
|
|
331
|
-
const server = http.createServer((req, res) => {
|
|
332
|
-
if (!isAuthorizedProxyRequest(req, proxySecret)) {
|
|
333
|
-
writeJsonError(res, 403, {error: {message: 'Forbidden'}});
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
574
|
+
function initializeStreamContentIndexMap(content, state) {
|
|
575
|
+
for (const [originalIndex, block] of content.entries()) {
|
|
576
|
+
if (state.droppedIndexes.has(originalIndex) || state.indexMap.has(originalIndex)) {
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
336
579
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
580
|
+
if (isReasoningContentBlock(block)) {
|
|
581
|
+
state.droppedIndexes.add(originalIndex);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
341
584
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
onDebug,
|
|
347
|
-
onError,
|
|
348
|
-
targetModel,
|
|
349
|
-
validToken,
|
|
350
|
-
});
|
|
351
|
-
});
|
|
585
|
+
state.indexMap.set(originalIndex, state.nextIndex);
|
|
586
|
+
state.nextIndex += 1;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
352
589
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
590
|
+
function getOrCreateMappedContentIndex(originalIndex, state) {
|
|
591
|
+
if (!state.indexMap.has(originalIndex)) {
|
|
592
|
+
state.indexMap.set(originalIndex, state.nextIndex);
|
|
593
|
+
state.nextIndex += 1;
|
|
594
|
+
}
|
|
357
595
|
|
|
358
|
-
|
|
359
|
-
});
|
|
596
|
+
return state.indexMap.get(originalIndex);
|
|
360
597
|
}
|
|
361
|
-
|
|
362
|
-
|
|
598
|
+
|
|
599
|
+
function startProxyServer(targetModel, validToken, options = {}) {
|
|
600
|
+
const {
|
|
601
|
+
availableModels = [],
|
|
602
|
+
baseUrl = BASE_URL,
|
|
603
|
+
debug = false,
|
|
604
|
+
onDebug = () => {},
|
|
605
|
+
onError = () => {},
|
|
606
|
+
proxySecret = null,
|
|
607
|
+
} = options;
|
|
608
|
+
|
|
609
|
+
return new Promise((resolve, reject) => {
|
|
610
|
+
const server = http.createServer((req, res) => {
|
|
611
|
+
if (!isAuthorizedProxyRequest(req, proxySecret)) {
|
|
612
|
+
writeJsonError(res, 403, {error: {message: 'Forbidden'}});
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!isAllowedProxyRoute(req)) {
|
|
617
|
+
writeJsonError(res, 404, {error: {message: 'Not Found'}});
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
handleMessageRequest(req, res, {
|
|
622
|
+
availableModels,
|
|
623
|
+
baseUrl,
|
|
624
|
+
debug,
|
|
625
|
+
onDebug,
|
|
626
|
+
onError,
|
|
627
|
+
targetModel,
|
|
628
|
+
validToken,
|
|
629
|
+
});
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
server.listen(0, '127.0.0.1', () => {
|
|
633
|
+
const address = server.address();
|
|
634
|
+
resolve({server, url: `http://127.0.0.1:${address.port}`});
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
server.on('error', (error) => reject(error));
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export {
|
|
363
642
|
buildProxyRequestOptions,
|
|
364
643
|
normalizeProxyHeaders,
|
|
365
644
|
PROXY_AUTH_HEADER,
|
|
366
645
|
resolveMappedModel,
|
|
646
|
+
sanitizeAnthropicCompatiblePayload,
|
|
647
|
+
sanitizeAnthropicStreamEvent,
|
|
367
648
|
startProxyServer,
|
|
368
649
|
};
|
package/src/routerlab.js
CHANGED
|
@@ -114,9 +114,9 @@ const STRATEGIES = [
|
|
|
114
114
|
value: 'deepseek-v4-beta',
|
|
115
115
|
name: 'deepseek-v4 beta',
|
|
116
116
|
selectionName: 'deepseek-v4 beta',
|
|
117
|
-
description: 'Maps Claude requests to the deepseek-v4 family. Opus 4.7 => deepseek-v4-pro, Sonnet 4.6 => deepseek-v4-
|
|
118
|
-
selectionDescription: 'Opus 4.7 => deepseek-v4-pro, Sonnet 4.6 => deepseek-v4-
|
|
119
|
-
verificationModels: ['deepseek-v4-pro', 'deepseek-v4-flash'],
|
|
117
|
+
description: 'Maps Claude requests to the deepseek-v4 family. Opus 4.7 => claude-deepseek-v4-pro, Sonnet 4.6 => claude-deepseek-v4-pro, Haiku => claude-deepseek-v4-flash.',
|
|
118
|
+
selectionDescription: 'Opus 4.7 => claude-deepseek-v4-pro, Sonnet 4.6 => claude-deepseek-v4-pro, Haiku => claude-deepseek-v4-flash.',
|
|
119
|
+
verificationModels: ['claude-deepseek-v4-pro', 'claude-deepseek-v4-flash'],
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
122
|
value: 'claude-qwen3.6-plus',
|