adaptive-memory-multi-model-router 2.13.18 → 2.13.20

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 (91) hide show
  1. package/.dockerignore +82 -0
  2. package/.env.example +303 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +83 -12
  4. package/.github/ISSUE_TEMPLATE/config.yml +12 -6
  5. package/.github/ISSUE_TEMPLATE/feature_request.md +61 -10
  6. package/.github/PULL_REQUEST_TEMPLATE.md +53 -26
  7. package/.github/dependabot.yml +9 -0
  8. package/.github/workflows/codeql.yml +38 -0
  9. package/.github/workflows/npm-publish.yml +20 -0
  10. package/.github/workflows/stale.yml +56 -0
  11. package/ARCHITECTURE.md +346 -0
  12. package/AUDIT_REPORT.md +28 -0
  13. package/CHANGELOG.md +386 -22
  14. package/CONTRIBUTORS.md +20 -0
  15. package/Dockerfile +53 -0
  16. package/Dockerfile.proxy +33 -0
  17. package/PR_STATUS_REPORT.md +148 -0
  18. package/README.md +22 -0
  19. package/RUNKIT.md +83 -0
  20. package/_schema.html +61 -15
  21. package/articles/AI_AGENT_LLM_ROUTING.md +150 -0
  22. package/articles/FROM_ZERO_TO_10K.md +107 -0
  23. package/articles/LLM_BENCHMARK_DEEP_DIVE.md +153 -0
  24. package/articles/TWEETS_10K_DOWNLOADS.md +47 -0
  25. package/articles/TWEETS_BENCHMARK_FIRST.md +46 -0
  26. package/articles/TWEETS_MCP_PLAY.md +51 -0
  27. package/articles/TWEETS_SEQUENTIAL_BROKEN.md +49 -0
  28. package/articles/TWEETS_WHY_BUILD.md +54 -0
  29. package/benchmark-results.json +26 -45
  30. package/cli/a3m +840 -0
  31. package/demo/package.json +13 -0
  32. package/demo/public/index.html +762 -0
  33. package/demo/server.js +405 -0
  34. package/dist/cli.js +4 -0
  35. package/docker-compose.yml +74 -0
  36. package/docs/.nojekyll +0 -0
  37. package/docs/BENCHMARK.md +96 -22
  38. package/docs/_config.yml +49 -0
  39. package/docs/api.html +513 -0
  40. package/docs/benchmark.html +387 -0
  41. package/docs/cli-cheatsheet.md +339 -0
  42. package/docs/comparison.md +108 -0
  43. package/docs/curl-examples.md +247 -0
  44. package/docs/index.html +390 -99
  45. package/docs/openapi.yaml +1318 -0
  46. package/docs/quick-start.html +366 -0
  47. package/docs/robots.txt +1 -1
  48. package/docs/sitemap.xml +23 -5
  49. package/docs/styles.css +682 -0
  50. package/examples/README.md +61 -0
  51. package/examples/a3m-sdk.js +124 -0
  52. package/examples/basic-route.js +54 -0
  53. package/examples/chat-loop.js +202 -0
  54. package/examples/classify-then-route.js +102 -0
  55. package/examples/cost-compare.js +120 -0
  56. package/examples/ensemble.js +160 -0
  57. package/integrations/langchain/README.md +216 -0
  58. package/integrations/langchain/a3m_langchain.ts +1360 -0
  59. package/integrations/langchain/example.ts +287 -0
  60. package/integrations/vercel-ai-sdk/README.md +49 -0
  61. package/integrations/vercel-ai-sdk/a3m_provider.ts +78 -0
  62. package/integrations/vercel-ai-sdk/example.ts +25 -0
  63. package/llms-full.txt +43 -0
  64. package/llms.txt +9 -0
  65. package/mcp-server/README.md +188 -0
  66. package/mcp-server/package.json +29 -0
  67. package/mcp-server/src/index.ts +744 -0
  68. package/mcp-server/tsconfig.json +19 -0
  69. package/package.json +3 -3
  70. package/proxy/README.md +227 -0
  71. package/proxy/package-lock.json +831 -0
  72. package/proxy/package.json +17 -0
  73. package/proxy/rate-limit.js +145 -0
  74. package/proxy/rate-limit.test.js +311 -0
  75. package/proxy/server.js +970 -0
  76. package/scripts/banner.js +29 -0
  77. package/scripts/compare-providers.sh +230 -0
  78. package/scripts/cross_post.py +443 -0
  79. package/scripts/publish_fcc.py +106 -0
  80. package/scripts/push-to-gitee.sh +52 -0
  81. package/src/tui/dashboard.ts +13 -0
  82. package/tests/__mocks__/tokenUtils.ts +22 -0
  83. package/tests/memory/episodicMemory.test.ts +227 -0
  84. package/tests/package-lock.json +1628 -0
  85. package/tests/package.json +18 -0
  86. package/tests/routing/ensembleVoting.test.ts +236 -0
  87. package/tests/routing/providerRetry.test.ts +360 -0
  88. package/tests/routing/queryTypePresets.test.ts +206 -0
  89. package/tests/tsconfig.json +21 -0
  90. package/tests/vitest.config.ts +18 -0
  91. package/.env +0 -2
@@ -0,0 +1,1360 @@
1
+ /**
2
+ * A3M Router — LangChain Integration
3
+ *
4
+ * Use A3M Router as a drop-in LLM provider inside LangChain chains and agents.
5
+ *
6
+ * This integration does NOT import the a3m-router npm package directly.
7
+ * Instead, it defines a clean Provider interface and includes a built-in
8
+ * ensemble routing engine. You provide provider configs, the integration
9
+ * handles routing, fallback, and optional parallel ensemble execution.
10
+ *
11
+ * Key features:
12
+ * - Single-provider routing (cheapest, fastest, priority-based)
13
+ * - Ensemble mode: run N providers in parallel, merge results
14
+ * - Automatic fallback on provider failure
15
+ * - Routing metadata (provider, model, latency, cost) on every response
16
+ * - Compatible with any LangChain chain, agent, or runnable
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * import { A3MLLM } from './a3m_langchain';
21
+ * import { PromptTemplate } from '@langchain/core/prompts';
22
+ *
23
+ * const llm = new A3MLLM({
24
+ * providers: {
25
+ * groq: {
26
+ * name: 'Groq',
27
+ * baseUrl: 'https://api.groq.com/openai/v1/chat/completions',
28
+ * apiKey: process.env.GROQ_API_KEY,
29
+ * models: ['llama-3.3-70b-versatile'],
30
+ * tier: 'cheap',
31
+ * },
32
+ * openai: {
33
+ * name: 'OpenAI',
34
+ * baseUrl: 'https://api.openai.com/v1/chat/completions',
35
+ * apiKey: process.env.OPENAI_API_KEY,
36
+ * models: ['gpt-4o-mini'],
37
+ * tier: 'premium',
38
+ * },
39
+ * },
40
+ * routingStrategy: 'cheapest',
41
+ * });
42
+ *
43
+ * const prompt = PromptTemplate.fromTemplate('Tell me about {topic}');
44
+ * const chain = prompt.pipe(llm);
45
+ * const result = await chain.invoke({ topic: 'quantum computing' });
46
+ * console.log(result); // LLM response text
47
+ * console.log(result.metadata); // Routing metadata
48
+ * ```
49
+ */
50
+
51
+ // ============================================================
52
+ // TYPE-ONLY LANGCHAIN IMPORTS (peer dependency)
53
+ // ============================================================
54
+
55
+ import type { BaseLLMParams } from '@langchain/core/language_models/llms';
56
+ import type { BaseLanguageModelCallOptions } from '@langchain/core/language_models/base';
57
+
58
+ // ============================================================
59
+ // TYPES — Provider Configuration
60
+ // ============================================================
61
+
62
+ /** Cost per 1M tokens in USD */
63
+ export interface A3MCost {
64
+ input: number;
65
+ output: number;
66
+ }
67
+
68
+ /** Tier classification for cost-based routing */
69
+ export type A3MProviderTier = 'free' | 'cheap' | 'mid' | 'premium' | 'enterprise';
70
+
71
+ /** API format expected by the provider endpoint */
72
+ export type A3MProviderFormat = 'openai' | 'anthropic' | 'google' | 'cohere';
73
+
74
+ /** A single provider configuration */
75
+ export interface A3MProviderConfig {
76
+ /** Human-readable provider name (e.g. "Groq", "OpenAI") */
77
+ name: string;
78
+ /** API base URL for chat completions */
79
+ baseUrl: string;
80
+ /** API key (typically from env var) */
81
+ apiKey?: string;
82
+ /** Available model names at this provider */
83
+ models: string[];
84
+ /** Cost tier for routing decisions */
85
+ tier: A3MProviderTier;
86
+ /** Cost per 1M tokens in USD (used for cost estimation) */
87
+ cost?: A3MCost;
88
+ /** API format — defaults to 'openai' */
89
+ format?: A3MProviderFormat;
90
+ /** Optional headers to include in every request */
91
+ headers?: Record<string, string>;
92
+ /** Max tokens for responses */
93
+ maxTokens?: number;
94
+ }
95
+
96
+ // ============================================================
97
+ // TYPES — Routing & Ensemble
98
+ // ============================================================
99
+
100
+ /** Strategy for selecting a single provider */
101
+ export type A3MRoutingStrategy =
102
+ | 'cheapest' // Pick the provider with lowest cost
103
+ | 'fastest' // Pick the provider with lowest priority number (first match)
104
+ | 'priority' // Pick by explicit priority order (highest first)
105
+ | 'random'; // Pick randomly from available providers
106
+
107
+ /** Strategy for merging ensemble results */
108
+ export type A3MEnsembleStrategy =
109
+ | 'first' // Return the first response received
110
+ | 'fastest' // Same as first
111
+ | 'longest' // Return response with the most tokens
112
+ | 'majority' // (Text) Not applicable — falls back to longest
113
+ | 'concat'; // Concatenate all responses with separators
114
+
115
+ /** Routing metadata attached to every response */
116
+ export interface A3MRoutingMetadata {
117
+ /** Provider ID that served this request */
118
+ provider: string;
119
+ /** Model name used */
120
+ model: string;
121
+ /** Response latency in milliseconds */
122
+ latencyMs: number;
123
+ /** Estimated cost in USD */
124
+ costUsd: number;
125
+ /** Provider tier */
126
+ tier: A3MProviderTier;
127
+ /** Token usage (if available from response) */
128
+ tokensUsed?: {
129
+ input: number;
130
+ output: number;
131
+ total: number;
132
+ };
133
+ /** Whether this was an ensemble (multi-provider) response */
134
+ ensemble: boolean;
135
+ /** If ensemble, list of providers that contributed */
136
+ ensembleProviders?: string[];
137
+ }
138
+
139
+ /** Result from a single provider call */
140
+ export interface A3MProviderResult {
141
+ providerId: string;
142
+ content: string;
143
+ model: string;
144
+ latencyMs: number;
145
+ costUsd: number;
146
+ tier: A3MProviderTier;
147
+ tokensUsed?: { input: number; output: number; total: number };
148
+ error?: string;
149
+ }
150
+
151
+ // ============================================================
152
+ // TYPES — Options
153
+ // ============================================================
154
+
155
+ export interface A3MLLMOptions {
156
+ /**
157
+ * Provider configurations.
158
+ * Can be a Record (keyed by provider ID) or an array.
159
+ */
160
+ providers: Record<string, A3MProviderConfig> | A3MProviderConfig[];
161
+
162
+ /**
163
+ * Default model to use when no routing is applied.
164
+ * If not set, the first model from the first provider is used.
165
+ */
166
+ defaultModel?: string;
167
+
168
+ /**
169
+ * Strategy for single-provider routing.
170
+ * @default 'cheapest'
171
+ */
172
+ routingStrategy?: A3MRoutingStrategy;
173
+
174
+ /**
175
+ * Temperature (0–2). Passed to all providers.
176
+ * @default 0.7
177
+ */
178
+ temperature?: number;
179
+
180
+ /**
181
+ * Max output tokens. Passed to all providers.
182
+ * @default 4096
183
+ */
184
+ maxTokens?: number;
185
+
186
+ /**
187
+ * Request timeout in milliseconds.
188
+ * @default 60000
189
+ */
190
+ timeout?: number;
191
+
192
+ /**
193
+ * Enable automatic fallback to next provider on failure.
194
+ * @default true
195
+ */
196
+ fallbackEnabled?: boolean;
197
+
198
+ /**
199
+ * Callback for logging routing decisions.
200
+ */
201
+ onRoute?: (info: { provider: string; model: string; strategy: string }) => void;
202
+
203
+ /**
204
+ * Callback for logging errors.
205
+ */
206
+ onError?: (info: { provider: string; error: string; willFallback: boolean }) => void;
207
+
208
+ /**
209
+ * Custom priority order for 'priority' routing strategy.
210
+ * Array of provider IDs in descending priority.
211
+ */
212
+ priorityOrder?: string[];
213
+
214
+ /**
215
+ * @deprecated Use routingStrategy instead.
216
+ */
217
+ strategy?: A3MRoutingStrategy;
218
+ }
219
+
220
+ /** Extended call options for ensemble mode */
221
+ export interface A3MEnsembleCallOptions extends BaseLanguageModelCallOptions {
222
+ /** Run providers in parallel and merge results */
223
+ ensemble?: boolean | A3MEnsembleStrategy;
224
+ /** Specific providers to include for this call */
225
+ providers?: string[];
226
+ /** Stop sequences */
227
+ stop?: string[];
228
+ /** Signal for cancellation */
229
+ signal?: AbortSignal;
230
+ }
231
+
232
+ // ============================================================
233
+ // DEFAULT PROVIDERS (built-in)
234
+ // ============================================================
235
+
236
+ /**
237
+ * Default provider definitions for common LLM APIs.
238
+ * Users can override or extend these via the `providers` option.
239
+ */
240
+ export const A3M_DEFAULT_PROVIDERS: Record<string, A3MProviderConfig> = {
241
+ groq: {
242
+ name: 'Groq',
243
+ baseUrl: 'https://api.groq.com/openai/v1/chat/completions',
244
+ apiKey: undefined, // Set via options or env
245
+ models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant'],
246
+ tier: 'cheap',
247
+ cost: { input: 0.59, output: 0.79 },
248
+ },
249
+ openai: {
250
+ name: 'OpenAI',
251
+ baseUrl: 'https://api.openai.com/v1/chat/completions',
252
+ apiKey: undefined,
253
+ models: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'],
254
+ tier: 'premium',
255
+ cost: { input: 2.5, output: 10 },
256
+ },
257
+ anthropic: {
258
+ name: 'Anthropic',
259
+ baseUrl: 'https://api.anthropic.com/v1/messages',
260
+ apiKey: undefined,
261
+ models: ['claude-sonnet-4-20250514', 'claude-3-haiku'],
262
+ tier: 'premium',
263
+ cost: { input: 3, output: 15 },
264
+ format: 'anthropic',
265
+ },
266
+ deepseek: {
267
+ name: 'DeepSeek',
268
+ baseUrl: 'https://api.deepseek.com/v1/chat/completions',
269
+ apiKey: undefined,
270
+ models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
271
+ tier: 'mid',
272
+ cost: { input: 0.14, output: 0.28 },
273
+ },
274
+ google: {
275
+ name: 'Google AI',
276
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta/models',
277
+ apiKey: undefined,
278
+ models: ['gemini-2.5-flash', 'gemini-2.5-pro'],
279
+ tier: 'free',
280
+ cost: { input: 0, output: 0 },
281
+ format: 'google',
282
+ },
283
+ cerebras: {
284
+ name: 'Cerebras',
285
+ baseUrl: 'https://api.cerebras.ai/v1/chat/completions',
286
+ apiKey: undefined,
287
+ models: ['llama-3.3-70b'],
288
+ tier: 'cheap',
289
+ cost: { input: 0.6, output: 0.6 },
290
+ },
291
+ nvidia: {
292
+ name: 'NVIDIA NIM',
293
+ baseUrl: 'https://integrate.api.nvidia.com/v1/chat/completions',
294
+ apiKey: undefined,
295
+ models: ['meta/llama-3.3-70b-instruct', 'meta/llama-3.1-8b-instruct'],
296
+ tier: 'free',
297
+ cost: { input: 0, output: 0 },
298
+ },
299
+ deepinfra: {
300
+ name: 'DeepInfra',
301
+ baseUrl: 'https://api.deepinfra.com/v1/openai/chat/completions',
302
+ apiKey: undefined,
303
+ models: ['meta-llama/Meta-Llama-3.1-70B-Instruct', 'mistralai/Mixtral-8x7B-Instruct-v0.1'],
304
+ tier: 'cheap',
305
+ cost: { input: 0.05, output: 0.05 },
306
+ },
307
+ together: {
308
+ name: 'Together AI',
309
+ baseUrl: 'https://api.together.xyz/v1/chat/completions',
310
+ apiKey: undefined,
311
+ models: ['meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo'],
312
+ tier: 'cheap',
313
+ cost: { input: 0.18, output: 0.18 },
314
+ },
315
+ mistral: {
316
+ name: 'Mistral',
317
+ baseUrl: 'https://api.mistral.ai/v1/chat/completions',
318
+ apiKey: undefined,
319
+ models: ['mistral-small-latest', 'mistral-large-latest'],
320
+ tier: 'mid',
321
+ cost: { input: 0.2, output: 0.6 },
322
+ },
323
+ };
324
+
325
+ // ============================================================
326
+ // INTERNAL — Provider Registry
327
+ // ============================================================
328
+
329
+ interface NormalizedProvider {
330
+ id: string;
331
+ config: A3MProviderConfig;
332
+ }
333
+
334
+ /**
335
+ * Normalize provider configs into a sorted, keyed registry.
336
+ * Applies defaults and validates required fields.
337
+ */
338
+ function normalizeProviders(
339
+ input: Record<string, A3MProviderConfig> | A3MProviderConfig[],
340
+ ): Map<string, NormalizedProvider> {
341
+ const map = new Map<string, NormalizedProvider>();
342
+
343
+ if (Array.isArray(input)) {
344
+ for (let i = 0; i < input.length; i++) {
345
+ const id = `provider_${i}`;
346
+ map.set(id, { id, config: applyDefaults(input[i]) });
347
+ }
348
+ } else {
349
+ for (const [id, config] of Object.entries(input)) {
350
+ map.set(id, { id, config: applyDefaults(config) });
351
+ }
352
+ }
353
+
354
+ return map;
355
+ }
356
+
357
+ function applyDefaults(config: A3MProviderConfig): A3MProviderConfig {
358
+ return {
359
+ ...config,
360
+ format: config.format || 'openai',
361
+ cost: config.cost || { input: 0, output: 0 },
362
+ maxTokens: config.maxTokens || 4096,
363
+ headers: config.headers || {},
364
+ };
365
+ }
366
+
367
+ // ============================================================
368
+ // INTERNAL — HTTP Request Helpers
369
+ // ============================================================
370
+
371
+ interface BuildRequestOptions {
372
+ model: string;
373
+ messages: Array<{ role: string; content: string }>;
374
+ temperature?: number;
375
+ maxTokens?: number;
376
+ stop?: string[];
377
+ stream?: boolean;
378
+ }
379
+
380
+ interface BuildRequestResult {
381
+ url: string;
382
+ headers: Record<string, string>;
383
+ body: unknown;
384
+ }
385
+
386
+ /**
387
+ * Build a provider-specific request payload.
388
+ * Handles OpenAI, Anthropic, Google, and Cohere formats.
389
+ */
390
+ function buildProviderRequest(
391
+ provider: A3MProviderConfig,
392
+ opts: BuildRequestOptions,
393
+ ): BuildRequestResult {
394
+ const apiKey = provider.apiKey || '';
395
+ const headers: Record<string, string> = {
396
+ ...provider.headers,
397
+ };
398
+
399
+ switch (provider.format) {
400
+ case 'anthropic': {
401
+ // Anthropic uses x-api-key and a different payload shape
402
+ let systemPrompt = '';
403
+ const nonSystemMessages = opts.messages.filter((m) => {
404
+ if (m.role === 'system') {
405
+ systemPrompt += m.content + '\n';
406
+ return false;
407
+ }
408
+ return true;
409
+ });
410
+
411
+ headers['Content-Type'] = 'application/json';
412
+ headers['x-api-key'] = apiKey;
413
+ headers['anthropic-version'] = '2023-06-01';
414
+
415
+ return {
416
+ url: provider.baseUrl,
417
+ headers,
418
+ body: {
419
+ model: opts.model,
420
+ max_tokens: opts.maxTokens || 4096,
421
+ system: systemPrompt.trim() || undefined,
422
+ messages: nonSystemMessages.map((m) => ({
423
+ role: m.role === 'assistant' ? 'assistant' : 'user',
424
+ content: m.content,
425
+ })),
426
+ temperature: opts.temperature,
427
+ stop_sequences: opts.stop,
428
+ },
429
+ };
430
+ }
431
+
432
+ case 'google': {
433
+ // Google Gemini uses a different URL pattern and body
434
+ const systemMsg = opts.messages.find((m) => m.role === 'system');
435
+ const contents = opts.messages
436
+ .filter((m) => m.role !== 'system')
437
+ .map((m) => ({
438
+ role: m.role === 'assistant' ? 'model' : 'user',
439
+ parts: [{ text: m.content }],
440
+ }));
441
+
442
+ const modelId = opts.model;
443
+ const url = `${provider.baseUrl}/${modelId}:generateContent?key=${apiKey}`;
444
+
445
+ headers['Content-Type'] = 'application/json';
446
+
447
+ return {
448
+ url,
449
+ headers,
450
+ body: {
451
+ contents,
452
+ systemInstruction: systemMsg
453
+ ? { parts: [{ text: systemMsg.content }] }
454
+ : undefined,
455
+ generationConfig: {
456
+ maxOutputTokens: opts.maxTokens || 4096,
457
+ temperature: opts.temperature,
458
+ stopSequences: opts.stop,
459
+ },
460
+ },
461
+ };
462
+ }
463
+
464
+ case 'cohere': {
465
+ headers['Content-Type'] = 'application/json';
466
+ headers['Authorization'] = `Bearer ${apiKey}`;
467
+
468
+ return {
469
+ url: provider.baseUrl,
470
+ headers,
471
+ body: {
472
+ model: opts.model,
473
+ message: opts.messages.map((m) => m.content).join('\n'),
474
+ max_tokens: opts.maxTokens || 4096,
475
+ temperature: opts.temperature,
476
+ chat_history: opts.messages.slice(0, -1).map((m) => ({
477
+ role: m.role === 'assistant' ? 'CHATBOT' : 'USER',
478
+ message: m.content,
479
+ })),
480
+ },
481
+ };
482
+ }
483
+
484
+ case 'openai':
485
+ default: {
486
+ headers['Content-Type'] = 'application/json';
487
+ headers['Authorization'] = `Bearer ${apiKey}`;
488
+
489
+ return {
490
+ url: provider.baseUrl,
491
+ headers,
492
+ body: {
493
+ model: opts.model,
494
+ messages: opts.messages,
495
+ temperature: opts.temperature,
496
+ max_tokens: opts.maxTokens || 4096,
497
+ stop: opts.stop,
498
+ },
499
+ };
500
+ }
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Parse a raw API response into standardised content.
506
+ */
507
+ function parseProviderResponse(
508
+ provider: A3MProviderConfig,
509
+ data: Record<string, unknown>,
510
+ ): { content: string; model: string; tokensUsed?: { input: number; output: number; total: number } } {
511
+ switch (provider.format) {
512
+ case 'anthropic': {
513
+ const content = ((data as any).content?.[0]?.text || '') as string;
514
+ const usage = (data as any).usage;
515
+ return {
516
+ content,
517
+ model: (data as any).model as string || '',
518
+ tokensUsed: usage
519
+ ? {
520
+ input: usage.input_tokens || 0,
521
+ output: usage.output_tokens || 0,
522
+ total: (usage.input_tokens || 0) + (usage.output_tokens || 0),
523
+ }
524
+ : undefined,
525
+ };
526
+ }
527
+
528
+ case 'google': {
529
+ const candidate = (data as any).candidates?.[0];
530
+ const content = candidate?.content?.parts?.[0]?.text || '';
531
+ const usage = (data as any).usageMetadata;
532
+ return {
533
+ content,
534
+ model: (data as any).modelVersion as string || '',
535
+ tokensUsed: usage
536
+ ? {
537
+ input: usage.promptTokenCount || 0,
538
+ output: usage.candidatesTokenCount || 0,
539
+ total: (usage.promptTokenCount || 0) + (usage.candidatesTokenCount || 0),
540
+ }
541
+ : undefined,
542
+ };
543
+ }
544
+
545
+ case 'cohere': {
546
+ return {
547
+ content: ((data as any).text || (data as any).generation?.text || '') as string,
548
+ model: (data as any).model as string || '',
549
+ tokensUsed: (data as any).meta?.billed_units
550
+ ? {
551
+ input: (data as any).meta.billed_units.input_tokens || 0,
552
+ output: (data as any).meta.billed_units.output_tokens || 0,
553
+ total: ((data as any).meta.billed_units.input_tokens || 0) +
554
+ ((data as any).meta.billed_units.output_tokens || 0),
555
+ }
556
+ : undefined,
557
+ };
558
+ }
559
+
560
+ case 'openai':
561
+ default: {
562
+ const choice = (data as any).choices?.[0];
563
+ const content = choice?.message?.content || choice?.text || '';
564
+ const usage = (data as any).usage;
565
+ return {
566
+ content,
567
+ model: (data as any).model as string || '',
568
+ tokensUsed: usage
569
+ ? {
570
+ input: usage.prompt_tokens || 0,
571
+ output: usage.completion_tokens || 0,
572
+ total: usage.total_tokens || 0,
573
+ }
574
+ : undefined,
575
+ };
576
+ }
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Execute a single provider call and return the result.
582
+ */
583
+ async function callProvider(
584
+ providerId: string,
585
+ provider: A3MProviderConfig,
586
+ messages: Array<{ role: string; content: string }>,
587
+ options: {
588
+ temperature?: number;
589
+ maxTokens?: number;
590
+ stop?: string[];
591
+ timeout: number;
592
+ signal?: AbortSignal;
593
+ },
594
+ ): Promise<A3MProviderResult> {
595
+ const startTime = performance.now();
596
+ const model = provider.models[0];
597
+
598
+ try {
599
+ const request = buildProviderRequest(provider, {
600
+ model,
601
+ messages,
602
+ temperature: options.temperature,
603
+ maxTokens: options.maxTokens,
604
+ stop: options.stop,
605
+ });
606
+
607
+ const controller = new AbortController();
608
+ const timer = setTimeout(() => controller.abort(), options.timeout);
609
+
610
+ // Wire up external signal
611
+ if (options.signal) {
612
+ options.signal.addEventListener('abort', () => controller.abort(), { once: true });
613
+ }
614
+
615
+ let response: Response;
616
+ try {
617
+ response = await fetch(request.url, {
618
+ method: 'POST',
619
+ headers: request.headers,
620
+ body: JSON.stringify(request.body),
621
+ signal: controller.signal,
622
+ });
623
+ } finally {
624
+ clearTimeout(timer);
625
+ }
626
+
627
+ const latencyMs = Math.round(performance.now() - startTime);
628
+
629
+ if (!response.ok) {
630
+ const errorText = await response.text().catch(() => 'Unknown error');
631
+ return {
632
+ providerId,
633
+ content: '',
634
+ model,
635
+ latencyMs,
636
+ costUsd: 0,
637
+ tier: provider.tier,
638
+ error: `HTTP ${response.status}: ${errorText}`,
639
+ };
640
+ }
641
+
642
+ const data = await response.json() as Record<string, unknown>;
643
+ const parsed = parseProviderResponse(provider, data);
644
+ const inputTokens = parsed.tokensUsed?.input || 0;
645
+ const outputTokens = parsed.tokensUsed?.output || 0;
646
+ const costUsd = estimateCost(provider, inputTokens, outputTokens);
647
+
648
+ return {
649
+ providerId,
650
+ content: parsed.content,
651
+ model: parsed.model || model,
652
+ latencyMs,
653
+ costUsd,
654
+ tier: provider.tier,
655
+ tokensUsed: parsed.tokensUsed,
656
+ };
657
+ } catch (error: unknown) {
658
+ const latencyMs = Math.round(performance.now() - startTime);
659
+ return {
660
+ providerId,
661
+ content: '',
662
+ model,
663
+ latencyMs,
664
+ costUsd: 0,
665
+ tier: provider.tier,
666
+ error: error instanceof Error ? error.message : String(error),
667
+ };
668
+ }
669
+ }
670
+
671
+ /**
672
+ * Estimate cost in USD based on token usage and provider rates.
673
+ */
674
+ function estimateCost(
675
+ provider: A3MProviderConfig,
676
+ inputTokens: number,
677
+ outputTokens: number,
678
+ ): number {
679
+ if (!provider.cost) return 0;
680
+ const inputCost = (inputTokens / 1_000_000) * provider.cost.input;
681
+ const outputCost = (outputTokens / 1_000_000) * provider.cost.output;
682
+ return Math.round((inputCost + outputCost) * 1_000_000) / 1_000_000;
683
+ }
684
+
685
+ /**
686
+ * Estimate output tokens from text length (rough heuristic).
687
+ */
688
+ function estimateOutputTokens(text: string): number {
689
+ // ~4 chars per token for English
690
+ return Math.ceil(text.length / 4);
691
+ }
692
+
693
+ // ============================================================
694
+ // INTERNAL — Routing Logic
695
+ // ============================================================
696
+
697
+ /**
698
+ * Select a provider based on the routing strategy.
699
+ * Returns a list of provider IDs in priority order (best first).
700
+ */
701
+ function selectProviders(
702
+ registry: Map<string, NormalizedProvider>,
703
+ strategy: A3MRoutingStrategy,
704
+ priorityOrder?: string[],
705
+ ): string[] {
706
+ const entries = Array.from(registry.entries());
707
+ if (entries.length === 0) return [];
708
+
709
+ switch (strategy) {
710
+ case 'cheapest': {
711
+ // Sort by cost ascending (cheapest first), then by tier
712
+ return entries
713
+ .sort(([, a], [, b]) => {
714
+ const costA = (a.config.cost?.input ?? 0) + (a.config.cost?.output ?? 0);
715
+ const costB = (b.config.cost?.input ?? 0) + (b.config.cost?.output ?? 0);
716
+ if (costA !== costB) return costA - costB;
717
+ // If same cost, prefer free > cheap > mid > premium
718
+ const tierOrder = { free: 0, cheap: 1, mid: 2, premium: 3, enterprise: 4 };
719
+ return (tierOrder[a.config.tier] ?? 5) - (tierOrder[b.config.tier] ?? 5);
720
+ })
721
+ .map(([id]) => id);
722
+ }
723
+
724
+ case 'priority': {
725
+ if (priorityOrder && priorityOrder.length > 0) {
726
+ // Use explicit priority order, appending unlisted providers at the end
727
+ const listed = priorityOrder.filter((id) => registry.has(id));
728
+ const unlisted = entries
729
+ .filter(([id]) => !priorityOrder.includes(id))
730
+ .map(([id]) => id);
731
+ return [...listed, ...unlisted];
732
+ }
733
+ // Fall back to insertion order
734
+ return entries.map(([id]) => id);
735
+ }
736
+
737
+ case 'random': {
738
+ // Return all providers in random order
739
+ const ids = entries.map(([id]) => id);
740
+ for (let i = ids.length - 1; i > 0; i--) {
741
+ const j = Math.floor(Math.random() * (i + 1));
742
+ [ids[i], ids[j]] = [ids[j], ids[i]];
743
+ }
744
+ return ids;
745
+ }
746
+
747
+ case 'fastest':
748
+ default: {
749
+ // Return in insertion order (assumes fast providers registered first)
750
+ return entries.map(([id]) => id);
751
+ }
752
+ }
753
+ }
754
+
755
+ // ============================================================
756
+ // A3MLLM — LangChain LLM Integration
757
+ // ============================================================
758
+
759
+ /**
760
+ * A3M Router LangChain LLM.
761
+ *
762
+ * Extends LangChain's LLM base class to route prompts through
763
+ * the best available provider. Supports single-provider routing
764
+ * with fallback, and optional parallel ensemble execution.
765
+ *
766
+ * Every response includes routing metadata accessible via
767
+ * the `metadata` property on the output.
768
+ *
769
+ * @example
770
+ * ```typescript
771
+ * const llm = new A3MLLM({
772
+ * providers: A3M_DEFAULT_PROVIDERS, // or your own
773
+ * routingStrategy: 'cheapest',
774
+ * temperature: 0.7,
775
+ * });
776
+ *
777
+ * // Basic usage
778
+ * const response = await llm.invoke('What is 2+2?');
779
+ *
780
+ * // Access routing metadata
781
+ * console.log(response.metadata.provider);
782
+ * console.log(response.metadata.costUsd);
783
+ * console.log(response.metadata.latencyMs);
784
+ *
785
+ * // Ensemble mode
786
+ * const result = await llm.ensembleInvoke('Explain quantum computing', {
787
+ * ensemble: 'longest',
788
+ * });
789
+ * console.log(result.text);
790
+ * console.log(result.metadata.ensembleProviders);
791
+ * ```
792
+ */
793
+ export class A3MLLM {
794
+ /** LangChain namespace identifier */
795
+ lc_namespace = ['a3m_router', 'langchain'];
796
+
797
+ /** Provider registry */
798
+ private registry: Map<string, NormalizedProvider>;
799
+
800
+ /** Resolved options */
801
+ private options: Required<A3MLLMOptions>;
802
+
803
+ /** Cached provider selection order */
804
+ private providerOrder: string[] | null = null;
805
+
806
+ constructor(options: A3MLLMOptions) {
807
+ // Merge providers with defaults (user providers override built-in defaults)
808
+ const mergedProviders = this.mergeProviders(options.providers);
809
+
810
+ this.registry = normalizeProviders(mergedProviders);
811
+
812
+ this.options = {
813
+ providers: options.providers,
814
+ defaultModel: options.defaultModel || '',
815
+ routingStrategy: options.strategy || options.routingStrategy || 'cheapest',
816
+ temperature: options.temperature ?? 0.7,
817
+ maxTokens: options.maxTokens ?? 4096,
818
+ timeout: options.timeout ?? 60000,
819
+ fallbackEnabled: options.fallbackEnabled ?? true,
820
+ onRoute: options.onRoute || (() => {}),
821
+ onError: options.onError || (() => {}),
822
+ priorityOrder: options.priorityOrder || [],
823
+ };
824
+
825
+ this.providerOrder = null; // will be computed lazily
826
+ }
827
+
828
+ /**
829
+ * Merge user providers with built-in defaults.
830
+ * User providers with the same ID override defaults.
831
+ */
832
+ private mergeProviders(
833
+ input: Record<string, A3MProviderConfig> | A3MProviderConfig[],
834
+ ): Record<string, A3MProviderConfig> {
835
+ // Start with all default providers
836
+ const merged: Record<string, A3MProviderConfig> = { ...A3M_DEFAULT_PROVIDERS };
837
+
838
+ if (Array.isArray(input)) {
839
+ // Array format: replace defaults entirely
840
+ return input.reduce((acc, p, i) => {
841
+ acc[`provider_${i}`] = p;
842
+ return acc;
843
+ }, {} as Record<string, A3MProviderConfig>);
844
+ }
845
+
846
+ // Record format: merge into defaults (user values override)
847
+ for (const [id, config] of Object.entries(input)) {
848
+ merged[id] = { ...merged[id], ...config };
849
+ }
850
+
851
+ return merged;
852
+ }
853
+
854
+ /**
855
+ * Get the list of provider IDs in routing order.
856
+ */
857
+ getProviderOrder(): string[] {
858
+ if (!this.providerOrder) {
859
+ this.providerOrder = selectProviders(
860
+ this.registry,
861
+ this.options.routingStrategy,
862
+ this.options.priorityOrder,
863
+ );
864
+ }
865
+ return this.providerOrder;
866
+ }
867
+
868
+ /**
869
+ * Get the primary (best) provider ID based on routing strategy.
870
+ */
871
+ getPrimaryProvider(): string | null {
872
+ const order = this.getProviderOrder();
873
+ return order.length > 0 ? order[0] : null;
874
+ }
875
+
876
+ /**
877
+ * Get a provider config by ID.
878
+ */
879
+ getProvider(id: string): A3MProviderConfig | undefined {
880
+ return this.registry.get(id)?.config;
881
+ }
882
+
883
+ /**
884
+ * Get all registered providers.
885
+ */
886
+ getAllProviders(): Record<string, A3MProviderConfig> {
887
+ const result: Record<string, A3MProviderConfig> = {};
888
+ for (const [id, np] of this.registry.entries()) {
889
+ result[id] = np.config;
890
+ }
891
+ return result;
892
+ }
893
+
894
+ /**
895
+ * Recompute provider order (e.g., after changing strategy).
896
+ */
897
+ refreshRouting(): void {
898
+ this.providerOrder = null;
899
+ }
900
+
901
+ // ==================================================================
902
+ // LangChain LLM Interface — _call
903
+ // ==================================================================
904
+
905
+ /**
906
+ * Core LangChain `_call` implementation.
907
+ *
908
+ * Routes the prompt through the best available provider.
909
+ * On failure, attempts fallback providers if enabled.
910
+ *
911
+ * Returns the response text with metadata attached.
912
+ */
913
+ async _call(
914
+ prompt: string,
915
+ options?: A3MEnsembleCallOptions,
916
+ ): Promise<string> {
917
+ const result = await this.callWithMetadata(prompt, options);
918
+ return result.text;
919
+ }
920
+
921
+ // ==================================================================
922
+ // Core Invocation
923
+ // ==================================================================
924
+
925
+ /**
926
+ * Invoke the LLM with a string prompt or message array.
927
+ * Returns the response text.
928
+ *
929
+ * This is the main entry point for LangChain chain compatibility.
930
+ */
931
+ async invoke(
932
+ input: string | Array<{ role: string; content: string }>,
933
+ options?: A3MEnsembleCallOptions,
934
+ ): Promise<string> {
935
+ const messages = typeof input === 'string'
936
+ ? [{ role: 'user' as const, content: input }]
937
+ : input;
938
+
939
+ return this._callWithMessages(messages, options);
940
+ }
941
+
942
+ /**
943
+ * Invoke the LLM and return both text and metadata.
944
+ */
945
+ async invokeWithMetadata(
946
+ input: string | Array<{ role: string; content: string }>,
947
+ options?: A3MEnsembleCallOptions,
948
+ ): Promise<{ text: string; metadata: A3MRoutingMetadata }> {
949
+ const messages = typeof input === 'string'
950
+ ? [{ role: 'user' as const, content: input }]
951
+ : input;
952
+
953
+ return this._callWithMessagesWithMetadata(messages, options);
954
+ }
955
+
956
+ /**
957
+ * Ensemble mode: run multiple providers in parallel and merge results.
958
+ * Returns the merged text and metadata about all providers.
959
+ */
960
+ async ensembleInvoke(
961
+ input: string | Array<{ role: string; content: string }>,
962
+ options?: A3MEnsembleCallOptions,
963
+ ): Promise<{ text: string; metadata: A3MRoutingMetadata }> {
964
+ const messages = typeof input === 'string'
965
+ ? [{ role: 'user' as const, content: input }]
966
+ : input;
967
+
968
+ const providerIds = options?.providers || this.getProviderOrder();
969
+ const strategy: A3MEnsembleStrategy =
970
+ typeof options?.ensemble === 'string'
971
+ ? options.ensemble
972
+ : 'longest';
973
+
974
+ // Run all selected providers in parallel
975
+ const results = await Promise.all(
976
+ providerIds.map((id) => {
977
+ const np = this.registry.get(id);
978
+ if (!np) return null;
979
+ return callProvider(id, np.config, messages, {
980
+ temperature: this.options.temperature,
981
+ maxTokens: this.options.maxTokens,
982
+ stop: options?.stop,
983
+ timeout: this.options.timeout,
984
+ signal: options?.signal,
985
+ });
986
+ }),
987
+ );
988
+
989
+ const successful = results.filter(
990
+ (r): r is A3MProviderResult => r !== null && !r.error,
991
+ );
992
+
993
+ if (successful.length === 0) {
994
+ const errors = results
995
+ .filter((r): r is A3MProviderResult => r !== null)
996
+ .map((r) => `${r.providerId}: ${r.error}`);
997
+ throw new Error(
998
+ `A3M Ensemble: All ${providerIds.length} providers failed. Errors: ${errors.join('; ')}`,
999
+ );
1000
+ }
1001
+
1002
+ // Merge results based on strategy
1003
+ let mergedContent: string;
1004
+ switch (strategy) {
1005
+ case 'first': {
1006
+ // First to respond (lowest latency)
1007
+ successful.sort((a, b) => a.latencyMs - b.latencyMs);
1008
+ mergedContent = successful[0].content;
1009
+ break;
1010
+ }
1011
+
1012
+ case 'longest': {
1013
+ // Most verbose response
1014
+ successful.sort((a, b) => b.content.length - a.content.length);
1015
+ mergedContent = successful[0].content;
1016
+ break;
1017
+ }
1018
+
1019
+ case 'concat': {
1020
+ // Concatenate all responses with clear separation
1021
+ mergedContent = successful
1022
+ .map(
1023
+ (r, i) =>
1024
+ `[Provider ${i + 1}: ${r.providerId} (${r.model})]\n${r.content}`,
1025
+ )
1026
+ .join('\n\n---\n\n');
1027
+ break;
1028
+ }
1029
+
1030
+ default: {
1031
+ // Default to longest
1032
+ successful.sort((a, b) => b.content.length - a.content.length);
1033
+ mergedContent = successful[0].content;
1034
+ }
1035
+ }
1036
+
1037
+ // Compute aggregate metadata
1038
+ const totalCost = successful.reduce((sum, r) => sum + r.costUsd, 0);
1039
+ const avgLatency = Math.round(
1040
+ successful.reduce((sum, r) => sum + r.latencyMs, 0) / successful.length,
1041
+ );
1042
+ const bestProvider = successful[0];
1043
+
1044
+ const metadata: A3MRoutingMetadata = {
1045
+ provider: bestProvider.providerId,
1046
+ model: bestProvider.model,
1047
+ latencyMs: avgLatency,
1048
+ costUsd: totalCost,
1049
+ tier: bestProvider.tier,
1050
+ tokensUsed: bestProvider.tokensUsed,
1051
+ ensemble: true,
1052
+ ensembleProviders: successful.map((r) => r.providerId),
1053
+ };
1054
+
1055
+ return { text: mergedContent, metadata };
1056
+ }
1057
+
1058
+ // ==================================================================
1059
+ // Internal: call with metadata
1060
+ // ==================================================================
1061
+
1062
+ /**
1063
+ * Call the LLM and return the text. (metadata available via separate method)
1064
+ */
1065
+ private async callWithMetadata(
1066
+ prompt: string,
1067
+ options?: A3MEnsembleCallOptions,
1068
+ ): Promise<{ text: string; metadata: A3MRoutingMetadata }> {
1069
+ // Ensemble mode check
1070
+ if (options?.ensemble) {
1071
+ return this.ensembleInvoke(prompt, options);
1072
+ }
1073
+
1074
+ const messages = [{ role: 'user' as const, content: prompt }];
1075
+
1076
+ // Single-provider routing with fallback
1077
+ const providerIds = options?.providers || this.getProviderOrder();
1078
+
1079
+ let lastError: string | null = null;
1080
+
1081
+ for (let i = 0; i < providerIds.length; i++) {
1082
+ const id = providerIds[i];
1083
+ const np = this.registry.get(id);
1084
+ if (!np) continue;
1085
+
1086
+ this.options.onRoute({
1087
+ provider: id,
1088
+ model: np.config.models[0] || 'unknown',
1089
+ strategy: this.options.routingStrategy,
1090
+ });
1091
+
1092
+ const result = await callProvider(id, np.config, messages, {
1093
+ temperature: this.options.temperature,
1094
+ maxTokens: this.options.maxTokens,
1095
+ stop: options?.stop,
1096
+ timeout: this.options.timeout,
1097
+ signal: options?.signal,
1098
+ });
1099
+
1100
+ if (result.error) {
1101
+ lastError = result.error;
1102
+ const willFallback = this.options.fallbackEnabled && i < providerIds.length - 1;
1103
+ this.options.onError({
1104
+ provider: id,
1105
+ error: result.error,
1106
+ willFallback,
1107
+ });
1108
+
1109
+ if (willFallback) {
1110
+ continue; // Try next provider
1111
+ }
1112
+
1113
+ throw new Error(
1114
+ `A3M Router: All providers exhausted. Last error (${id}): ${result.error}`,
1115
+ );
1116
+ }
1117
+
1118
+ // Success
1119
+ const metadata: A3MRoutingMetadata = {
1120
+ provider: id,
1121
+ model: result.model,
1122
+ latencyMs: result.latencyMs,
1123
+ costUsd: result.costUsd,
1124
+ tier: result.tier,
1125
+ tokensUsed: result.tokensUsed,
1126
+ ensemble: false,
1127
+ };
1128
+
1129
+ return { text: result.content, metadata };
1130
+ }
1131
+
1132
+ // If we exhausted all providers without success
1133
+ throw new Error(
1134
+ `A3M Router: No providers available. Last error: ${lastError || 'Unknown'}`,
1135
+ );
1136
+ }
1137
+
1138
+ /**
1139
+ * Call with message array (chat-style input).
1140
+ */
1141
+ private async _callWithMessages(
1142
+ messages: Array<{ role: string; content: string }>,
1143
+ options?: A3MEnsembleCallOptions,
1144
+ ): Promise<string> {
1145
+ // Ensemble mode
1146
+ if (options?.ensemble) {
1147
+ const result = await this.ensembleInvoke(messages, options);
1148
+ return result.text;
1149
+ }
1150
+
1151
+ // Single-provider routing with fallback
1152
+ const providerIds = options?.providers || this.getProviderOrder();
1153
+ let lastError: string | null = null;
1154
+
1155
+ for (let i = 0; i < providerIds.length; i++) {
1156
+ const id = providerIds[i];
1157
+ const np = this.registry.get(id);
1158
+ if (!np) continue;
1159
+
1160
+ this.options.onRoute({
1161
+ provider: id,
1162
+ model: np.config.models[0] || 'unknown',
1163
+ strategy: this.options.routingStrategy,
1164
+ });
1165
+
1166
+ const result = await callProvider(id, np.config, messages, {
1167
+ temperature: this.options.temperature,
1168
+ maxTokens: this.options.maxTokens,
1169
+ stop: options?.stop,
1170
+ timeout: this.options.timeout,
1171
+ signal: options?.signal,
1172
+ });
1173
+
1174
+ if (result.error) {
1175
+ lastError = result.error;
1176
+ const willFallback = this.options.fallbackEnabled && i < providerIds.length - 1;
1177
+ this.options.onError({
1178
+ provider: id,
1179
+ error: result.error,
1180
+ willFallback,
1181
+ });
1182
+
1183
+ if (willFallback) continue;
1184
+
1185
+ throw new Error(
1186
+ `A3M Router: All providers exhausted. Last error (${id}): ${result.error}`,
1187
+ );
1188
+ }
1189
+
1190
+ return result.content;
1191
+ }
1192
+
1193
+ throw new Error(
1194
+ `A3M Router: No providers available. Last error: ${lastError || 'Unknown'}`,
1195
+ );
1196
+ }
1197
+
1198
+ /**
1199
+ * Call with message array, return text + metadata.
1200
+ */
1201
+ private async _callWithMessagesWithMetadata(
1202
+ messages: Array<{ role: string; content: string }>,
1203
+ options?: A3MEnsembleCallOptions,
1204
+ ): Promise<{ text: string; metadata: A3MRoutingMetadata }> {
1205
+ if (options?.ensemble) {
1206
+ return this.ensembleInvoke(messages, options);
1207
+ }
1208
+
1209
+ const providerIds = options?.providers || this.getProviderOrder();
1210
+
1211
+ for (let i = 0; i < providerIds.length; i++) {
1212
+ const id = providerIds[i];
1213
+ const np = this.registry.get(id);
1214
+ if (!np) continue;
1215
+
1216
+ const result = await callProvider(id, np.config, messages, {
1217
+ temperature: this.options.temperature,
1218
+ maxTokens: this.options.maxTokens,
1219
+ stop: options?.stop,
1220
+ timeout: this.options.timeout,
1221
+ signal: options?.signal,
1222
+ });
1223
+
1224
+ if (result.error) {
1225
+ if (this.options.fallbackEnabled && i < providerIds.length - 1) continue;
1226
+ throw new Error(
1227
+ `A3M Router: All providers exhausted. Last error (${id}): ${result.error}`,
1228
+ );
1229
+ }
1230
+
1231
+ return {
1232
+ text: result.content,
1233
+ metadata: {
1234
+ provider: id,
1235
+ model: result.model,
1236
+ latencyMs: result.latencyMs,
1237
+ costUsd: result.costUsd,
1238
+ tier: result.tier,
1239
+ tokensUsed: result.tokensUsed,
1240
+ ensemble: false,
1241
+ },
1242
+ };
1243
+ }
1244
+
1245
+ throw new Error('A3M Router: No providers available.');
1246
+ }
1247
+ }
1248
+
1249
+ // ============================================================
1250
+ // FACTORY HELPERS
1251
+ // ============================================================
1252
+
1253
+ /**
1254
+ * Create an A3MLLM pre-configured for a single provider.
1255
+ * Convenience for users who want to use a specific provider.
1256
+ *
1257
+ * @example
1258
+ * ```typescript
1259
+ * const groq = createA3MProvider('groq', {
1260
+ * apiKey: process.env.GROQ_API_KEY,
1261
+ * });
1262
+ * ```
1263
+ */
1264
+ export function createA3MProvider(
1265
+ providerId: keyof typeof A3M_DEFAULT_PROVIDERS | string,
1266
+ overrides?: Partial<A3MProviderConfig>,
1267
+ ): A3MLLM {
1268
+ const defaultConfig = (A3M_DEFAULT_PROVIDERS as Record<string, A3MProviderConfig>)[providerId];
1269
+ if (!defaultConfig && !overrides?.baseUrl) {
1270
+ throw new Error(
1271
+ `Unknown provider "${providerId}". Provide a custom config with baseUrl.`,
1272
+ );
1273
+ }
1274
+
1275
+ const config: A3MProviderConfig = {
1276
+ ...defaultConfig,
1277
+ ...overrides,
1278
+ } as A3MProviderConfig;
1279
+
1280
+ return new A3MLLM({
1281
+ providers: { [providerId]: config },
1282
+ });
1283
+ }
1284
+
1285
+ /**
1286
+ * Create an A3MLLM with automatic cheapest-cost routing.
1287
+ * Scans all configured providers and picks the cheapest available.
1288
+ *
1289
+ * @example
1290
+ * ```typescript
1291
+ * const router = createA3MRouter({
1292
+ * groq: { apiKey: process.env.GROQ_API_KEY },
1293
+ * openai: { apiKey: process.env.OPENAI_API_KEY },
1294
+ * });
1295
+ * ```
1296
+ */
1297
+ export function createA3MRouter(
1298
+ providerKeys: Record<string, { apiKey?: string; models?: string[] }>,
1299
+ ): A3MLLM {
1300
+ const providers: Record<string, A3MProviderConfig> = {};
1301
+
1302
+ for (const [id, keyConfig] of Object.entries(providerKeys)) {
1303
+ const defaults = (A3M_DEFAULT_PROVIDERS as Record<string, A3MProviderConfig>)[id];
1304
+ if (defaults) {
1305
+ providers[id] = {
1306
+ ...defaults,
1307
+ ...keyConfig,
1308
+ };
1309
+ } else {
1310
+ // User-provided custom provider (requires baseUrl)
1311
+ if (!keyConfig.apiKey) {
1312
+ console.warn(`[A3M] Skipping unknown provider "${id}" — no apiKey or default config available.`);
1313
+ continue;
1314
+ }
1315
+ }
1316
+ }
1317
+
1318
+ return new A3MLLM({
1319
+ providers,
1320
+ routingStrategy: 'cheapest',
1321
+ });
1322
+ }
1323
+
1324
+ // ============================================================
1325
+ // LangChain Compatibility Helpers
1326
+ // ============================================================
1327
+
1328
+ /**
1329
+ * Type guard to check if a value has A3M routing metadata attached.
1330
+ */
1331
+ export function hasA3MMetadata(
1332
+ value: unknown,
1333
+ ): value is { text: string; metadata: A3MRoutingMetadata } {
1334
+ return (
1335
+ typeof value === 'object' &&
1336
+ value !== null &&
1337
+ 'text' in value &&
1338
+ 'metadata' in (value as any)
1339
+ );
1340
+ }
1341
+
1342
+ /**
1343
+ * Type definition for LangChain runnable compatibility.
1344
+ * Used when piping A3MLLM into LangChain chains.
1345
+ */
1346
+ export interface A3MRunnable {
1347
+ invoke(input: string, options?: A3MEnsembleCallOptions): Promise<string>;
1348
+ }
1349
+
1350
+ // ============================================================
1351
+ // Version info
1352
+ // ============================================================
1353
+
1354
+ /** Integration version */
1355
+ export const VERSION = '0.1.0';
1356
+
1357
+ /**
1358
+ * Package name
1359
+ */
1360
+ export const PACKAGE_NAME = 'a3m-langchain';