@khanglvm/llm-router 1.1.1 → 1.3.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.
@@ -10,13 +10,20 @@ import {
10
10
  CODEX_ENDPOINT,
11
11
  mapCodexVariant
12
12
  } from './codex-request-transformer.js';
13
+ import {
14
+ CLAUDE_CODE_OAUTH_CONFIG
15
+ } from './subscription-constants.js';
13
16
  import { FORMATS } from '../translator/index.js';
14
17
 
18
+ const UNSUPPORTED_PARAMETER_PATTERN = /Unsupported parameter:\s*([A-Za-z0-9_.-]+)/gi;
19
+ const MAX_UNSUPPORTED_PARAMETER_RETRIES = 6;
20
+
15
21
  /**
16
22
  * Subscription provider types.
17
23
  */
18
24
  export const SUBSCRIPTION_TYPES = {
19
- CHATGPT_CODEX: 'chatgpt-codex'
25
+ CHATGPT_CODEX: 'chatgpt-codex',
26
+ CLAUDE_CODE: 'claude-code'
20
27
  };
21
28
 
22
29
  /**
@@ -105,7 +112,7 @@ export async function makeSubscriptionProviderCall({ provider, body, stream }) {
105
112
  // Get valid access token (auto-refreshes if expired)
106
113
  let accessToken;
107
114
  try {
108
- accessToken = await getValidAccessToken(profileId);
115
+ accessToken = await getValidAccessToken(profileId, { subscriptionType: subType });
109
116
  } catch (error) {
110
117
  return {
111
118
  ok: false,
@@ -148,6 +155,9 @@ export async function makeSubscriptionProviderCall({ provider, body, stream }) {
148
155
  if (subType === SUBSCRIPTION_TYPES.CHATGPT_CODEX) {
149
156
  return makeCodexProviderCall({ provider, body, stream, accessToken });
150
157
  }
158
+ if (subType === SUBSCRIPTION_TYPES.CLAUDE_CODE) {
159
+ return makeClaudeCodeProviderCall({ provider, body, stream, accessToken });
160
+ }
151
161
 
152
162
  return {
153
163
  ok: false,
@@ -176,6 +186,7 @@ export async function makeSubscriptionProviderCall({ provider, body, stream }) {
176
186
  async function makeCodexProviderCall({ provider, body, stream, accessToken }) {
177
187
  // Transform request for Codex backend
178
188
  const codexBody = transformRequestForCodex(body);
189
+ stripCodexTokenLimitFields(codexBody);
179
190
 
180
191
  // Apply variant settings if specified in model config
181
192
  const modelConfig = (provider.models || []).find(m => m.id === body.model);
@@ -188,15 +199,60 @@ async function makeCodexProviderCall({ provider, body, stream, accessToken }) {
188
199
 
189
200
  // Make the request
190
201
  try {
191
- const response = await fetch(CODEX_ENDPOINT, {
192
- method: 'POST',
193
- headers,
194
- body: JSON.stringify(codexBody),
195
- signal: stream ? null : AbortSignal.timeout(120000) // 2 min timeout for non-streaming
196
- });
197
-
198
- if (!response.ok) {
202
+ const removedUnsupportedParameters = new Set();
203
+ for (let attempt = 0; attempt <= MAX_UNSUPPORTED_PARAMETER_RETRIES; attempt += 1) {
204
+ const response = await fetch(CODEX_ENDPOINT, {
205
+ method: 'POST',
206
+ headers,
207
+ body: JSON.stringify(codexBody),
208
+ signal: stream ? undefined : AbortSignal.timeout(120000) // 2 min timeout for non-streaming
209
+ });
210
+
211
+ if (response.ok) {
212
+ // For streaming, pass through the response
213
+ if (stream) {
214
+ return {
215
+ ok: true,
216
+ status: 200,
217
+ retryable: false,
218
+ response: new Response(response.body, {
219
+ status: 200,
220
+ headers: {
221
+ 'Content-Type': 'text/event-stream',
222
+ 'Cache-Control': 'no-cache',
223
+ 'Connection': 'keep-alive'
224
+ }
225
+ })
226
+ };
227
+ }
228
+
229
+ // For non-streaming, pass through
230
+ const responseText = await response.text();
231
+ return {
232
+ ok: true,
233
+ status: 200,
234
+ retryable: false,
235
+ response: new Response(responseText, {
236
+ status: 200,
237
+ headers: { 'Content-Type': 'application/json' }
238
+ })
239
+ };
240
+ }
241
+
199
242
  const errorText = await response.text();
243
+ const unsupportedParameters = extractUnsupportedParameters(errorText);
244
+ let removedAnyUnsupportedParameter = false;
245
+ for (const parameter of unsupportedParameters) {
246
+ const normalized = parameter.toLowerCase();
247
+ if (removedUnsupportedParameters.has(normalized)) continue;
248
+ if (!removeUnsupportedParameter(codexBody, parameter)) continue;
249
+ removedUnsupportedParameters.add(normalized);
250
+ removedAnyUnsupportedParameter = true;
251
+ }
252
+ if (removedAnyUnsupportedParameter) {
253
+ continue;
254
+ }
255
+
200
256
  return {
201
257
  ok: false,
202
258
  status: response.status,
@@ -208,48 +264,145 @@ async function makeCodexProviderCall({ provider, body, stream, accessToken }) {
208
264
  })
209
265
  };
210
266
  }
267
+
268
+ return {
269
+ ok: false,
270
+ status: 400,
271
+ retryable: false,
272
+ errorKind: 'provider_error',
273
+ response: new Response(JSON.stringify({
274
+ detail: 'Codex request failed after removing unsupported parameters.'
275
+ }), {
276
+ status: 400,
277
+ headers: { 'Content-Type': 'application/json' }
278
+ })
279
+ };
280
+ } catch (error) {
281
+ // Handle timeout or network errors
282
+ if (error.name === 'TimeoutError' || error.name === 'AbortError') {
283
+ return {
284
+ ok: false,
285
+ status: 504,
286
+ retryable: true,
287
+ errorKind: 'timeout_error',
288
+ response: new Response(JSON.stringify({
289
+ type: 'error',
290
+ error: {
291
+ type: 'timeout_error',
292
+ message: 'Codex API request timed out'
293
+ }
294
+ }), {
295
+ status: 504,
296
+ headers: { 'Content-Type': 'application/json' }
297
+ })
298
+ };
299
+ }
211
300
 
212
- // For streaming, pass through the response
213
- if (stream) {
301
+ return {
302
+ ok: false,
303
+ status: 503,
304
+ retryable: true,
305
+ errorKind: 'network_error',
306
+ response: new Response(JSON.stringify({
307
+ type: 'error',
308
+ error: {
309
+ type: 'api_error',
310
+ message: `Codex API network error: ${error instanceof Error ? error.message : String(error)}`
311
+ }
312
+ }), {
313
+ status: 503,
314
+ headers: { 'Content-Type': 'application/json' }
315
+ })
316
+ };
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Make a Claude Code OAuth API call.
322
+ *
323
+ * @param {Object} options - Call options
324
+ * @returns {Promise<Object>} Call result
325
+ */
326
+ async function makeClaudeCodeProviderCall({ provider, body, stream, accessToken }) {
327
+ const apiBaseUrl = String(CLAUDE_CODE_OAUTH_CONFIG.apiBaseUrl || '').replace(/\/+$/, '');
328
+ const messagesPath = String(CLAUDE_CODE_OAUTH_CONFIG.messagesPath || '/v1/messages?beta=true');
329
+ const endpoint = `${apiBaseUrl}${messagesPath.startsWith('/') ? messagesPath : `/${messagesPath}`}`;
330
+ const headers = {
331
+ 'Content-Type': 'application/json',
332
+ 'Authorization': `Bearer ${accessToken}`,
333
+ 'anthropic-beta': CLAUDE_CODE_OAUTH_CONFIG.oauthBeta,
334
+ 'anthropic-version': provider?.anthropicVersion || '2023-06-01',
335
+ ...(provider.headers || {})
336
+ };
337
+ const claudeBody = {
338
+ ...(body || {}),
339
+ stream: Boolean(stream)
340
+ };
341
+
342
+ try {
343
+ const response = await fetch(endpoint, {
344
+ method: 'POST',
345
+ headers,
346
+ body: JSON.stringify(claudeBody),
347
+ signal: stream ? undefined : AbortSignal.timeout(120000)
348
+ });
349
+
350
+ if (response.ok) {
351
+ if (stream) {
352
+ return {
353
+ ok: true,
354
+ status: 200,
355
+ retryable: false,
356
+ subscriptionType: SUBSCRIPTION_TYPES.CLAUDE_CODE,
357
+ response: new Response(response.body, {
358
+ status: 200,
359
+ headers: {
360
+ 'Content-Type': 'text/event-stream',
361
+ 'Cache-Control': 'no-cache',
362
+ 'Connection': 'keep-alive'
363
+ }
364
+ })
365
+ };
366
+ }
367
+
368
+ const responseText = await response.text();
214
369
  return {
215
370
  ok: true,
216
371
  status: 200,
217
372
  retryable: false,
218
- response: new Response(response.body, {
373
+ subscriptionType: SUBSCRIPTION_TYPES.CLAUDE_CODE,
374
+ response: new Response(responseText, {
219
375
  status: 200,
220
- headers: {
221
- 'Content-Type': 'text/event-stream',
222
- 'Cache-Control': 'no-cache',
223
- 'Connection': 'keep-alive'
224
- }
376
+ headers: { 'Content-Type': 'application/json' }
225
377
  })
226
378
  };
227
379
  }
228
-
229
- // For non-streaming, pass through
230
- const responseText = await response.text();
380
+
381
+ const errorText = await response.text();
231
382
  return {
232
- ok: true,
233
- status: 200,
234
- retryable: false,
235
- response: new Response(responseText, {
236
- status: 200,
383
+ ok: false,
384
+ status: response.status,
385
+ retryable: isRetryableStatus(response.status),
386
+ errorKind: 'provider_error',
387
+ subscriptionType: SUBSCRIPTION_TYPES.CLAUDE_CODE,
388
+ response: new Response(errorText, {
389
+ status: response.status,
237
390
  headers: { 'Content-Type': 'application/json' }
238
391
  })
239
392
  };
240
393
  } catch (error) {
241
- // Handle timeout or network errors
242
394
  if (error.name === 'TimeoutError' || error.name === 'AbortError') {
243
395
  return {
244
396
  ok: false,
245
397
  status: 504,
246
398
  retryable: true,
247
399
  errorKind: 'timeout_error',
400
+ subscriptionType: SUBSCRIPTION_TYPES.CLAUDE_CODE,
248
401
  response: new Response(JSON.stringify({
249
402
  type: 'error',
250
403
  error: {
251
404
  type: 'timeout_error',
252
- message: 'Codex API request timed out'
405
+ message: 'Claude Code OAuth API request timed out'
253
406
  }
254
407
  }), {
255
408
  status: 504,
@@ -257,17 +410,18 @@ async function makeCodexProviderCall({ provider, body, stream, accessToken }) {
257
410
  })
258
411
  };
259
412
  }
260
-
413
+
261
414
  return {
262
415
  ok: false,
263
416
  status: 503,
264
417
  retryable: true,
265
418
  errorKind: 'network_error',
419
+ subscriptionType: SUBSCRIPTION_TYPES.CLAUDE_CODE,
266
420
  response: new Response(JSON.stringify({
267
421
  type: 'error',
268
422
  error: {
269
423
  type: 'api_error',
270
- message: `Codex API network error: ${error instanceof Error ? error.message : String(error)}`
424
+ message: `Claude Code OAuth API network error: ${error instanceof Error ? error.message : String(error)}`
271
425
  }
272
426
  }), {
273
427
  status: 503,
@@ -287,6 +441,100 @@ function isRetryableStatus(status) {
287
441
  return status === 429 || (status >= 500 && status < 600);
288
442
  }
289
443
 
444
+ function stripCodexTokenLimitFields(body) {
445
+ if (!body || typeof body !== 'object') return;
446
+ delete body.max_tokens;
447
+ delete body.max_output_tokens;
448
+ delete body.max_completion_tokens;
449
+ }
450
+
451
+ function extractUnsupportedParameters(errorText) {
452
+ const detail = extractErrorDetail(errorText);
453
+ if (!detail) return [];
454
+ const matches = [];
455
+ let match = UNSUPPORTED_PARAMETER_PATTERN.exec(detail);
456
+ while (match) {
457
+ const name = String(match[1] || '').trim();
458
+ if (name) matches.push(name);
459
+ match = UNSUPPORTED_PARAMETER_PATTERN.exec(detail);
460
+ }
461
+ UNSUPPORTED_PARAMETER_PATTERN.lastIndex = 0;
462
+ return [...new Set(matches)];
463
+ }
464
+
465
+ function extractErrorDetail(errorText) {
466
+ const raw = String(errorText || '').trim();
467
+ if (!raw) return '';
468
+ try {
469
+ const parsed = JSON.parse(raw);
470
+ if (typeof parsed?.detail === 'string' && parsed.detail.trim()) return parsed.detail.trim();
471
+ if (typeof parsed?.error?.message === 'string' && parsed.error.message.trim()) return parsed.error.message.trim();
472
+ if (typeof parsed?.message === 'string' && parsed.message.trim()) return parsed.message.trim();
473
+ } catch {
474
+ // keep raw payload if not JSON
475
+ }
476
+ return raw;
477
+ }
478
+
479
+ function removeUnsupportedParameter(body, parameterPath) {
480
+ const normalizedPath = String(parameterPath || '').trim();
481
+ if (!normalizedPath || !body || typeof body !== 'object') return false;
482
+
483
+ if (Object.prototype.hasOwnProperty.call(body, normalizedPath)) {
484
+ delete body[normalizedPath];
485
+ return true;
486
+ }
487
+
488
+ const parts = normalizedPath
489
+ .replace(/\[(\d+)\]/g, '.$1')
490
+ .split('.')
491
+ .filter(Boolean);
492
+ if (parts.length < 2) {
493
+ return removeKeysRecursively(body, normalizedPath);
494
+ }
495
+
496
+ let node = body;
497
+ for (let index = 0; index < parts.length - 1; index += 1) {
498
+ const segment = parts[index];
499
+ if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, segment)) {
500
+ return false;
501
+ }
502
+ node = node[segment];
503
+ }
504
+
505
+ const leaf = parts[parts.length - 1];
506
+ if (!node || typeof node !== 'object' || !Object.prototype.hasOwnProperty.call(node, leaf)) {
507
+ return removeKeysRecursively(body, leaf);
508
+ }
509
+ delete node[leaf];
510
+ return true;
511
+ }
512
+
513
+ function removeKeysRecursively(node, targetKey) {
514
+ if (!node || typeof node !== 'object') return false;
515
+ let removed = false;
516
+ if (Array.isArray(node)) {
517
+ for (const item of node) {
518
+ if (removeKeysRecursively(item, targetKey)) {
519
+ removed = true;
520
+ }
521
+ }
522
+ return removed;
523
+ }
524
+
525
+ for (const key of Object.keys(node)) {
526
+ if (key === targetKey) {
527
+ delete node[key];
528
+ removed = true;
529
+ continue;
530
+ }
531
+ if (removeKeysRecursively(node[key], targetKey)) {
532
+ removed = true;
533
+ }
534
+ }
535
+ return removed;
536
+ }
537
+
290
538
  /**
291
539
  * Login to a subscription provider.
292
540
  *
@@ -300,11 +548,13 @@ function isRetryableStatus(status) {
300
548
  export async function loginSubscription(profileId, options = {}) {
301
549
  if (options.deviceCode) {
302
550
  return loginWithDeviceCode(profileId, {
551
+ subscriptionType: options.subscriptionType,
303
552
  onCode: options.onCode
304
553
  });
305
554
  }
306
555
 
307
556
  return loginWithBrowser(profileId, {
557
+ subscriptionType: options.subscriptionType,
308
558
  onUrl: options.onUrl
309
559
  });
310
560
  }
@@ -313,19 +563,27 @@ export async function loginSubscription(profileId, options = {}) {
313
563
  * Logout from a subscription provider.
314
564
  *
315
565
  * @param {string} profileId - Profile ID
566
+ * @param {Object} [options] - Options
567
+ * @param {string} [options.subscriptionType] - Subscription type
316
568
  */
317
- export async function logoutSubscription(profileId) {
318
- await logout(profileId);
569
+ export async function logoutSubscription(profileId, options = {}) {
570
+ await logout(profileId, {
571
+ subscriptionType: options.subscriptionType || SUBSCRIPTION_TYPES.CHATGPT_CODEX
572
+ });
319
573
  }
320
574
 
321
575
  /**
322
576
  * Get authentication status for a subscription profile.
323
577
  *
324
578
  * @param {string} profileId - Profile ID
579
+ * @param {Object} [options] - Options
580
+ * @param {string} [options.subscriptionType] - Subscription type
325
581
  * @returns {Promise<Object>} Status object
326
582
  */
327
- export async function getSubscriptionStatus(profileId) {
328
- return getAuthStatus(profileId);
583
+ export async function getSubscriptionStatus(profileId, options = {}) {
584
+ return getAuthStatus(profileId, {
585
+ subscriptionType: options.subscriptionType || SUBSCRIPTION_TYPES.CHATGPT_CODEX
586
+ });
329
587
  }
330
588
 
331
589
  /**
@@ -337,25 +595,40 @@ export async function getSubscriptionStatus(profileId) {
337
595
  * @returns {Object} Headers object
338
596
  */
339
597
  export function buildSubscriptionProviderHeaders(provider, accessToken) {
340
- return {
598
+ const subType = provider?.subscriptionType || provider?.subscription_type;
599
+ const headers = {
341
600
  'Content-Type': 'application/json',
342
601
  'Authorization': `Bearer ${accessToken}`,
343
602
  ...(provider.headers || {})
344
603
  };
604
+ if (subType === SUBSCRIPTION_TYPES.CLAUDE_CODE) {
605
+ headers['anthropic-beta'] = CLAUDE_CODE_OAUTH_CONFIG.oauthBeta;
606
+ headers['anthropic-version'] = provider?.anthropicVersion || '2023-06-01';
607
+ }
608
+ return headers;
345
609
  }
346
610
 
347
611
  /**
348
612
  * Get the target format for a subscription provider.
349
- * Subscription providers always use OpenAI format.
613
+ * Target format depends on subscription provider type.
350
614
  *
351
615
  * @param {Object} provider - Provider config
352
616
  * @param {string} sourceFormat - Source format
353
617
  * @returns {string} Target format
354
618
  */
355
619
  export function resolveSubscriptionProviderFormat(provider, sourceFormat) {
356
- // Subscription providers use OpenAI format internally
620
+ void sourceFormat;
621
+ const subType = provider?.subscriptionType || provider?.subscription_type;
622
+ if (subType === SUBSCRIPTION_TYPES.CLAUDE_CODE) {
623
+ return FORMATS.CLAUDE;
624
+ }
357
625
  return FORMATS.OPENAI;
358
626
  }
359
627
 
360
628
  // Re-export for convenience
361
- export { CODEX_SUBSCRIPTION_MODELS, CODEX_OAUTH_CONFIG } from './subscription-constants.js';
629
+ export {
630
+ CODEX_SUBSCRIPTION_MODELS,
631
+ CODEX_OAUTH_CONFIG,
632
+ CLAUDE_CODE_SUBSCRIPTION_MODELS,
633
+ CLAUDE_CODE_OAUTH_CONFIG
634
+ } from './subscription-constants.js';