ai.matey.core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cjs/bridge.js +657 -0
  3. package/dist/cjs/bridge.js.map +1 -0
  4. package/dist/cjs/capability-inference.js +349 -0
  5. package/dist/cjs/capability-inference.js.map +1 -0
  6. package/dist/cjs/capability-matcher.js +216 -0
  7. package/dist/cjs/capability-matcher.js.map +1 -0
  8. package/dist/cjs/index.js +31 -0
  9. package/dist/cjs/index.js.map +1 -0
  10. package/dist/cjs/middleware-stack.js +256 -0
  11. package/dist/cjs/middleware-stack.js.map +1 -0
  12. package/dist/cjs/model-pricing.js +350 -0
  13. package/dist/cjs/model-pricing.js.map +1 -0
  14. package/dist/cjs/model-translation.js +171 -0
  15. package/dist/cjs/model-translation.js.map +1 -0
  16. package/dist/cjs/router.js +1388 -0
  17. package/dist/cjs/router.js.map +1 -0
  18. package/dist/esm/bridge.js +652 -0
  19. package/dist/esm/bridge.js.map +1 -0
  20. package/dist/esm/capability-inference.js +343 -0
  21. package/dist/esm/capability-inference.js.map +1 -0
  22. package/dist/esm/capability-matcher.js +210 -0
  23. package/dist/esm/capability-matcher.js.map +1 -0
  24. package/dist/esm/index.js +15 -0
  25. package/dist/esm/index.js.map +1 -0
  26. package/dist/esm/middleware-stack.js +250 -0
  27. package/dist/esm/middleware-stack.js.map +1 -0
  28. package/dist/esm/model-pricing.js +340 -0
  29. package/dist/esm/model-pricing.js.map +1 -0
  30. package/dist/esm/model-translation.js +163 -0
  31. package/dist/esm/model-translation.js.map +1 -0
  32. package/dist/esm/router.js +1383 -0
  33. package/dist/esm/router.js.map +1 -0
  34. package/dist/types/bridge.d.ts +254 -0
  35. package/dist/types/bridge.d.ts.map +1 -0
  36. package/dist/types/capability-inference.d.ts +35 -0
  37. package/dist/types/capability-inference.d.ts.map +1 -0
  38. package/dist/types/capability-matcher.d.ts +104 -0
  39. package/dist/types/capability-matcher.d.ts.map +1 -0
  40. package/dist/types/index.d.ts +15 -0
  41. package/dist/types/index.d.ts.map +1 -0
  42. package/dist/types/middleware-stack.d.ts +102 -0
  43. package/dist/types/middleware-stack.d.ts.map +1 -0
  44. package/dist/types/model-pricing.d.ts +81 -0
  45. package/dist/types/model-pricing.d.ts.map +1 -0
  46. package/dist/types/model-translation.d.ts +171 -0
  47. package/dist/types/model-translation.d.ts.map +1 -0
  48. package/dist/types/router.d.ts +287 -0
  49. package/dist/types/router.d.ts.map +1 -0
  50. package/package.json +70 -0
  51. package/readme.md +34 -0
@@ -0,0 +1,1383 @@
1
+ /**
2
+ * Router Implementation
3
+ *
4
+ * The Router manages multiple backend adapters with intelligent routing,
5
+ * fallback strategies, circuit breaker pattern, and health checking.
6
+ * It implements the BackendAdapter interface so it can be used as a backend.
7
+ *
8
+ * @module
9
+ */
10
+ import { AdapterError, ErrorCode } from 'ai.matey.errors';
11
+ import { findBestModel } from './capability-matcher.js';
12
+ import { inferCapabilities } from './capability-inference.js';
13
+ // ============================================================================
14
+ // Router Implementation
15
+ // ============================================================================
16
+ /**
17
+ * Router manages multiple backend adapters with intelligent routing.
18
+ */
19
+ export class Router {
20
+ metadata;
21
+ config;
22
+ backends = new Map();
23
+ modelMapping = new Map(); // model -> backend (for routing)
24
+ modelTranslationMapping = new Map(); // model -> model (for translation)
25
+ backendTranslationMappings = new Map(); // backend -> (model -> model)
26
+ modelPatterns = [];
27
+ fallbackChain = [];
28
+ roundRobinIndex = 0;
29
+ healthCheckInterval;
30
+ // Stats
31
+ stats = {
32
+ totalRequests: 0,
33
+ successfulRequests: 0,
34
+ failedRequests: 0,
35
+ totalFallbacks: 0,
36
+ parallelRequests: 0,
37
+ sinceTimestamp: Date.now(),
38
+ };
39
+ constructor(config = {}) {
40
+ this.config = {
41
+ routingStrategy: config.routingStrategy ?? 'explicit',
42
+ fallbackStrategy: config.fallbackStrategy ?? 'sequential',
43
+ defaultBackend: config.defaultBackend,
44
+ healthCheckInterval: config.healthCheckInterval ?? 0,
45
+ enableCircuitBreaker: config.enableCircuitBreaker ?? false,
46
+ circuitBreakerThreshold: config.circuitBreakerThreshold ?? 5,
47
+ circuitBreakerTimeout: config.circuitBreakerTimeout ?? 60000,
48
+ trackLatency: config.trackLatency ?? true,
49
+ trackCost: config.trackCost ?? false,
50
+ capabilityBasedRouting: config.capabilityBasedRouting ?? false,
51
+ optimization: config.optimization ?? 'balanced',
52
+ optimizationWeights: config.optimizationWeights,
53
+ capabilityCacheDuration: config.capabilityCacheDuration ?? 3600000, // 1 hour
54
+ customRouter: config.customRouter,
55
+ customFallback: config.customFallback,
56
+ modelTranslation: config.modelTranslation ?? {
57
+ strategy: 'hybrid',
58
+ warnOnDefault: true,
59
+ strictMode: false,
60
+ },
61
+ };
62
+ this.metadata = {
63
+ name: 'router',
64
+ version: '1.0.0',
65
+ provider: 'Universal Router',
66
+ capabilities: {
67
+ streaming: true,
68
+ multiModal: true,
69
+ tools: true,
70
+ systemMessageStrategy: 'in-messages',
71
+ supportsMultipleSystemMessages: true,
72
+ },
73
+ config: this.config,
74
+ };
75
+ // Start health checking if enabled
76
+ if (this.config.healthCheckInterval && this.config.healthCheckInterval > 0) {
77
+ this.startHealthChecking();
78
+ }
79
+ }
80
+ // ==========================================================================
81
+ // Format Conversion (Not Applicable for Router)
82
+ // ==========================================================================
83
+ /**
84
+ * Convert IR request to provider format.
85
+ * Not applicable for Router - use the specific backend adapter instead.
86
+ */
87
+ fromIR(_request) {
88
+ throw new Error('fromIR() not applicable for Router - route to a specific backend first');
89
+ }
90
+ /**
91
+ * Convert provider response to IR format.
92
+ * Not applicable for Router - use the specific backend adapter instead.
93
+ */
94
+ toIR(_response, _originalRequest, _latencyMs) {
95
+ throw new Error('toIR() not applicable for Router - route to a specific backend first');
96
+ }
97
+ // ==========================================================================
98
+ // Backend Management
99
+ // ==========================================================================
100
+ /**
101
+ * Register a backend adapter.
102
+ */
103
+ register(name, adapter) {
104
+ if (this.backends.has(name)) {
105
+ throw new AdapterError({
106
+ code: ErrorCode.ROUTING_FAILED,
107
+ message: `Backend '${name}' is already registered`,
108
+ isRetryable: false,
109
+ provenance: { router: this.metadata.name },
110
+ });
111
+ }
112
+ this.backends.set(name, {
113
+ adapter,
114
+ isHealthy: true,
115
+ circuitBreakerState: 'closed',
116
+ consecutiveFailures: 0,
117
+ latencies: [],
118
+ totalRequests: 0,
119
+ successfulRequests: 0,
120
+ failedRequests: 0,
121
+ totalCost: 0,
122
+ });
123
+ return this;
124
+ }
125
+ /**
126
+ * Unregister a backend adapter.
127
+ */
128
+ unregister(name) {
129
+ if (!this.backends.has(name)) {
130
+ throw new AdapterError({
131
+ code: ErrorCode.ROUTING_FAILED,
132
+ message: `Backend '${name}' is not registered`,
133
+ isRetryable: false,
134
+ provenance: { router: this.metadata.name },
135
+ });
136
+ }
137
+ // Check if it's the default backend
138
+ if (this.config.defaultBackend === name) {
139
+ throw new AdapterError({
140
+ code: ErrorCode.ROUTING_FAILED,
141
+ message: `Cannot unregister default backend '${name}'`,
142
+ isRetryable: false,
143
+ provenance: { router: this.metadata.name },
144
+ });
145
+ }
146
+ // Check if it's the last backend
147
+ if (this.backends.size === 1) {
148
+ throw new AdapterError({
149
+ code: ErrorCode.ROUTING_FAILED,
150
+ message: `Cannot unregister last backend '${name}'`,
151
+ isRetryable: false,
152
+ provenance: { router: this.metadata.name },
153
+ });
154
+ }
155
+ this.backends.delete(name);
156
+ return this;
157
+ }
158
+ /**
159
+ * Get a registered backend adapter.
160
+ */
161
+ get(name) {
162
+ return this.backends.get(name)?.adapter;
163
+ }
164
+ /**
165
+ * Check if backend is registered.
166
+ */
167
+ has(name) {
168
+ return this.backends.has(name);
169
+ }
170
+ /**
171
+ * List all registered backend names.
172
+ */
173
+ listBackends() {
174
+ return Array.from(this.backends.keys());
175
+ }
176
+ getBackendInfo(name) {
177
+ if (name !== undefined) {
178
+ const state = this.backends.get(name);
179
+ if (!state) {
180
+ return undefined;
181
+ }
182
+ return this.createBackendInfo(name, state);
183
+ }
184
+ const infos = [];
185
+ for (const [backendName, state] of this.backends.entries()) {
186
+ infos.push(this.createBackendInfo(backendName, state));
187
+ }
188
+ return infos;
189
+ }
190
+ // ==========================================================================
191
+ // Routing Configuration
192
+ // ==========================================================================
193
+ /**
194
+ * Set fallback chain for sequential failover.
195
+ */
196
+ setFallbackChain(chain) {
197
+ // Validate all backends exist
198
+ for (const name of chain) {
199
+ if (!this.backends.has(name)) {
200
+ throw new AdapterError({
201
+ code: ErrorCode.ROUTING_FAILED,
202
+ message: `Backend '${name}' in fallback chain is not registered`,
203
+ isRetryable: false,
204
+ provenance: { router: this.metadata.name },
205
+ });
206
+ }
207
+ }
208
+ this.fallbackChain = [...chain];
209
+ return this;
210
+ }
211
+ /**
212
+ * Get current fallback chain.
213
+ */
214
+ getFallbackChain() {
215
+ return this.fallbackChain;
216
+ }
217
+ /**
218
+ * Set model to backend mapping.
219
+ */
220
+ setModelMapping(mapping) {
221
+ this.modelMapping.clear();
222
+ for (const [model, backend] of Object.entries(mapping)) {
223
+ if (!this.backends.has(backend)) {
224
+ throw new AdapterError({
225
+ code: ErrorCode.ROUTING_FAILED,
226
+ message: `Backend '${String(backend)}' in model mapping is not registered`,
227
+ isRetryable: false,
228
+ provenance: { router: this.metadata.name },
229
+ });
230
+ }
231
+ this.modelMapping.set(model, backend);
232
+ }
233
+ return this;
234
+ }
235
+ /**
236
+ * Get current model mapping.
237
+ */
238
+ getModelMapping() {
239
+ const mapping = {};
240
+ for (const [model, backend] of this.modelMapping.entries()) {
241
+ mapping[model] = backend;
242
+ }
243
+ return mapping;
244
+ }
245
+ /**
246
+ * Set model name translation mapping (for fallback scenarios).
247
+ * Maps source model names to target model names.
248
+ *
249
+ * @example
250
+ * ```typescript
251
+ * router.setModelTranslationMapping({
252
+ * 'gpt-4': 'claude-3-5-sonnet-20241022',
253
+ * 'gpt-3.5-turbo': 'claude-3-5-haiku-20241022'
254
+ * });
255
+ * ```
256
+ */
257
+ setModelTranslationMapping(mapping) {
258
+ this.modelTranslationMapping.clear();
259
+ for (const [sourceModel, targetModel] of Object.entries(mapping)) {
260
+ this.modelTranslationMapping.set(sourceModel, targetModel);
261
+ }
262
+ return this;
263
+ }
264
+ /**
265
+ * Get current model translation mapping.
266
+ */
267
+ getModelTranslationMapping() {
268
+ const mapping = {};
269
+ for (const [source, target] of this.modelTranslationMapping.entries()) {
270
+ mapping[source] = target;
271
+ }
272
+ return mapping;
273
+ }
274
+ /**
275
+ * Set backend-specific model translation mapping.
276
+ * This takes priority over global model translation mapping.
277
+ *
278
+ * @example
279
+ * ```typescript
280
+ * router.setBackendTranslationMapping('anthropic', {
281
+ * 'gpt-4': 'claude-3-5-sonnet-20241022',
282
+ * 'gpt-3.5-turbo': 'claude-3-5-haiku-20241022'
283
+ * });
284
+ * ```
285
+ */
286
+ setBackendTranslationMapping(backendName, mapping) {
287
+ // Validate backend exists
288
+ if (!this.backends.has(backendName)) {
289
+ throw new AdapterError({
290
+ code: ErrorCode.ROUTING_FAILED,
291
+ message: `Backend '${backendName}' is not registered`,
292
+ isRetryable: false,
293
+ provenance: { router: this.metadata.name },
294
+ });
295
+ }
296
+ // Create or get existing backend mapping
297
+ let backendMapping = this.backendTranslationMappings.get(backendName);
298
+ if (!backendMapping) {
299
+ backendMapping = new Map();
300
+ this.backendTranslationMappings.set(backendName, backendMapping);
301
+ }
302
+ // Clear and populate mapping
303
+ backendMapping.clear();
304
+ for (const [sourceModel, targetModel] of Object.entries(mapping)) {
305
+ backendMapping.set(sourceModel, targetModel);
306
+ }
307
+ return this;
308
+ }
309
+ /**
310
+ * Get backend-specific model translation mapping.
311
+ */
312
+ getBackendTranslationMapping(backendName) {
313
+ const backendMapping = this.backendTranslationMappings.get(backendName);
314
+ if (!backendMapping) {
315
+ return {};
316
+ }
317
+ const mapping = {};
318
+ for (const [source, target] of backendMapping.entries()) {
319
+ mapping[source] = target;
320
+ }
321
+ return mapping;
322
+ }
323
+ /**
324
+ * Clear all model translation mappings.
325
+ *
326
+ * @returns This router for chaining
327
+ */
328
+ clearModelTranslationMapping() {
329
+ this.modelTranslationMapping.clear();
330
+ return this;
331
+ }
332
+ /**
333
+ * Clear backend-specific model translation mappings.
334
+ *
335
+ * @param backendName Optional backend name to clear. If not provided, clears all.
336
+ * @returns This router for chaining
337
+ */
338
+ clearBackendTranslationMapping(backendName) {
339
+ if (backendName) {
340
+ const backendMapping = this.backendTranslationMappings.get(backendName);
341
+ if (backendMapping) {
342
+ backendMapping.clear();
343
+ }
344
+ }
345
+ else {
346
+ for (const mapping of this.backendTranslationMappings.values()) {
347
+ mapping.clear();
348
+ }
349
+ }
350
+ return this;
351
+ }
352
+ /**
353
+ * Set model pattern mappings.
354
+ */
355
+ setModelPatterns(patterns) {
356
+ // Validate all backends exist
357
+ for (const pattern of patterns) {
358
+ if (!this.backends.has(pattern.backend)) {
359
+ throw new AdapterError({
360
+ code: ErrorCode.ROUTING_FAILED,
361
+ message: `Backend '${pattern.backend}' in model pattern is not registered`,
362
+ isRetryable: false,
363
+ provenance: { router: this.metadata.name },
364
+ });
365
+ }
366
+ }
367
+ this.modelPatterns = [...patterns];
368
+ return this;
369
+ }
370
+ /**
371
+ * Get current model patterns.
372
+ */
373
+ getModelPatterns() {
374
+ return this.modelPatterns;
375
+ }
376
+ // ==========================================================================
377
+ // Routing Operations
378
+ // ==========================================================================
379
+ /**
380
+ * Select backend for a request.
381
+ */
382
+ async selectBackend(request, preferredBackend) {
383
+ const context = {
384
+ stats: this.getStats(),
385
+ metadata: request.metadata?.custom ?? {},
386
+ preferredBackend,
387
+ };
388
+ // Try explicit preference first
389
+ if (preferredBackend && this.isBackendAvailable(preferredBackend)) {
390
+ return preferredBackend;
391
+ }
392
+ // Try capability-based routing if enabled
393
+ if (this.config.capabilityBasedRouting) {
394
+ const capabilityBackend = await this.selectBackendByCapabilities(request);
395
+ if (capabilityBackend) {
396
+ return capabilityBackend;
397
+ }
398
+ }
399
+ // Apply routing strategy
400
+ const strategy = this.config.routingStrategy ?? 'explicit';
401
+ let selectedBackend = null;
402
+ switch (strategy) {
403
+ case 'explicit':
404
+ selectedBackend = this.routeExplicit(preferredBackend);
405
+ break;
406
+ case 'model-based':
407
+ selectedBackend = this.routeByModel(request);
408
+ break;
409
+ case 'cost-optimized':
410
+ selectedBackend = this.routeByCost(request);
411
+ break;
412
+ case 'latency-optimized':
413
+ selectedBackend = this.routeByLatency();
414
+ break;
415
+ case 'round-robin':
416
+ selectedBackend = this.routeRoundRobin();
417
+ break;
418
+ case 'random':
419
+ selectedBackend = this.routeRandom();
420
+ break;
421
+ case 'custom':
422
+ if (this.config.customRouter) {
423
+ selectedBackend = await this.config.customRouter(request, this.getAvailableBackends(), context);
424
+ }
425
+ break;
426
+ }
427
+ // Fallback to default backend
428
+ if (!selectedBackend && this.config.defaultBackend) {
429
+ selectedBackend = this.config.defaultBackend;
430
+ }
431
+ // Final fallback: first available backend
432
+ if (!selectedBackend) {
433
+ const available = this.getAvailableBackends();
434
+ selectedBackend = available[0] ?? null;
435
+ }
436
+ if (!selectedBackend) {
437
+ throw new AdapterError({
438
+ code: ErrorCode.NO_BACKEND_AVAILABLE,
439
+ message: 'No available backend for routing',
440
+ isRetryable: false,
441
+ provenance: { router: this.metadata.name },
442
+ });
443
+ }
444
+ return selectedBackend;
445
+ }
446
+ /**
447
+ * Execute request with automatic backend selection and fallback.
448
+ */
449
+ async execute(request, signal) {
450
+ this.stats.totalRequests++;
451
+ const preferredBackend = request.metadata?.custom?.backend;
452
+ const attemptedBackends = [];
453
+ try {
454
+ // Select primary backend
455
+ const primaryBackend = await this.selectBackend(request, preferredBackend);
456
+ attemptedBackends.push(primaryBackend);
457
+ // Translate model for this backend
458
+ const originalModel = request.parameters?.model ?? '';
459
+ const translationResult = this.translateModelForBackend(originalModel, primaryBackend);
460
+ // Create request with translated model
461
+ const translatedRequest = {
462
+ ...request,
463
+ parameters: {
464
+ ...request.parameters,
465
+ model: translationResult.translated,
466
+ },
467
+ };
468
+ // Try primary backend
469
+ const response = await this.executeOnBackend(primaryBackend, translatedRequest, signal);
470
+ this.stats.successfulRequests++;
471
+ return response;
472
+ }
473
+ catch (primaryError) {
474
+ // Handle fallback
475
+ if (this.config.fallbackStrategy === 'none') {
476
+ this.stats.failedRequests++;
477
+ throw primaryError;
478
+ }
479
+ try {
480
+ const response = await this.executeFallback(request, attemptedBackends, primaryError, signal);
481
+ this.stats.successfulRequests++;
482
+ this.stats.totalFallbacks++;
483
+ return response;
484
+ }
485
+ catch (fallbackError) {
486
+ this.stats.failedRequests++;
487
+ throw fallbackError;
488
+ }
489
+ }
490
+ }
491
+ /**
492
+ * Execute streaming request with automatic backend selection and fallback.
493
+ */
494
+ async *executeStream(request, signal) {
495
+ this.stats.totalRequests++;
496
+ const preferredBackend = request.metadata?.custom?.backend;
497
+ const attemptedBackends = [];
498
+ try {
499
+ // Select primary backend
500
+ const primaryBackend = await this.selectBackend(request, preferredBackend);
501
+ attemptedBackends.push(primaryBackend);
502
+ // Translate model for this backend
503
+ const originalModel = request.parameters?.model ?? '';
504
+ const translationResult = this.translateModelForBackend(originalModel, primaryBackend);
505
+ // Create request with translated model
506
+ const translatedRequest = {
507
+ ...request,
508
+ parameters: {
509
+ ...request.parameters,
510
+ model: translationResult.translated,
511
+ },
512
+ };
513
+ // Try primary backend streaming
514
+ const stream = this.executeStreamOnBackend(primaryBackend, translatedRequest, signal);
515
+ for await (const chunk of stream) {
516
+ yield chunk;
517
+ }
518
+ this.stats.successfulRequests++;
519
+ }
520
+ catch (primaryError) {
521
+ // For streaming, fallback is more complex - yield error chunk
522
+ this.stats.failedRequests++;
523
+ yield {
524
+ type: 'error',
525
+ sequence: 0,
526
+ error: {
527
+ code: primaryError instanceof AdapterError ? primaryError.code : 'UNKNOWN_ERROR',
528
+ message: primaryError instanceof Error ? primaryError.message : String(primaryError),
529
+ },
530
+ };
531
+ }
532
+ }
533
+ /**
534
+ * Dispatch request to multiple backends in parallel.
535
+ */
536
+ async dispatchParallel(request, options = {}, signal) {
537
+ this.stats.totalRequests++;
538
+ this.stats.parallelRequests++;
539
+ const { backends: targetBackends, strategy = 'first', timeout, cancelOnFirstSuccess = true, } = options;
540
+ // Determine which backends to use
541
+ const backendsToUse = targetBackends && targetBackends.length > 0
542
+ ? targetBackends.filter((name) => this.isBackendAvailable(name))
543
+ : this.getAvailableBackends();
544
+ if (backendsToUse.length === 0) {
545
+ throw new AdapterError({
546
+ code: ErrorCode.NO_BACKEND_AVAILABLE,
547
+ message: 'No available backends for parallel dispatch',
548
+ isRetryable: false,
549
+ provenance: { router: this.metadata.name },
550
+ });
551
+ }
552
+ const startTime = Date.now();
553
+ const abortController = new AbortController();
554
+ const combinedSignal = signal
555
+ ? this.combineSignals([signal, abortController.signal])
556
+ : abortController.signal;
557
+ // Create timeout if specified
558
+ let timeoutId;
559
+ if (timeout) {
560
+ timeoutId = setTimeout(() => abortController.abort(), timeout);
561
+ }
562
+ try {
563
+ const promises = backendsToUse.map(async (backendName) => {
564
+ const backendStartTime = Date.now();
565
+ try {
566
+ const response = await this.executeOnBackend(backendName, request, combinedSignal);
567
+ return {
568
+ backend: backendName,
569
+ response,
570
+ latencyMs: Date.now() - backendStartTime,
571
+ success: true,
572
+ };
573
+ }
574
+ catch (error) {
575
+ return {
576
+ backend: backendName,
577
+ error: error instanceof AdapterError ? error : this.wrapError(error),
578
+ success: false,
579
+ };
580
+ }
581
+ });
582
+ let results;
583
+ if (strategy === 'first') {
584
+ // Return first successful response
585
+ const firstSuccess = await Promise.race(promises);
586
+ if (cancelOnFirstSuccess) {
587
+ abortController.abort();
588
+ }
589
+ results = [firstSuccess];
590
+ }
591
+ else {
592
+ // Wait for all responses
593
+ results = await Promise.all(promises);
594
+ }
595
+ // Process results
596
+ const successful = results.filter((r) => r.success);
597
+ const failed = results
598
+ .filter((r) => !r.success)
599
+ .map((r) => ({
600
+ backend: r.backend,
601
+ error: r.error,
602
+ }));
603
+ if (successful.length === 0) {
604
+ throw new AdapterError({
605
+ code: ErrorCode.ALL_BACKENDS_FAILED,
606
+ message: `All parallel backends failed: ${failed.map((f) => f.backend).join(', ')}`,
607
+ isRetryable: true,
608
+ provenance: { router: this.metadata.name },
609
+ });
610
+ }
611
+ const firstSuccess = successful[0];
612
+ if (!firstSuccess?.response) {
613
+ throw new AdapterError({
614
+ code: ErrorCode.INTERNAL_ERROR,
615
+ message: 'No successful response in parallel dispatch',
616
+ isRetryable: false,
617
+ provenance: { router: this.metadata.name },
618
+ });
619
+ }
620
+ return {
621
+ response: firstSuccess.response,
622
+ allResponses: strategy === 'all'
623
+ ? successful.map((s) => ({
624
+ backend: s.backend,
625
+ response: s.response,
626
+ latencyMs: s.latencyMs,
627
+ }))
628
+ : undefined,
629
+ successfulBackends: successful.map((s) => s.backend),
630
+ failedBackends: failed,
631
+ totalTimeMs: Date.now() - startTime,
632
+ };
633
+ }
634
+ finally {
635
+ if (timeoutId) {
636
+ clearTimeout(timeoutId);
637
+ }
638
+ }
639
+ }
640
+ async checkHealth(name) {
641
+ if (name !== undefined) {
642
+ const state = this.backends.get(name);
643
+ if (!state) {
644
+ return false;
645
+ }
646
+ try {
647
+ const healthy = state.adapter.healthCheck ? await state.adapter.healthCheck() : true;
648
+ state.isHealthy = healthy;
649
+ state.lastHealthCheck = Date.now();
650
+ return healthy;
651
+ }
652
+ catch {
653
+ state.isHealthy = false;
654
+ state.lastHealthCheck = Date.now();
655
+ return false;
656
+ }
657
+ }
658
+ // Check all backends
659
+ const results = {};
660
+ const promises = Array.from(this.backends.entries()).map(async ([backendName, state]) => {
661
+ try {
662
+ const healthy = state.adapter.healthCheck ? await state.adapter.healthCheck() : true;
663
+ state.isHealthy = healthy;
664
+ state.lastHealthCheck = Date.now();
665
+ results[backendName] = healthy;
666
+ }
667
+ catch {
668
+ state.isHealthy = false;
669
+ state.lastHealthCheck = Date.now();
670
+ results[backendName] = false;
671
+ }
672
+ });
673
+ await Promise.all(promises);
674
+ return results;
675
+ }
676
+ /**
677
+ * Manually open circuit breaker for a backend.
678
+ */
679
+ openCircuitBreaker(name, timeoutMs) {
680
+ const state = this.backends.get(name);
681
+ if (!state) {
682
+ throw new AdapterError({
683
+ code: ErrorCode.ROUTING_FAILED,
684
+ message: `Backend '${name}' not found`,
685
+ isRetryable: false,
686
+ provenance: { router: this.metadata.name },
687
+ });
688
+ }
689
+ state.circuitBreakerState = 'open';
690
+ state.circuitOpenedAt = Date.now();
691
+ // Auto-close after timeout
692
+ if (timeoutMs ?? this.config.circuitBreakerTimeout) {
693
+ setTimeout(() => {
694
+ if (state.circuitBreakerState === 'open') {
695
+ state.circuitBreakerState = 'half-open';
696
+ }
697
+ }, timeoutMs ?? this.config.circuitBreakerTimeout);
698
+ }
699
+ }
700
+ /**
701
+ * Manually close circuit breaker for a backend.
702
+ */
703
+ closeCircuitBreaker(name) {
704
+ const state = this.backends.get(name);
705
+ if (!state) {
706
+ throw new AdapterError({
707
+ code: ErrorCode.ROUTING_FAILED,
708
+ message: `Backend '${name}' not found`,
709
+ isRetryable: false,
710
+ provenance: { router: this.metadata.name },
711
+ });
712
+ }
713
+ state.circuitBreakerState = 'closed';
714
+ state.consecutiveFailures = 0;
715
+ state.circuitOpenedAt = undefined;
716
+ }
717
+ /**
718
+ * Reset circuit breaker statistics.
719
+ */
720
+ resetCircuitBreaker(name) {
721
+ if (name) {
722
+ const state = this.backends.get(name);
723
+ if (state) {
724
+ state.consecutiveFailures = 0;
725
+ state.circuitBreakerState = 'closed';
726
+ state.circuitOpenedAt = undefined;
727
+ }
728
+ }
729
+ else {
730
+ for (const state of this.backends.values()) {
731
+ state.consecutiveFailures = 0;
732
+ state.circuitBreakerState = 'closed';
733
+ state.circuitOpenedAt = undefined;
734
+ }
735
+ }
736
+ }
737
+ /**
738
+ * Check if circuit breaker is open for a backend.
739
+ *
740
+ * @param name Backend name
741
+ * @returns true if circuit breaker is open, false otherwise
742
+ */
743
+ isCircuitBreakerOpen(name) {
744
+ const state = this.backends.get(name);
745
+ if (!state) {
746
+ return false;
747
+ }
748
+ return state.circuitBreakerState === 'open';
749
+ }
750
+ // ==========================================================================
751
+ // Statistics & Monitoring
752
+ // ==========================================================================
753
+ /**
754
+ * Get router statistics.
755
+ */
756
+ getStats() {
757
+ const backendStats = {};
758
+ for (const [name, state] of this.backends.entries()) {
759
+ backendStats[name] = this.calculateBackendStats(state);
760
+ }
761
+ return {
762
+ totalRequests: this.stats.totalRequests,
763
+ successfulRequests: this.stats.successfulRequests,
764
+ failedRequests: this.stats.failedRequests,
765
+ totalFallbacks: this.stats.totalFallbacks,
766
+ parallelRequests: this.stats.parallelRequests,
767
+ backendStats,
768
+ sinceTimestamp: this.stats.sinceTimestamp,
769
+ };
770
+ }
771
+ /**
772
+ * Reset router statistics.
773
+ */
774
+ resetStats() {
775
+ this.stats = {
776
+ totalRequests: 0,
777
+ successfulRequests: 0,
778
+ failedRequests: 0,
779
+ totalFallbacks: 0,
780
+ parallelRequests: 0,
781
+ sinceTimestamp: Date.now(),
782
+ };
783
+ for (const state of this.backends.values()) {
784
+ state.totalRequests = 0;
785
+ state.successfulRequests = 0;
786
+ state.failedRequests = 0;
787
+ state.latencies = [];
788
+ state.totalCost = 0;
789
+ }
790
+ }
791
+ /**
792
+ * Get statistics for specific backend.
793
+ */
794
+ getBackendStats(name) {
795
+ const state = this.backends.get(name);
796
+ if (!state) {
797
+ return undefined;
798
+ }
799
+ return this.calculateBackendStats(state);
800
+ }
801
+ // ==========================================================================
802
+ // Utility Methods
803
+ // ==========================================================================
804
+ /**
805
+ * Clone router with new configuration.
806
+ */
807
+ clone(config) {
808
+ const newRouter = new Router({ ...this.config, ...config });
809
+ // Copy backend registrations
810
+ for (const [name, state] of this.backends.entries()) {
811
+ newRouter.register(name, state.adapter);
812
+ }
813
+ // Copy model mappings
814
+ newRouter.modelMapping = new Map(this.modelMapping);
815
+ newRouter.modelPatterns = [...this.modelPatterns];
816
+ newRouter.fallbackChain = [...this.fallbackChain];
817
+ return newRouter;
818
+ }
819
+ /**
820
+ * Clean up resources.
821
+ */
822
+ dispose() {
823
+ if (this.healthCheckInterval) {
824
+ clearInterval(this.healthCheckInterval);
825
+ this.healthCheckInterval = undefined;
826
+ }
827
+ }
828
+ // ==========================================================================
829
+ // Private Helper Methods
830
+ // ==========================================================================
831
+ /**
832
+ * Execute request on specific backend.
833
+ */
834
+ async executeOnBackend(name, request, signal) {
835
+ const state = this.backends.get(name);
836
+ if (!state) {
837
+ throw new AdapterError({
838
+ code: ErrorCode.ROUTING_FAILED,
839
+ message: `Backend '${name}' not found`,
840
+ isRetryable: false,
841
+ provenance: { router: this.metadata.name },
842
+ });
843
+ }
844
+ // Check circuit breaker
845
+ if (this.config.enableCircuitBreaker) {
846
+ this.checkCircuitBreaker(name, state);
847
+ }
848
+ state.totalRequests++;
849
+ const startTime = Date.now();
850
+ try {
851
+ const response = await state.adapter.execute(request, signal);
852
+ // Track success
853
+ state.successfulRequests++;
854
+ state.consecutiveFailures = 0;
855
+ if (this.config.trackLatency) {
856
+ const latency = Date.now() - startTime;
857
+ state.latencies.push(latency);
858
+ // Keep only last 100 latencies
859
+ if (state.latencies.length > 100) {
860
+ state.latencies.shift();
861
+ }
862
+ }
863
+ // Track cost
864
+ if (this.config.trackCost && state.adapter.estimateCost) {
865
+ const cost = await state.adapter.estimateCost(request);
866
+ if (cost !== null) {
867
+ state.totalCost += cost;
868
+ }
869
+ }
870
+ // Update circuit breaker
871
+ if (state.circuitBreakerState === 'half-open') {
872
+ state.circuitBreakerState = 'closed';
873
+ }
874
+ return response;
875
+ }
876
+ catch (error) {
877
+ // Track failure
878
+ state.failedRequests++;
879
+ state.consecutiveFailures++;
880
+ // Update circuit breaker
881
+ if (this.config.enableCircuitBreaker &&
882
+ state.consecutiveFailures >= (this.config.circuitBreakerThreshold ?? 5)) {
883
+ this.openCircuitBreaker(name);
884
+ }
885
+ throw error;
886
+ }
887
+ }
888
+ /**
889
+ * Execute streaming request on specific backend.
890
+ */
891
+ executeStreamOnBackend(name, request, signal) {
892
+ const state = this.backends.get(name);
893
+ if (!state) {
894
+ throw new AdapterError({
895
+ code: ErrorCode.ROUTING_FAILED,
896
+ message: `Backend '${name}' not found`,
897
+ isRetryable: false,
898
+ provenance: { router: this.metadata.name },
899
+ });
900
+ }
901
+ // Check circuit breaker
902
+ if (this.config.enableCircuitBreaker) {
903
+ this.checkCircuitBreaker(name, state);
904
+ }
905
+ state.totalRequests++;
906
+ return state.adapter.executeStream(request, signal);
907
+ }
908
+ /**
909
+ * Execute fallback strategy.
910
+ */
911
+ async executeFallback(request, attemptedBackends, error, signal) {
912
+ const strategy = this.config.fallbackStrategy ?? 'sequential';
913
+ if (strategy === 'sequential') {
914
+ return this.fallbackSequential(request, attemptedBackends, signal);
915
+ }
916
+ else if (strategy === 'parallel') {
917
+ return this.fallbackParallel(request, attemptedBackends, signal);
918
+ }
919
+ else if (strategy === 'custom' && this.config.customFallback) {
920
+ const available = this.getAvailableBackends().filter((name) => !attemptedBackends.includes(name));
921
+ const nextBackend = await this.config.customFallback(request, attemptedBackends[attemptedBackends.length - 1], error, attemptedBackends, available);
922
+ if (nextBackend && !attemptedBackends.includes(nextBackend)) {
923
+ attemptedBackends.push(nextBackend);
924
+ return this.executeOnBackend(nextBackend, request, signal);
925
+ }
926
+ }
927
+ throw error;
928
+ }
929
+ /**
930
+ * Translate model name for a specific backend.
931
+ *
932
+ * Applies translation strategy: backend-specific exact → global exact → pattern → default → passthrough
933
+ */
934
+ translateModelForBackend(modelName, backendName) {
935
+ const strategy = this.config.modelTranslation?.strategy ?? 'hybrid';
936
+ const strictMode = this.config.modelTranslation?.strictMode ?? false;
937
+ // 0. Try backend-specific exact match (highest priority)
938
+ if (strategy !== 'none') {
939
+ const backendMapping = this.backendTranslationMappings.get(backendName);
940
+ if (backendMapping) {
941
+ const backendExactMatch = backendMapping.get(modelName);
942
+ if (backendExactMatch) {
943
+ return {
944
+ translated: backendExactMatch,
945
+ source: 'exact',
946
+ wasTranslated: true,
947
+ };
948
+ }
949
+ }
950
+ }
951
+ // 1. Try global exact match (all strategies except 'none')
952
+ if (strategy !== 'none') {
953
+ const exactMatch = this.modelTranslationMapping.get(modelName);
954
+ if (exactMatch) {
955
+ return {
956
+ translated: exactMatch,
957
+ source: 'exact',
958
+ wasTranslated: true,
959
+ };
960
+ }
961
+ }
962
+ // 2. Try pattern match (pattern and hybrid strategies)
963
+ if (strategy === 'pattern' || strategy === 'hybrid') {
964
+ // Sort patterns by priority (higher priority first)
965
+ const sortedPatterns = [...this.modelPatterns].sort((a, b) => {
966
+ const priorityA = a.priority ?? 0;
967
+ const priorityB = b.priority ?? 0;
968
+ return priorityB - priorityA;
969
+ });
970
+ for (const patternMapping of sortedPatterns) {
971
+ if (patternMapping.pattern.test(modelName)) {
972
+ // Use targetModel if specified, otherwise original model
973
+ const translated = patternMapping.targetModel ?? modelName;
974
+ return {
975
+ translated,
976
+ source: 'pattern',
977
+ wasTranslated: patternMapping.targetModel !== undefined,
978
+ };
979
+ }
980
+ }
981
+ }
982
+ // 3. Try backend default (hybrid strategy only)
983
+ if (strategy === 'hybrid') {
984
+ const backendState = this.backends.get(backendName);
985
+ const adapter = backendState?.adapter;
986
+ const defaultModel = adapter?.config?.defaultModel;
987
+ if (defaultModel) {
988
+ // TODO: Emit warning event when warnOnDefault is true
989
+ // For now, the translation result indicates wasTranslated=true
990
+ return {
991
+ translated: defaultModel,
992
+ source: 'default',
993
+ wasTranslated: true,
994
+ };
995
+ }
996
+ }
997
+ // 4. No translation found
998
+ if (strictMode) {
999
+ throw new AdapterError({
1000
+ code: ErrorCode.ROUTING_FAILED,
1001
+ message: `No translation found for model: ${modelName}`,
1002
+ isRetryable: false,
1003
+ provenance: { router: this.metadata.name, backend: backendName },
1004
+ });
1005
+ }
1006
+ // Return original model (passthrough)
1007
+ return {
1008
+ translated: modelName,
1009
+ source: 'none',
1010
+ wasTranslated: false,
1011
+ };
1012
+ }
1013
+ /**
1014
+ * Sequential fallback: try backends one by one.
1015
+ */
1016
+ async fallbackSequential(request, attemptedBackends, signal) {
1017
+ const available = this.getAvailableBackends().filter((name) => !attemptedBackends.includes(name));
1018
+ // Use fallback chain if configured
1019
+ const candidates = this.fallbackChain.length > 0
1020
+ ? this.fallbackChain.filter((name) => available.includes(name))
1021
+ : available;
1022
+ let lastError;
1023
+ for (const backendName of candidates) {
1024
+ try {
1025
+ attemptedBackends.push(backendName);
1026
+ // Translate model for this backend
1027
+ const originalModel = request.parameters?.model ?? '';
1028
+ const translationResult = this.translateModelForBackend(originalModel, backendName);
1029
+ // Create request with translated model
1030
+ const translatedRequest = {
1031
+ ...request,
1032
+ parameters: {
1033
+ ...request.parameters,
1034
+ model: translationResult.translated,
1035
+ },
1036
+ };
1037
+ return await this.executeOnBackend(backendName, translatedRequest, signal);
1038
+ }
1039
+ catch (error) {
1040
+ lastError = error;
1041
+ continue;
1042
+ }
1043
+ }
1044
+ throw (lastError ??
1045
+ new AdapterError({
1046
+ code: ErrorCode.ALL_BACKENDS_FAILED,
1047
+ message: 'All fallback backends failed',
1048
+ isRetryable: false,
1049
+ provenance: { router: this.metadata.name },
1050
+ }));
1051
+ }
1052
+ /**
1053
+ * Parallel fallback: try all remaining backends at once.
1054
+ */
1055
+ async fallbackParallel(request, attemptedBackends, signal) {
1056
+ const available = this.getAvailableBackends().filter((name) => !attemptedBackends.includes(name));
1057
+ if (available.length === 0) {
1058
+ throw new AdapterError({
1059
+ code: ErrorCode.NO_BACKEND_AVAILABLE,
1060
+ message: 'No available fallback backends',
1061
+ isRetryable: false,
1062
+ provenance: { router: this.metadata.name },
1063
+ });
1064
+ }
1065
+ // Create promises with model translation for each backend
1066
+ const originalModel = request.parameters?.model ?? '';
1067
+ const promises = available.map((backendName) => {
1068
+ // Translate model for this backend
1069
+ const translationResult = this.translateModelForBackend(originalModel, backendName);
1070
+ // Create request with translated model
1071
+ const translatedRequest = {
1072
+ ...request,
1073
+ parameters: {
1074
+ ...request.parameters,
1075
+ model: translationResult.translated,
1076
+ },
1077
+ };
1078
+ return this.executeOnBackend(backendName, translatedRequest, signal);
1079
+ });
1080
+ return Promise.race(promises);
1081
+ }
1082
+ /**
1083
+ * Check circuit breaker state.
1084
+ */
1085
+ checkCircuitBreaker(name, state) {
1086
+ if (state.circuitBreakerState === 'open') {
1087
+ // Check if timeout has passed
1088
+ const timeout = this.config.circuitBreakerTimeout ?? 60000;
1089
+ if (state.circuitOpenedAt && Date.now() - state.circuitOpenedAt > timeout) {
1090
+ state.circuitBreakerState = 'half-open';
1091
+ }
1092
+ else {
1093
+ throw new AdapterError({
1094
+ code: ErrorCode.PROVIDER_UNAVAILABLE,
1095
+ message: `Circuit breaker is open for backend '${name}'`,
1096
+ isRetryable: true,
1097
+ provenance: { router: this.metadata.name, backend: name },
1098
+ });
1099
+ }
1100
+ }
1101
+ }
1102
+ /**
1103
+ * Get list of available backends.
1104
+ */
1105
+ getAvailableBackends() {
1106
+ const available = [];
1107
+ for (const [name, state] of this.backends.entries()) {
1108
+ if (state.isHealthy && state.circuitBreakerState !== 'open') {
1109
+ available.push(name);
1110
+ }
1111
+ }
1112
+ return available;
1113
+ }
1114
+ /**
1115
+ * Check if backend is available.
1116
+ */
1117
+ isBackendAvailable(name) {
1118
+ const state = this.backends.get(name);
1119
+ if (!state) {
1120
+ return false;
1121
+ }
1122
+ return state.isHealthy && state.circuitBreakerState !== 'open';
1123
+ }
1124
+ /**
1125
+ * Routing: explicit backend selection.
1126
+ */
1127
+ routeExplicit(preferredBackend) {
1128
+ if (preferredBackend && this.isBackendAvailable(preferredBackend)) {
1129
+ return preferredBackend;
1130
+ }
1131
+ return null;
1132
+ }
1133
+ /**
1134
+ * Routing: model-based selection.
1135
+ */
1136
+ routeByModel(request) {
1137
+ const model = request.parameters?.model;
1138
+ if (!model) {
1139
+ return null;
1140
+ }
1141
+ // Check exact mapping
1142
+ const exactMatch = this.modelMapping.get(model);
1143
+ if (exactMatch && this.isBackendAvailable(exactMatch)) {
1144
+ return exactMatch;
1145
+ }
1146
+ // Check pattern matching
1147
+ for (const pattern of this.modelPatterns) {
1148
+ if (pattern.pattern.test(model) && this.isBackendAvailable(pattern.backend)) {
1149
+ return pattern.backend;
1150
+ }
1151
+ }
1152
+ return null;
1153
+ }
1154
+ /**
1155
+ * Routing: cost-optimized selection.
1156
+ */
1157
+ routeByCost(_request) {
1158
+ if (!this.config.trackCost) {
1159
+ return null;
1160
+ }
1161
+ let bestBackend = null;
1162
+ let lowestAvgCost = Infinity;
1163
+ for (const [name, state] of this.backends.entries()) {
1164
+ if (!this.isBackendAvailable(name)) {
1165
+ continue;
1166
+ }
1167
+ const stats = this.calculateBackendStats(state);
1168
+ const avgCost = stats.averageCost ?? 0;
1169
+ if (avgCost < lowestAvgCost) {
1170
+ lowestAvgCost = avgCost;
1171
+ bestBackend = name;
1172
+ }
1173
+ }
1174
+ return bestBackend;
1175
+ }
1176
+ /**
1177
+ * Routing: latency-optimized selection.
1178
+ */
1179
+ routeByLatency() {
1180
+ if (!this.config.trackLatency) {
1181
+ return null;
1182
+ }
1183
+ let bestBackend = null;
1184
+ let lowestLatency = Infinity;
1185
+ for (const [name, state] of this.backends.entries()) {
1186
+ if (!this.isBackendAvailable(name)) {
1187
+ continue;
1188
+ }
1189
+ const stats = this.calculateBackendStats(state);
1190
+ const avgLatency = stats.averageLatencyMs;
1191
+ if (avgLatency < lowestLatency) {
1192
+ lowestLatency = avgLatency;
1193
+ bestBackend = name;
1194
+ }
1195
+ }
1196
+ return bestBackend;
1197
+ }
1198
+ /**
1199
+ * Routing: round-robin selection.
1200
+ */
1201
+ routeRoundRobin() {
1202
+ const available = this.getAvailableBackends();
1203
+ if (available.length === 0) {
1204
+ return null;
1205
+ }
1206
+ const backend = available[this.roundRobinIndex % available.length];
1207
+ this.roundRobinIndex++;
1208
+ return backend ?? null;
1209
+ }
1210
+ /**
1211
+ * Routing: random selection.
1212
+ */
1213
+ routeRandom() {
1214
+ const available = this.getAvailableBackends();
1215
+ if (available.length === 0) {
1216
+ return null;
1217
+ }
1218
+ const index = Math.floor(Math.random() * available.length);
1219
+ return available[index] ?? null;
1220
+ }
1221
+ /**
1222
+ * Select backend based on capability requirements.
1223
+ * Returns the backend name with the best matching model, or null if none match.
1224
+ */
1225
+ async selectBackendByCapabilities(request) {
1226
+ // Extract capability requirements from request metadata
1227
+ const capabilityRequirements = request.metadata?.custom?.capabilityRequirements;
1228
+ // Build requirements from config if not provided
1229
+ const requirements = {
1230
+ required: capabilityRequirements?.required,
1231
+ preferred: capabilityRequirements?.preferred,
1232
+ optimization: capabilityRequirements?.optimization ?? this.config.optimization ?? 'balanced',
1233
+ weights: capabilityRequirements?.weights ?? this.config.optimizationWeights,
1234
+ };
1235
+ // Collect available models from all backends
1236
+ const availableModels = [];
1237
+ for (const backendName of this.getAvailableBackends()) {
1238
+ const backend = this.backends.get(backendName)?.adapter;
1239
+ if (!backend) {
1240
+ continue;
1241
+ }
1242
+ // Try to get models from backend's listModels()
1243
+ if (typeof backend.listModels === 'function') {
1244
+ try {
1245
+ const result = await backend.listModels();
1246
+ for (const model of result.models) {
1247
+ availableModels.push({ model, backend: backendName });
1248
+ }
1249
+ }
1250
+ catch {
1251
+ // If listModels fails, try to infer from requested model
1252
+ const requestedModel = request.parameters?.model;
1253
+ if (requestedModel) {
1254
+ const inferredModel = {
1255
+ id: requestedModel,
1256
+ name: requestedModel,
1257
+ capabilities: inferCapabilities(requestedModel),
1258
+ };
1259
+ availableModels.push({ model: inferredModel, backend: backendName });
1260
+ }
1261
+ }
1262
+ }
1263
+ else {
1264
+ // Backend doesn't support listModels, try to infer from requested model
1265
+ const requestedModel = request.parameters?.model;
1266
+ if (requestedModel) {
1267
+ const inferredModel = {
1268
+ id: requestedModel,
1269
+ name: requestedModel,
1270
+ capabilities: inferCapabilities(requestedModel),
1271
+ };
1272
+ availableModels.push({ model: inferredModel, backend: backendName });
1273
+ }
1274
+ }
1275
+ }
1276
+ if (availableModels.length === 0) {
1277
+ return null;
1278
+ }
1279
+ // Find best match
1280
+ const bestMatch = findBestModel(requirements, availableModels);
1281
+ if (!bestMatch?.meetsRequirements) {
1282
+ return null;
1283
+ }
1284
+ return bestMatch.backend;
1285
+ }
1286
+ /**
1287
+ * Calculate backend statistics.
1288
+ */
1289
+ calculateBackendStats(state) {
1290
+ const successRate = state.totalRequests > 0 ? (state.successfulRequests / state.totalRequests) * 100 : 0;
1291
+ const sortedLatencies = [...state.latencies].sort((a, b) => a - b);
1292
+ const avgLatency = sortedLatencies.length > 0
1293
+ ? sortedLatencies.reduce((a, b) => a + b, 0) / sortedLatencies.length
1294
+ : 0;
1295
+ const p50 = sortedLatencies[Math.floor(sortedLatencies.length * 0.5)] ?? 0;
1296
+ const p95 = sortedLatencies[Math.floor(sortedLatencies.length * 0.95)] ?? 0;
1297
+ const p99 = sortedLatencies[Math.floor(sortedLatencies.length * 0.99)] ?? 0;
1298
+ const avgCost = state.totalRequests > 0 ? state.totalCost / state.totalRequests : 0;
1299
+ return {
1300
+ totalRequests: state.totalRequests,
1301
+ successfulRequests: state.successfulRequests,
1302
+ failedRequests: state.failedRequests,
1303
+ successRate,
1304
+ averageLatencyMs: avgLatency,
1305
+ p50LatencyMs: p50,
1306
+ p95LatencyMs: p95,
1307
+ p99LatencyMs: p99,
1308
+ totalCost: this.config.trackCost ? state.totalCost : undefined,
1309
+ averageCost: this.config.trackCost ? avgCost : undefined,
1310
+ };
1311
+ }
1312
+ /**
1313
+ * Create backend info from state.
1314
+ */
1315
+ createBackendInfo(name, state) {
1316
+ return {
1317
+ name,
1318
+ adapter: state.adapter,
1319
+ metadata: state.adapter.metadata,
1320
+ isHealthy: state.isHealthy,
1321
+ lastHealthCheck: state.lastHealthCheck,
1322
+ circuitBreakerState: state.circuitBreakerState,
1323
+ consecutiveFailures: state.consecutiveFailures,
1324
+ stats: this.calculateBackendStats(state),
1325
+ };
1326
+ }
1327
+ /**
1328
+ * Start periodic health checking.
1329
+ */
1330
+ startHealthChecking() {
1331
+ if (this.healthCheckInterval) {
1332
+ return;
1333
+ }
1334
+ const interval = this.config.healthCheckInterval ?? 0;
1335
+ if (interval <= 0) {
1336
+ return;
1337
+ }
1338
+ this.healthCheckInterval = setInterval(() => {
1339
+ this.checkHealth().catch(() => {
1340
+ // Ignore errors in background health checks
1341
+ });
1342
+ }, interval);
1343
+ }
1344
+ /**
1345
+ * Combine multiple abort signals.
1346
+ */
1347
+ combineSignals(signals) {
1348
+ const controller = new AbortController();
1349
+ for (const signal of signals) {
1350
+ if (signal.aborted) {
1351
+ controller.abort();
1352
+ break;
1353
+ }
1354
+ signal.addEventListener('abort', () => controller.abort(), { once: true });
1355
+ }
1356
+ return controller.signal;
1357
+ }
1358
+ /**
1359
+ * Wrap unknown error as AdapterError.
1360
+ */
1361
+ wrapError(error) {
1362
+ if (error instanceof AdapterError) {
1363
+ return error;
1364
+ }
1365
+ return new AdapterError({
1366
+ code: ErrorCode.INTERNAL_ERROR,
1367
+ message: error instanceof Error ? error.message : String(error),
1368
+ isRetryable: false,
1369
+ provenance: { router: this.metadata.name },
1370
+ cause: error instanceof Error ? error : undefined,
1371
+ });
1372
+ }
1373
+ }
1374
+ // ============================================================================
1375
+ // Factory Function
1376
+ // ============================================================================
1377
+ /**
1378
+ * Create a new Router instance.
1379
+ */
1380
+ export function createRouter(config) {
1381
+ return new Router(config);
1382
+ }
1383
+ //# sourceMappingURL=router.js.map