@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.
- package/dist/adapters/openrouter/OpenRouterAdapter.js +4 -4
- package/dist/errors.d.ts +9 -8
- package/dist/factory.js +11 -5
- package/dist/index.d.ts +3 -1
- package/dist/openrouter-catalog.js +37 -28
- package/dist/providers/OpenRouterProvider.js +17 -0
- package/dist/router/Router.d.ts +7 -2
- package/dist/router/Router.js +115 -54
- package/dist/router/RouterTypes.d.ts +10 -0
- package/dist/router/RouterWrapper.js +33 -14
- package/dist/router/fallbackUtils.d.ts +38 -0
- package/dist/router/fallbackUtils.js +176 -0
- package/dist/router/partialErrorPayload.d.ts +26 -0
- package/dist/router/partialErrorPayload.js +133 -0
- package/dist/router.d.ts +1 -1
- package/dist/utils/openrouterEnv.d.ts +8 -0
- package/dist/utils/openrouterEnv.js +18 -0
- package/dist/utils/openrouterModelVendor.d.ts +10 -0
- package/dist/utils/openrouterModelVendor.js +40 -0
- package/package.json +1 -1
|
@@ -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
|
|
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,
|
|
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:
|
|
13
|
-
|
|
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:
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
//
|
|
205
|
+
// Never double-prefix vendor/model strings (e.g. openrouter + openai/gpt-5.4)
|
|
206
|
+
return trimmed;
|
|
194
207
|
}
|
|
195
|
-
//
|
|
196
|
-
const aliasMatch = models.find(m => m.aliases.includes(
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
const inferredProvider = await this.inferProviderFromModel(modelName);
|
|
220
|
+
const inferredProvider = await this.inferProviderFromModel(trimmed);
|
|
210
221
|
if (inferredProvider) {
|
|
211
|
-
const candidateId = `${inferredProvider}/${
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return `${providerHint}/${modelName}`;
|
|
228
|
+
const patternVendor = inferVendorSlugFromBareModel(trimmed);
|
|
229
|
+
if (patternVendor) {
|
|
230
|
+
return `${patternVendor}/${trimmed}`;
|
|
221
231
|
}
|
|
222
|
-
|
|
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 {
|
package/dist/router/Router.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
*/
|
package/dist/router/Router.js
CHANGED
|
@@ -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
|
|
20
|
-
const hasOpenRouterKey =
|
|
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
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
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:
|
|
204
|
+
request: candidateInput.request,
|
|
175
205
|
exec: input.exec,
|
|
176
206
|
});
|
|
177
207
|
const maxTokensRequested = extractMaxTokensRequested(spec);
|
|
178
|
-
const requestedModel = spec?.args?.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
|
-
|
|
216
|
+
execResult = await provider.execute(spec);
|
|
183
217
|
const endedAt = Date.now();
|
|
184
218
|
const parsed = adapter.parseResponse({
|
|
185
219
|
requestId,
|
|
186
|
-
request: { ...
|
|
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:
|
|
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,
|
|
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:
|
|
292
|
+
provider: candidate.provider,
|
|
254
293
|
retryIndex,
|
|
255
294
|
fallbackIndex,
|
|
256
295
|
requestIds: { routerRequestId: requestId },
|
|
257
296
|
},
|
|
258
|
-
timing
|
|
297
|
+
timing,
|
|
259
298
|
maxTokensRequested,
|
|
260
|
-
modelUsed
|
|
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
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
414
|
-
|
|
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 = '
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
+
}
|