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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +50 -0
- package/.github/ISSUE_TEMPLATE/config.yml +11 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +37 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +44 -0
- package/.github/workflows/npm-stats-validation.yml +152 -0
- package/.github/workflows/pages.yml +37 -0
- package/CHANGELOG.md +122 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +110 -0
- package/LAUNCH-PAIN-DRIVEN.md +339 -0
- package/LAUNCH.md +575 -0
- package/POPULARITY_BOOSTERS.md +285 -0
- package/README.md +231 -300
- package/SECURITY.md +69 -0
- package/articles/CONTENT_STRUCTURE.md +292 -0
- package/articles/DEVTO_COST_GUIDE.md +473 -0
- package/articles/DEVTO_FINAL.md +416 -0
- package/articles/DEVTO_MULTI_PROVIDER.md +542 -0
- package/articles/HN_10X_BETTER.md +430 -0
- package/articles/HN_CHINESE_STYLE.md +308 -0
- package/articles/HN_FINAL.md +199 -0
- package/articles/HN_POSTED_VERSION.md +56 -0
- package/articles/HN_RESEARCH.md +364 -0
- package/articles/PAIN-DRIVEN-devto-v2.md +308 -0
- package/articles/PAIN-DRIVEN-devto-v3.md +268 -0
- package/articles/PAIN-DRIVEN-devto.md +242 -0
- package/articles/PAIN-DRIVEN-hackernews-v2.md +138 -0
- package/articles/PAIN-DRIVEN-hackernews-v3.md +151 -0
- package/articles/PAIN-DRIVEN-hackernews.md +131 -0
- package/articles/PAIN-DRIVEN-reddit-v2.md +301 -0
- package/articles/PAIN-DRIVEN-reddit-v3.md +236 -0
- package/articles/PAIN-DRIVEN-reddit.md +218 -0
- package/articles/PAIN-DRIVEN-twitter-v2.md +110 -0
- package/articles/PAIN-DRIVEN-twitter-v3.md +121 -0
- package/articles/PAIN-DRIVEN-twitter.md +120 -0
- package/articles/PORTKEY_VS_A3M.md +147 -0
- package/articles/REDDIT_FINAL.md +232 -0
- package/articles/TWITTER_FINAL.md +167 -0
- package/articles/WHY_10X_BETTER.md +261 -0
- package/articles/WHY_CHINESE_STYLE_BETTER.md +323 -0
- package/articles/hashnode-llm-cost-optimization.md +125 -0
- package/articles/medium-building-llm-router.md +205 -0
- package/articles/twitter-thread-cost-savings.md +98 -0
- package/articles/youtube-tutorial-script.md +262 -0
- package/assets/banner.svg +109 -0
- package/assets/logo.svg +68 -0
- package/assets/social-preview.svg +64 -0
- package/demo/demo-script.md +53 -0
- package/dist/analytics/costAnalytics.d.ts +77 -0
- package/dist/analytics/costAnalytics.d.ts.map +1 -0
- package/dist/analytics/costAnalytics.js +219 -0
- package/dist/analytics/costAnalytics.js.map +1 -0
- package/dist/cache/semanticCache.d.ts +62 -0
- package/dist/cache/semanticCache.d.ts.map +1 -0
- package/dist/cache/semanticCache.js +176 -0
- package/dist/cache/semanticCache.js.map +1 -0
- package/dist/cli.js +35 -0
- package/dist/index.d.ts +4 -723
- package/dist/index.js +11 -362
- package/dist/index.js.map +1 -1
- package/dist/integrations/langchainAdapter.d.ts +146 -0
- package/dist/integrations/langchainAdapter.d.ts.map +1 -0
- package/dist/integrations/langchainAdapter.js +731 -0
- package/dist/integrations/langchainAdapter.js.map +1 -0
- package/dist/integrations/oauth.d.ts +69 -0
- package/dist/integrations/oauth.d.ts.map +1 -0
- package/dist/integrations/oauth.js +225 -21
- package/dist/integrations/oauth.js.map +1 -0
- package/dist/memory/autoFetch.d.ts +39 -0
- package/dist/memory/autoFetch.d.ts.map +1 -0
- package/dist/memory/autoFetch.js +80 -88
- package/dist/memory/autoFetch.js.map +1 -0
- package/dist/memory/memoryTree.d.ts +76 -0
- package/dist/memory/memoryTree.d.ts.map +1 -0
- package/dist/memory/memoryTree.js +185 -130
- package/dist/memory/memoryTree.js.map +1 -0
- package/dist/memory/obsidianVault.d.ts +71 -0
- package/dist/memory/obsidianVault.d.ts.map +1 -0
- package/dist/memory/obsidianVault.js +207 -22
- package/dist/memory/obsidianVault.js.map +1 -0
- package/dist/providers/providerConfig.d.ts +49 -0
- package/dist/providers/providerConfig.d.ts.map +1 -0
- package/dist/providers/providerConfig.js +806 -401
- package/dist/providers/providerConfig.js.map +1 -0
- package/dist/security/guardrails.d.ts +76 -0
- package/dist/security/guardrails.d.ts.map +1 -0
- package/dist/security/guardrails.js +479 -0
- package/dist/security/guardrails.js.map +1 -0
- package/dist/server/dashboard.d.ts +58 -0
- package/dist/server/dashboard.d.ts.map +1 -0
- package/dist/server/dashboard.js +553 -0
- package/dist/server/dashboard.js.map +1 -0
- package/dist/server/modelMapper.d.ts +43 -0
- package/dist/server/modelMapper.d.ts.map +1 -0
- package/dist/server/modelMapper.js +154 -0
- package/dist/server/modelMapper.js.map +1 -0
- package/dist/server/proxyServer.d.ts +41 -0
- package/dist/server/proxyServer.d.ts.map +1 -0
- package/dist/server/proxyServer.js +932 -0
- package/dist/server/proxyServer.js.map +1 -0
- package/dist/skills/__tests__/skill_manager.test.d.ts +2 -0
- package/dist/skills/__tests__/skill_manager.test.d.ts.map +1 -0
- package/dist/skills/__tests__/skill_manager.test.js +268 -0
- package/dist/skills/__tests__/skill_manager.test.js.map +1 -0
- package/docs-site/index.html +347 -0
- package/package.json +53 -7
- package/playground/README.md +51 -0
- package/playground/codesandbox.json +12 -0
- package/playground/index.js +39 -0
- package/scripts/update-npm-badges.js +158 -0
- package/src/analytics/costAnalytics.ts +304 -0
- package/src/cache/semanticCache.ts +221 -0
- package/src/index.ts +6 -0
- package/src/integrations/langchainAdapter.ts +955 -0
- package/src/providers/providerConfig.ts +923 -0
- package/src/security/guardrails.ts +585 -0
- package/src/server/dashboard.ts +610 -0
- package/src/server/modelMapper.ts +182 -0
- package/src/server/proxyServer.ts +1105 -0
- package/src/types/langchain.d.ts +83 -0
- 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
|