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.
- package/.dockerignore +82 -0
- package/.env.example +303 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +83 -12
- package/.github/ISSUE_TEMPLATE/config.yml +12 -6
- package/.github/ISSUE_TEMPLATE/feature_request.md +61 -10
- package/.github/PULL_REQUEST_TEMPLATE.md +53 -26
- package/.github/dependabot.yml +9 -0
- package/.github/workflows/codeql.yml +38 -0
- package/.github/workflows/npm-publish.yml +20 -0
- package/.github/workflows/stale.yml +56 -0
- package/ARCHITECTURE.md +346 -0
- package/AUDIT_REPORT.md +28 -0
- package/CHANGELOG.md +386 -22
- package/CONTRIBUTORS.md +20 -0
- package/Dockerfile +53 -0
- package/Dockerfile.proxy +33 -0
- package/PR_STATUS_REPORT.md +148 -0
- package/README.md +22 -0
- package/RUNKIT.md +83 -0
- package/_schema.html +61 -15
- package/articles/AI_AGENT_LLM_ROUTING.md +150 -0
- package/articles/FROM_ZERO_TO_10K.md +107 -0
- package/articles/LLM_BENCHMARK_DEEP_DIVE.md +153 -0
- package/articles/TWEETS_10K_DOWNLOADS.md +47 -0
- package/articles/TWEETS_BENCHMARK_FIRST.md +46 -0
- package/articles/TWEETS_MCP_PLAY.md +51 -0
- package/articles/TWEETS_SEQUENTIAL_BROKEN.md +49 -0
- package/articles/TWEETS_WHY_BUILD.md +54 -0
- package/benchmark-results.json +26 -45
- package/cli/a3m +840 -0
- package/demo/package.json +13 -0
- package/demo/public/index.html +762 -0
- package/demo/server.js +405 -0
- package/dist/cli.js +4 -0
- package/docker-compose.yml +74 -0
- package/docs/.nojekyll +0 -0
- package/docs/BENCHMARK.md +96 -22
- package/docs/_config.yml +49 -0
- package/docs/api.html +513 -0
- package/docs/benchmark.html +387 -0
- package/docs/cli-cheatsheet.md +339 -0
- package/docs/comparison.md +108 -0
- package/docs/curl-examples.md +247 -0
- package/docs/index.html +390 -99
- package/docs/openapi.yaml +1318 -0
- package/docs/quick-start.html +366 -0
- package/docs/robots.txt +1 -1
- package/docs/sitemap.xml +23 -5
- package/docs/styles.css +682 -0
- package/examples/README.md +61 -0
- package/examples/a3m-sdk.js +124 -0
- package/examples/basic-route.js +54 -0
- package/examples/chat-loop.js +202 -0
- package/examples/classify-then-route.js +102 -0
- package/examples/cost-compare.js +120 -0
- package/examples/ensemble.js +160 -0
- package/integrations/langchain/README.md +216 -0
- package/integrations/langchain/a3m_langchain.ts +1360 -0
- package/integrations/langchain/example.ts +287 -0
- package/integrations/vercel-ai-sdk/README.md +49 -0
- package/integrations/vercel-ai-sdk/a3m_provider.ts +78 -0
- package/integrations/vercel-ai-sdk/example.ts +25 -0
- package/llms-full.txt +43 -0
- package/llms.txt +9 -0
- package/mcp-server/README.md +188 -0
- package/mcp-server/package.json +29 -0
- package/mcp-server/src/index.ts +744 -0
- package/mcp-server/tsconfig.json +19 -0
- package/package.json +3 -3
- package/proxy/README.md +227 -0
- package/proxy/package-lock.json +831 -0
- package/proxy/package.json +17 -0
- package/proxy/rate-limit.js +145 -0
- package/proxy/rate-limit.test.js +311 -0
- package/proxy/server.js +970 -0
- package/scripts/banner.js +29 -0
- package/scripts/compare-providers.sh +230 -0
- package/scripts/cross_post.py +443 -0
- package/scripts/publish_fcc.py +106 -0
- package/scripts/push-to-gitee.sh +52 -0
- package/src/tui/dashboard.ts +13 -0
- package/tests/__mocks__/tokenUtils.ts +22 -0
- package/tests/memory/episodicMemory.test.ts +227 -0
- package/tests/package-lock.json +1628 -0
- package/tests/package.json +18 -0
- package/tests/routing/ensembleVoting.test.ts +236 -0
- package/tests/routing/providerRetry.test.ts +360 -0
- package/tests/routing/queryTypePresets.test.ts +206 -0
- package/tests/tsconfig.json +21 -0
- package/tests/vitest.config.ts +18 -0
- 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';
|