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 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 sonnet ou haiku
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 sonnet or haiku
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-scionos",
3
- "version": "4.3.4",
3
+ "version": "4.4.0",
4
4
  "description": "RouterLab launcher, strategy proxy and secure token wrapper for Claude Code CLI",
5
5
  "type": "module",
6
6
  "main": "index.js",
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 normalizeProxyHeaders(headers) {
21
- const normalizedHeaders = {};
22
-
23
- for (const [key, value] of Object.entries(headers ?? {})) {
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 buildProxyRequestOptions(url, method, upstreamHeaders, validToken, bodyLength, timeout) {
35
- const headers = normalizeProxyHeaders(upstreamHeaders);
36
- delete headers.authorization;
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
- if (bodyLength !== undefined) {
42
- headers['Content-Length'] = String(bodyLength);
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
- hostname: url.hostname,
47
- port: url.port || 443,
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 getPreferredClaudeGptModel(requestedModel = '') {
56
- if (requestedModel.includes('haiku') || requestedModel.includes('mini')) {
57
- return 'claude-gpt-5.4-mini';
83
+ function sanitizeAnthropicCompatiblePayload(payload) {
84
+ if (!payload || typeof payload !== 'object') {
85
+ return {payload, changed: false};
58
86
  }
59
87
 
60
- if (requestedModel.includes('opus')) {
61
- return 'claude-gpt-5.5';
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
- return 'claude-gpt-5.4';
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
- function getPreferredClaudeModel(requestedModel = '') {
68
- if (requestedModel.includes('haiku') || requestedModel.includes('mini')) {
69
- return 'claude-haiku-4-5-20251001';
70
- }
103
+ const sanitizedContent = sanitizeContentBlocks(message.content);
104
+ if (!sanitizedContent.changed) {
105
+ return message;
106
+ }
71
107
 
72
- if (requestedModel.includes('opus')) {
73
- return 'claude-opus-4-6';
108
+ changed = true;
109
+ return {
110
+ ...message,
111
+ content: sanitizedContent.content,
112
+ };
113
+ });
74
114
  }
75
115
 
76
- return 'claude-sonnet-4-6';
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
- function resolveMappedModel(targetModel, requestedModel = '', availableModels = []) {
88
- if (targetModel === 'claude') {
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
- if (availableClaudeModels.length === 0) {
95
- return preferredModel;
96
- }
300
+ if (bodyJson) {
301
+ bodyJson = sanitizeAnthropicCompatiblePayload(bodyJson).payload;
302
+ }
97
303
 
98
- if (availableClaudeModels.includes(preferredModel)) {
99
- return preferredModel;
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
- return (
103
- availableClaudeModels.find((model) => model === 'claude-sonnet-4-6')
104
- ?? availableClaudeModels[0]
105
- ?? preferredModel
106
- );
107
- }
108
-
109
- if (targetModel === 'claude-gpt-special') {
110
- return 'claude-gpt-5.4-sp';
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
- if (targetModel === 'deepseek-v4-beta') {
114
- const preferredModel = getPreferredDeepseekV4Model(requestedModel);
115
- const availableDeepseekModels = Array.isArray(availableModels)
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
- if (availableDeepseekModels.length === 0) {
120
- return preferredModel;
121
- }
122
-
123
- if (availableDeepseekModels.includes(preferredModel)) {
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
- return (
128
- availableDeepseekModels.find((model) => model === 'deepseek-v4-flash')
129
- ?? availableDeepseekModels[0]
130
- ?? preferredModel
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 (targetModel !== 'aws') {
135
- if (targetModel !== 'claude-gpt') {
136
- return targetModel;
137
- }
406
+ if (!contentType.includes('application/json')) {
407
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
408
+ proxyRes.pipe(res);
409
+ return;
410
+ }
138
411
 
139
- const preferredModel = getPreferredClaudeGptModel(requestedModel);
140
- const availableClaudeGptModels = Array.isArray(availableModels)
141
- ? availableModels.filter((model) => model.startsWith('claude-gpt-'))
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
- if (availableClaudeGptModels.length === 0) {
145
- return preferredModel;
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
- if (availableClaudeGptModels.includes(preferredModel)) {
149
- return preferredModel;
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
- return (
153
- availableClaudeGptModels.find((model) => model === 'claude-gpt-5.4')
154
- ?? availableClaudeGptModels[0]
155
- ?? preferredModel
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
- if (requestedModel.includes('haiku')) {
160
- return 'aws-claude-haiku-4-5-20251001';
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
- if (requestedModel.includes('opus')) {
164
- return 'aws-claude-opus-4-6';
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
- return 'aws-claude-sonnet-4-6';
475
+ callback();
476
+ },
477
+ });
168
478
  }
169
479
 
170
- function writeJsonError(res, statusCode, payload) {
171
- if (res.headersSent) {
172
- return;
480
+ function sanitizeSseEvent(eventText, state, options = {}) {
481
+ if (!eventText.trim()) {
482
+ return '';
173
483
  }
174
484
 
175
- res.writeHead(statusCode);
176
- res.end(JSON.stringify(payload));
177
- }
485
+ const lines = eventText.split(/\r?\n/);
486
+ const dataLines = lines.filter((line) => line.startsWith('data:'));
178
487
 
179
- function getRequestPath(req) {
180
- return new URL(req.url, 'http://127.0.0.1').pathname;
181
- }
488
+ if (dataLines.length === 0) {
489
+ return eventText;
490
+ }
182
491
 
183
- function isAuthorizedProxyRequest(req, proxySecret) {
184
- if (!proxySecret) {
185
- return true;
492
+ const data = dataLines.map((line) => line.slice(5).trimStart()).join('\n');
493
+ if (data === '[DONE]') {
494
+ return eventText;
186
495
  }
187
496
 
188
- return req.headers[PROXY_AUTH_HEADER] === proxySecret;
189
- }
497
+ let parsed;
498
+ try {
499
+ parsed = JSON.parse(data);
500
+ } catch {
501
+ return eventText;
502
+ }
190
503
 
191
- function isAllowedProxyRoute(req) {
192
- return req.method === 'POST' && getRequestPath(req) === MESSAGES_PATH;
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
- async function handleMessageRequest(req, res, options) {
196
- const {availableModels = [], baseUrl, debug, onDebug, onError, targetModel, validToken} = options;
197
- const chunks = [];
198
- const maxSize = 100 * 1024 * 1024;
199
- let totalSize = 0;
200
-
201
- req.on('data', (chunk) => {
202
- totalSize += chunk.length;
203
- if (totalSize > maxSize) {
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
- chunks.push(chunk);
210
- });
524
+ outputLines.push(line);
525
+ }
211
526
 
212
- req.on('end', async () => {
213
- try {
214
- const bodyBuffer = Buffer.concat(chunks);
215
- let bodyJson;
527
+ return outputLines.join('\n');
528
+ }
216
529
 
217
- try {
218
- bodyJson = JSON.parse(bodyBuffer.toString());
219
- } catch {
220
- bodyJson = null;
221
- }
530
+ function sanitizeAnthropicStreamEvent(event, state) {
531
+ if (!event || typeof event !== 'object') {
532
+ return event;
533
+ }
222
534
 
223
- if (bodyJson?.model) {
224
- const preferredModel = resolveMappedModel(targetModel, bodyJson.model);
225
- const newModel = resolveMappedModel(targetModel, bodyJson.model, availableModels);
226
- if (debug) {
227
- onDebug(`[Proxy] Swapping model ${bodyJson.model} -> ${newModel}`);
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
- bodyJson.model = newModel;
234
- }
541
+ if (event.type === 'content_block_start') {
542
+ const originalIndex = event.index;
235
543
 
236
- const payload = bodyJson ? JSON.stringify(bodyJson) : bodyBuffer;
237
- await forwardRequest(req, res, {
238
- baseUrl,
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
- async function forwardRequest(req, res, options) {
260
- const {baseUrl, bodyLength, debug, onDebug, onError, payload, timeout, validToken} = options;
261
- const https = await import('node:https');
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
- const proxyReq = https.request(requestOptions, (proxyRes) => {
273
- if (debug) {
274
- onDebug(`[Proxy] Upstream response status: ${proxyRes.statusCode}`);
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
- res.writeHead(proxyRes.statusCode, proxyRes.headers);
278
- proxyRes.pipe(res);
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
- writeJsonError(res, req.method === 'POST' && getRequestPath(req) === MESSAGES_PATH ? 500 : 502, {
288
- error: {
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
- if (req.method === 'POST' || req.method === 'PUT') {
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 startProxyServer(targetModel, validToken, options = {}) {
321
- const {
322
- availableModels = [],
323
- baseUrl = BASE_URL,
324
- debug = false,
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
- if (!isAllowedProxyRoute(req)) {
338
- writeJsonError(res, 404, {error: {message: 'Not Found'}});
339
- return;
340
- }
580
+ if (isReasoningContentBlock(block)) {
581
+ state.droppedIndexes.add(originalIndex);
582
+ continue;
583
+ }
341
584
 
342
- handleMessageRequest(req, res, {
343
- availableModels,
344
- baseUrl,
345
- debug,
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
- server.listen(0, '127.0.0.1', () => {
354
- const address = server.address();
355
- resolve({server, url: `http://127.0.0.1:${address.port}`});
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
- server.on('error', (error) => reject(error));
359
- });
596
+ return state.indexMap.get(originalIndex);
360
597
  }
361
-
362
- export {
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-flash, Haiku => deepseek-v4-flash.',
118
- selectionDescription: 'Opus 4.7 => deepseek-v4-pro, Sonnet 4.6 => deepseek-v4-flash, Haiku => deepseek-v4-flash.',
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',