@x12i/ai-providers-router 4.8.12 → 4.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -321,7 +321,15 @@ Full reference: [Environment variables](./docs/environment-variables.md) · [Con
321
321
 
322
322
  The router uses [`@x12i/logxer`](https://www.npmjs.com/package/@x12i/logxer) for structured, package-scoped logging.
323
323
 
324
- **Package prefix:** `AI_PROVIDER_ROUTER`
324
+ **Logxer identity** (npm package `@x12i/ai-providers-router`):
325
+
326
+ | Identifier | Value | Used for |
327
+ |------------|-------|----------|
328
+ | `package` (log field) | `AIProviderRouter` | Structured logs, shadow capture, Mongo `package` column |
329
+ | `envPrefix` | `AI_PROVIDER_ROUTER` | Env vars, `LOGXER_PACKAGE_LEVELS`, stack `packageLevels` keys |
330
+ | `debugNamespace` | `ai-providers-router` | `DEBUG=ai-providers-router` |
331
+
332
+ Exported constants: `ROUTER_LOG_ENV_PREFIX`, `ROUTER_LOGXER_PACKAGE`.
325
333
 
326
334
  ```bash
327
335
  # Canonical (preferred)
@@ -330,10 +338,19 @@ AI_PROVIDER_ROUTER_LOGS_LEVEL=info
330
338
  # Legacy (still supported when _LOGS_LEVEL is unset)
331
339
  AI_PROVIDER_ROUTER_LOG_LEVEL=info
332
340
 
333
- # Log full AI request/response payloads (router-specific, separate from log level)
341
+ # Log full AI request/response payloads (requires _LOGS_LEVEL=verbose to print)
334
342
  AI_PROVIDER_ROUTER_VERBOSE=true
335
343
  ```
336
344
 
345
+ **Tiered AI interaction logging**
346
+
347
+ | Level | Env threshold | Router `verbose` flag | What you get |
348
+ |-------|---------------|----------------------|--------------|
349
+ | `info` | `_LOGS_LEVEL=info` | — | One **AI call completed** line per `invoke` / `stream` / `batch` (provider, model, duration, tokens, requestId) |
350
+ | `debug` | `_LOGS_LEVEL=debug` | — | Routing resolution, fallback attempts, retries, batch poll progress |
351
+ | `verbose` | `_LOGS_LEVEL=verbose` | `VERBOSE=true` | Full sanitized request/response via **AI interaction complete** |
352
+ | `error` | always (if enabled) | — | Failed invoke/stream/batch with stack |
353
+
337
354
  **Log levels:** `error` · `warn` · `info` · `debug` · `verbose` · `off`
338
355
 
339
356
  When neither `_LOGS_LEVEL` nor `_LOG_LEVEL` is set, no `logLevel` / `logging` is passed, and the logxer registry has no entry, the router defaults to **`info`** (not logxer's package-only default of `warn`). `createRouter()` loads `LOGXER_PACKAGE_LEVELS` / `LOGXER_PACKAGE_LOGS_DEFAULT` via `applyPackageLogLevelsFromEnv()` after `.env`.
@@ -369,10 +386,22 @@ AI_PROVIDER_ROUTER_LOGS_LEVEL=error # wins over bulk for this prefix only
369
386
  **Programmatic (router only):**
370
387
 
371
388
  ```ts
372
- const router = await createRouter({ logLevel: 'debug', verbose: true });
373
- const router2 = await createRouter({ logger: createLogger({ level: 'info', verbose: false }) });
389
+ import { createRouter, createLogger, ROUTER_LOGXER_PACKAGE } from '@x12i/ai-providers-router';
390
+
391
+ // info summaries always; payloads when verbose + log level verbose
392
+ const router = await createRouter({ logLevel: 'info', verbose: true });
393
+
394
+ // inject custom logger
395
+ const router2 = await createRouter({
396
+ logger: createLogger({ level: 'debug', verbose: false }),
397
+ });
398
+
399
+ // stack identity for host config
400
+ console.log(ROUTER_LOGXER_PACKAGE.envPrefix); // AI_PROVIDER_ROUTER
374
401
  ```
375
402
 
403
+ See also: [Logxer integration checklist](./docs/logxer-package-integration-checklist.md) (generic, shareable).
404
+
376
405
  Verbose mode logs sanitized AI request/response payloads. Cross-cutting sinks (console, file, format) are configured in the **host** via `@x12i/logxer` — not via provider packages.
377
406
 
378
407
  ---
package/dist/factory.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { applyPackageLogLevelsFromEnv } from '@x12i/logxer';
2
2
  import { LLMProviderRouter } from './router/RouterWrapper.js';
3
3
  import dns from 'node:dns';
4
- import { resolveOpenRouterApiKey, OPENROUTER_API_KEY_ERC, resolveUseOpenRouter } from './utils/openrouterEnv.js';
4
+ import { resolveOpenRouterApiKey, OPENROUTER_API_KEY_ERC, resolvePreferOpenRouterPolicy } from './utils/openrouterEnv.js';
5
5
  import dotenv from 'dotenv';
6
6
  // Fix IPv6-first DNS resolution issue on Windows (forces IPv4-first to avoid connect timeouts)
7
7
  // This prevents undici from trying IPv6 first on networks that silently drop IPv6 traffic
@@ -121,7 +121,7 @@ export async function createRouter(config) {
121
121
  apiKey: OPENROUTER_API_KEY_ERC,
122
122
  httpReferer: 'ENV.OPENROUTER_HTTP_REFERER||ENV.OPEN_ROUTER_HTTP_REFERER',
123
123
  xTitle: 'ENV.OPENROUTER_X_TITLE||ENV.OPEN_ROUTER_X_TITLE',
124
- useOpenRouter: 'ENV.USE_OPENROUTER',
124
+ preferOpenRouter: 'ENV.PREFER_OPENROUTER||ENV.USE_OPENROUTER',
125
125
  },
126
126
  },
127
127
  };
@@ -134,19 +134,19 @@ export async function createRouter(config) {
134
134
  ercResult.config.providers.openrouter.apiKey = resolvedOpenRouterKey;
135
135
  }
136
136
  // Use explicit config if provided (Advanced Mode), otherwise use ERC auto-discovered config (Zero-Config Mode)
137
- const useOpenRouter = resolveUseOpenRouter({
138
- userValue: config?.useOpenRouter,
137
+ const preferOpenRouter = resolvePreferOpenRouterPolicy({
138
+ userValue: config?.preferOpenRouter ?? config?.useOpenRouter,
139
139
  });
140
140
  const finalConfig = Object.keys(config || {}).length > 0 ? {
141
141
  ...config,
142
- useOpenRouter: config?.useOpenRouter ?? useOpenRouter,
142
+ preferOpenRouter: config?.preferOpenRouter ?? config?.useOpenRouter ?? preferOpenRouter,
143
143
  } : {
144
144
  logLevel: (ercResult.config.router.logsLevel ||
145
145
  ercResult.config.router.logLevel),
146
146
  verbose: ercResult.config.router.verbose === 'true' || ercResult.config.router.verbose === true,
147
147
  timeoutMs: ercResult.config.router.timeoutMs ? parseInt(String(ercResult.config.router.timeoutMs), 10) : undefined,
148
148
  defaultTimeoutMs: ercResult.config.router.timeoutMs ? parseInt(String(ercResult.config.router.timeoutMs), 10) : undefined,
149
- useOpenRouter,
149
+ preferOpenRouter,
150
150
  };
151
151
  const router = new LLMProviderRouter(finalConfig);
152
152
  // Configure providers from ERC auto-detected config OR explicit config
package/dist/index.d.ts CHANGED
@@ -7,8 +7,8 @@ export type { FallbackAttempt } from './errors.js';
7
7
  export type { PartialRouterPayload } from './router/partialErrorPayload.js';
8
8
  export type { RequestInterceptor, ResponseInterceptor } from './interceptors.js';
9
9
  export type { UsageTracker, AdapterLoader, ProviderInit } from './types.js';
10
- export { Logger, getLogger, createLogger, createRouterLogxer, ROUTER_LOG_ENV_PREFIX, DebugLogAbstract, runWithLogContext, getLogContext, patchLogContext, applyPackageLogLevelsFromEnv, } from './logger.js';
11
- export type { LogLevel, LoggerConfig, LogMeta, LogRuntimeContext, StackLoggingOptions } from './logger.js';
10
+ export { Logger, getLogger, createLogger, createRouterLogxer, ROUTER_LOG_ENV_PREFIX, ROUTER_LOGXER_PACKAGE, extractAIModelHint, DebugLogAbstract, runWithLogContext, getLogContext, patchLogContext, applyPackageLogLevelsFromEnv, } from './logger.js';
11
+ export type { LogLevel, LoggerConfig, LogMeta, LogRuntimeContext, StackLoggingOptions, AICallCompletedMeta } from './logger.js';
12
12
  export { AIGateway } from './gateway.js';
13
13
  export type { EnhancedLLMResponse } from './gateway.js';
14
14
  export { applyResponseNormalization } from './normalization/applyResponseNormalization.js';
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ export { createRouter, createRouterFromConfig } from './factory.js';
39
39
  // Error classes
40
40
  export { ProviderNotFoundError, FallbackExhaustedError, ProviderNotInstalledError, ProviderTimeoutError } from './errors.js';
41
41
  // Logger (logxer correlation; host `StackLoggingOptions` via createRouter / LoggerConfig)
42
- export { Logger, getLogger, createLogger, createRouterLogxer, ROUTER_LOG_ENV_PREFIX, DebugLogAbstract, runWithLogContext, getLogContext, patchLogContext, applyPackageLogLevelsFromEnv, } from './logger.js';
42
+ export { Logger, getLogger, createLogger, createRouterLogxer, ROUTER_LOG_ENV_PREFIX, ROUTER_LOGXER_PACKAGE, extractAIModelHint, DebugLogAbstract, runWithLogContext, getLogContext, patchLogContext, applyPackageLogLevelsFromEnv, } from './logger.js';
43
43
  // Gateway (thin invoke wrapper)
44
44
  export { AIGateway } from './gateway.js';
45
45
  // Response normalization (Run Analysis G6/G8)
package/dist/logger.d.ts CHANGED
@@ -7,6 +7,18 @@ export type { LogLevel, LogMeta, LogRuntimeContext, StackLoggingOptions };
7
7
  export { DebugLogAbstract, runWithLogContext, getLogContext, patchLogContext, applyPackageLogLevelsFromEnv, };
8
8
  /** Stable logxer `envPrefix` for this package (`AI_PROVIDER_ROUTER_LOGS_LEVEL`). */
9
9
  export declare const ROUTER_LOG_ENV_PREFIX = "AI_PROVIDER_ROUTER";
10
+ /**
11
+ * Logxer package identity for `@x12i/ai-providers-router`.
12
+ *
13
+ * - `packageName` → `package` field in structured logs / shadow / Mongo
14
+ * - `envPrefix` → `AI_PROVIDER_ROUTER_LOGS_LEVEL`, `LOGXER_PACKAGE_LEVELS`, stack `packageLevels`
15
+ * - `debugNamespace` → `DEBUG=ai-providers-router`
16
+ */
17
+ export declare const ROUTER_LOGXER_PACKAGE: {
18
+ readonly packageName: "AIProviderRouter";
19
+ readonly envPrefix: "AI_PROVIDER_ROUTER";
20
+ readonly debugNamespace: "ai-providers-router";
21
+ };
10
22
  export interface LoggerConfig {
11
23
  verbose?: boolean;
12
24
  /** Explicit instance level (wins over `logging` stack and registry). */
@@ -24,6 +36,24 @@ export declare function createRouterLogxer(options?: {
24
36
  logging?: StackLoggingOptions;
25
37
  level?: LogLevel;
26
38
  }): Logxer;
39
+ export interface AICallCompletedMeta extends LogMeta {
40
+ mode: 'sync' | 'stream' | 'batch';
41
+ provider: string;
42
+ model?: string;
43
+ durationMs?: number;
44
+ requestId?: string;
45
+ correlationId?: string;
46
+ usage?: {
47
+ promptTokens?: number;
48
+ completionTokens?: number;
49
+ totalTokens?: number;
50
+ };
51
+ batchItemCount?: number;
52
+ attemptCount?: number;
53
+ fallbackUsed?: boolean;
54
+ }
55
+ /** Extract model id from a router request or response shape for logging summaries. */
56
+ export declare function extractAIModelHint(source: unknown): string | undefined;
27
57
  /**
28
58
  * Logger class that wraps logxer with proper log levels
29
59
  */
@@ -53,6 +83,12 @@ export declare class Logger {
53
83
  logVerbose(message: string, data?: LogMeta): void;
54
84
  logAIRequest(provider: string, request: unknown, metadata?: LogMeta): void;
55
85
  logAIResponse(provider: string, response: unknown, metadata?: LogMeta): void;
86
+ /** Info-level summary for every completed AI call (no full payloads). */
87
+ logAICallCompleted(meta: AICallCompletedMeta): void;
88
+ logAIRoutingResolved(data: LogMeta): void;
89
+ logAIFallbackAttempt(data: LogMeta): void;
90
+ logAIRetryAttempt(data: LogMeta): void;
91
+ logAIBatchProgress(data: LogMeta): void;
56
92
  logAIIteraction(provider: string, request: unknown, response: unknown, duration?: number, metadata?: LogMeta): void;
57
93
  /**
58
94
  * @deprecated Logxer sanitizes payloads on emit when `sanitization.enabled` is set (default for this logger).
package/dist/logger.js CHANGED
@@ -6,11 +6,19 @@ import { applyPackageLogLevelsFromEnv, createLogxer, DebugLogAbstract, runWithLo
6
6
  export { DebugLogAbstract, runWithLogContext, getLogContext, patchLogContext, applyPackageLogLevelsFromEnv, };
7
7
  /** Stable logxer `envPrefix` for this package (`AI_PROVIDER_ROUTER_LOGS_LEVEL`). */
8
8
  export const ROUTER_LOG_ENV_PREFIX = 'AI_PROVIDER_ROUTER';
9
- const LOGXER_PACKAGE = {
10
- packageName: 'AI Provider Router',
9
+ /**
10
+ * Logxer package identity for `@x12i/ai-providers-router`.
11
+ *
12
+ * - `packageName` → `package` field in structured logs / shadow / Mongo
13
+ * - `envPrefix` → `AI_PROVIDER_ROUTER_LOGS_LEVEL`, `LOGXER_PACKAGE_LEVELS`, stack `packageLevels`
14
+ * - `debugNamespace` → `DEBUG=ai-providers-router`
15
+ */
16
+ export const ROUTER_LOGXER_PACKAGE = {
17
+ packageName: 'AIProviderRouter',
11
18
  envPrefix: ROUTER_LOG_ENV_PREFIX,
12
19
  debugNamespace: 'ai-providers-router',
13
20
  };
21
+ const LOGXER_PACKAGE = ROUTER_LOGXER_PACKAGE;
14
22
  const DEFAULT_LOGXER_OPTIONS = {
15
23
  sanitization: {
16
24
  enabled: true,
@@ -51,6 +59,43 @@ function createGateway(options = {}) {
51
59
  export function createRouterLogxer(options) {
52
60
  return createGateway(options);
53
61
  }
62
+ /** Extract model id from a router request or response shape for logging summaries. */
63
+ export function extractAIModelHint(source) {
64
+ if (!source || typeof source !== 'object')
65
+ return undefined;
66
+ const obj = source;
67
+ const readModel = (value) => {
68
+ if (typeof value === 'string' && value.trim() !== '')
69
+ return value.trim();
70
+ if (value && typeof value === 'object') {
71
+ const obj = value;
72
+ for (const key of ['id', 'model', 'name']) {
73
+ const nested = obj[key];
74
+ if (typeof nested === 'string' && nested.trim() !== '')
75
+ return nested.trim();
76
+ }
77
+ }
78
+ return undefined;
79
+ };
80
+ const request = obj.request;
81
+ if (request && typeof request === 'object') {
82
+ const req = request;
83
+ const fromConfig = req.config && typeof req.config === 'object'
84
+ ? readModel(req.config.model)
85
+ : undefined;
86
+ if (fromConfig)
87
+ return fromConfig;
88
+ const fromRequest = readModel(req.model);
89
+ if (fromRequest)
90
+ return fromRequest;
91
+ }
92
+ const metadata = obj.metadata;
93
+ if (metadata && typeof metadata === 'object') {
94
+ const meta = metadata;
95
+ return readModel(meta.modelUsed) ?? readModel(meta.model);
96
+ }
97
+ return readModel(obj.model);
98
+ }
54
99
  /**
55
100
  * Logger class that wraps logxer with proper log levels
56
101
  */
@@ -131,35 +176,63 @@ export class Logger {
131
176
  logAIRequest(provider, request, metadata) {
132
177
  if (!this.verbose)
133
178
  return;
134
- this.logVerbose('AI Request Sent', {
179
+ this.gateway.verbose('AI request sent', {
135
180
  provider,
136
181
  request,
137
- metadata: metadata ?? {},
138
- timestamp: new Date().toISOString(),
182
+ ...(metadata ?? {}),
139
183
  debugKind: DebugLogAbstract.STATE,
140
184
  });
141
185
  }
142
186
  logAIResponse(provider, response, metadata) {
143
187
  if (!this.verbose)
144
188
  return;
145
- this.logVerbose('AI Response Received', {
189
+ this.gateway.verbose('AI response received', {
146
190
  provider,
147
191
  response,
148
- metadata: metadata ?? {},
149
- timestamp: new Date().toISOString(),
192
+ ...(metadata ?? {}),
150
193
  debugKind: DebugLogAbstract.STATE,
151
194
  });
152
195
  }
196
+ /** Info-level summary for every completed AI call (no full payloads). */
197
+ logAICallCompleted(meta) {
198
+ this.info('AI call completed', {
199
+ ...meta,
200
+ debugKind: DebugLogAbstract.EVENT,
201
+ });
202
+ }
203
+ logAIRoutingResolved(data) {
204
+ this.debug('AI routing resolved', {
205
+ ...data,
206
+ debugKind: DebugLogAbstract.INTENT,
207
+ });
208
+ }
209
+ logAIFallbackAttempt(data) {
210
+ this.debug('AI fallback attempt', {
211
+ ...data,
212
+ debugKind: DebugLogAbstract.EVENT,
213
+ });
214
+ }
215
+ logAIRetryAttempt(data) {
216
+ this.debug('AI retry attempt', {
217
+ ...data,
218
+ debugKind: DebugLogAbstract.EVENT,
219
+ });
220
+ }
221
+ logAIBatchProgress(data) {
222
+ this.debug('AI batch progress', {
223
+ ...data,
224
+ debugKind: DebugLogAbstract.EVENT,
225
+ });
226
+ }
153
227
  logAIIteraction(provider, request, response, duration, metadata) {
154
228
  if (!this.verbose)
155
229
  return;
156
- this.logVerbose('AI Interaction Complete', {
230
+ this.gateway.verbose('AI interaction complete', {
157
231
  provider,
158
232
  request,
159
233
  response,
160
- duration: duration ? `${duration}ms` : undefined,
161
- metadata: metadata ?? {},
162
- timestamp: new Date().toISOString(),
234
+ durationMs: duration,
235
+ ...(metadata ?? {}),
163
236
  debugKind: DebugLogAbstract.EVENT,
164
237
  });
165
238
  }
@@ -10,13 +10,14 @@ export declare class AIRouter {
10
10
  private adapters;
11
11
  private routerConfig;
12
12
  constructor(providers: ProviderRegistry, adapters: AdapterRegistry, routerConfig?: RouterConfig);
13
+ private get logger();
13
14
  /**
14
15
  * Resolve provider module name for a specific fallback candidate.
15
16
  */
16
17
  private resolveProviderNameForCandidate;
17
18
  /**
18
19
  * Resolve provider name from request.
19
- * Prefers OpenRouter when USE_OPENROUTER is true (default); falls back to OpenRouter when
20
+ * Prefers OpenRouter when PREFER_OPENROUTER is true (default) and a key is present; falls back to OpenRouter when
20
21
  * the requested vendor has no direct provider registered but an OpenRouter key exists.
21
22
  */
22
23
  private resolveProviderName;
@@ -15,6 +15,9 @@ export class AIRouter {
15
15
  this.adapters = adapters;
16
16
  this.routerConfig = routerConfig;
17
17
  }
18
+ get logger() {
19
+ return this.routerConfig.logger;
20
+ }
18
21
  /**
19
22
  * Resolve provider module name for a specific fallback candidate.
20
23
  */
@@ -33,7 +36,7 @@ export class AIRouter {
33
36
  }
34
37
  /**
35
38
  * Resolve provider name from request.
36
- * Prefers OpenRouter when USE_OPENROUTER is true (default); falls back to OpenRouter when
39
+ * Prefers OpenRouter when PREFER_OPENROUTER is true (default) and a key is present; falls back to OpenRouter when
37
40
  * the requested vendor has no direct provider registered but an OpenRouter key exists.
38
41
  */
39
42
  resolveProviderName(input) {
@@ -118,6 +121,17 @@ export class AIRouter {
118
121
  requestConfig: requestConfig && typeof requestConfig === 'object' ? requestConfig : undefined,
119
122
  routerFallbackChain: this.routerConfig.fallbackChain,
120
123
  });
124
+ this.logger?.logAIRoutingResolved({
125
+ requestId,
126
+ mode: 'sync',
127
+ requestedProvider: input.provider ?? (typeof requestConfig?.provider === 'string' ? requestConfig.provider : undefined),
128
+ resolvedProvider: primaryProviderName,
129
+ model: primaryModel,
130
+ preferOpenRouter: shouldPreferOpenRouter(this.routerConfig),
131
+ openRouterKeyPresent: hasOpenRouterApiKey(),
132
+ fallbackCandidateCount: fallbackCandidates.length,
133
+ fallbackCandidates: fallbackCandidates.map((c) => ({ provider: c.provider, model: c.model })),
134
+ });
121
135
  const maxRetries = Math.max(0, Math.min(10, Number(input.exec?.retries ?? 0) || 0));
122
136
  const attempts = [];
123
137
  const normalizeUsage = (usage) => {
@@ -161,6 +175,14 @@ export class AIRouter {
161
175
  const candidate = fallbackCandidates[fallbackIndex];
162
176
  const candidateInput = buildCandidateRequest(input, candidate);
163
177
  const providerName = this.resolveProviderNameForCandidate(candidateInput, candidate.provider);
178
+ this.logger?.logAIFallbackAttempt({
179
+ requestId,
180
+ mode: 'sync',
181
+ fallbackIndex,
182
+ candidateProvider: candidate.provider,
183
+ resolvedModule: providerName,
184
+ model: candidate.model,
185
+ });
164
186
  if (!this.providers.has(providerName)) {
165
187
  const err = new Error(`Provider not registered: ${providerName}. Available: ${this.providers.list().join(', ')}`);
166
188
  lastError = err;
@@ -207,6 +229,16 @@ export class AIRouter {
207
229
  candidateInput.request?.config?.model ??
208
230
  candidateInput.request?.model;
209
231
  for (let retryIndex = 0; retryIndex <= maxRetries; retryIndex++) {
232
+ if (retryIndex > 0) {
233
+ this.logger?.logAIRetryAttempt({
234
+ requestId,
235
+ mode: 'sync',
236
+ provider: providerName,
237
+ model: candidate.model,
238
+ retryIndex,
239
+ maxRetries,
240
+ });
241
+ }
210
242
  const startedAt = Date.now();
211
243
  let execResult;
212
244
  try {
@@ -338,6 +370,18 @@ export class AIRouter {
338
370
  async *runStream(input) {
339
371
  const requestId = input.requestId ?? newId();
340
372
  const providerName = this.resolveProviderName(input);
373
+ const requestConfig = input.request?.config;
374
+ const model = (typeof requestConfig?.model === 'string' ? requestConfig.model : undefined) ??
375
+ (typeof input.request?.model === 'string' ? input.request.model : undefined);
376
+ this.logger?.logAIRoutingResolved({
377
+ requestId,
378
+ mode: 'stream',
379
+ requestedProvider: input.provider ?? (typeof requestConfig?.provider === 'string' ? requestConfig.provider : undefined),
380
+ resolvedProvider: providerName,
381
+ model,
382
+ preferOpenRouter: shouldPreferOpenRouter(this.routerConfig),
383
+ openRouterKeyPresent: hasOpenRouterApiKey(),
384
+ });
341
385
  const provider = this.providers.get(providerName);
342
386
  const adapter = this.adapters.get(providerName);
343
387
  // Check capabilities
@@ -414,6 +458,12 @@ export class AIRouter {
414
458
  async runBatch(providerName, items, exec) {
415
459
  const provider = this.providers.get(providerName);
416
460
  const adapter = this.adapters.get(providerName);
461
+ this.logger?.logAIRoutingResolved({
462
+ mode: 'batch',
463
+ requestedProvider: providerName,
464
+ resolvedProvider: providerName,
465
+ batchItemCount: items.length,
466
+ });
417
467
  // Check capabilities
418
468
  if (!provider.capabilities.modes.batch) {
419
469
  throw new Error(`Provider '${providerName}' does not support batch mode`);
@@ -443,6 +493,12 @@ export class AIRouter {
443
493
  })));
444
494
  // Submit batch
445
495
  const handle = await provider.submitBatch(specs);
496
+ this.logger?.logAIBatchProgress({
497
+ provider: providerName,
498
+ batchItemCount: itemsWithIds.length,
499
+ phase: 'submitted',
500
+ batchId: handle.batchId,
501
+ });
446
502
  // Poll until complete
447
503
  while (true) {
448
504
  const status = await provider.getBatchStatus(handle);
@@ -452,6 +508,13 @@ export class AIRouter {
452
508
  if (status.state === 'failed' || status.state === 'canceled') {
453
509
  throw new Error(`Batch ended with state: ${status.state}`);
454
510
  }
511
+ this.logger?.logAIBatchProgress({
512
+ provider: providerName,
513
+ batchItemCount: itemsWithIds.length,
514
+ phase: 'polling',
515
+ batchState: status.state,
516
+ batchId: handle.batchId,
517
+ });
455
518
  // Wait before polling again
456
519
  await new Promise((r) => setTimeout(r, 2000));
457
520
  }
@@ -104,9 +104,11 @@ export interface RouterConfig {
104
104
  defaultTimeoutMs?: number;
105
105
  /**
106
106
  * Prefer OpenRouter for vendor calls when OPENROUTER_API_KEY is set (default: true).
107
- * Maps to USE_OPENROUTER env. When false, direct providers are used when keys exist;
107
+ * Maps to PREFER_OPENROUTER env. When false, direct providers are used when keys exist;
108
108
  * OpenRouter is still used as fallback for providers without a direct key.
109
109
  */
110
+ preferOpenRouter?: boolean;
111
+ /** @deprecated Use preferOpenRouter (maps to USE_OPENROUTER env as legacy fallback). */
110
112
  useOpenRouter?: boolean;
111
113
  /** OpenRouter provider config (e.g. from gateway); used when env OPEN_ROUTER_KEY is not visible */
112
114
  openrouter?: {
@@ -4,7 +4,7 @@ import { AdapterRegistry } from '../registry/AdapterRegistry.js';
4
4
  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
- import { getLogger, runWithLogContext, DebugLogAbstract } from '../logger.js';
7
+ import { getLogger, runWithLogContext, DebugLogAbstract, extractAIModelHint } from '../logger.js';
8
8
  import { resolveOpenRouterApiKey, OPENROUTER_API_KEY_ERC, shouldPreferOpenRouter } from '../utils/openrouterEnv.js';
9
9
  import { resolveModelVendorSlug } from '../utils/openrouterModelVendor.js';
10
10
  /**
@@ -66,8 +66,11 @@ export class LLMProviderRouter {
66
66
  this.adapterRegistry.register(new OpenAIAdapter());
67
67
  this.adapterRegistry.register(new GrokAdapter());
68
68
  this.adapterRegistry.register(new OpenRouterAdapter());
69
- // Create router
70
- this.router = new AIRouter(this.providerRegistry, this.adapterRegistry, this.config);
69
+ // Create router (inject logger for core routing/fallback debug logs)
70
+ this.router = new AIRouter(this.providerRegistry, this.adapterRegistry, {
71
+ ...this.config,
72
+ logger: this.logger,
73
+ });
71
74
  this.logger.info('Router initialized with ProviderModule architecture', {
72
75
  verbose: this.logger.verbose,
73
76
  logLevel: this.logger.level,
@@ -232,10 +235,21 @@ export class LLMProviderRouter {
232
235
  throw err;
233
236
  }
234
237
  const invokeDuration = Date.now() - invokeStartTime;
235
- // Log response
236
- this.logger.logAIResponse(result.provider, result, {
238
+ const attempts = result.metadata?.attempts;
239
+ this.logger.logAICallCompleted({
240
+ mode: 'sync',
241
+ provider: result.provider,
242
+ model: extractAIModelHint(result) ?? extractAIModelHint(processedRequest),
243
+ durationMs: invokeDuration,
244
+ requestId: result.requestId,
245
+ correlationId: request.requestId,
246
+ usage: result.usage,
247
+ attemptCount: attempts?.length,
248
+ fallbackUsed: attempts?.some((a) => (a.routing?.fallbackIndex ?? 0) > 0),
249
+ });
250
+ this.logger.logAIIteraction(result.provider, processedRequest, result, invokeDuration, {
237
251
  requestId: result.requestId,
238
- duration: invokeDuration,
252
+ correlationId: request.requestId,
239
253
  usage: result.usage,
240
254
  });
241
255
  // Apply response interceptors
@@ -265,6 +279,7 @@ export class LLMProviderRouter {
265
279
  * Stream request
266
280
  */
267
281
  async *stream(request) {
282
+ const startTime = Date.now();
268
283
  // Auto-register providers if needed
269
284
  await this.ensureProvidersRegistered();
270
285
  // Apply request interceptors
@@ -281,34 +296,64 @@ export class LLMProviderRouter {
281
296
  processedRequest.exec.timeoutMs = this.config.timeoutMs ?? this.config.defaultTimeoutMs ?? 60000;
282
297
  }
283
298
  // idempotencyKey and signal are passed through as-is if provided
284
- // Log request
285
299
  this.logger.logAIRequest(request.provider || 'unknown', processedRequest, {
286
300
  requestId: processedRequest.requestId,
287
301
  correlationId: request.requestId,
302
+ mode: 'stream',
288
303
  });
289
- // Stream
290
- for await (const event of this.router.runStream(processedRequest)) {
291
- // Apply response interceptors to completed events
292
- if (event.type === 'completed') {
293
- let processedResponse = event.response;
294
- for (const interceptor of this.responseInterceptors) {
295
- processedResponse = await interceptor(processedResponse, event.response.provider);
304
+ try {
305
+ for await (const event of this.router.runStream(processedRequest)) {
306
+ if (event.type === 'completed') {
307
+ let processedResponse = event.response;
308
+ for (const interceptor of this.responseInterceptors) {
309
+ processedResponse = await interceptor(processedResponse, event.response.provider);
310
+ }
311
+ const durationMs = Date.now() - startTime;
312
+ this.logger.logAICallCompleted({
313
+ mode: 'stream',
314
+ provider: processedResponse.provider,
315
+ model: extractAIModelHint(processedResponse) ?? extractAIModelHint(processedRequest),
316
+ durationMs,
317
+ requestId: event.requestId,
318
+ correlationId: request.requestId,
319
+ usage: processedResponse.usage,
320
+ });
321
+ this.logger.logAIIteraction(processedResponse.provider, processedRequest, processedResponse, durationMs, {
322
+ requestId: event.requestId,
323
+ correlationId: request.requestId,
324
+ usage: processedResponse.usage,
325
+ mode: 'stream',
326
+ });
327
+ yield {
328
+ type: 'completed',
329
+ requestId: event.requestId,
330
+ response: processedResponse,
331
+ };
332
+ }
333
+ else {
334
+ yield event;
296
335
  }
297
- yield {
298
- type: 'completed',
299
- requestId: event.requestId,
300
- response: processedResponse,
301
- };
302
- }
303
- else {
304
- yield event;
305
336
  }
306
337
  }
338
+ catch (error) {
339
+ const err = error instanceof Error ? error : new Error(String(error));
340
+ this.logger.error('Router stream execution failed', {
341
+ provider: request.provider,
342
+ error: err.message,
343
+ stack: err.stack,
344
+ requestId: processedRequest.requestId,
345
+ correlationId: request.requestId,
346
+ mode: 'stream',
347
+ debugKind: DebugLogAbstract.ANOMALY,
348
+ });
349
+ throw err;
350
+ }
307
351
  }
308
352
  /**
309
353
  * Batch request
310
354
  */
311
355
  async createBatch(providerName, items, exec) {
356
+ const startTime = Date.now();
312
357
  // Auto-register providers if needed
313
358
  await this.ensureProvidersRegistered();
314
359
  // Ensure exec.timeoutMs is always set (router owns execution semantics)
@@ -319,7 +364,34 @@ export class LLMProviderRouter {
319
364
  idempotencyKey: exec?.idempotencyKey,
320
365
  signal: exec?.signal,
321
366
  };
322
- return this.router.runBatch(providerName, items, execWithDefaults);
367
+ this.logger.logAIRequest(providerName, { provider: providerName, itemCount: items.length, items }, {
368
+ mode: 'batch',
369
+ batchItemCount: items.length,
370
+ });
371
+ try {
372
+ const result = await this.router.runBatch(providerName, items, execWithDefaults);
373
+ const durationMs = Date.now() - startTime;
374
+ this.logger.logAICallCompleted({
375
+ mode: 'batch',
376
+ provider: result.provider,
377
+ durationMs,
378
+ batchItemCount: result.items.length,
379
+ });
380
+ this.logger.logAIIteraction(result.provider, { provider: providerName, itemCount: items.length, items }, result, durationMs, { mode: 'batch', batchItemCount: result.items.length });
381
+ return result;
382
+ }
383
+ catch (error) {
384
+ const err = error instanceof Error ? error : new Error(String(error));
385
+ this.logger.error('Router batch execution failed', {
386
+ provider: providerName,
387
+ error: err.message,
388
+ stack: err.stack,
389
+ batchItemCount: items.length,
390
+ mode: 'batch',
391
+ debugKind: DebugLogAbstract.ANOMALY,
392
+ });
393
+ throw err;
394
+ }
323
395
  }
324
396
  /**
325
397
  * List registered providers
@@ -6,21 +6,32 @@ export declare function resolveOpenRouterApiKey(): string | undefined;
6
6
  export declare function hasOpenRouterApiKey(): boolean;
7
7
  /** ERC placeholder resolution: prefer canonical name, fall back to legacy. */
8
8
  export declare const OPENROUTER_API_KEY_ERC = "ENV.OPENROUTER_API_KEY||ENV.OPEN_ROUTER_KEY";
9
- export interface ResolveUseOpenRouterOptions {
10
- /** Programmatic override from RouterConfig.useOpenRouter — wins over environment. */
9
+ export interface ResolvePreferOpenRouterOptions {
10
+ /** Programmatic override from RouterConfig.preferOpenRouter — wins over environment. */
11
11
  userValue?: boolean;
12
12
  /** For tests; defaults to `process.env`. */
13
13
  env?: NodeJS.ProcessEnv;
14
14
  }
15
+ /** @deprecated Use ResolvePreferOpenRouterOptions */
16
+ export type ResolveUseOpenRouterOptions = ResolvePreferOpenRouterOptions;
17
+ export interface PreferOpenRouterConfig {
18
+ preferOpenRouter?: boolean;
19
+ /** @deprecated Use preferOpenRouter */
20
+ useOpenRouter?: boolean;
21
+ }
15
22
  /**
16
- * Whether to prefer OpenRouter for vendor calls when an OpenRouter key is available.
23
+ * Read operator preference from env. Prefers PREFER_OPENROUTER; USE_OPENROUTER is legacy fallback.
24
+ * Default when both are unset: `true`.
25
+ */
26
+ export declare function readPreferOpenRouterFromEnv(env?: NodeJS.ProcessEnv): boolean;
27
+ /**
28
+ * Whether the operator prefers OpenRouter for vendor calls (before key availability is applied).
17
29
  *
18
- * Default: `true` (route through OpenRouter even when direct provider keys exist).
19
- * Set `USE_OPENROUTER=false` to use direct providers when keys exist; OpenRouter remains
20
- * available as fallback for providers without a direct key.
30
+ * Default: `true`. Set `PREFER_OPENROUTER=false` to force vendor-direct when keys exist;
31
+ * OpenRouter remains available as fallback for providers without a direct key.
21
32
  */
22
- export declare function resolveUseOpenRouter(options?: ResolveUseOpenRouterOptions): boolean;
33
+ export declare function resolvePreferOpenRouterPolicy(options?: ResolvePreferOpenRouterOptions): boolean;
34
+ /** @deprecated Use resolvePreferOpenRouterPolicy */
35
+ export declare function resolveUseOpenRouter(options?: ResolvePreferOpenRouterOptions): boolean;
23
36
  /** True when OpenRouter should be the primary transport (not merely fallback). */
24
- export declare function shouldPreferOpenRouter(config: {
25
- useOpenRouter?: boolean;
26
- } | undefined, env?: NodeJS.ProcessEnv): boolean;
37
+ export declare function shouldPreferOpenRouter(config: PreferOpenRouterConfig | undefined, env?: NodeJS.ProcessEnv): boolean;
@@ -16,31 +16,56 @@ export function hasOpenRouterApiKey() {
16
16
  }
17
17
  /** ERC placeholder resolution: prefer canonical name, fall back to legacy. */
18
18
  export const OPENROUTER_API_KEY_ERC = 'ENV.OPENROUTER_API_KEY||ENV.OPEN_ROUTER_KEY';
19
+ function parsePreferOpenRouterEnvValue(raw) {
20
+ if (raw === undefined || raw === '')
21
+ return undefined;
22
+ if (raw === 'false')
23
+ return false;
24
+ if (raw === 'true')
25
+ return true;
26
+ return true;
27
+ }
19
28
  /**
20
- * Whether to prefer OpenRouter for vendor calls when an OpenRouter key is available.
29
+ * Read operator preference from env. Prefers PREFER_OPENROUTER; USE_OPENROUTER is legacy fallback.
30
+ * Default when both are unset: `true`.
31
+ */
32
+ export function readPreferOpenRouterFromEnv(env = process.env) {
33
+ const prefer = parsePreferOpenRouterEnvValue(env.PREFER_OPENROUTER);
34
+ if (prefer !== undefined)
35
+ return prefer;
36
+ const legacy = parsePreferOpenRouterEnvValue(env.USE_OPENROUTER);
37
+ if (legacy !== undefined)
38
+ return legacy;
39
+ return true;
40
+ }
41
+ function resolveConfigPreferOpenRouter(config) {
42
+ if (config?.preferOpenRouter !== undefined)
43
+ return config.preferOpenRouter;
44
+ if (config?.useOpenRouter !== undefined)
45
+ return config.useOpenRouter;
46
+ return undefined;
47
+ }
48
+ /**
49
+ * Whether the operator prefers OpenRouter for vendor calls (before key availability is applied).
21
50
  *
22
- * Default: `true` (route through OpenRouter even when direct provider keys exist).
23
- * Set `USE_OPENROUTER=false` to use direct providers when keys exist; OpenRouter remains
24
- * available as fallback for providers without a direct key.
51
+ * Default: `true`. Set `PREFER_OPENROUTER=false` to force vendor-direct when keys exist;
52
+ * OpenRouter remains available as fallback for providers without a direct key.
25
53
  */
26
- export function resolveUseOpenRouter(options = {}) {
54
+ export function resolvePreferOpenRouterPolicy(options = {}) {
27
55
  const { userValue, env = process.env } = options;
28
56
  if (userValue === false)
29
57
  return false;
30
58
  if (userValue === true)
31
59
  return true;
32
- const raw = env.USE_OPENROUTER;
33
- if (raw === undefined || raw === '')
34
- return true;
35
- if (raw === 'false')
36
- return false;
37
- if (raw === 'true')
38
- return true;
39
- return true;
60
+ return readPreferOpenRouterFromEnv(env);
61
+ }
62
+ /** @deprecated Use resolvePreferOpenRouterPolicy */
63
+ export function resolveUseOpenRouter(options = {}) {
64
+ return resolvePreferOpenRouterPolicy(options);
40
65
  }
41
66
  /** True when OpenRouter should be the primary transport (not merely fallback). */
42
67
  export function shouldPreferOpenRouter(config, env) {
43
68
  if (!hasOpenRouterApiKey())
44
69
  return false;
45
- return resolveUseOpenRouter({ userValue: config?.useOpenRouter, env });
70
+ return resolvePreferOpenRouterPolicy({ userValue: resolveConfigPreferOpenRouter(config), env });
46
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@x12i/ai-providers-router",
3
- "version": "4.8.12",
3
+ "version": "4.9.1",
4
4
  "description": "Unified router for all LLM provider implementations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -46,7 +46,7 @@
46
46
  "@x12i/ai-provider-grok": "^3.2.0",
47
47
  "@x12i/ai-provider-interface": "^3.2.1",
48
48
  "@x12i/ai-provider-openai": "^3.2.1",
49
- "@x12i/logxer": "^4.5.1",
49
+ "@x12i/logxer": "^4.6.0",
50
50
  "ai-io-normalizer": "^6.0.3"
51
51
  },
52
52
  "devDependencies": {