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

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