adaptive-memory-multi-model-router 2.13.18 → 2.13.22
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,744 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A3M Router MCP Server
|
|
5
|
+
*
|
|
6
|
+
* Exposes A3M's parallel multi-LLM routing and ensemble execution
|
|
7
|
+
* as MCP tools for any MCP-compatible AI agent (Claude Code, Cursor, etc.).
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* npx a3m-mcp # Start MCP server via stdio
|
|
11
|
+
* A3M_API_KEY=sk-... npx a3m-mcp # With custom API key
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import {
|
|
17
|
+
CallToolRequestSchema,
|
|
18
|
+
ListToolsRequestSchema,
|
|
19
|
+
ErrorCode,
|
|
20
|
+
McpError,
|
|
21
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
22
|
+
import {
|
|
23
|
+
routeQuery,
|
|
24
|
+
extractQueryFeatures,
|
|
25
|
+
getAvailableProviders,
|
|
26
|
+
findCheapestAvailableProvider,
|
|
27
|
+
findFastestAvailableProvider,
|
|
28
|
+
healthCheck,
|
|
29
|
+
} from 'adaptive-memory-multi-model-router';
|
|
30
|
+
|
|
31
|
+
// ============================================================
|
|
32
|
+
// TYPES
|
|
33
|
+
// ============================================================
|
|
34
|
+
|
|
35
|
+
interface ProviderInfo {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
models: string[];
|
|
39
|
+
tier: string;
|
|
40
|
+
costPer1kInput: number;
|
|
41
|
+
costPer1kOutput: number;
|
|
42
|
+
type: string;
|
|
43
|
+
priority: number;
|
|
44
|
+
maxTokens: number;
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ApiCallResult {
|
|
49
|
+
content: string;
|
|
50
|
+
model: string;
|
|
51
|
+
provider: string;
|
|
52
|
+
totalTokens: number;
|
|
53
|
+
cost: number;
|
|
54
|
+
latencyMs: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============================================================
|
|
58
|
+
// LOGGING
|
|
59
|
+
// ============================================================
|
|
60
|
+
|
|
61
|
+
function log(level: 'info' | 'warn' | 'error', msg: string, ...args: unknown[]): void {
|
|
62
|
+
const prefix = `[A3M-MCP] ${level.toUpperCase()}`;
|
|
63
|
+
process.stderr.write(`${prefix} ${msg}${args.length ? ' ' + JSON.stringify(args) : ''}\n`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================
|
|
67
|
+
// PROVIDER HELPERS
|
|
68
|
+
// ============================================================
|
|
69
|
+
|
|
70
|
+
function getFilteredProviders(): Record<string, ProviderInfo> {
|
|
71
|
+
const raw = getAvailableProviders();
|
|
72
|
+
const result: Record<string, ProviderInfo> = {};
|
|
73
|
+
|
|
74
|
+
for (const [id, p] of Object.entries(raw)) {
|
|
75
|
+
const pd = p as Record<string, unknown>;
|
|
76
|
+
result[id] = {
|
|
77
|
+
id: pd.id as string || id,
|
|
78
|
+
name: pd.name as string || id,
|
|
79
|
+
models: (pd.models as string[]) || [],
|
|
80
|
+
tier: pd.tier as string || 'unknown',
|
|
81
|
+
costPer1kInput: ((pd.costPerK as Record<string, number> | undefined)?.input) || 0,
|
|
82
|
+
costPer1kOutput: ((pd.costPerK as Record<string, number> | undefined)?.output) || 0,
|
|
83
|
+
type: pd.type as string || 'api',
|
|
84
|
+
priority: (pd.priority as number) || 999,
|
|
85
|
+
maxTokens: (pd.maxTokens as number) || 4096,
|
|
86
|
+
enabled: !!(pd.apiKey as string | undefined) || pd.type === 'local' || pd.type === 'cli',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findEnabledProviders(): Array<{ id: string; info: ProviderInfo }> {
|
|
93
|
+
const providers = getFilteredProviders();
|
|
94
|
+
return Object.entries(providers)
|
|
95
|
+
.filter(([_, info]) => info.enabled)
|
|
96
|
+
.map(([id, info]) => ({ id, info }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ============================================================
|
|
100
|
+
// API EXECUTION
|
|
101
|
+
// ============================================================
|
|
102
|
+
|
|
103
|
+
async function callProviderApi(
|
|
104
|
+
providerId: string,
|
|
105
|
+
model: string,
|
|
106
|
+
prompt: string,
|
|
107
|
+
maxTokens: number = 2048,
|
|
108
|
+
signal?: AbortSignal,
|
|
109
|
+
): Promise<ApiCallResult | null> {
|
|
110
|
+
const providers = getAvailableProviders() as Record<string, Record<string, unknown>>;
|
|
111
|
+
const provider = providers[providerId];
|
|
112
|
+
|
|
113
|
+
if (!provider) {
|
|
114
|
+
log('error', `Provider "${providerId}" not found`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const apiKey = provider.apiKey as string | undefined;
|
|
119
|
+
const baseUrl = provider.baseUrl as string;
|
|
120
|
+
const type = provider.type as string;
|
|
121
|
+
const format = provider.format as string | undefined;
|
|
122
|
+
const costPerK = provider.costPerK as Record<string, number> | undefined;
|
|
123
|
+
|
|
124
|
+
if (!apiKey && type !== 'local') {
|
|
125
|
+
log('warn', `No API key for provider "${providerId}"`);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const startTime = Date.now();
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
if (type === 'cli') {
|
|
133
|
+
// CLI-based providers (CommandCode etc.) — skip for now in MCP context
|
|
134
|
+
log('warn', `CLI provider "${providerId}" not supported in MCP server`);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let requestUrl = baseUrl;
|
|
139
|
+
let requestBody: Record<string, unknown>;
|
|
140
|
+
let requestHeaders: Record<string, string> = {
|
|
141
|
+
'Content-Type': 'application/json',
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (format === 'google') {
|
|
145
|
+
// Google AI format
|
|
146
|
+
requestUrl = `${baseUrl}/${model}:generateContent?key=${apiKey}`;
|
|
147
|
+
requestBody = {
|
|
148
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
149
|
+
generationConfig: { maxOutputTokens: maxTokens },
|
|
150
|
+
};
|
|
151
|
+
} else if (format === 'anthropic') {
|
|
152
|
+
requestHeaders['x-api-key'] = apiKey;
|
|
153
|
+
requestHeaders['anthropic-version'] = '2023-06-01';
|
|
154
|
+
requestBody = {
|
|
155
|
+
model,
|
|
156
|
+
messages: [{ role: 'user', content: prompt }],
|
|
157
|
+
max_tokens: maxTokens,
|
|
158
|
+
};
|
|
159
|
+
} else {
|
|
160
|
+
// OpenAI-compatible (default)
|
|
161
|
+
requestHeaders['Authorization'] = `Bearer ${apiKey}`;
|
|
162
|
+
requestBody = {
|
|
163
|
+
model,
|
|
164
|
+
messages: [{ role: 'user', content: prompt }],
|
|
165
|
+
max_tokens: maxTokens,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const resp = await fetch(requestUrl, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: requestHeaders,
|
|
172
|
+
body: JSON.stringify(requestBody),
|
|
173
|
+
signal,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const latencyMs = Date.now() - startTime;
|
|
177
|
+
const data = await resp.json() as Record<string, unknown>;
|
|
178
|
+
|
|
179
|
+
if (!resp.ok) {
|
|
180
|
+
const errMsg = (data.error as Record<string, unknown> | undefined)?.message as string
|
|
181
|
+
|| (data.error as string | undefined)
|
|
182
|
+
|| resp.statusText;
|
|
183
|
+
log('error', `Provider "${providerId}" returned ${resp.status}: ${errMsg}`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Parse response based on format
|
|
188
|
+
let content = '';
|
|
189
|
+
let usage: Record<string, number> = {};
|
|
190
|
+
|
|
191
|
+
if (format === 'google') {
|
|
192
|
+
const candidates = data.candidates as Array<Record<string, unknown>> | undefined;
|
|
193
|
+
if (candidates?.[0]?.content) {
|
|
194
|
+
const parts = (candidates[0].content as Record<string, unknown>).parts as Array<Record<string, unknown>> | undefined;
|
|
195
|
+
content = parts?.map((p) => p.text as string).join('') || '';
|
|
196
|
+
}
|
|
197
|
+
const metadata = data.usageMetadata as Record<string, number> | undefined;
|
|
198
|
+
usage = {
|
|
199
|
+
prompt_tokens: metadata?.promptTokenCount || 0,
|
|
200
|
+
completion_tokens: metadata?.candidatesTokenCount || 0,
|
|
201
|
+
total_tokens: (metadata?.promptTokenCount || 0) + (metadata?.candidatesTokenCount || 0),
|
|
202
|
+
};
|
|
203
|
+
} else if (format === 'anthropic') {
|
|
204
|
+
content = (data.content as Array<Record<string, unknown>> | undefined)
|
|
205
|
+
?.map((b) => b.text as string)
|
|
206
|
+
.join('') || '';
|
|
207
|
+
usage = {
|
|
208
|
+
prompt_tokens: data.input_tokens as number || 0,
|
|
209
|
+
completion_tokens: data.output_tokens as number || 0,
|
|
210
|
+
total_tokens: ((data.input_tokens as number) || 0) + ((data.output_tokens as number) || 0),
|
|
211
|
+
};
|
|
212
|
+
} else {
|
|
213
|
+
// OpenAI-compatible
|
|
214
|
+
const choices = data.choices as Array<Record<string, unknown>> | undefined;
|
|
215
|
+
content = choices?.[0]?.message
|
|
216
|
+
? (choices[0].message as Record<string, unknown>).content as string
|
|
217
|
+
: '';
|
|
218
|
+
usage = (data.usage as Record<string, number>) || {};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const inputCost = (usage.prompt_tokens || 0) / 1000 * (costPerK?.input || 0) / 1000;
|
|
222
|
+
const outputCost = (usage.completion_tokens || 0) / 1000 * (costPerK?.output || 0) / 1000;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
content: content.trim(),
|
|
226
|
+
model: (data.model as string) || model,
|
|
227
|
+
provider: providerId,
|
|
228
|
+
totalTokens: usage.total_tokens || 0,
|
|
229
|
+
cost: inputCost + outputCost,
|
|
230
|
+
latencyMs,
|
|
231
|
+
};
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
234
|
+
log('warn', `Request to "${providerId}" aborted`);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
log('error', `Error calling provider "${providerId}":`, (err as Error).message);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ============================================================
|
|
243
|
+
// MCP SERVER
|
|
244
|
+
// ============================================================
|
|
245
|
+
|
|
246
|
+
const server = new Server(
|
|
247
|
+
{
|
|
248
|
+
name: 'a3m-mcp-server',
|
|
249
|
+
version: '0.1.0',
|
|
250
|
+
description: 'A3M Router — parallel multi-LLM execution for AI agents',
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
capabilities: {
|
|
254
|
+
tools: {},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// ============================================================
|
|
260
|
+
// TOOL: LIST
|
|
261
|
+
// ============================================================
|
|
262
|
+
|
|
263
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
264
|
+
log('info', 'Client requested tool list');
|
|
265
|
+
return {
|
|
266
|
+
tools: [
|
|
267
|
+
{
|
|
268
|
+
name: 'a3m_route',
|
|
269
|
+
description: 'Route a query to the optimal LLM provider. Returns which model/ provider is best suited for the query, with reasoning and cost estimate. Does NOT execute the query — use a3m_ensemble for execution.',
|
|
270
|
+
inputSchema: {
|
|
271
|
+
type: 'object',
|
|
272
|
+
properties: {
|
|
273
|
+
query: {
|
|
274
|
+
type: 'string',
|
|
275
|
+
description: 'The user query to route',
|
|
276
|
+
},
|
|
277
|
+
provider: {
|
|
278
|
+
type: 'string',
|
|
279
|
+
description: 'Optional: Force a specific provider (e.g. "groq", "google", "cerebras")',
|
|
280
|
+
},
|
|
281
|
+
budget_multiplier: {
|
|
282
|
+
type: 'number',
|
|
283
|
+
description: 'Optional: Budget multiplier (default 1.0, lower = cheaper, higher = more capable)',
|
|
284
|
+
default: 1.0,
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
required: ['query'],
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: 'a3m_ensemble',
|
|
292
|
+
description: 'Execute a query across multiple providers in parallel and merge results. Returns individual responses with confidence scores plus a synthesized best answer. This is A3M\'s unique differentiator — no other router does parallel multi-LLM execution with result merging.',
|
|
293
|
+
inputSchema: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
query: {
|
|
297
|
+
type: 'string',
|
|
298
|
+
description: 'The user query to execute in parallel across providers',
|
|
299
|
+
},
|
|
300
|
+
providers: {
|
|
301
|
+
type: 'array',
|
|
302
|
+
items: { type: 'string' },
|
|
303
|
+
description: 'Optional: Specific providers to use (e.g. ["groq", "google", "cerebras"]). Defaults to all enabled providers.',
|
|
304
|
+
},
|
|
305
|
+
max_tokens: {
|
|
306
|
+
type: 'number',
|
|
307
|
+
description: 'Optional: Max tokens per response (default: 2048)',
|
|
308
|
+
default: 2048,
|
|
309
|
+
},
|
|
310
|
+
timeout_ms: {
|
|
311
|
+
type: 'number',
|
|
312
|
+
description: 'Optional: Timeout per provider in ms (default: 30000)',
|
|
313
|
+
default: 30000,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
required: ['query'],
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'a3m_classify',
|
|
321
|
+
description: 'Classify a query by type and get a provider recommendation. Analyzes the query for code, math, creative, reasoning, and domain signals. Returns classification (fast/creative/deep/code) plus a provider recommendation.',
|
|
322
|
+
inputSchema: {
|
|
323
|
+
type: 'object',
|
|
324
|
+
properties: {
|
|
325
|
+
query: {
|
|
326
|
+
type: 'string',
|
|
327
|
+
description: 'The query to classify',
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
required: ['query'],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: 'a3m_providers',
|
|
335
|
+
description: 'List all configured LLM providers with model info, cost tiers, and availability status.',
|
|
336
|
+
inputSchema: {
|
|
337
|
+
type: 'object',
|
|
338
|
+
properties: {},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// ============================================================
|
|
346
|
+
// TOOL: CALL
|
|
347
|
+
// ============================================================
|
|
348
|
+
|
|
349
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
350
|
+
const { name, arguments: args } = request.params;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
switch (name) {
|
|
354
|
+
case 'a3m_route':
|
|
355
|
+
return await handleRoute(args as Record<string, unknown>);
|
|
356
|
+
case 'a3m_ensemble':
|
|
357
|
+
return await handleEnsemble(args as Record<string, unknown>);
|
|
358
|
+
case 'a3m_classify':
|
|
359
|
+
return handleClassify(args as Record<string, unknown>);
|
|
360
|
+
case 'a3m_providers':
|
|
361
|
+
return handleProviders();
|
|
362
|
+
default:
|
|
363
|
+
throw new McpError(
|
|
364
|
+
ErrorCode.MethodNotFound,
|
|
365
|
+
`Unknown tool: ${name}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (err instanceof McpError) throw err;
|
|
370
|
+
log('error', `Tool "${name}" failed:`, (err as Error).message);
|
|
371
|
+
return {
|
|
372
|
+
content: [
|
|
373
|
+
{
|
|
374
|
+
type: 'text',
|
|
375
|
+
text: `Error: ${(err as Error).message}`,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
isError: true,
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// ============================================================
|
|
384
|
+
// HANDLER: a3m_route
|
|
385
|
+
// ============================================================
|
|
386
|
+
|
|
387
|
+
function handleRoute(args: Record<string, unknown>) {
|
|
388
|
+
const query = args.query as string;
|
|
389
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
390
|
+
throw new McpError(ErrorCode.InvalidParams, 'query must be a non-empty string');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const forcedProvider = args.provider as string | undefined;
|
|
394
|
+
const budgetMultiplier = (args.budget_multiplier as number) || 1.0;
|
|
395
|
+
|
|
396
|
+
const features = extractQueryFeatures(query);
|
|
397
|
+
const route = routeQuery(query, forcedProvider ? [forcedProvider] : undefined, budgetMultiplier);
|
|
398
|
+
|
|
399
|
+
// Build enriched response
|
|
400
|
+
let queryType = 'general';
|
|
401
|
+
if (features.has_code) queryType = 'code';
|
|
402
|
+
else if (features.requires_reasoning) queryType = 'deep';
|
|
403
|
+
else if (features.is_creative) queryType = 'creative';
|
|
404
|
+
else if (features.complexity < 0.2) queryType = 'fast';
|
|
405
|
+
|
|
406
|
+
const providers = getFilteredProviders();
|
|
407
|
+
const providerInfo = route.primary_model
|
|
408
|
+
? providers[route.primary_model.split('/')[0]]
|
|
409
|
+
: null;
|
|
410
|
+
|
|
411
|
+
const result = {
|
|
412
|
+
model: route.primary_model,
|
|
413
|
+
tier: providerInfo?.tier || 'unknown',
|
|
414
|
+
provider: route.primary_model?.split('/')[0] || 'unknown',
|
|
415
|
+
confidence: route.confidence,
|
|
416
|
+
reasoning: route.reasoning,
|
|
417
|
+
estimated_cost: route.estimated_cost,
|
|
418
|
+
estimated_latency_ms: route.estimated_latency_ms,
|
|
419
|
+
fallback_models: route.fallback_models,
|
|
420
|
+
query_features: {
|
|
421
|
+
complexity: features.complexity,
|
|
422
|
+
has_code: features.has_code,
|
|
423
|
+
has_math: features.has_math,
|
|
424
|
+
is_creative: features.is_creative,
|
|
425
|
+
requires_reasoning: features.requires_reasoning,
|
|
426
|
+
is_multilingual: features.is_multilingual,
|
|
427
|
+
},
|
|
428
|
+
classification: queryType,
|
|
429
|
+
suggestion: getSuggestion(queryType, route),
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
content: [
|
|
434
|
+
{
|
|
435
|
+
type: 'text',
|
|
436
|
+
text: JSON.stringify(result, null, 2),
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function getSuggestion(type: string, route: { primary_model: string; estimated_cost: number }): string {
|
|
443
|
+
switch (type) {
|
|
444
|
+
case 'code':
|
|
445
|
+
return `Use "${route.primary_model}" for code. Cost: ~$${(route.estimated_cost || 0).toFixed(6)}`;
|
|
446
|
+
case 'deep':
|
|
447
|
+
return `Use "${route.primary_model}" for reasoning. Cost: ~$${(route.estimated_cost || 0).toFixed(6)}`;
|
|
448
|
+
case 'creative':
|
|
449
|
+
return `Use "${route.primary_model}" for creativity. Cost: ~$${(route.estimated_cost || 0).toFixed(6)}`;
|
|
450
|
+
case 'fast':
|
|
451
|
+
return `Use "${route.primary_model}" for speed. Cost: ~$${(route.estimated_cost || 0).toFixed(6)}`;
|
|
452
|
+
default:
|
|
453
|
+
return `Recommended: "${route.primary_model}" (${route.reasoning || ''})`;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ============================================================
|
|
458
|
+
// HANDLER: a3m_ensemble
|
|
459
|
+
// ============================================================
|
|
460
|
+
|
|
461
|
+
async function handleEnsemble(args: Record<string, unknown>) {
|
|
462
|
+
const query = args.query as string;
|
|
463
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
464
|
+
throw new McpError(ErrorCode.InvalidParams, 'query must be a non-empty string');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const maxTokens = (args.max_tokens as number) || 2048;
|
|
468
|
+
const timeoutMs = (args.timeout_ms as number) || 30000;
|
|
469
|
+
|
|
470
|
+
// Determine which providers to use
|
|
471
|
+
let targetProviders: Array<{ id: string; info: ProviderInfo }>;
|
|
472
|
+
|
|
473
|
+
if (args.providers && Array.isArray(args.providers) && args.providers.length > 0) {
|
|
474
|
+
const requested = args.providers as string[];
|
|
475
|
+
const all = getFilteredProviders();
|
|
476
|
+
targetProviders = requested
|
|
477
|
+
.map((id) => ({ id, info: all[id] }))
|
|
478
|
+
.filter((p) => p.info && p.info.enabled);
|
|
479
|
+
if (targetProviders.length === 0) {
|
|
480
|
+
return {
|
|
481
|
+
content: [
|
|
482
|
+
{
|
|
483
|
+
type: 'text',
|
|
484
|
+
text: JSON.stringify({
|
|
485
|
+
error: 'None of the requested providers are available/enabled',
|
|
486
|
+
requested_providers: requested,
|
|
487
|
+
available: Object.keys(getFilteredProviders()).filter((k) => getFilteredProviders()[k].enabled),
|
|
488
|
+
}, null, 2),
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
isError: true,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
} else {
|
|
495
|
+
targetProviders = findEnabledProviders();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (targetProviders.length === 0) {
|
|
499
|
+
return {
|
|
500
|
+
content: [
|
|
501
|
+
{
|
|
502
|
+
type: 'text',
|
|
503
|
+
text: 'No enabled providers found. Configure API keys via environment variables (e.g. GROQ_API_KEY, GOOGLE_API_KEY, CEREBRAS_API_KEY).',
|
|
504
|
+
},
|
|
505
|
+
],
|
|
506
|
+
isError: true,
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Classify the query first
|
|
511
|
+
const features = extractQueryFeatures(query);
|
|
512
|
+
|
|
513
|
+
// Execute in parallel across all target providers
|
|
514
|
+
const controllers: AbortController[] = [];
|
|
515
|
+
const results = await Promise.all(
|
|
516
|
+
targetProviders.map(async ({ id, info }) => {
|
|
517
|
+
const model = info.models[0];
|
|
518
|
+
if (!model) {
|
|
519
|
+
return { provider: id, model: 'none', error: 'No models configured', content: null };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const controller = new AbortController();
|
|
523
|
+
controllers.push(controller);
|
|
524
|
+
|
|
525
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
526
|
+
|
|
527
|
+
try {
|
|
528
|
+
const result = await callProviderApi(id, model, query, maxTokens, controller.signal);
|
|
529
|
+
clearTimeout(timeout);
|
|
530
|
+
return { provider: id, model, error: null, content: result };
|
|
531
|
+
} catch (err) {
|
|
532
|
+
clearTimeout(timeout);
|
|
533
|
+
return { provider: id, model, error: (err as Error).message, content: null };
|
|
534
|
+
}
|
|
535
|
+
}),
|
|
536
|
+
);
|
|
537
|
+
|
|
538
|
+
// Separate successful and failed results
|
|
539
|
+
const successes = results.filter((r): r is typeof r & { content: NonNullable<typeof r.content> } =>
|
|
540
|
+
r.content !== null && r.content.content.length > 0,
|
|
541
|
+
);
|
|
542
|
+
const failures = results.filter((r) => r.error || !r.content || r.content.content.length === 0);
|
|
543
|
+
|
|
544
|
+
// Compute confidence scores based on response quality
|
|
545
|
+
const totalSuccesses = successes.length;
|
|
546
|
+
const scoredResponses = successes.map((r) => {
|
|
547
|
+
const responseLength = r.content.content.length;
|
|
548
|
+
const lengthScore = Math.min(responseLength / 500, 1.0);
|
|
549
|
+
const latencyPenalty = Math.max(0, 1 - r.content.latencyMs / 10000);
|
|
550
|
+
const confidence = Math.round((0.6 * lengthScore + 0.2 * latencyPenalty + 0.2) * 100) / 100;
|
|
551
|
+
return { ...r, confidence: Math.min(confidence, 0.98) };
|
|
552
|
+
}).sort((a, b) => b.confidence - a.confidence);
|
|
553
|
+
|
|
554
|
+
// Synthesize best answer (highest confidence response with context from others)
|
|
555
|
+
let bestAnswer: string;
|
|
556
|
+
if (scoredResponses.length === 0) {
|
|
557
|
+
bestAnswer = 'All providers failed to generate a response.';
|
|
558
|
+
} else if (scoredResponses.length === 1) {
|
|
559
|
+
bestAnswer = scoredResponses[0].content.content;
|
|
560
|
+
} else {
|
|
561
|
+
// Use top response with agreement signal
|
|
562
|
+
const top = scoredResponses[0];
|
|
563
|
+
const agreement = scoredResponses.filter(
|
|
564
|
+
(r) => r.content.content.slice(0, 100).toLowerCase() === top.content.content.slice(0, 100).toLowerCase(),
|
|
565
|
+
).length;
|
|
566
|
+
const agreementRatio = agreement / totalSuccesses;
|
|
567
|
+
bestAnswer = top.content.content;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const ensemble = {
|
|
571
|
+
query,
|
|
572
|
+
query_classification: {
|
|
573
|
+
complexity: features.complexity,
|
|
574
|
+
type: features.has_code ? 'code' : features.requires_reasoning ? 'deep' : features.is_creative ? 'creative' : 'general',
|
|
575
|
+
has_code: features.has_code,
|
|
576
|
+
has_math: features.has_math,
|
|
577
|
+
is_creative: features.is_creative,
|
|
578
|
+
},
|
|
579
|
+
parallel_responses: scoredResponses.map((r) => ({
|
|
580
|
+
provider: r.provider,
|
|
581
|
+
model: r.model,
|
|
582
|
+
confidence: r.confidence,
|
|
583
|
+
latency_ms: r.content.latencyMs,
|
|
584
|
+
cost: r.content.cost,
|
|
585
|
+
tokens: r.content.totalTokens,
|
|
586
|
+
content: r.content.content,
|
|
587
|
+
})),
|
|
588
|
+
failed_providers: failures.map((r) => ({
|
|
589
|
+
provider: r.provider,
|
|
590
|
+
model: r.model,
|
|
591
|
+
error: r.error || 'Empty response',
|
|
592
|
+
})),
|
|
593
|
+
best_answer: bestAnswer,
|
|
594
|
+
stats: {
|
|
595
|
+
total_providers: targetProviders.length,
|
|
596
|
+
successful: successes.length,
|
|
597
|
+
failed: failures.length,
|
|
598
|
+
total_cost: successes.reduce((sum, r) => sum + r.content.cost, 0),
|
|
599
|
+
total_tokens: successes.reduce((sum, r) => sum + r.content.totalTokens, 0),
|
|
600
|
+
avg_latency_ms: successes.length > 0
|
|
601
|
+
? Math.round(successes.reduce((sum, r) => sum + r.content.latencyMs, 0) / successes.length)
|
|
602
|
+
: 0,
|
|
603
|
+
},
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{
|
|
609
|
+
type: 'text',
|
|
610
|
+
text: JSON.stringify(ensemble, null, 2),
|
|
611
|
+
},
|
|
612
|
+
],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ============================================================
|
|
617
|
+
// HANDLER: a3m_classify
|
|
618
|
+
// ============================================================
|
|
619
|
+
|
|
620
|
+
function handleClassify(args: Record<string, unknown>) {
|
|
621
|
+
const query = args.query as string;
|
|
622
|
+
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
|
623
|
+
throw new McpError(ErrorCode.InvalidParams, 'query must be a non-empty string');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const features = extractQueryFeatures(query);
|
|
627
|
+
const route = routeQuery(query);
|
|
628
|
+
|
|
629
|
+
let type: string;
|
|
630
|
+
let recommendation: string;
|
|
631
|
+
|
|
632
|
+
if (features.has_code) {
|
|
633
|
+
type = 'code';
|
|
634
|
+
recommendation = 'Use a fast coding model (Groq, Cerebras) for quick code tasks, or premium (OpenAI, Anthropic) for complex codegen.';
|
|
635
|
+
} else if (features.requires_reasoning && features.complexity > 0.5) {
|
|
636
|
+
type = 'deep';
|
|
637
|
+
recommendation = 'Use a reasoning-capable model (Mistral, premium tier) for deep analytical tasks.';
|
|
638
|
+
} else if (features.is_creative) {
|
|
639
|
+
type = 'creative';
|
|
640
|
+
recommendation = 'Use a creative model (Mistral, Google AI) for open-ended creative work.';
|
|
641
|
+
} else if (features.complexity < 0.2) {
|
|
642
|
+
type = 'fast';
|
|
643
|
+
recommendation = 'Use a fast, cheap model (Groq, Cerebras, free tier) for simple queries.';
|
|
644
|
+
} else {
|
|
645
|
+
type = 'general';
|
|
646
|
+
recommendation = 'Standard routing applies — use the default model selection.';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const providers = getFilteredProviders();
|
|
650
|
+
const bestModel = route.primary_model;
|
|
651
|
+
const bestProvider = bestModel ? providers[bestModel.split('/')[0]] : null;
|
|
652
|
+
|
|
653
|
+
return {
|
|
654
|
+
content: [
|
|
655
|
+
{
|
|
656
|
+
type: 'text',
|
|
657
|
+
text: JSON.stringify({
|
|
658
|
+
query,
|
|
659
|
+
classification: {
|
|
660
|
+
type,
|
|
661
|
+
complexity: features.complexity,
|
|
662
|
+
signals: {
|
|
663
|
+
has_code: features.has_code,
|
|
664
|
+
has_math: features.has_math,
|
|
665
|
+
is_creative: features.is_creative,
|
|
666
|
+
requires_reasoning: features.requires_reasoning,
|
|
667
|
+
is_multilingual: features.is_multilingual,
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
recommended_provider: bestProvider ? {
|
|
671
|
+
id: bestProvider.id,
|
|
672
|
+
name: bestProvider.name,
|
|
673
|
+
model: bestModel,
|
|
674
|
+
tier: bestProvider.tier,
|
|
675
|
+
cost_per_1k_input: bestProvider.costPer1kInput,
|
|
676
|
+
cost_per_1k_output: bestProvider.costPer1kOutput,
|
|
677
|
+
} : null,
|
|
678
|
+
reasoning: route.reasoning,
|
|
679
|
+
suggestion: recommendation,
|
|
680
|
+
}, null, 2),
|
|
681
|
+
},
|
|
682
|
+
],
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ============================================================
|
|
687
|
+
// HANDLER: a3m_providers
|
|
688
|
+
// ============================================================
|
|
689
|
+
|
|
690
|
+
function handleProviders() {
|
|
691
|
+
const providers = getFilteredProviders();
|
|
692
|
+
const enabled = Object.values(providers).filter((p) => p.enabled);
|
|
693
|
+
const disabled = Object.values(providers).filter((p) => !p.enabled);
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
content: [
|
|
697
|
+
{
|
|
698
|
+
type: 'text',
|
|
699
|
+
text: JSON.stringify({
|
|
700
|
+
total: Object.keys(providers).length,
|
|
701
|
+
enabled: enabled.length,
|
|
702
|
+
disabled: disabled.length,
|
|
703
|
+
providers: Object.values(providers).sort((a, b) => a.priority - b.priority),
|
|
704
|
+
env_tips: {
|
|
705
|
+
groq: 'GROQ_API_KEY',
|
|
706
|
+
cerebras: 'CEREBRAS_API_KEY',
|
|
707
|
+
mistral: 'MISTRAL_API_KEY',
|
|
708
|
+
google: 'GOOGLE_API_KEY',
|
|
709
|
+
minimax: 'MINIMAX_API_KEY',
|
|
710
|
+
},
|
|
711
|
+
}, null, 2),
|
|
712
|
+
},
|
|
713
|
+
],
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ============================================================
|
|
718
|
+
// STARTUP
|
|
719
|
+
// ============================================================
|
|
720
|
+
|
|
721
|
+
async function main(): Promise<void> {
|
|
722
|
+
const providers = getFilteredProviders();
|
|
723
|
+
const enabledCount = Object.values(providers).filter((p) => p.enabled).length;
|
|
724
|
+
const totalCount = Object.keys(providers).length;
|
|
725
|
+
|
|
726
|
+
log('info', 'A3M Router MCP Server v0.1.0');
|
|
727
|
+
log('info', `Providers: ${totalCount} total, ${enabledCount} enabled`);
|
|
728
|
+
log('info', 'Tools available: a3m_route, a3m_ensemble, a3m_classify, a3m_providers');
|
|
729
|
+
log('info', `Node ${process.version}, ${process.platform}`);
|
|
730
|
+
|
|
731
|
+
for (const [id, info] of Object.entries(providers)) {
|
|
732
|
+
const status = info.enabled ? 'ENABLED' : 'DISABLED';
|
|
733
|
+
log('info', ` ${status.padEnd(8)} ${id.padEnd(12)} ${info.tier.padEnd(8)} ${(info.models[0] || 'no models')}`);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const transport = new StdioServerTransport();
|
|
737
|
+
await server.connect(transport);
|
|
738
|
+
log('info', 'MCP server connected via stdio');
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
main().catch((err) => {
|
|
742
|
+
log('error', 'Fatal error:', err);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
});
|