adaptive-memory-multi-model-router 1.9.5 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +11 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.md +37 -0
  4. package/.github/PULL_REQUEST_TEMPLATE.md +44 -0
  5. package/.github/workflows/npm-stats-validation.yml +152 -0
  6. package/.github/workflows/pages.yml +37 -0
  7. package/CHANGELOG.md +122 -0
  8. package/CODE_OF_CONDUCT.md +128 -0
  9. package/CONTRIBUTING.md +110 -0
  10. package/LAUNCH-PAIN-DRIVEN.md +339 -0
  11. package/LAUNCH.md +575 -0
  12. package/POPULARITY_BOOSTERS.md +285 -0
  13. package/README.md +231 -300
  14. package/SECURITY.md +69 -0
  15. package/articles/CONTENT_STRUCTURE.md +292 -0
  16. package/articles/DEVTO_COST_GUIDE.md +473 -0
  17. package/articles/DEVTO_FINAL.md +416 -0
  18. package/articles/DEVTO_MULTI_PROVIDER.md +542 -0
  19. package/articles/DEVTO_V2_ANNOUNCEMENT.md +160 -0
  20. package/articles/HN_10X_BETTER.md +430 -0
  21. package/articles/HN_CHINESE_STYLE.md +308 -0
  22. package/articles/HN_FINAL.md +199 -0
  23. package/articles/HN_POSTED_VERSION.md +56 -0
  24. package/articles/HN_RESEARCH.md +364 -0
  25. package/articles/PAIN-DRIVEN-devto-v2.md +308 -0
  26. package/articles/PAIN-DRIVEN-devto-v3.md +268 -0
  27. package/articles/PAIN-DRIVEN-devto.md +242 -0
  28. package/articles/PAIN-DRIVEN-hackernews-v2.md +138 -0
  29. package/articles/PAIN-DRIVEN-hackernews-v3.md +151 -0
  30. package/articles/PAIN-DRIVEN-hackernews.md +131 -0
  31. package/articles/PAIN-DRIVEN-reddit-v2.md +301 -0
  32. package/articles/PAIN-DRIVEN-reddit-v3.md +236 -0
  33. package/articles/PAIN-DRIVEN-reddit.md +218 -0
  34. package/articles/PAIN-DRIVEN-twitter-v2.md +110 -0
  35. package/articles/PAIN-DRIVEN-twitter-v3.md +121 -0
  36. package/articles/PAIN-DRIVEN-twitter.md +120 -0
  37. package/articles/PORTKEY_VS_A3M.md +147 -0
  38. package/articles/REDDIT_FINAL.md +232 -0
  39. package/articles/TWITTER_FINAL.md +167 -0
  40. package/articles/WHY_10X_BETTER.md +261 -0
  41. package/articles/WHY_CHINESE_STYLE_BETTER.md +323 -0
  42. package/articles/hashnode-llm-cost-optimization.md +125 -0
  43. package/articles/medium-building-llm-router.md +205 -0
  44. package/articles/twitter-thread-cost-savings.md +98 -0
  45. package/articles/youtube-tutorial-script.md +262 -0
  46. package/assets/banner.svg +109 -0
  47. package/assets/logo.svg +68 -0
  48. package/assets/social-preview.svg +64 -0
  49. package/demo/demo-script.md +53 -0
  50. package/dist/analytics/costAnalytics.d.ts +77 -0
  51. package/dist/analytics/costAnalytics.d.ts.map +1 -0
  52. package/dist/analytics/costAnalytics.js +219 -0
  53. package/dist/analytics/costAnalytics.js.map +1 -0
  54. package/dist/cache/semanticCache.d.ts +62 -0
  55. package/dist/cache/semanticCache.d.ts.map +1 -0
  56. package/dist/cache/semanticCache.js +176 -0
  57. package/dist/cache/semanticCache.js.map +1 -0
  58. package/dist/cli.js +35 -0
  59. package/dist/index.d.ts +30 -722
  60. package/dist/index.js +76 -355
  61. package/dist/index.js.map +1 -1
  62. package/dist/integrations/langchainAdapter.d.ts +146 -0
  63. package/dist/integrations/langchainAdapter.d.ts.map +1 -0
  64. package/dist/integrations/langchainAdapter.js +731 -0
  65. package/dist/integrations/langchainAdapter.js.map +1 -0
  66. package/dist/integrations/oauth.d.ts +69 -0
  67. package/dist/integrations/oauth.d.ts.map +1 -0
  68. package/dist/integrations/oauth.js +225 -21
  69. package/dist/integrations/oauth.js.map +1 -0
  70. package/dist/memory/autoFetch.d.ts +39 -0
  71. package/dist/memory/autoFetch.d.ts.map +1 -0
  72. package/dist/memory/autoFetch.js +80 -88
  73. package/dist/memory/autoFetch.js.map +1 -0
  74. package/dist/memory/memoryTree.d.ts +76 -0
  75. package/dist/memory/memoryTree.d.ts.map +1 -0
  76. package/dist/memory/memoryTree.js +185 -130
  77. package/dist/memory/memoryTree.js.map +1 -0
  78. package/dist/memory/obsidianVault.d.ts +71 -0
  79. package/dist/memory/obsidianVault.d.ts.map +1 -0
  80. package/dist/memory/obsidianVault.js +207 -22
  81. package/dist/memory/obsidianVault.js.map +1 -0
  82. package/dist/providers/providerConfig.d.ts +49 -0
  83. package/dist/providers/providerConfig.d.ts.map +1 -0
  84. package/dist/providers/providerConfig.js +806 -401
  85. package/dist/providers/providerConfig.js.map +1 -0
  86. package/dist/security/guardrails.d.ts +76 -0
  87. package/dist/security/guardrails.d.ts.map +1 -0
  88. package/dist/security/guardrails.js +479 -0
  89. package/dist/security/guardrails.js.map +1 -0
  90. package/dist/server/dashboard.d.ts +58 -0
  91. package/dist/server/dashboard.d.ts.map +1 -0
  92. package/dist/server/dashboard.js +553 -0
  93. package/dist/server/dashboard.js.map +1 -0
  94. package/dist/server/modelMapper.d.ts +43 -0
  95. package/dist/server/modelMapper.d.ts.map +1 -0
  96. package/dist/server/modelMapper.js +154 -0
  97. package/dist/server/modelMapper.js.map +1 -0
  98. package/dist/server/proxyServer.d.ts +41 -0
  99. package/dist/server/proxyServer.d.ts.map +1 -0
  100. package/dist/server/proxyServer.js +932 -0
  101. package/dist/server/proxyServer.js.map +1 -0
  102. package/dist/skills/__tests__/skill_manager.test.d.ts +2 -0
  103. package/dist/skills/__tests__/skill_manager.test.d.ts.map +1 -0
  104. package/dist/skills/__tests__/skill_manager.test.js +268 -0
  105. package/dist/skills/__tests__/skill_manager.test.js.map +1 -0
  106. package/docs-site/index.html +347 -0
  107. package/package.json +53 -7
  108. package/playground/README.md +51 -0
  109. package/playground/codesandbox.json +12 -0
  110. package/playground/index.js +39 -0
  111. package/scripts/update-npm-badges.js +158 -0
  112. package/src/analytics/costAnalytics.ts +304 -0
  113. package/src/cache/semanticCache.ts +221 -0
  114. package/src/index.ts +99 -0
  115. package/src/integrations/langchainAdapter.ts +955 -0
  116. package/src/providers/providerConfig.ts +923 -0
  117. package/src/security/guardrails.ts +585 -0
  118. package/src/server/dashboard.ts +610 -0
  119. package/src/server/modelMapper.ts +182 -0
  120. package/src/server/proxyServer.ts +1105 -0
  121. package/src/types/langchain.d.ts +83 -0
  122. package/tsconfig.build.json +20 -0
@@ -0,0 +1,1105 @@
1
+ /**
2
+ * A3M Router - OpenAI-Compatible Proxy Server
3
+ *
4
+ * Lightweight HTTP server that accepts OpenAI API requests and routes them
5
+ * through the A3M Router engine. Uses only Node.js built-in http module.
6
+ *
7
+ * Endpoints:
8
+ * POST /v1/chat/completions — OpenAI-compatible chat
9
+ * POST /v1/completions — OpenAI completions
10
+ * GET /v1/models — List available models
11
+ * GET /health — Health check with provider status
12
+ *
13
+ * Build: npx tsc
14
+ * Run: npx a3m-router serve [--port 8787]
15
+ */
16
+
17
+ import * as http from "http";
18
+ import { resolveModel, listAvailableModels, ModelMapping } from "./modelMapper";
19
+ import { getAvailableProviders, healthCheck as providerHealthCheck } from "../providers/providerConfig";
20
+ import { CostTracker } from "../cost/costTracker";
21
+
22
+ // ============================================================
23
+ // TYPES
24
+ // ============================================================
25
+
26
+ interface ChatMessage {
27
+ role: "system" | "user" | "assistant" | "tool";
28
+ content: string;
29
+ }
30
+
31
+ interface ChatRequest {
32
+ model: string;
33
+ messages: ChatMessage[];
34
+ stream?: boolean;
35
+ temperature?: number;
36
+ max_tokens?: number;
37
+ top_p?: number;
38
+ frequency_penalty?: number;
39
+ presence_penalty?: number;
40
+ stop?: string | string[];
41
+ n?: number;
42
+ }
43
+
44
+ interface CompletionRequest {
45
+ model: string;
46
+ prompt: string | string[];
47
+ stream?: boolean;
48
+ temperature?: number;
49
+ max_tokens?: number;
50
+ top_p?: number;
51
+ echo?: boolean;
52
+ stop?: string | string[];
53
+ }
54
+
55
+ interface RequestLog {
56
+ id: string;
57
+ model: string;
58
+ resolvedProvider: string;
59
+ resolvedModel: string;
60
+ latencyMs: number;
61
+ tokensIn: number;
62
+ tokensOut: number;
63
+ cost: number;
64
+ status: "success" | "error";
65
+ error?: string;
66
+ timestamp: number;
67
+ }
68
+
69
+ // ============================================================
70
+ // HELPERS
71
+ // ============================================================
72
+
73
+ function generateId(): string {
74
+ return "chatcmpl-" + Math.random().toString(36).substring(2, 14) + Date.now().toString(36);
75
+ }
76
+
77
+ function readBody(req: http.IncomingMessage): Promise<string> {
78
+ return new Promise((resolve, reject) => {
79
+ const chunks: Buffer[] = [];
80
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
81
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
82
+ req.on("error", reject);
83
+ });
84
+ }
85
+
86
+ function jsonResponse(res: http.ServerResponse, statusCode: number, body: any): void {
87
+ const payload = JSON.stringify(body);
88
+ res.writeHead(statusCode, {
89
+ "Content-Type": "application/json",
90
+ "Access-Control-Allow-Origin": "*",
91
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
92
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
93
+ });
94
+ res.end(payload);
95
+ }
96
+
97
+ function errorResponse(res: http.ServerResponse, statusCode: number, message: string, errorType: string = "server_error"): void {
98
+ jsonResponse(res, statusCode, {
99
+ error: {
100
+ message,
101
+ type: errorType,
102
+ code: statusCode,
103
+ },
104
+ });
105
+ }
106
+
107
+ // ============================================================
108
+ // PROVIDER CALL
109
+ // ============================================================
110
+
111
+ interface ProviderCallResult {
112
+ content: string;
113
+ model: string;
114
+ usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
115
+ finish_reason: string;
116
+ }
117
+
118
+ /**
119
+ * Call the actual LLM provider with the given messages.
120
+ * Handles OpenAI-compatible APIs, Anthropic, Google, and local providers.
121
+ */
122
+ async function callProvider(
123
+ mapping: ModelMapping,
124
+ messages: ChatMessage[],
125
+ options: { temperature?: number; max_tokens?: number; stream?: boolean; stop?: string | string[] }
126
+ ): Promise<ProviderCallResult> {
127
+ const { providerId, model, baseUrl, apiKey, type } = mapping;
128
+ const maxTokens = options.max_tokens || 1024;
129
+
130
+ // Local providers (Ollama, vLLM, LM Studio)
131
+ if (type === "local" || type === "cli") {
132
+ return callLocalProvider(mapping, messages, maxTokens);
133
+ }
134
+
135
+ // Anthropic has a different API format
136
+ if (providerId === "anthropic") {
137
+ return callAnthropicProvider(mapping, messages, options);
138
+ }
139
+
140
+ // Google Gemini has a different API format
141
+ if (providerId === "google") {
142
+ return callGoogleProvider(mapping, messages, options);
143
+ }
144
+
145
+ // Standard OpenAI-compatible API (Groq, Cerebras, Mistral, DeepSeek, OpenAI, etc.)
146
+ return callOpenAICompatibleProvider(mapping, messages, options);
147
+ }
148
+
149
+ /**
150
+ * Standard OpenAI-compatible API call.
151
+ */
152
+ async function callOpenAICompatibleProvider(
153
+ mapping: ModelMapping,
154
+ messages: ChatMessage[],
155
+ options: { temperature?: number; max_tokens?: number; stream?: boolean; stop?: string | string[] }
156
+ ): Promise<ProviderCallResult> {
157
+ const { model, baseUrl, apiKey } = mapping;
158
+
159
+ const body: any = {
160
+ model,
161
+ messages,
162
+ max_tokens: options.max_tokens || 1024,
163
+ };
164
+ if (options.temperature !== undefined) body.temperature = options.temperature;
165
+ if (options.stop) body.stop = options.stop;
166
+
167
+ const headers: Record<string, string> = {
168
+ "Content-Type": "application/json",
169
+ };
170
+ if (apiKey) {
171
+ headers["Authorization"] = `Bearer ${apiKey}`;
172
+ }
173
+
174
+ const resp = await fetch(baseUrl, {
175
+ method: "POST",
176
+ headers,
177
+ body: JSON.stringify(body),
178
+ });
179
+
180
+ const data = await resp.json() as any;
181
+
182
+ if (data.error) {
183
+ throw new Error(data.error.message || JSON.stringify(data.error));
184
+ }
185
+
186
+ return {
187
+ content: data.choices?.[0]?.message?.content || "",
188
+ model: data.model || model,
189
+ usage: data.usage || {
190
+ prompt_tokens: 0,
191
+ completion_tokens: 0,
192
+ total_tokens: 0,
193
+ },
194
+ finish_reason: data.choices?.[0]?.finish_reason || "stop",
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Anthropic Messages API call.
200
+ */
201
+ async function callAnthropicProvider(
202
+ mapping: ModelMapping,
203
+ messages: ChatMessage[],
204
+ options: { temperature?: number; max_tokens?: number; stream?: boolean; stop?: string | string[] }
205
+ ): Promise<ProviderCallResult> {
206
+ const { model, baseUrl, apiKey } = mapping;
207
+
208
+ // Convert OpenAI format to Anthropic format
209
+ let systemPrompt = "";
210
+ const anthropicMessages: any[] = [];
211
+ for (const msg of messages) {
212
+ if (msg.role === "system") {
213
+ systemPrompt += (systemPrompt ? "\n" : "") + msg.content;
214
+ } else {
215
+ anthropicMessages.push({ role: msg.role, content: msg.content });
216
+ }
217
+ }
218
+
219
+ const body: any = {
220
+ model,
221
+ messages: anthropicMessages,
222
+ max_tokens: options.max_tokens || 1024,
223
+ };
224
+ if (systemPrompt) body.system = systemPrompt;
225
+ if (options.temperature !== undefined) body.temperature = options.temperature;
226
+ if (options.stop) body.stop_sequences = Array.isArray(options.stop) ? options.stop : [options.stop];
227
+
228
+ const resp = await fetch(baseUrl, {
229
+ method: "POST",
230
+ headers: {
231
+ "Content-Type": "application/json",
232
+ "x-api-key": apiKey || "",
233
+ "anthropic-version": "2023-06-01",
234
+ },
235
+ body: JSON.stringify(body),
236
+ });
237
+
238
+ const data = await resp.json() as any;
239
+
240
+ if (data.error) {
241
+ throw new Error(data.error.message || JSON.stringify(data.error));
242
+ }
243
+
244
+ const content = data.content?.[0]?.text || "";
245
+ const inputTokens = data.usage?.input_tokens || 0;
246
+ const outputTokens = data.usage?.output_tokens || 0;
247
+
248
+ return {
249
+ content,
250
+ model: data.model || model,
251
+ usage: {
252
+ prompt_tokens: inputTokens,
253
+ completion_tokens: outputTokens,
254
+ total_tokens: inputTokens + outputTokens,
255
+ },
256
+ finish_reason: data.stop_reason || "stop",
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Google Gemini API call.
262
+ */
263
+ async function callGoogleProvider(
264
+ mapping: ModelMapping,
265
+ messages: ChatMessage[],
266
+ options: { temperature?: number; max_tokens?: number; stream?: boolean; stop?: string | string[] }
267
+ ): Promise<ProviderCallResult> {
268
+ const { model, apiKey } = mapping;
269
+ const baseUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
270
+
271
+ // Convert OpenAI format to Gemini format
272
+ const contents: any[] = [];
273
+ let systemInstruction: any = null;
274
+
275
+ for (const msg of messages) {
276
+ if (msg.role === "system") {
277
+ systemInstruction = { parts: [{ text: msg.content }] };
278
+ } else {
279
+ contents.push({
280
+ role: msg.role === "assistant" ? "model" : "user",
281
+ parts: [{ text: msg.content }],
282
+ });
283
+ }
284
+ }
285
+
286
+ const body: any = {
287
+ contents,
288
+ generationConfig: {
289
+ maxOutputTokens: options.max_tokens || 1024,
290
+ },
291
+ };
292
+ if (systemInstruction) body.systemInstruction = systemInstruction;
293
+ if (options.temperature !== undefined) body.generationConfig.temperature = options.temperature;
294
+ if (options.stop) body.generationConfig.stopSequences = Array.isArray(options.stop) ? options.stop : [options.stop];
295
+
296
+ const resp = await fetch(baseUrl, {
297
+ method: "POST",
298
+ headers: { "Content-Type": "application/json" },
299
+ body: JSON.stringify(body),
300
+ });
301
+
302
+ const data = await resp.json() as any;
303
+
304
+ if (data.error) {
305
+ throw new Error(data.error.message || JSON.stringify(data.error));
306
+ }
307
+
308
+ const content = data.candidates?.[0]?.content?.parts?.[0]?.text || "";
309
+ const tokensIn = data.usageMetadata?.promptTokenCount || 0;
310
+ const tokensOut = data.usageMetadata?.candidatesTokenCount || 0;
311
+
312
+ return {
313
+ content,
314
+ model,
315
+ usage: {
316
+ prompt_tokens: tokensIn,
317
+ completion_tokens: tokensOut,
318
+ total_tokens: tokensIn + tokensOut,
319
+ },
320
+ finish_reason: data.candidates?.[0]?.finishReason || "stop",
321
+ };
322
+ }
323
+
324
+ /**
325
+ * Local provider call (Ollama, vLLM, LM Studio).
326
+ */
327
+ async function callLocalProvider(
328
+ mapping: ModelMapping,
329
+ messages: ChatMessage[],
330
+ maxTokens: number
331
+ ): Promise<ProviderCallResult> {
332
+ const { model, baseUrl, providerId } = mapping;
333
+
334
+ // Ollama uses /api/chat
335
+ if (providerId === "ollama") {
336
+ const ollamaUrl = (baseUrl || "http://127.0.0.1:11434/api/chat").replace("/api/generate", "/api/chat");
337
+ const resp = await fetch(ollamaUrl, {
338
+ method: "POST",
339
+ headers: { "Content-Type": "application/json" },
340
+ body: JSON.stringify({ model, messages, stream: false, options: { num_predict: maxTokens } }),
341
+ });
342
+ const data = await resp.json() as any;
343
+ return {
344
+ content: data.message?.content || "",
345
+ model,
346
+ usage: { prompt_tokens: data.prompt_eval_count || 0, completion_tokens: data.eval_count || 0, total_tokens: (data.prompt_eval_count || 0) + (data.eval_count || 0) },
347
+ finish_reason: "stop",
348
+ };
349
+ }
350
+
351
+ // vLLM and LM Studio use OpenAI-compatible API
352
+ const resp = await fetch(baseUrl, {
353
+ method: "POST",
354
+ headers: { "Content-Type": "application/json" },
355
+ body: JSON.stringify({ model, messages, max_tokens: maxTokens }),
356
+ });
357
+ const data = await resp.json() as any;
358
+
359
+ if (data.error) {
360
+ throw new Error(data.error.message || JSON.stringify(data.error));
361
+ }
362
+
363
+ return {
364
+ content: data.choices?.[0]?.message?.content || "",
365
+ model: data.model || model,
366
+ usage: data.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
367
+ finish_reason: data.choices?.[0]?.finish_reason || "stop",
368
+ };
369
+ }
370
+
371
+ // ============================================================
372
+ // STREAMING SUPPORT
373
+ // ============================================================
374
+
375
+ /**
376
+ * Stream a provider response as SSE chunks.
377
+ */
378
+ async function streamProviderResponse(
379
+ res: http.ServerResponse,
380
+ mapping: ModelMapping,
381
+ messages: ChatMessage[],
382
+ options: { temperature?: number; max_tokens?: number; stop?: string | string[] },
383
+ requestId: string
384
+ ): Promise<void> {
385
+ const { model, baseUrl, apiKey, type, providerId } = mapping;
386
+ const maxTokens = options.max_tokens || 1024;
387
+
388
+ // Set SSE headers
389
+ res.writeHead(200, {
390
+ "Content-Type": "text/event-stream",
391
+ "Cache-Control": "no-cache",
392
+ Connection: "keep-alive",
393
+ "Access-Control-Allow-Origin": "*",
394
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
395
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
396
+ });
397
+
398
+ // Build request body
399
+ const body: any = { model, messages, max_tokens: maxTokens, stream: true };
400
+ if (options.temperature !== undefined) body.temperature = options.temperature;
401
+ if (options.stop) body.stop = options.stop;
402
+
403
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
404
+
405
+ // Provider-specific header setup
406
+ if (providerId === "anthropic") {
407
+ headers["x-api-key"] = apiKey || "";
408
+ headers["anthropic-version"] = "2023-06-01";
409
+ // Anthropic streaming format is different but we handle it below
410
+ } else if (providerId === "google") {
411
+ // Google doesn't support SSE streaming in the same way; fall back to non-streaming
412
+ try {
413
+ const result = await callProvider(mapping, messages, { ...options, stream: false });
414
+ const chunk = {
415
+ id: requestId,
416
+ object: "chat.completion.chunk",
417
+ created: Math.floor(Date.now() / 1000),
418
+ model: result.model,
419
+ choices: [{ index: 0, delta: { content: result.content }, finish_reason: null }],
420
+ };
421
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
422
+ const doneChunk = {
423
+ id: requestId,
424
+ object: "chat.completion.chunk",
425
+ created: Math.floor(Date.now() / 1000),
426
+ model: result.model,
427
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
428
+ };
429
+ res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
430
+ res.write("data: [DONE]\n\n");
431
+ res.end();
432
+ } catch (err: any) {
433
+ const errorChunk = {
434
+ id: requestId,
435
+ object: "chat.completion.chunk",
436
+ created: Math.floor(Date.now() / 1000),
437
+ model,
438
+ choices: [{ index: 0, delta: { content: `Error: ${err.message}` }, finish_reason: "stop" }],
439
+ };
440
+ res.write(`data: ${JSON.stringify(errorChunk)}\n\n`);
441
+ res.write("data: [DONE]\n\n");
442
+ res.end();
443
+ }
444
+ return;
445
+ } else if (apiKey) {
446
+ headers["Authorization"] = `Bearer ${apiKey}`;
447
+ }
448
+
449
+ // Determine the correct URL for streaming
450
+ let streamUrl = baseUrl;
451
+
452
+ // Anthropic streaming URL is the same but we need to set stream: true
453
+ if (providerId === "anthropic") {
454
+ body.stream = true;
455
+ }
456
+
457
+ // Ollama streaming
458
+ if (providerId === "ollama") {
459
+ streamUrl = (baseUrl || "http://127.0.0.1:11434/api/chat").replace("/api/generate", "/api/chat");
460
+ body.stream = true;
461
+ }
462
+
463
+ try {
464
+ const resp = await fetch(streamUrl, {
465
+ method: "POST",
466
+ headers,
467
+ body: JSON.stringify(body),
468
+ });
469
+
470
+ if (!resp.ok) {
471
+ const errText = await resp.text();
472
+ const errorChunk = {
473
+ id: requestId,
474
+ object: "chat.completion.chunk",
475
+ created: Math.floor(Date.now() / 1000),
476
+ model,
477
+ choices: [{ index: 0, delta: { content: `Error: ${resp.status} ${errText.substring(0, 200)}` }, finish_reason: "stop" }],
478
+ };
479
+ res.write(`data: ${JSON.stringify(errorChunk)}\n\n`);
480
+ res.write("data: [DONE]\n\n");
481
+ res.end();
482
+ return;
483
+ }
484
+
485
+ if (!resp.body) {
486
+ // No streaming body available; fall back to reading full response
487
+ const text = await resp.text();
488
+ try {
489
+ const data = JSON.parse(text);
490
+ const content = providerId === "anthropic"
491
+ ? (data.content?.map((c: any) => c.text).join("") || "")
492
+ : (data.choices?.[0]?.message?.content || text);
493
+ const chunk = {
494
+ id: requestId,
495
+ object: "chat.completion.chunk",
496
+ created: Math.floor(Date.now() / 1000),
497
+ model,
498
+ choices: [{ index: 0, delta: { content }, finish_reason: null }],
499
+ };
500
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
501
+ } catch {
502
+ const chunk = {
503
+ id: requestId,
504
+ object: "chat.completion.chunk",
505
+ created: Math.floor(Date.now() / 1000),
506
+ model,
507
+ choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
508
+ };
509
+ res.write(`data: ${JSON.stringify(chunk)}\n\n`);
510
+ }
511
+ const doneChunk = {
512
+ id: requestId,
513
+ object: "chat.completion.chunk",
514
+ created: Math.floor(Date.now() / 1000),
515
+ model,
516
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
517
+ };
518
+ res.write(`data: ${JSON.stringify(doneChunk)}\n\n`);
519
+ res.write("data: [DONE]\n\n");
520
+ res.end();
521
+ return;
522
+ }
523
+
524
+ // Stream the response through
525
+ const reader = resp.body.getReader();
526
+ const decoder = new TextDecoder();
527
+ let buffer = "";
528
+
529
+ while (true) {
530
+ const { done, value } = await reader.read();
531
+ if (done) break;
532
+
533
+ buffer += decoder.decode(value, { stream: true });
534
+
535
+ // Process SSE lines from the upstream provider
536
+ const lines = buffer.split("\n");
537
+ buffer = lines.pop() || "";
538
+
539
+ for (const line of lines) {
540
+ const trimmed = line.trim();
541
+
542
+ // Skip empty lines and comments
543
+ if (!trimmed || trimmed.startsWith(":")) continue;
544
+
545
+ // If the upstream is already sending SSE format, relay it (replacing the ID)
546
+ if (trimmed.startsWith("data: ")) {
547
+ const payload = trimmed.substring(6);
548
+
549
+ // Check for [DONE]
550
+ if (payload === "[DONE]") {
551
+ res.write("data: [DONE]\n\n");
552
+ continue;
553
+ }
554
+
555
+ try {
556
+ const parsed = JSON.parse(payload);
557
+
558
+ // Normalize the chunk to OpenAI format
559
+ const normalizedChunk: any = {
560
+ id: requestId,
561
+ object: "chat.completion.chunk",
562
+ created: Math.floor(Date.now() / 1000),
563
+ model: parsed.model || model,
564
+ choices: [],
565
+ };
566
+
567
+ // OpenAI/Groq/Cerebras format
568
+ if (parsed.choices?.[0]?.delta) {
569
+ normalizedChunk.choices = parsed.choices;
570
+ } else if (parsed.choices?.[0]?.text) {
571
+ normalizedChunk.choices = [{
572
+ index: 0,
573
+ delta: { content: parsed.choices[0].text },
574
+ finish_reason: null,
575
+ }];
576
+ }
577
+ // Anthropic streaming format
578
+ else if (parsed.type === "content_block_delta" && parsed.delta?.text) {
579
+ normalizedChunk.choices = [{
580
+ index: 0,
581
+ delta: { content: parsed.delta.text },
582
+ finish_reason: null,
583
+ }];
584
+ } else if (parsed.type === "message_stop") {
585
+ normalizedChunk.choices = [{ index: 0, delta: {}, finish_reason: "stop" }];
586
+ }
587
+ // Ollama streaming format
588
+ else if (parsed.message?.content) {
589
+ normalizedChunk.choices = [{
590
+ index: 0,
591
+ delta: { content: parsed.message.content },
592
+ finish_reason: parsed.done ? "stop" : null,
593
+ }];
594
+ } else {
595
+ // Unknown format — relay as-is with our ID
596
+ normalizedChunk.choices = [{
597
+ index: 0,
598
+ delta: { content: JSON.stringify(parsed) },
599
+ finish_reason: null,
600
+ }];
601
+ }
602
+
603
+ res.write(`data: ${JSON.stringify(normalizedChunk)}\n\n`);
604
+ } catch {
605
+ // Non-JSON data — relay as content
606
+ const fallbackChunk = {
607
+ id: requestId,
608
+ object: "chat.completion.chunk",
609
+ created: Math.floor(Date.now() / 1000),
610
+ model,
611
+ choices: [{ index: 0, delta: { content: payload }, finish_reason: null }],
612
+ };
613
+ res.write(`data: ${JSON.stringify(fallbackChunk)}\n\n`);
614
+ }
615
+ }
616
+ }
617
+ }
618
+
619
+ // Ensure [DONE] is sent
620
+ res.write("data: [DONE]\n\n");
621
+ res.end();
622
+ } catch (err: any) {
623
+ const errorChunk = {
624
+ id: requestId,
625
+ object: "chat.completion.chunk",
626
+ created: Math.floor(Date.now() / 1000),
627
+ model,
628
+ choices: [{ index: 0, delta: { content: `Stream error: ${err.message}` }, finish_reason: "stop" }],
629
+ };
630
+ res.write(`data: ${JSON.stringify(errorChunk)}\n\n`);
631
+ res.write("data: [DONE]\n\n");
632
+ res.end();
633
+ }
634
+ }
635
+
636
+ // ============================================================
637
+ // FALLBACK CHAIN
638
+ // ============================================================
639
+
640
+ /**
641
+ * Try the primary mapping, then fall back to alternatives.
642
+ */
643
+ async function callWithFallback(
644
+ model: string,
645
+ messages: ChatMessage[],
646
+ options: { temperature?: number; max_tokens?: number; stream?: boolean; stop?: string | string[] },
647
+ prompt: string
648
+ ): Promise<{ result: ProviderCallResult; mapping: ModelMapping }> {
649
+ const mapping = resolveModel(model, prompt);
650
+ if (!mapping) {
651
+ throw new Error(`No provider available for model "${model}". Check your API keys and provider configuration.`);
652
+ }
653
+
654
+ // Try primary
655
+ try {
656
+ const result = await callProvider(mapping, messages, options);
657
+ return { result, mapping };
658
+ } catch (primaryError: any) {
659
+ console.error(`[a3m-proxy] Primary provider ${mapping.providerId} failed: ${primaryError.message}`);
660
+ }
661
+
662
+ // Try fallback providers
663
+ const available = getAvailableProviders();
664
+ for (const [providerId, provider] of Object.entries(available)) {
665
+ if (providerId === mapping.providerId) continue;
666
+ if (provider.type !== "api") continue;
667
+ if (!provider.apiKey) continue;
668
+ if (!provider.models || provider.models.length === 0) continue;
669
+
670
+ const fallbackMapping: ModelMapping = {
671
+ providerId,
672
+ model: provider.models[0],
673
+ baseUrl: provider.baseUrl || "",
674
+ apiKey: provider.apiKey || null,
675
+ costPerK: provider.costPerK || { input: 0, output: 0 },
676
+ type: provider.type || "api",
677
+ };
678
+
679
+ try {
680
+ const result = await callProvider(fallbackMapping, messages, options);
681
+ return { result, mapping: fallbackMapping };
682
+ } catch (fallbackError: any) {
683
+ console.error(`[a3m-proxy] Fallback provider ${providerId} failed: ${fallbackError.message}`);
684
+ }
685
+ }
686
+
687
+ throw new Error(`All providers failed for model "${model}". Check your API keys.`);
688
+ }
689
+
690
+ // ============================================================
691
+ // REQUEST HANDLERS
692
+ // ============================================================
693
+
694
+ const requestLogs: RequestLog[] = [];
695
+ const costTracker = new CostTracker();
696
+
697
+ /**
698
+ * Handle POST /v1/chat/completions
699
+ */
700
+ async function handleChatCompletions(
701
+ req: http.IncomingMessage,
702
+ res: http.ServerResponse
703
+ ): Promise<void> {
704
+ const body = await readBody(req);
705
+ let request: ChatRequest;
706
+
707
+ try {
708
+ request = JSON.parse(body);
709
+ } catch {
710
+ errorResponse(res, 400, "Invalid JSON in request body", "invalid_request_error");
711
+ return;
712
+ }
713
+
714
+ if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
715
+ errorResponse(res, 400, "messages is required and must be a non-empty array", "invalid_request_error");
716
+ return;
717
+ }
718
+
719
+ const model = request.model || "auto";
720
+ const stream = request.stream || false;
721
+ const requestId = generateId();
722
+
723
+ // Build the prompt from messages for routing
724
+ const promptForRouting = request.messages.map((m) => m.content).join(" ");
725
+
726
+ // Resolve model
727
+ const mapping = resolveModel(model, promptForRouting);
728
+ if (!mapping) {
729
+ errorResponse(res, 503, `No provider available for model "${model}". Configure API keys.`, "server_error");
730
+ return;
731
+ }
732
+
733
+ const startTime = Date.now();
734
+
735
+ if (stream) {
736
+ // Streaming response
737
+ try {
738
+ await streamProviderResponse(
739
+ res,
740
+ mapping,
741
+ request.messages,
742
+ { temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
743
+ requestId
744
+ );
745
+
746
+ const latencyMs = Date.now() - startTime;
747
+ logRequest({
748
+ id: requestId,
749
+ model,
750
+ resolvedProvider: mapping.providerId,
751
+ resolvedModel: mapping.model,
752
+ latencyMs,
753
+ tokensIn: 0,
754
+ tokensOut: 0,
755
+ cost: 0,
756
+ status: "success",
757
+ timestamp: Date.now(),
758
+ });
759
+
760
+ console.log(
761
+ `[a3m-proxy] ${requestId} stream model=${model}→${mapping.providerId}/${mapping.model} latency=${latencyMs}ms`
762
+ );
763
+ } catch (err: any) {
764
+ if (!res.headersSent) {
765
+ errorResponse(res, 500, err.message);
766
+ }
767
+ }
768
+ } else {
769
+ // Non-streaming response
770
+ try {
771
+ const { result, mapping: usedMapping } = await callWithFallback(
772
+ model,
773
+ request.messages,
774
+ { temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
775
+ promptForRouting
776
+ );
777
+
778
+ const latencyMs = Date.now() - startTime;
779
+ const inputCost = (result.usage.prompt_tokens / 1000) * (usedMapping.costPerK.input);
780
+ const outputCost = (result.usage.completion_tokens / 1000) * (usedMapping.costPerK.output);
781
+ const totalCost = inputCost + outputCost;
782
+
783
+ // Track cost
784
+ costTracker.record(usedMapping.providerId, usedMapping.model, result.usage.prompt_tokens, result.usage.completion_tokens);
785
+
786
+ const response = {
787
+ id: requestId,
788
+ object: "chat.completion",
789
+ created: Math.floor(Date.now() / 1000),
790
+ model: result.model,
791
+ choices: [
792
+ {
793
+ index: 0,
794
+ message: { role: "assistant" as const, content: result.content },
795
+ finish_reason: result.finish_reason,
796
+ },
797
+ ],
798
+ usage: result.usage,
799
+ };
800
+
801
+ jsonResponse(res, 200, response);
802
+
803
+ logRequest({
804
+ id: requestId,
805
+ model,
806
+ resolvedProvider: usedMapping.providerId,
807
+ resolvedModel: usedMapping.model,
808
+ latencyMs,
809
+ tokensIn: result.usage.prompt_tokens,
810
+ tokensOut: result.usage.completion_tokens,
811
+ cost: totalCost,
812
+ status: "success",
813
+ timestamp: Date.now(),
814
+ });
815
+
816
+ console.log(
817
+ `[a3m-proxy] ${requestId} model=${model}→${usedMapping.providerId}/${usedMapping.model} latency=${latencyMs}ms tokens=${result.usage.total_tokens} cost=$${totalCost.toFixed(6)}`
818
+ );
819
+ } catch (err: any) {
820
+ const latencyMs = Date.now() - startTime;
821
+
822
+ logRequest({
823
+ id: requestId,
824
+ model,
825
+ resolvedProvider: mapping.providerId,
826
+ resolvedModel: mapping.model,
827
+ latencyMs,
828
+ tokensIn: 0,
829
+ tokensOut: 0,
830
+ cost: 0,
831
+ status: "error",
832
+ error: err.message,
833
+ timestamp: Date.now(),
834
+ });
835
+
836
+ console.error(
837
+ `[a3m-proxy] ${requestId} ERROR model=${model}→${mapping.providerId}/${mapping.model} latency=${latencyMs}ms error=${err.message}`
838
+ );
839
+
840
+ if (!res.headersSent) {
841
+ errorResponse(res, 502, err.message, "upstream_error");
842
+ }
843
+ }
844
+ }
845
+ }
846
+
847
+ /**
848
+ * Handle POST /v1/completions
849
+ */
850
+ async function handleCompletions(
851
+ req: http.IncomingMessage,
852
+ res: http.ServerResponse
853
+ ): Promise<void> {
854
+ const body = await readBody(req);
855
+ let request: CompletionRequest;
856
+
857
+ try {
858
+ request = JSON.parse(body);
859
+ } catch {
860
+ errorResponse(res, 400, "Invalid JSON in request body", "invalid_request_error");
861
+ return;
862
+ }
863
+
864
+ // Convert prompt to messages format
865
+ const prompts = Array.isArray(request.prompt) ? request.prompt : [request.prompt || ""];
866
+ const messages: ChatMessage[] = prompts.map((p) => ({ role: "user" as const, content: p }));
867
+
868
+ const model = request.model || "auto";
869
+ const stream = request.stream || false;
870
+ const requestId = generateId();
871
+ const promptForRouting = prompts.join(" ");
872
+
873
+ const mapping = resolveModel(model, promptForRouting);
874
+ if (!mapping) {
875
+ errorResponse(res, 503, `No provider available for model "${model}".`, "server_error");
876
+ return;
877
+ }
878
+
879
+ const startTime = Date.now();
880
+
881
+ if (stream) {
882
+ await streamProviderResponse(
883
+ res, mapping, messages,
884
+ { temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
885
+ requestId
886
+ );
887
+ } else {
888
+ try {
889
+ const { result, mapping: usedMapping } = await callWithFallback(
890
+ model, messages,
891
+ { temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
892
+ promptForRouting
893
+ );
894
+
895
+ const latencyMs = Date.now() - startTime;
896
+
897
+ const response = {
898
+ id: requestId,
899
+ object: "text_completion",
900
+ created: Math.floor(Date.now() / 1000),
901
+ model: result.model,
902
+ choices: [
903
+ {
904
+ text: result.content,
905
+ index: 0,
906
+ finish_reason: result.finish_reason,
907
+ },
908
+ ],
909
+ usage: result.usage,
910
+ };
911
+
912
+ jsonResponse(res, 200, response);
913
+
914
+ costTracker.record(usedMapping.providerId, usedMapping.model, result.usage.prompt_tokens, result.usage.completion_tokens);
915
+
916
+ console.log(
917
+ `[a3m-proxy] ${requestId} completion model=${model}→${usedMapping.providerId}/${usedMapping.model} latency=${latencyMs}ms`
918
+ );
919
+ } catch (err: any) {
920
+ if (!res.headersSent) {
921
+ errorResponse(res, 502, err.message, "upstream_error");
922
+ }
923
+ }
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Handle GET /v1/models
929
+ */
930
+ function handleModels(res: http.ServerResponse): void {
931
+ const models = listAvailableModels();
932
+ jsonResponse(res, 200, {
933
+ object: "list",
934
+ data: models,
935
+ });
936
+ }
937
+
938
+ /**
939
+ * Handle GET /health
940
+ */
941
+ async function handleHealth(res: http.ServerResponse): Promise<void> {
942
+ const available = getAvailableProviders();
943
+ const providerStatus: Record<string, any> = {};
944
+ let healthyCount = 0;
945
+
946
+ // Quick health check — just report if API keys exist
947
+ for (const [id, provider] of Object.entries(available)) {
948
+ const hasKey = !!provider.apiKey;
949
+ const isAvailable = provider.type !== "api" || hasKey;
950
+ providerStatus[id] = {
951
+ name: provider.name || id,
952
+ type: provider.type,
953
+ models: provider.models?.length || 0,
954
+ available: isAvailable,
955
+ };
956
+ if (isAvailable) healthyCount++;
957
+ }
958
+
959
+ const costSummary = costTracker.getSummary();
960
+
961
+ jsonResponse(res, 200, {
962
+ status: "ok",
963
+ version: "2.0.0",
964
+ providers: {
965
+ total: Object.keys(available).length,
966
+ healthy: healthyCount,
967
+ details: providerStatus,
968
+ },
969
+ cost: {
970
+ total: costSummary.total_cost,
971
+ requests: costSummary.request_count,
972
+ },
973
+ uptime: process.uptime(),
974
+ recentRequests: requestLogs.slice(-20),
975
+ });
976
+ }
977
+
978
+ /**
979
+ * Log a request for the /health endpoint.
980
+ */
981
+ function logRequest(entry: RequestLog): void {
982
+ requestLogs.push(entry);
983
+ // Keep only the last 1000 entries
984
+ if (requestLogs.length > 1000) {
985
+ requestLogs.splice(0, requestLogs.length - 1000);
986
+ }
987
+ }
988
+
989
+ // ============================================================
990
+ // SERVER CREATION
991
+ // ============================================================
992
+
993
+ /**
994
+ * Create and start the proxy server.
995
+ *
996
+ * @param port - Port to listen on (default: 8787, env: PORT)
997
+ * @returns The http.Server instance
998
+ */
999
+ export function createProxyServer(port?: number): http.Server {
1000
+ const listenPort = port || parseInt(process.env.PORT || "8787", 10);
1001
+
1002
+ const server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => {
1003
+ const method = req.method || "GET";
1004
+ const url = req.url || "/";
1005
+
1006
+ // CORS preflight
1007
+ if (method === "OPTIONS") {
1008
+ res.writeHead(204, {
1009
+ "Access-Control-Allow-Origin": "*",
1010
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1011
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
1012
+ "Access-Control-Max-Age": "86400",
1013
+ });
1014
+ res.end();
1015
+ return;
1016
+ }
1017
+
1018
+ try {
1019
+ // Route: POST /v1/chat/completions
1020
+ if (method === "POST" && url === "/v1/chat/completions") {
1021
+ await handleChatCompletions(req, res);
1022
+ return;
1023
+ }
1024
+
1025
+ // Route: POST /v1/completions
1026
+ if (method === "POST" && url === "/v1/completions") {
1027
+ await handleCompletions(req, res);
1028
+ return;
1029
+ }
1030
+
1031
+ // Route: GET /v1/models
1032
+ if (method === "GET" && url === "/v1/models") {
1033
+ handleModels(res);
1034
+ return;
1035
+ }
1036
+
1037
+ // Route: GET /health
1038
+ if (method === "GET" && url === "/health") {
1039
+ await handleHealth(res);
1040
+ return;
1041
+ }
1042
+
1043
+ // 404 for everything else
1044
+ errorResponse(res, 404, `Not found: ${method} ${url}`, "not_found");
1045
+ } catch (err: any) {
1046
+ console.error(`[a3m-proxy] Unhandled error: ${err.message}`);
1047
+ if (!res.headersSent) {
1048
+ errorResponse(res, 500, err.message);
1049
+ }
1050
+ }
1051
+ });
1052
+
1053
+ server.listen(listenPort, () => {
1054
+ console.log(``);
1055
+ console.log(` A3M Router Proxy Server`);
1056
+ console.log(` ─────────────────────────────────────────`);
1057
+ console.log(` Listening: http://localhost:${listenPort}`);
1058
+ console.log(` Endpoints:`);
1059
+ console.log(` POST /v1/chat/completions (OpenAI chat)`);
1060
+ console.log(` POST /v1/completions (OpenAI completions)`);
1061
+ console.log(` GET /v1/models (List models)`);
1062
+ console.log(` GET /health (Health check)`);
1063
+ console.log(``);
1064
+ console.log(` Example:`);
1065
+ console.log(` curl http://localhost:${listenPort}/v1/chat/completions \\`);
1066
+ console.log(` -H "Content-Type: application/json" \\`);
1067
+ console.log(` -d '{"model":"auto","messages":[{"role":"user","content":"Hello"}]}'`);
1068
+ console.log(``);
1069
+ });
1070
+
1071
+ server.on("error", (err: any) => {
1072
+ if (err.code === "EADDRINUSE") {
1073
+ console.error(`[a3m-proxy] Port ${listenPort} is already in use. Use --port or PORT env var.`);
1074
+ process.exit(1);
1075
+ } else {
1076
+ console.error(`[a3m-proxy] Server error: ${err.message}`);
1077
+ }
1078
+ });
1079
+
1080
+ // Graceful shutdown
1081
+ const shutdown = () => {
1082
+ console.log(`\n[a3m-proxy] Shutting down...`);
1083
+ server.close(() => {
1084
+ console.log(`[a3m-proxy] Server closed.`);
1085
+ process.exit(0);
1086
+ });
1087
+ // Force close after 5s
1088
+ setTimeout(() => {
1089
+ console.error(`[a3m-proxy] Forced shutdown after timeout.`);
1090
+ process.exit(1);
1091
+ }, 5000);
1092
+ };
1093
+
1094
+ process.on("SIGINT", shutdown);
1095
+ process.on("SIGTERM", shutdown);
1096
+
1097
+ return server;
1098
+ }
1099
+
1100
+ // ============================================================
1101
+ // EXPORTS
1102
+ // ============================================================
1103
+
1104
+ export { CostTracker, costTracker, requestLogs };
1105
+ export default createProxyServer;