@x12i/ai-providers-router 4.8.2 → 4.8.4

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.
@@ -1,4 +1,5 @@
1
1
  import { openRouterCatalog } from '../../openrouter-catalog.js';
2
+ import { resolveModelVendorSlug } from '../../utils/openrouterModelVendor.js';
2
3
  import { extractCostUsdFromProviderUsage } from '../../normalization/cost.js';
3
4
  import { getReasoningCapabilitiesFromRegistry, hasReasoningParamInCatalog } from './reasoning-capabilities.js';
4
5
  /**
@@ -258,13 +259,12 @@ export class OpenRouterAdapter {
258
259
  const { requestId, mode, request, exec } = input;
259
260
  // Extract model from request (could be in request.model or request.config.model)
260
261
  let model = request.model || request.config?.model || 'openai/gpt-4o-mini';
261
- // Determine the original provider name from the request context
262
- // When OpenRouter mode is enabled, the interceptor sets request.config.provider
263
- // to preserve the original provider name (e.g., "openai", "grok") for model mapping
262
+ // Determine the model vendor slug for normalization (not the OpenRouter transport id).
264
263
  const originalProvider = request.config?.provider;
264
+ const modelVendor = resolveModelVendorSlug(model, originalProvider);
265
265
  // Normalize model name for OpenRouter using catalog data
266
266
  // (e.g., "gpt-4o" + provider="openai" → "openai/gpt-4o")
267
- model = await this.normalizeModelName(model, originalProvider);
267
+ model = await this.normalizeModelName(model, modelVendor);
268
268
  // Validate model is available in OpenRouter catalog
269
269
  try {
270
270
  const isAvailable = await openRouterCatalog.isModelAvailable(model);
package/dist/factory.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { LLMProviderRouter } from './router/RouterWrapper.js';
2
2
  import dns from 'node:dns';
3
+ import { resolveOpenRouterApiKey, OPENROUTER_API_KEY_ERC } from './utils/openrouterEnv.js';
3
4
  import dotenv from 'dotenv';
4
5
  // Fix IPv6-first DNS resolution issue on Windows (forces IPv4-first to avoid connect timeouts)
5
6
  // This prevents undici from trying IPv6 first on networks that silently drop IPv6 traffic
@@ -114,9 +115,9 @@ export async function createRouter(config) {
114
115
  apiKey: 'ENV.GOOGLE_API_KEY',
115
116
  },
116
117
  openrouter: {
117
- apiKey: 'ENV.OPEN_ROUTER_KEY',
118
- httpReferer: 'ENV.OPEN_ROUTER_HTTP_REFERER',
119
- xTitle: 'ENV.OPEN_ROUTER_X_TITLE',
118
+ apiKey: OPENROUTER_API_KEY_ERC,
119
+ httpReferer: 'ENV.OPENROUTER_HTTP_REFERER||ENV.OPEN_ROUTER_HTTP_REFERER',
120
+ xTitle: 'ENV.OPENROUTER_X_TITLE||ENV.OPEN_ROUTER_X_TITLE',
120
121
  useOpenRouter: 'ENV.USE_OPENROUTER',
121
122
  },
122
123
  },
@@ -125,6 +126,10 @@ export async function createRouter(config) {
125
126
  const ercResult = {
126
127
  config: resolveEnvPlaceholders(ercConfig)
127
128
  };
129
+ const resolvedOpenRouterKey = resolveOpenRouterApiKey();
130
+ if (resolvedOpenRouterKey && ercResult.config.providers?.openrouter) {
131
+ ercResult.config.providers.openrouter.apiKey = resolvedOpenRouterKey;
132
+ }
128
133
  // Use explicit config if provided (Advanced Mode), otherwise use ERC auto-discovered config (Zero-Config Mode)
129
134
  const finalConfig = Object.keys(config || {}).length > 0 ? config : {
130
135
  logLevel: ercResult.config.router.logLevel,
@@ -136,13 +141,14 @@ export async function createRouter(config) {
136
141
  // Configure providers from ERC auto-detected config OR explicit config
137
142
  const providerConfigs = config?.providerConfigs || ercResult.config.providers;
138
143
  // Check if OpenRouter mode should be enabled
139
- // Default: true if OPEN_ROUTER_KEY is present, else false
144
+ // Default: true if OPENROUTER_API_KEY (or legacy OPEN_ROUTER_KEY) is present, else false
140
145
  // Can be explicitly disabled with USE_OPENROUTER=false
141
- const openRouterKey = providerConfigs.openrouter?.apiKey;
146
+ const openRouterKey = providerConfigs.openrouter?.apiKey ?? resolvedOpenRouterKey;
142
147
  const useOpenRouterEnv = providerConfigs.openrouter?.useOpenRouter;
143
148
  // Check if API key is valid (not placeholder, not empty)
144
149
  const hasValidKey = openRouterKey &&
145
150
  openRouterKey !== 'ENV.OPEN_ROUTER_KEY' &&
151
+ openRouterKey !== OPENROUTER_API_KEY_ERC &&
146
152
  typeof openRouterKey === 'string' &&
147
153
  openRouterKey.trim() !== '';
148
154
  // Determine if OpenRouter should be enabled
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { getDirname } from './utils/esm-compat.js';
4
+ import { inferVendorSlugFromBareModel, isOpenRouterTransportProvider, resolveModelVendorSlug, } from './utils/openrouterModelVendor.js';
4
5
  // Get __dirname equivalent for ESM
5
6
  const __dirname = getDirname(import.meta.url);
6
7
  /**
@@ -184,43 +185,51 @@ export class OpenRouterCatalogLoader {
184
185
  */
185
186
  async normalizeModelName(modelName, providerHint) {
186
187
  const models = await this.getModels();
187
- // If already in OpenRouter format (contains '/'), validate it
188
- if (modelName.includes('/')) {
189
- const model = models.find(m => m.openrouterId === modelName);
190
- if (model) {
191
- return model.openrouterId;
188
+ const trimmed = modelName.trim();
189
+ const vendorHint = isOpenRouterTransportProvider(providerHint) ? undefined : providerHint;
190
+ // Already vendor-qualified (`openai/gpt-5.4`, etc.)
191
+ if (trimmed.includes('/')) {
192
+ const catalogMatch = models.find((m) => m.openrouterId === trimmed);
193
+ if (catalogMatch)
194
+ return catalogMatch.openrouterId;
195
+ const aliasMatch = models.find((m) => m.aliases.includes(trimmed));
196
+ if (aliasMatch)
197
+ return aliasMatch.openrouterId;
198
+ const [prefix] = trimmed.split('/');
199
+ if (prefix && !isOpenRouterTransportProvider(prefix)) {
200
+ const isKnownVendor = (await this.findProviderBySlug(prefix)) != null ||
201
+ (await this.findProviderByVendorId(prefix)) != null;
202
+ if (isKnownVendor)
203
+ return trimmed;
192
204
  }
193
- // If not found but contains '/', it might be an alias, continue to check
205
+ // Never double-prefix vendor/model strings (e.g. openrouter + openai/gpt-5.4)
206
+ return trimmed;
194
207
  }
195
- // Try to find by exact alias match
196
- const aliasMatch = models.find(m => m.aliases.includes(modelName));
197
- if (aliasMatch) {
208
+ // Bare alias (`gpt-4o`, tier keys resolved upstream, etc.)
209
+ const aliasMatch = models.find((m) => m.aliases.includes(trimmed));
210
+ if (aliasMatch)
198
211
  return aliasMatch.openrouterId;
199
- }
200
- // If provider hint provided, try to construct OpenRouter ID
201
- if (providerHint) {
202
- const candidateId = `${providerHint}/${modelName}`;
203
- const model = models.find(m => m.openrouterId === candidateId);
204
- if (model) {
212
+ const resolvedVendor = resolveModelVendorSlug(trimmed, providerHint) ?? vendorHint;
213
+ if (resolvedVendor) {
214
+ const candidateId = `${resolvedVendor}/${trimmed}`;
215
+ const model = models.find((m) => m.openrouterId === candidateId);
216
+ if (model)
205
217
  return model.openrouterId;
206
- }
218
+ return candidateId;
207
219
  }
208
- // Fallback: try to infer provider and construct ID
209
- const inferredProvider = await this.inferProviderFromModel(modelName);
220
+ const inferredProvider = await this.inferProviderFromModel(trimmed);
210
221
  if (inferredProvider) {
211
- const candidateId = `${inferredProvider}/${modelName}`;
212
- const model = models.find(m => m.openrouterId === candidateId);
213
- if (model) {
222
+ const candidateId = `${inferredProvider}/${trimmed}`;
223
+ const model = models.find((m) => m.openrouterId === candidateId);
224
+ if (model)
214
225
  return model.openrouterId;
215
- }
226
+ return candidateId;
216
227
  }
217
- // Best-effort fallback: if we have a provider hint and the model is bare,
218
- // still prefix it (helps with very new models like `gpt-5.5` before catalog refresh).
219
- if (providerHint && !modelName.includes('/')) {
220
- return `${providerHint}/${modelName}`;
228
+ const patternVendor = inferVendorSlugFromBareModel(trimmed);
229
+ if (patternVendor) {
230
+ return `${patternVendor}/${trimmed}`;
221
231
  }
222
- // Last resort: return as-is (might be a direct model name)
223
- return modelName;
232
+ return trimmed;
224
233
  }
225
234
  }
226
235
  // Export singleton instance
@@ -3,6 +3,7 @@ import { applyResponseNormalization } from '../normalization/applyResponseNormal
3
3
  import { extractCostUsdFromRouterResponse } from '../normalization/cost.js';
4
4
  import { FallbackExhaustedError } from '../errors.js';
5
5
  import { buildCandidateRequest, buildFallbackCandidates, isRetryableError, summarizeError, toError, } from './fallbackUtils.js';
6
+ import { hasOpenRouterApiKey } from '../utils/openrouterEnv.js';
6
7
  import { attachPartialRouterPayload, buildPartialRouterPayload } from './partialErrorPayload.js';
7
8
  /**
8
9
  * Main router class
@@ -36,10 +37,8 @@ export class AIRouter {
36
37
  resolveProviderName(input) {
37
38
  const hasOpenRouterAdapter = this.adapters.has('openrouter');
38
39
  const hasOpenRouterProvider = this.providers.has('openrouter');
39
- // Check if OPEN_ROUTER_KEY is set in environment (completely automatic detection)
40
- const hasOpenRouterKey = !!(process.env.OPEN_ROUTER_KEY &&
41
- process.env.OPEN_ROUTER_KEY.trim() !== '' &&
42
- !process.env.OPEN_ROUTER_KEY.startsWith('ENV.'));
40
+ // Check if OpenRouter API key is set (OPENROUTER_API_KEY canonical; OPEN_ROUTER_KEY legacy)
41
+ const hasOpenRouterKey = hasOpenRouterApiKey();
43
42
  // Normalize config.provider (handle string, trim whitespace)
44
43
  const cfgProviderRaw = input.request?.config?.provider;
45
44
  const cfgProvider = typeof cfgProviderRaw === 'string' ? cfgProviderRaw.trim() : undefined;
@@ -71,7 +70,7 @@ export class AIRouter {
71
70
  // Check if OpenRouter mode could work automatically
72
71
  if (hasOpenRouterAdapter && !hasOpenRouterKey) {
73
72
  throw new Error(`Provider '${cfgProvider}' specified in request.config.provider but not registered. ` +
74
- `OpenRouter adapter is available - set OPEN_ROUTER_KEY environment variable to enable automatic OpenRouter mode. ` +
73
+ `OpenRouter adapter is available - set OPENROUTER_API_KEY environment variable to enable automatic OpenRouter mode. ` +
75
74
  `Available providers: ${available || '(none)'}`);
76
75
  }
77
76
  throw new Error(`Provider '${cfgProvider}' specified in request.config.provider but not registered. Available providers: ${available || '(none)'}`);
@@ -90,10 +89,10 @@ export class AIRouter {
90
89
  if (hasProviderInConfig && hasOpenRouterAdapter && !hasRegisteredProviders) {
91
90
  if (!hasOpenRouterKey) {
92
91
  throw new Error('OpenRouter adapter available and config.provider specified, but OpenRouter provider module not registered. ' +
93
- 'Set OPEN_ROUTER_KEY environment variable to enable automatic OpenRouter mode (works with both createRouter() and manual initialization).');
92
+ 'Set OPENROUTER_API_KEY environment variable to enable automatic OpenRouter mode (works with both createRouter() and manual initialization).');
94
93
  }
95
94
  throw new Error('OpenRouter adapter available and config.provider specified, but OpenRouter provider module not registered. ' +
96
- 'OPEN_ROUTER_KEY is set but provider module initialization failed. Check that @x12i/ai-provider-openai is installed.');
95
+ 'OPENROUTER_API_KEY is set but provider module initialization failed. Check that @x12i/ai-provider-openai is installed.');
97
96
  }
98
97
  if (hasProviderInConfig && !hasOpenRouterAdapter) {
99
98
  throw new Error(`Provider '${input.request?.config?.provider}' specified in config but no providers registered and OpenRouter adapter not available.`);
@@ -101,7 +100,7 @@ export class AIRouter {
101
100
  // Final fallback error with OpenRouter suggestion
102
101
  if (hasOpenRouterAdapter && !hasOpenRouterKey) {
103
102
  throw new Error('No provider specified and no providers registered. ' +
104
- 'OpenRouter adapter is available - set OPEN_ROUTER_KEY environment variable to enable automatic OpenRouter mode.');
103
+ 'OpenRouter adapter is available - set OPENROUTER_API_KEY environment variable to enable automatic OpenRouter mode.');
105
104
  }
106
105
  throw new Error('No provider specified and no providers registered');
107
106
  }
@@ -5,6 +5,8 @@ import { OpenAIAdapter } from '../adapters/openai/OpenAIAdapter.js';
5
5
  import { GrokAdapter } from '../adapters/grok/GrokAdapter.js';
6
6
  import { OpenRouterAdapter } from '../adapters/openrouter/OpenRouterAdapter.js';
7
7
  import { getLogger } from '../logger.js';
8
+ import { resolveOpenRouterApiKey, OPENROUTER_API_KEY_ERC } from '../utils/openrouterEnv.js';
9
+ import { resolveModelVendorSlug } from '../utils/openrouterModelVendor.js';
8
10
  /**
9
11
  * Resolve ENV. placeholders to actual environment variable values
10
12
  * Replaces nx-config2 functionality to avoid ESM/CommonJS compatibility issues
@@ -373,9 +375,18 @@ export class LLMProviderRouter {
373
375
  * Used by ensureProvidersRegistered() and by the OpenRouter retry path when the first run skipped registration.
374
376
  */
375
377
  async tryRegisterOpenRouter(config) {
376
- const openRouterKey = (this.config?.openrouter?.apiKey && typeof this.config.openrouter.apiKey === 'string' && !this.config.openrouter.apiKey.startsWith('ENV.'))
378
+ const configuredKey = this.config?.openrouter?.apiKey &&
379
+ typeof this.config.openrouter.apiKey === 'string' &&
380
+ !this.config.openrouter.apiKey.startsWith('ENV.')
377
381
  ? this.config.openrouter.apiKey
378
- : config.openrouter?.apiKey;
382
+ : undefined;
383
+ const resolvedConfigKey = config.openrouter?.apiKey &&
384
+ typeof config.openrouter.apiKey === 'string' &&
385
+ !config.openrouter.apiKey.startsWith('ENV.') &&
386
+ !config.openrouter.apiKey.includes('||')
387
+ ? config.openrouter.apiKey
388
+ : undefined;
389
+ const openRouterKey = configuredKey ?? resolvedConfigKey ?? resolveOpenRouterApiKey();
379
390
  const hasOpenRouterKey = openRouterKey &&
380
391
  typeof openRouterKey === 'string' &&
381
392
  openRouterKey.trim() !== '' &&
@@ -409,27 +420,29 @@ export class LLMProviderRouter {
409
420
  newRequest.request.config.provider = callerProvider;
410
421
  return newRequest;
411
422
  }
412
- let effectiveProvider = originalProvider;
413
- if (!effectiveProvider) {
414
- const model = newRequest.request.config?.model || newRequest.request?.model;
423
+ let effectiveProvider = originalProvider ?? callerProvider;
424
+ const model = newRequest.request.config?.model || newRequest.request?.model;
425
+ const inferredFromModel = typeof model === 'string' ? resolveModelVendorSlug(model, effectiveProvider) : undefined;
426
+ if (inferredFromModel) {
427
+ effectiveProvider = inferredFromModel;
428
+ }
429
+ else if (!effectiveProvider || effectiveProvider === 'openrouter') {
415
430
  if (model && typeof model === 'string') {
416
431
  if (model.startsWith('gpt-') || model.startsWith('openai/'))
417
432
  effectiveProvider = 'openai';
418
433
  else if (model.startsWith('claude-') || model.startsWith('anthropic/'))
419
434
  effectiveProvider = 'anthropic';
420
435
  else if (model.startsWith('grok-') || model.startsWith('xai/'))
421
- effectiveProvider = 'grok';
436
+ effectiveProvider = 'x-ai';
422
437
  else if (model.startsWith('gemini-') || model.startsWith('google/'))
423
438
  effectiveProvider = 'google';
424
439
  }
425
- if (!effectiveProvider)
440
+ if (!effectiveProvider || effectiveProvider === 'openrouter') {
426
441
  effectiveProvider = 'openai';
442
+ }
427
443
  }
428
444
  newRequest.request.config.provider = effectiveProvider;
429
- if (effectiveProvider !== 'openrouter') {
430
- this.logger?.warn?.(`[OpenRouterProxy] Forcing provider=openrouter (effectiveProvider=${effectiveProvider}, originalProvider=${originalProvider ?? newRequest.provider}, model=${newRequest.request?.config?.model ?? newRequest.request?.model ?? 'unknown'})`);
431
- newRequest.provider = 'openrouter';
432
- }
445
+ newRequest.provider = 'openrouter';
433
446
  return newRequest;
434
447
  });
435
448
  this.logger.info('Auto-registered OpenRouter provider and enabled OpenRouter mode');
@@ -450,8 +463,11 @@ export class LLMProviderRouter {
450
463
  if (this.providerRegistry.has('openrouter'))
451
464
  return;
452
465
  // Retry OpenRouter only: key may be available now (e.g. from this.config passed by gateway)
453
- const detectionConfig = { providers: { openrouter: { apiKey: 'ENV.OPEN_ROUTER_KEY' } } };
466
+ const detectionConfig = { providers: { openrouter: { apiKey: OPENROUTER_API_KEY_ERC } } };
454
467
  const resolved = resolveEnvPlaceholders(detectionConfig);
468
+ if (resolved.providers?.openrouter && resolveOpenRouterApiKey()) {
469
+ resolved.providers.openrouter.apiKey = resolveOpenRouterApiKey();
470
+ }
455
471
  await this.tryRegisterOpenRouter(resolved.providers);
456
472
  return;
457
473
  }
@@ -468,12 +484,15 @@ export class LLMProviderRouter {
468
484
  baseURL: 'ENV.XAI_API_BASE',
469
485
  },
470
486
  openrouter: {
471
- apiKey: 'ENV.OPEN_ROUTER_KEY',
487
+ apiKey: OPENROUTER_API_KEY_ERC,
472
488
  },
473
489
  }
474
490
  };
475
491
  const resolvedConfig = resolveEnvPlaceholders(detectionConfig);
476
492
  const config = resolvedConfig.providers;
493
+ if (config.openrouter && resolveOpenRouterApiKey()) {
494
+ config.openrouter.apiKey = resolveOpenRouterApiKey();
495
+ }
477
496
  await this.tryRegisterOpenRouter(config);
478
497
  // If OpenRouter is being used, we SKIP auto-registering other providers
479
498
  // to avoid global state conflicts (e.g. ai-provider-openai singleton config).
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Canonical OpenRouter API key env var is OPENROUTER_API_KEY (ai-tools / gateway).
3
+ * OPEN_ROUTER_KEY is accepted for backward compatibility only.
4
+ */
5
+ export declare function resolveOpenRouterApiKey(): string | undefined;
6
+ export declare function hasOpenRouterApiKey(): boolean;
7
+ /** ERC placeholder resolution: prefer canonical name, fall back to legacy. */
8
+ export declare const OPENROUTER_API_KEY_ERC = "ENV.OPENROUTER_API_KEY||ENV.OPEN_ROUTER_KEY";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Canonical OpenRouter API key env var is OPENROUTER_API_KEY (ai-tools / gateway).
3
+ * OPEN_ROUTER_KEY is accepted for backward compatibility only.
4
+ */
5
+ export function resolveOpenRouterApiKey() {
6
+ for (const envVar of ['OPENROUTER_API_KEY', 'OPEN_ROUTER_KEY']) {
7
+ const value = process.env[envVar];
8
+ if (typeof value === 'string' && value.trim() !== '' && !value.startsWith('ENV.')) {
9
+ return value.trim();
10
+ }
11
+ }
12
+ return undefined;
13
+ }
14
+ export function hasOpenRouterApiKey() {
15
+ return resolveOpenRouterApiKey() !== undefined;
16
+ }
17
+ /** ERC placeholder resolution: prefer canonical name, fall back to legacy. */
18
+ export const OPENROUTER_API_KEY_ERC = 'ENV.OPENROUTER_API_KEY||ENV.OPEN_ROUTER_KEY';
@@ -0,0 +1,10 @@
1
+ /** OpenRouter module id — transport, not a model vendor slug. */
2
+ export declare const OPENROUTER_TRANSPORT_PROVIDER = "openrouter";
3
+ export declare function isOpenRouterTransportProvider(provider?: string): boolean;
4
+ /**
5
+ * Resolve the model vendor slug used in OpenRouter ids (`vendor/model`).
6
+ * When config.provider is `openrouter`, infer from the model string instead.
7
+ */
8
+ export declare function resolveModelVendorSlug(model: string, providerHint?: string): string | undefined;
9
+ /** Pattern fallback for bare model ids not yet in the bundled catalog. */
10
+ export declare function inferVendorSlugFromBareModel(modelName: string): string | undefined;
@@ -0,0 +1,40 @@
1
+ /** OpenRouter module id — transport, not a model vendor slug. */
2
+ export const OPENROUTER_TRANSPORT_PROVIDER = 'openrouter';
3
+ export function isOpenRouterTransportProvider(provider) {
4
+ return provider === OPENROUTER_TRANSPORT_PROVIDER;
5
+ }
6
+ /**
7
+ * Resolve the model vendor slug used in OpenRouter ids (`vendor/model`).
8
+ * When config.provider is `openrouter`, infer from the model string instead.
9
+ */
10
+ export function resolveModelVendorSlug(model, providerHint) {
11
+ const trimmed = typeof model === 'string' ? model.trim() : '';
12
+ if (!trimmed)
13
+ return undefined;
14
+ if (trimmed.includes('/')) {
15
+ const prefix = trimmed.split('/')[0]?.trim();
16
+ if (prefix && !isOpenRouterTransportProvider(prefix)) {
17
+ return prefix;
18
+ }
19
+ return undefined;
20
+ }
21
+ if (providerHint && !isOpenRouterTransportProvider(providerHint)) {
22
+ return providerHint;
23
+ }
24
+ return inferVendorSlugFromBareModel(trimmed);
25
+ }
26
+ /** Pattern fallback for bare model ids not yet in the bundled catalog. */
27
+ export function inferVendorSlugFromBareModel(modelName) {
28
+ if (modelName.startsWith('gpt-') || modelName.startsWith('o1-') || modelName.startsWith('o3-') || modelName.startsWith('o4-')) {
29
+ return 'openai';
30
+ }
31
+ if (modelName.startsWith('claude-'))
32
+ return 'anthropic';
33
+ if (modelName.startsWith('grok-'))
34
+ return 'x-ai';
35
+ if (modelName.startsWith('gemini-'))
36
+ return 'google';
37
+ if (modelName.startsWith('llama-'))
38
+ return 'meta-llama';
39
+ return undefined;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/ai-providers-router",
3
- "version": "4.8.2",
3
+ "version": "4.8.4",
4
4
  "description": "Unified router for all LLM provider implementations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",