@x12i/ai-providers-router 4.8.0 → 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/errors.d.ts CHANGED
@@ -5,18 +5,19 @@ import type { ProviderId } from './types.js';
5
5
  export declare class ProviderNotFoundError extends Error {
6
6
  constructor(providerName: ProviderId | string);
7
7
  }
8
+ export type FallbackAttempt = {
9
+ provider: ProviderId | string;
10
+ model?: string;
11
+ httpStatus?: number;
12
+ error: Error;
13
+ responsePreview?: string;
14
+ };
8
15
  /**
9
16
  * Error thrown when all providers in the fallback chain have failed
10
17
  */
11
18
  export declare class FallbackExhaustedError extends Error {
12
- attempts: Array<{
13
- provider: ProviderId;
14
- error: Error;
15
- }>;
16
- constructor(attempts: Array<{
17
- provider: ProviderId;
18
- error: Error;
19
- }>);
19
+ attempts: FallbackAttempt[];
20
+ constructor(attempts: FallbackAttempt[]);
20
21
  }
21
22
  /**
22
23
  * Error thrown when a provider package is not installed
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
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { LLMProviderRouter } from './router.js';
2
- export type { RouterConfig, HealthCheckResult, ProviderId, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, } from './router.js';
2
+ export type { RouterConfig, HealthCheckResult, ProviderId, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, NormalizedRouterOutput, ProviderModelRef, } from './router.js';
3
3
  export { createRouter, createRouterFromConfig } from './factory.js';
4
4
  export type { CreateRouterConfig } from './factory.js';
5
5
  export { ProviderNotFoundError, FallbackExhaustedError, ProviderNotInstalledError, ProviderTimeoutError } from './errors.js';
6
+ export type { FallbackAttempt } from './errors.js';
7
+ export type { PartialRouterPayload } from './router/partialErrorPayload.js';
6
8
  export type { RequestInterceptor, ResponseInterceptor } from './interceptors.js';
7
9
  export type { UsageTracker, AdapterLoader, ProviderInit } from './types.js';
8
10
  export { Logger, getLogger, createLogger } from './logger.js';
@@ -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
@@ -4,6 +4,21 @@
4
4
  */
5
5
  import { OpenRouter } from '@openrouter/sdk';
6
6
  import { ProviderTimeoutError } from '../errors.js';
7
+ function preservePartialResponseOnError(error) {
8
+ if (error == null || typeof error !== 'object')
9
+ return;
10
+ const e = error;
11
+ if (e.rawResponse !== undefined)
12
+ return;
13
+ const response = e.response;
14
+ if (response == null || typeof response !== 'object')
15
+ return;
16
+ const resp = response;
17
+ const body = resp.data ?? resp.body ?? resp;
18
+ if (body != null && typeof body === 'object') {
19
+ e.rawResponse = body;
20
+ }
21
+ }
7
22
  /**
8
23
  * Create OpenRouter ProviderModule using @openrouter/sdk directly
9
24
  */
@@ -124,6 +139,7 @@ export function createOpenRouterProvider(config) {
124
139
  if (timedOut && typeof timeoutMs === 'number') {
125
140
  throw new ProviderTimeoutError('openrouter', timeoutMs, operation);
126
141
  }
142
+ preservePartialResponseOnError(error);
127
143
  throw error;
128
144
  }
129
145
  finally {
@@ -223,6 +239,7 @@ export function createOpenRouterProvider(config) {
223
239
  if (timedOut && typeof timeoutMs === 'number') {
224
240
  throw new ProviderTimeoutError('openrouter', timeoutMs, operation);
225
241
  }
242
+ preservePartialResponseOnError(error);
226
243
  throw error;
227
244
  }
228
245
  finally {
@@ -1,6 +1,6 @@
1
1
  import type { ProviderRegistry } from '../registry/ProviderRegistry.js';
2
2
  import type { AdapterRegistry } from '../registry/AdapterRegistry.js';
3
- import type { AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem } from './RouterTypes.js';
3
+ import type { AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, RouterConfig } from './RouterTypes.js';
4
4
  /**
5
5
  * Main router class
6
6
  * Orchestrates provider execution using ProviderModules and router-side adapters
@@ -8,7 +8,12 @@ import type { AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBat
8
8
  export declare class AIRouter {
9
9
  private providers;
10
10
  private adapters;
11
- constructor(providers: ProviderRegistry, adapters: AdapterRegistry);
11
+ private routerConfig;
12
+ constructor(providers: ProviderRegistry, adapters: AdapterRegistry, routerConfig?: RouterConfig);
13
+ /**
14
+ * Resolve provider module name for a specific fallback candidate.
15
+ */
16
+ private resolveProviderNameForCandidate;
12
17
  /**
13
18
  * Resolve provider name from request, checking OpenRouter mode first
14
19
  */
@@ -1,14 +1,35 @@
1
1
  import { newId } from '../utils/ids.js';
2
2
  import { applyResponseNormalization } from '../normalization/applyResponseNormalization.js';
3
3
  import { extractCostUsdFromRouterResponse } from '../normalization/cost.js';
4
+ import { FallbackExhaustedError } from '../errors.js';
5
+ import { buildCandidateRequest, buildFallbackCandidates, isRetryableError, summarizeError, toError, } from './fallbackUtils.js';
6
+ import { hasOpenRouterApiKey } from '../utils/openrouterEnv.js';
7
+ import { attachPartialRouterPayload, buildPartialRouterPayload } from './partialErrorPayload.js';
4
8
  /**
5
9
  * Main router class
6
10
  * Orchestrates provider execution using ProviderModules and router-side adapters
7
11
  */
8
12
  export class AIRouter {
9
- constructor(providers, adapters) {
13
+ constructor(providers, adapters, routerConfig = {}) {
10
14
  this.providers = providers;
11
15
  this.adapters = adapters;
16
+ this.routerConfig = routerConfig;
17
+ }
18
+ /**
19
+ * Resolve provider module name for a specific fallback candidate.
20
+ */
21
+ resolveProviderNameForCandidate(input, candidateProvider) {
22
+ const candidateInput = {
23
+ ...input,
24
+ request: {
25
+ ...input.request,
26
+ config: {
27
+ ...(input.request?.config ?? {}),
28
+ provider: candidateProvider,
29
+ },
30
+ },
31
+ };
32
+ return this.resolveProviderName(candidateInput);
12
33
  }
13
34
  /**
14
35
  * Resolve provider name from request, checking OpenRouter mode first
@@ -16,10 +37,8 @@ export class AIRouter {
16
37
  resolveProviderName(input) {
17
38
  const hasOpenRouterAdapter = this.adapters.has('openrouter');
18
39
  const hasOpenRouterProvider = this.providers.has('openrouter');
19
- // Check if OPEN_ROUTER_KEY is set in environment (completely automatic detection)
20
- const hasOpenRouterKey = !!(process.env.OPEN_ROUTER_KEY &&
21
- process.env.OPEN_ROUTER_KEY.trim() !== '' &&
22
- !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();
23
42
  // Normalize config.provider (handle string, trim whitespace)
24
43
  const cfgProviderRaw = input.request?.config?.provider;
25
44
  const cfgProvider = typeof cfgProviderRaw === 'string' ? cfgProviderRaw.trim() : undefined;
@@ -51,7 +70,7 @@ export class AIRouter {
51
70
  // Check if OpenRouter mode could work automatically
52
71
  if (hasOpenRouterAdapter && !hasOpenRouterKey) {
53
72
  throw new Error(`Provider '${cfgProvider}' specified in request.config.provider but not registered. ` +
54
- `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. ` +
55
74
  `Available providers: ${available || '(none)'}`);
56
75
  }
57
76
  throw new Error(`Provider '${cfgProvider}' specified in request.config.provider but not registered. Available providers: ${available || '(none)'}`);
@@ -70,10 +89,10 @@ export class AIRouter {
70
89
  if (hasProviderInConfig && hasOpenRouterAdapter && !hasRegisteredProviders) {
71
90
  if (!hasOpenRouterKey) {
72
91
  throw new Error('OpenRouter adapter available and config.provider specified, but OpenRouter provider module not registered. ' +
73
- '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).');
74
93
  }
75
94
  throw new Error('OpenRouter adapter available and config.provider specified, but OpenRouter provider module not registered. ' +
76
- '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.');
77
96
  }
78
97
  if (hasProviderInConfig && !hasOpenRouterAdapter) {
79
98
  throw new Error(`Provider '${input.request?.config?.provider}' specified in config but no providers registered and OpenRouter adapter not available.`);
@@ -81,7 +100,7 @@ export class AIRouter {
81
100
  // Final fallback error with OpenRouter suggestion
82
101
  if (hasOpenRouterAdapter && !hasOpenRouterKey) {
83
102
  throw new Error('No provider specified and no providers registered. ' +
84
- '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.');
85
104
  }
86
105
  throw new Error('No provider specified and no providers registered');
87
106
  }
@@ -93,12 +112,15 @@ export class AIRouter {
93
112
  async runSync(input) {
94
113
  const requestId = input.requestId ?? newId();
95
114
  const primaryProviderName = this.resolveProviderName(input);
96
- const fallbackProvidersRaw = input.request?.config?.fallbackProviders;
97
- const fallbackProviders = Array.isArray(fallbackProvidersRaw)
98
- ? fallbackProvidersRaw.filter((p) => typeof p === 'string' && p.trim().length > 0).map((p) => p.trim())
99
- : [];
100
- // Candidates are ordered: primary, then fallbacks.
101
- const providerCandidates = [primaryProviderName, ...fallbackProviders.filter((p) => p !== primaryProviderName)];
115
+ const requestConfig = input.request?.config;
116
+ const primaryModel = (typeof requestConfig?.model === 'string' ? requestConfig.model : undefined) ??
117
+ (typeof input.request?.model === 'string' ? input.request.model : undefined);
118
+ const fallbackCandidates = buildFallbackCandidates({
119
+ primaryProvider: primaryProviderName,
120
+ primaryModel,
121
+ requestConfig: requestConfig && typeof requestConfig === 'object' ? requestConfig : undefined,
122
+ routerFallbackChain: this.routerConfig.fallbackChain,
123
+ });
102
124
  const maxRetries = Math.max(0, Math.min(10, Number(input.exec?.retries ?? 0) || 0));
103
125
  const attempts = [];
104
126
  const normalizeUsage = (usage) => {
@@ -129,61 +151,73 @@ export class AIRouter {
129
151
  (typeof args.maxTokens === 'number' ? args.maxTokens : undefined);
130
152
  return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
131
153
  };
132
- const summarizeError = (err) => {
133
- const e = err instanceof Error ? err : new Error(String(err));
134
- const name = typeof e.name === 'string' ? e.name : 'Error';
135
- const message = typeof e.message === 'string' ? e.message : String(err);
136
- // Size cap to avoid shipping huge provider payloads.
137
- const capped = message.length > 500 ? message.slice(0, 500) : message;
138
- return { name, message: capped };
139
- };
140
154
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
141
- const isRetryableError = (err) => {
142
- const msg = err instanceof Error ? err.message : String(err);
143
- const name = err instanceof Error ? err.name : '';
144
- const combined = `${name} ${msg}`.toLowerCase();
145
- return (combined.includes('timeout') ||
146
- combined.includes('timed out') ||
147
- combined.includes('rate limit') ||
148
- combined.includes('429') ||
149
- combined.includes('econnreset') ||
150
- combined.includes('etimedout') ||
151
- combined.includes('503') ||
152
- combined.includes('502') ||
153
- combined.includes('504') ||
154
- combined.includes('temporarily unavailable'));
155
- };
156
155
  const backoffMs = (retryIndex) => {
157
156
  const base = 250 * Math.pow(2, Math.max(0, retryIndex));
158
157
  const jitter = Math.floor(Math.random() * 100);
159
158
  return Math.min(5000, base + jitter);
160
159
  };
161
160
  let lastError = undefined;
162
- for (let fallbackIndex = 0; fallbackIndex < providerCandidates.length; fallbackIndex++) {
163
- const providerName = providerCandidates[fallbackIndex];
161
+ const failedAttemptErrors = [];
162
+ let lastPartialPayload;
163
+ for (let fallbackIndex = 0; fallbackIndex < fallbackCandidates.length; fallbackIndex++) {
164
+ const candidate = fallbackCandidates[fallbackIndex];
165
+ const candidateInput = buildCandidateRequest(input, candidate);
166
+ const providerName = this.resolveProviderNameForCandidate(candidateInput, candidate.provider);
167
+ if (!this.providers.has(providerName)) {
168
+ const err = new Error(`Provider not registered: ${providerName}. Available: ${this.providers.list().join(', ')}`);
169
+ lastError = err;
170
+ failedAttemptErrors.push(err);
171
+ const partialPayload = buildPartialRouterPayload({
172
+ requestId,
173
+ engineProvider: candidate.provider,
174
+ providerModule: providerName,
175
+ modelUsed: candidate.model,
176
+ attempts,
177
+ providerError: err,
178
+ });
179
+ lastPartialPayload = partialPayload;
180
+ attachPartialRouterPayload(err, partialPayload);
181
+ attempts.push({
182
+ ok: false,
183
+ routing: {
184
+ provider: candidate.provider,
185
+ retryIndex: 0,
186
+ fallbackIndex,
187
+ requestIds: { routerRequestId: requestId },
188
+ },
189
+ modelUsed: candidate.model,
190
+ error: summarizeError(err),
191
+ });
192
+ continue;
193
+ }
164
194
  const provider = this.providers.get(providerName);
165
195
  const adapter = this.adapters.get(providerName);
166
196
  // Check capabilities
167
197
  if (!provider.capabilities.modes.sync) {
168
198
  throw new Error(`Provider '${providerName}' does not support sync mode`);
169
199
  }
170
- // Build call spec (once per provider candidate; retries reuse the same spec)
200
+ // Build call spec (once per fallback candidate; retries reuse the same spec)
171
201
  const spec = await adapter.buildCallSpec({
172
202
  requestId,
173
203
  mode: 'sync',
174
- request: input.request,
204
+ request: candidateInput.request,
175
205
  exec: input.exec,
176
206
  });
177
207
  const maxTokensRequested = extractMaxTokensRequested(spec);
178
- const requestedModel = spec?.args?.model ?? input.request?.config?.model ?? input.request?.model;
208
+ const requestedModel = spec?.args?.model ??
209
+ candidate.model ??
210
+ candidateInput.request?.config?.model ??
211
+ candidateInput.request?.model;
179
212
  for (let retryIndex = 0; retryIndex <= maxRetries; retryIndex++) {
180
213
  const startedAt = Date.now();
214
+ let execResult;
181
215
  try {
182
- const execResult = await provider.execute(spec);
216
+ execResult = await provider.execute(spec);
183
217
  const endedAt = Date.now();
184
218
  const parsed = adapter.parseResponse({
185
219
  requestId,
186
- request: { ...input.request, _callSpec: spec }, // Include call spec for adapter access
220
+ request: { ...candidateInput.request, _callSpec: spec },
187
221
  execResult,
188
222
  });
189
223
  const usage = normalizeUsage(parsed.usage);
@@ -203,7 +237,7 @@ export class AIRouter {
203
237
  const attempt = {
204
238
  ok: true,
205
239
  routing: {
206
- provider: providerName,
240
+ provider: candidate.provider,
207
241
  retryIndex,
208
242
  fallbackIndex,
209
243
  requestIds: {
@@ -242,24 +276,42 @@ export class AIRouter {
242
276
  usage,
243
277
  metadata: mergedMetadata,
244
278
  };
245
- return applyResponseNormalization(baseResponse, input.request);
279
+ return applyResponseNormalization(baseResponse, candidateInput.request);
246
280
  }
247
281
  catch (err) {
248
282
  const endedAt = Date.now();
249
283
  lastError = err;
284
+ failedAttemptErrors.push(err);
285
+ const modelUsed = typeof requestedModel === 'string'
286
+ ? requestedModel
287
+ : candidate.model;
288
+ const timing = { startedAt, endedAt, durationMs: endedAt - startedAt };
250
289
  attempts.push({
251
290
  ok: false,
252
291
  routing: {
253
- provider: providerName,
292
+ provider: candidate.provider,
254
293
  retryIndex,
255
294
  fallbackIndex,
256
295
  requestIds: { routerRequestId: requestId },
257
296
  },
258
- timing: { startedAt, endedAt, durationMs: endedAt - startedAt },
297
+ timing,
259
298
  maxTokensRequested,
260
- modelUsed: typeof requestedModel === 'string' ? requestedModel : undefined,
299
+ modelUsed,
261
300
  error: summarizeError(err),
262
301
  });
302
+ const partialPayload = buildPartialRouterPayload({
303
+ requestId,
304
+ engineProvider: candidate.provider,
305
+ providerModule: providerName,
306
+ modelUsed,
307
+ maxTokensRequested,
308
+ timing,
309
+ attempts,
310
+ execResult,
311
+ providerError: err,
312
+ });
313
+ lastPartialPayload = partialPayload;
314
+ attachPartialRouterPayload(err, partialPayload);
263
315
  const shouldRetry = retryIndex < maxRetries && isRetryableError(err);
264
316
  if (shouldRetry) {
265
317
  await sleep(backoffMs(retryIndex));
@@ -269,10 +321,19 @@ export class AIRouter {
269
321
  }
270
322
  }
271
323
  }
272
- // Fallback chain exhausted
273
- const error = lastError instanceof Error ? lastError : new Error(String(lastError ?? 'Unknown error'));
274
- error.attempts = attempts;
275
- throw error;
324
+ const failedTraces = attempts.filter((a) => !a.ok);
325
+ const exhaustedAttempts = failedTraces.map((trace, i) => ({
326
+ provider: trace.routing.provider,
327
+ model: trace.modelUsed,
328
+ httpStatus: trace.error?.httpStatus,
329
+ error: toError(failedAttemptErrors[i] ?? lastError),
330
+ ...(trace.error?.responsePreview !== undefined ? { responsePreview: trace.error.responsePreview } : {}),
331
+ }));
332
+ const exhaustedError = new FallbackExhaustedError(exhaustedAttempts);
333
+ if (lastPartialPayload) {
334
+ attachPartialRouterPayload(exhaustedError, lastPartialPayload);
335
+ }
336
+ throw exhaustedError;
276
337
  }
277
338
  /**
278
339
  * Execute a streaming request
@@ -18,6 +18,14 @@ export type AttemptTiming = {
18
18
  export type AttemptErrorSummary = {
19
19
  name: string;
20
20
  message: string;
21
+ httpStatus?: number;
22
+ responsePreview?: string;
23
+ };
24
+ /** Provider + model reference used in fallback chains (`engine` is a gateway alias for `provider`). */
25
+ export type ProviderModelRef = {
26
+ engine?: string;
27
+ provider?: string;
28
+ model?: string;
21
29
  };
22
30
  export type AttemptRouting = {
23
31
  provider: string;
@@ -93,6 +101,8 @@ export interface RouterConfig {
93
101
  httpReferer?: string;
94
102
  xTitle?: string;
95
103
  };
104
+ /** Default provider/model fallback chain when request config does not specify one. */
105
+ fallbackChain?: ProviderModelRef[];
96
106
  }
97
107
  /**
98
108
  * Router request - the input to the router
@@ -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
@@ -60,7 +62,7 @@ export class LLMProviderRouter {
60
62
  this.adapterRegistry.register(new GrokAdapter());
61
63
  this.adapterRegistry.register(new OpenRouterAdapter());
62
64
  // Create router
63
- this.router = new AIRouter(this.providerRegistry, this.adapterRegistry);
65
+ this.router = new AIRouter(this.providerRegistry, this.adapterRegistry, this.config);
64
66
  this.logger.info('Router initialized with ProviderModule architecture', {
65
67
  verbose: this.logger.verbose,
66
68
  logLevel: this.logger.level,
@@ -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,38 @@
1
+ import type { AttemptErrorSummary } from './RouterTypes.js';
2
+ export type FallbackCandidate = {
3
+ provider: string;
4
+ model?: string;
5
+ };
6
+ export type ProviderModelRef = {
7
+ engine?: string;
8
+ provider?: string;
9
+ model?: string;
10
+ };
11
+ export declare function extractHttpStatus(err: unknown): number | undefined;
12
+ export declare function extractResponsePreview(err: unknown): string | undefined;
13
+ export declare function toError(err: unknown): Error;
14
+ export declare function summarizeError(err: unknown): AttemptErrorSummary;
15
+ export declare function isModelNotFoundError(err: unknown): boolean;
16
+ /** Transient failures — retry the same provider/model candidate. */
17
+ export declare function isRetryableError(err: unknown): boolean;
18
+ /** Failures that should advance to the next fallback candidate (not retry same candidate). */
19
+ export declare function isFallbackEligibleError(err: unknown): boolean;
20
+ export declare function buildFallbackCandidates(input: {
21
+ primaryProvider: string;
22
+ primaryModel?: string;
23
+ requestConfig?: Record<string, unknown>;
24
+ routerFallbackChain?: ProviderModelRef[];
25
+ }): FallbackCandidate[];
26
+ export declare function buildCandidateRequest(input: AIRouterRequestLike, candidate: FallbackCandidate): AIRouterRequestLike;
27
+ type AIRouterRequestLike = {
28
+ request?: {
29
+ config?: Record<string, unknown>;
30
+ model?: string;
31
+ [key: string]: unknown;
32
+ };
33
+ provider?: string;
34
+ mode?: string;
35
+ exec?: unknown;
36
+ requestId?: string;
37
+ };
38
+ export {};
@@ -0,0 +1,176 @@
1
+ const PREVIEW_CAP = 500;
2
+ export function extractHttpStatus(err) {
3
+ const e = err;
4
+ if (!e)
5
+ return undefined;
6
+ for (const key of ['status', 'statusCode', 'httpStatus']) {
7
+ const v = e[key];
8
+ if (typeof v === 'number' && Number.isFinite(v))
9
+ return v;
10
+ }
11
+ const response = e.response;
12
+ if (response && typeof response.status === 'number')
13
+ return response.status;
14
+ const msg = err instanceof Error ? err.message : String(err);
15
+ const httpMatch = msg.match(/\bHTTP\s+(\d{3})\b/i);
16
+ if (httpMatch)
17
+ return Number(httpMatch[1]);
18
+ return undefined;
19
+ }
20
+ export function extractResponsePreview(err) {
21
+ const e = err;
22
+ if (!e)
23
+ return undefined;
24
+ const raw = e.body ??
25
+ e.responseBody ??
26
+ e.response?.data ??
27
+ e.data ??
28
+ e.error;
29
+ if (raw === undefined || raw === null)
30
+ return undefined;
31
+ let text;
32
+ if (typeof raw === 'string') {
33
+ text = raw;
34
+ }
35
+ else {
36
+ try {
37
+ text = JSON.stringify(raw);
38
+ }
39
+ catch {
40
+ text = String(raw);
41
+ }
42
+ }
43
+ return text.length > PREVIEW_CAP ? text.slice(0, PREVIEW_CAP) : text;
44
+ }
45
+ export function toError(err) {
46
+ return err instanceof Error ? err : new Error(String(err));
47
+ }
48
+ export function summarizeError(err) {
49
+ const e = toError(err);
50
+ const name = typeof e.name === 'string' ? e.name : 'Error';
51
+ const message = typeof e.message === 'string' ? e.message : String(err);
52
+ const capped = message.length > PREVIEW_CAP ? message.slice(0, PREVIEW_CAP) : message;
53
+ const httpStatus = extractHttpStatus(err);
54
+ const responsePreview = extractResponsePreview(err);
55
+ return {
56
+ name,
57
+ message: capped,
58
+ ...(httpStatus !== undefined ? { httpStatus } : {}),
59
+ ...(responsePreview !== undefined ? { responsePreview } : {}),
60
+ };
61
+ }
62
+ export function isModelNotFoundError(err) {
63
+ const status = extractHttpStatus(err);
64
+ if (status === 404)
65
+ return true;
66
+ const msg = err instanceof Error ? err.message : String(err);
67
+ const name = err instanceof Error ? err.name : '';
68
+ const combined = `${name} ${msg}`.toLowerCase();
69
+ return (combined.includes('model not found') ||
70
+ combined.includes('model_not_found') ||
71
+ combined.includes('model does not exist') ||
72
+ combined.includes('unknown model') ||
73
+ combined.includes('invalid model'));
74
+ }
75
+ /** Transient failures — retry the same provider/model candidate. */
76
+ export function isRetryableError(err) {
77
+ if (isModelNotFoundError(err))
78
+ return false;
79
+ const msg = err instanceof Error ? err.message : String(err);
80
+ const name = err instanceof Error ? err.name : '';
81
+ const combined = `${name} ${msg}`.toLowerCase();
82
+ return (combined.includes('timeout') ||
83
+ combined.includes('timed out') ||
84
+ combined.includes('rate limit') ||
85
+ combined.includes('429') ||
86
+ combined.includes('econnreset') ||
87
+ combined.includes('etimedout') ||
88
+ combined.includes('503') ||
89
+ combined.includes('502') ||
90
+ combined.includes('504') ||
91
+ combined.includes('temporarily unavailable'));
92
+ }
93
+ /** Failures that should advance to the next fallback candidate (not retry same candidate). */
94
+ export function isFallbackEligibleError(err) {
95
+ if (isModelNotFoundError(err))
96
+ return true;
97
+ const status = extractHttpStatus(err);
98
+ if (status !== undefined) {
99
+ if (status === 404)
100
+ return true;
101
+ if (status >= 500)
102
+ return false;
103
+ if (status === 429)
104
+ return false;
105
+ }
106
+ return !isRetryableError(err);
107
+ }
108
+ function parseProviderModelRef(entry) {
109
+ if (!entry || typeof entry !== 'object')
110
+ return undefined;
111
+ const ref = entry;
112
+ const providerRaw = ref.engine ?? ref.provider;
113
+ if (typeof providerRaw !== 'string' || !providerRaw.trim())
114
+ return undefined;
115
+ const provider = providerRaw.trim();
116
+ const model = typeof ref.model === 'string' && ref.model.trim() ? ref.model.trim() : undefined;
117
+ return { provider, model };
118
+ }
119
+ function dedupeCandidates(candidates) {
120
+ const seen = new Set();
121
+ const out = [];
122
+ for (const c of candidates) {
123
+ const key = `${c.provider}\0${c.model ?? ''}`;
124
+ if (seen.has(key))
125
+ continue;
126
+ seen.add(key);
127
+ out.push(c);
128
+ }
129
+ return out;
130
+ }
131
+ export function buildFallbackCandidates(input) {
132
+ const { primaryProvider, primaryModel, requestConfig, routerFallbackChain } = input;
133
+ const candidates = [{ provider: primaryProvider, model: primaryModel }];
134
+ const chainRaw = (Array.isArray(requestConfig?.fallbackChain) ? requestConfig.fallbackChain : undefined) ??
135
+ (Array.isArray(requestConfig?.fallbackEngines) ? requestConfig.fallbackEngines : undefined) ??
136
+ (Array.isArray(routerFallbackChain) ? routerFallbackChain : undefined);
137
+ if (Array.isArray(chainRaw) && chainRaw.length > 0) {
138
+ for (const entry of chainRaw) {
139
+ const parsed = parseProviderModelRef(entry);
140
+ if (parsed)
141
+ candidates.push(parsed);
142
+ }
143
+ }
144
+ else {
145
+ const fallbackProvidersRaw = requestConfig?.fallbackProviders;
146
+ const fallbackProviders = Array.isArray(fallbackProvidersRaw)
147
+ ? fallbackProvidersRaw
148
+ .filter((p) => typeof p === 'string' && p.trim().length > 0)
149
+ .map((p) => p.trim())
150
+ : [];
151
+ for (const provider of fallbackProviders) {
152
+ if (provider !== primaryProvider) {
153
+ candidates.push({ provider, model: primaryModel });
154
+ }
155
+ }
156
+ }
157
+ return dedupeCandidates(candidates);
158
+ }
159
+ export function buildCandidateRequest(input, candidate) {
160
+ const baseConfig = (input.request?.config && typeof input.request.config === 'object')
161
+ ? input.request.config
162
+ : {};
163
+ const model = candidate.model ?? (typeof baseConfig.model === 'string' ? baseConfig.model : undefined);
164
+ return {
165
+ ...input,
166
+ request: {
167
+ ...input.request,
168
+ ...(model !== undefined ? { model } : {}),
169
+ config: {
170
+ ...baseConfig,
171
+ provider: candidate.provider,
172
+ ...(model !== undefined ? { model } : {}),
173
+ },
174
+ },
175
+ };
176
+ }
@@ -0,0 +1,26 @@
1
+ import type { ProviderSDKExecResult } from '@x12i/ai-provider-interface';
2
+ import type { AttemptTrace, DiagnosticsMetadata, NormalizedUsage } from './RouterTypes.js';
3
+ export type PartialRouterPayload = {
4
+ requestId: string;
5
+ provider: string;
6
+ usage?: NormalizedUsage;
7
+ metadata: DiagnosticsMetadata;
8
+ rawResponse?: unknown;
9
+ };
10
+ export declare function attachPartialRouterPayload(err: unknown, payload: PartialRouterPayload): void;
11
+ export declare function normalizeUsageFromUnknown(usage: unknown): NormalizedUsage | undefined;
12
+ export declare function buildPartialRouterPayload(args: {
13
+ requestId: string;
14
+ engineProvider: string;
15
+ providerModule?: string;
16
+ modelUsed?: string;
17
+ maxTokensRequested?: number;
18
+ timing?: {
19
+ startedAt: number;
20
+ endedAt: number;
21
+ durationMs: number;
22
+ };
23
+ attempts?: AttemptTrace[];
24
+ execResult?: ProviderSDKExecResult;
25
+ providerError?: unknown;
26
+ }): PartialRouterPayload;
@@ -0,0 +1,133 @@
1
+ export function attachPartialRouterPayload(err, payload) {
2
+ if (err == null || typeof err !== 'object')
3
+ return;
4
+ err.response = payload;
5
+ }
6
+ function firstString(...values) {
7
+ for (const v of values) {
8
+ if (typeof v === 'string' && v.trim())
9
+ return v.trim();
10
+ }
11
+ return undefined;
12
+ }
13
+ export function normalizeUsageFromUnknown(usage) {
14
+ if (!usage || typeof usage !== 'object')
15
+ return undefined;
16
+ const u = usage;
17
+ const promptTokens = (typeof u.promptTokens === 'number' ? u.promptTokens : undefined) ??
18
+ (typeof u.inputTokens === 'number' ? u.inputTokens : undefined) ??
19
+ (typeof u.prompt_tokens === 'number' ? u.prompt_tokens : undefined) ??
20
+ (typeof u.input_tokens === 'number' ? u.input_tokens : undefined) ??
21
+ (typeof u.prompt === 'number' ? u.prompt : undefined);
22
+ const completionTokens = (typeof u.completionTokens === 'number' ? u.completionTokens : undefined) ??
23
+ (typeof u.outputTokens === 'number' ? u.outputTokens : undefined) ??
24
+ (typeof u.completion_tokens === 'number' ? u.completion_tokens : undefined) ??
25
+ (typeof u.output_tokens === 'number' ? u.output_tokens : undefined) ??
26
+ (typeof u.completion === 'number' ? u.completion : undefined);
27
+ const totalTokens = (typeof u.totalTokens === 'number' ? u.totalTokens : undefined) ??
28
+ (typeof u.total_tokens === 'number' ? u.total_tokens : undefined) ??
29
+ (typeof u.total === 'number' ? u.total : undefined);
30
+ if (promptTokens === undefined && completionTokens === undefined && totalTokens === undefined) {
31
+ return undefined;
32
+ }
33
+ const p = promptTokens ?? 0;
34
+ const c = completionTokens ?? 0;
35
+ return { promptTokens: p, completionTokens: c, totalTokens: totalTokens ?? p + c };
36
+ }
37
+ function extractIdsFromRaw(raw) {
38
+ if (!raw || typeof raw !== 'object')
39
+ return {};
40
+ const r = raw;
41
+ const id = firstString(r.id, r.request_id, r.requestId);
42
+ if (!id)
43
+ return {};
44
+ return { providerRequestId: id, openrouterRequestId: id };
45
+ }
46
+ function extractRawBody(err) {
47
+ if (err == null || typeof err !== 'object')
48
+ return undefined;
49
+ const e = err;
50
+ const response = e.response;
51
+ if (response != null && typeof response === 'object') {
52
+ const resp = response;
53
+ if (resp.data !== undefined)
54
+ return resp.data;
55
+ if (resp.body !== undefined)
56
+ return resp.body;
57
+ }
58
+ for (const key of ['rawResponse', 'body', 'data', 'lastResponse', 'routerResponse']) {
59
+ if (e[key] !== undefined)
60
+ return e[key];
61
+ }
62
+ const nestedError = e.error;
63
+ if (nestedError != null && typeof nestedError === 'object') {
64
+ return extractRawBody(nestedError);
65
+ }
66
+ return undefined;
67
+ }
68
+ function extractHeaderRequestId(err) {
69
+ if (err == null || typeof err !== 'object')
70
+ return undefined;
71
+ const e = err;
72
+ const response = e.response;
73
+ if (response == null || typeof response !== 'object')
74
+ return undefined;
75
+ const headers = response.headers;
76
+ if (headers == null || typeof headers !== 'object')
77
+ return undefined;
78
+ const h = headers;
79
+ return firstString(h['x-request-id'], h['x-openrouter-request-id'], h['request-id'], h['openrouter-request-id']);
80
+ }
81
+ function usageToGatewayTokens(usage) {
82
+ return {
83
+ prompt: usage.promptTokens,
84
+ completion: usage.completionTokens,
85
+ total: usage.totalTokens,
86
+ };
87
+ }
88
+ export function buildPartialRouterPayload(args) {
89
+ let usage = args.execResult?.rawResponse
90
+ ? normalizeUsageFromUnknown(args.execResult.rawResponse?.usage)
91
+ : undefined;
92
+ let rawResponse = args.execResult?.rawResponse;
93
+ let ids = rawResponse ? extractIdsFromRaw(rawResponse) : {};
94
+ const errorBody = extractRawBody(args.providerError);
95
+ if (errorBody !== undefined) {
96
+ rawResponse = rawResponse ?? errorBody;
97
+ usage = usage ?? normalizeUsageFromUnknown(errorBody?.usage);
98
+ const bodyIds = extractIdsFromRaw(errorBody);
99
+ ids = { ...bodyIds, ...ids };
100
+ }
101
+ const headerRequestId = extractHeaderRequestId(args.providerError);
102
+ if (headerRequestId) {
103
+ ids = {
104
+ providerRequestId: ids.providerRequestId ?? headerRequestId,
105
+ openrouterRequestId: ids.openrouterRequestId ?? headerRequestId,
106
+ };
107
+ }
108
+ const providerRequestId = ids.providerRequestId;
109
+ const openrouterRequestId = args.providerModule === 'openrouter' ? providerRequestId : ids.openrouterRequestId;
110
+ const metadata = {
111
+ provider: args.providerModule ?? args.engineProvider,
112
+ ...(args.modelUsed !== undefined ? { modelUsed: args.modelUsed } : {}),
113
+ ...(args.maxTokensRequested !== undefined ? { maxTokensRequested: args.maxTokensRequested } : {}),
114
+ requestIds: {
115
+ routerRequestId: args.requestId,
116
+ ...(providerRequestId ? { providerRequestId } : {}),
117
+ ...(openrouterRequestId ? { openrouterRequestId } : {}),
118
+ },
119
+ ...(args.timing ? { timing: args.timing, latencyMs: args.timing.durationMs } : {}),
120
+ ...(args.attempts ? { attempts: args.attempts } : {}),
121
+ };
122
+ if (usage) {
123
+ metadata.usage = usage;
124
+ metadata.tokens = usageToGatewayTokens(usage);
125
+ }
126
+ return {
127
+ requestId: args.requestId,
128
+ provider: args.engineProvider,
129
+ ...(usage ? { usage } : {}),
130
+ ...(rawResponse !== undefined ? { rawResponse } : {}),
131
+ metadata,
132
+ };
133
+ }
package/dist/router.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { LLMProviderRouter } from './router/RouterWrapper.js';
2
- export type { RouterConfig, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, ActivityCostStatus, NormalizedRouterOutput } from './router/RouterTypes.js';
2
+ export type { RouterConfig, AIRouterRequest, AIResponse, AIStreamEvent, AIBatchResponse, AIBatchRequestItem, ActivityCostStatus, NormalizedRouterOutput, ProviderModelRef, } from './router/RouterTypes.js';
3
3
  export { ProviderRegistry } from './registry/ProviderRegistry.js';
4
4
  export { AdapterRegistry } from './registry/AdapterRegistry.js';
5
5
  export { OpenAIAdapter } from './adapters/openai/OpenAIAdapter.js';
@@ -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.0",
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",