adaptive-memory-multi-model-router 2.14.59 → 2.15.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/checklist.md +35 -0
- package/.github/workflows/ci.yml +9 -6
- package/POPULARITY_BOOSTERS.md +18 -0
- package/PR_STATUS_REPORT.md +55 -148
- package/README.md +222 -1035
- package/assets/chart-accuracy-by-tier.svg +63 -0
- package/assets/chart-confusion-matrix.svg +98 -0
- package/assets/chart-cost-comparison.svg +66 -0
- package/assets/chart-latency-overhead.svg +102 -0
- package/assets/chart-routerena-leaderboard.svg +76 -0
- package/dist/analytics/costAnalytics.d.ts +1 -0
- package/dist/benchmark/comprehensive.d.ts +4 -53
- package/dist/benchmark/comprehensive.d.ts.map +1 -0
- package/dist/benchmark/comprehensive.js +112 -214
- package/dist/benchmark/comprehensive.js.map +1 -1
- package/dist/benchmark/reproducible.d.ts +1 -0
- package/dist/cache/semanticCache.d.ts +1 -0
- package/dist/cli/setupWizard.d.ts +6 -1
- package/dist/cli/setupWizard.d.ts.map +1 -1
- package/dist/cli/setupWizard.js +6 -9
- package/dist/cli/setupWizard.js.map +1 -1
- package/dist/cost/budgetEnforcer.d.ts +1 -0
- package/dist/cost/costTracker.d.ts +1 -0
- package/dist/ensemble/multiRoundDialog.d.ts +1 -0
- package/dist/ensemble/multiRoundDialog.d.ts.map +1 -0
- package/dist/ensemble/shapleyValue.d.ts +1 -0
- package/dist/ensemble/shapleyValue.d.ts.map +1 -0
- package/dist/ensemble.d.ts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/integrations/langchainAdapter.d.ts +1 -0
- package/dist/integrations/oauth.d.ts +1 -0
- package/dist/integrations/scienceAdapter.d.ts +1 -0
- package/dist/integrations/scienceAdapter.d.ts.map +1 -0
- package/dist/memory/autoFetch.d.ts +1 -0
- package/dist/memory/hybridMemory.d.ts +1 -0
- package/dist/memory/hybridMemory.d.ts.map +1 -0
- package/dist/memory/memoryTree.d.ts +1 -0
- package/dist/memory/memoryTree.d.ts.map +1 -1
- package/dist/memory/obsidianVault.d.ts +1 -0
- package/dist/memory/obsidianVault.d.ts.map +1 -1
- package/dist/memory/reasoningBank.d.ts +1 -0
- package/dist/memory/reasoningBank.d.ts.map +1 -0
- package/dist/observability/changeWatch.d.ts +1 -0
- package/dist/observability/fatigueDetector.d.ts +1 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/metrics.d.ts +1 -0
- package/dist/observability/metrics.d.ts.map +1 -1
- package/dist/observability/middleware.d.ts +1 -0
- package/dist/observability/tracer.d.ts +1 -0
- package/dist/observability/tracer.d.ts.map +1 -1
- package/dist/observability/types.d.ts +1 -0
- package/dist/providers/providerConfig.d.ts +1 -0
- package/dist/providers/providerConfig.d.ts.map +1 -1
- package/dist/routing/advancedRouter.d.ts +2 -1
- package/dist/routing/advancedRouter.d.ts.map +1 -1
- package/dist/routing/crossModelValidation.d.ts +1 -0
- package/dist/routing/providerHealth.d.ts +1 -0
- package/dist/routing/providerHealth.d.ts.map +1 -1
- package/dist/routing/providerRetry.d.ts +1 -0
- package/dist/sdk.d.ts +1 -0
- package/dist/security/guardrails.d.ts +1 -0
- package/dist/security/guardrails.d.ts.map +1 -1
- package/dist/server/dashboard.d.ts +1 -0
- package/dist/server/handlers/chatHandler.d.ts +11 -0
- package/dist/server/handlers/chatHandler.d.ts.map +1 -0
- package/dist/server/handlers/chatHandler.js +159 -0
- package/dist/server/handlers/chatHandler.js.map +1 -0
- package/dist/server/handlers/completionsHandler.d.ts +10 -0
- package/dist/server/handlers/completionsHandler.d.ts.map +1 -0
- package/dist/server/handlers/completionsHandler.js +124 -0
- package/dist/server/handlers/completionsHandler.js.map +1 -0
- package/dist/server/handlers/embeddingsHandler.d.ts +17 -0
- package/dist/server/handlers/embeddingsHandler.d.ts.map +1 -0
- package/dist/server/handlers/embeddingsHandler.js +235 -0
- package/dist/server/handlers/embeddingsHandler.js.map +1 -0
- package/dist/server/handlers/healthHandler.d.ts +10 -0
- package/dist/server/handlers/healthHandler.d.ts.map +1 -0
- package/dist/server/handlers/healthHandler.js +49 -0
- package/dist/server/handlers/healthHandler.js.map +1 -0
- package/dist/server/handlers/metricsHandler.d.ts +11 -0
- package/dist/server/handlers/metricsHandler.d.ts.map +1 -0
- package/dist/server/handlers/metricsHandler.js +24 -0
- package/dist/server/handlers/metricsHandler.js.map +1 -0
- package/dist/server/handlers/modelsHandler.d.ts +10 -0
- package/dist/server/handlers/modelsHandler.d.ts.map +1 -0
- package/dist/server/handlers/modelsHandler.js +19 -0
- package/dist/server/handlers/modelsHandler.js.map +1 -0
- package/dist/server/metrics.d.ts +96 -0
- package/dist/server/metrics.d.ts.map +1 -0
- package/dist/server/metrics.js +267 -0
- package/dist/server/metrics.js.map +1 -0
- package/dist/server/modelMapper.d.ts +1 -0
- package/dist/server/proxyServer.d.ts +53 -17
- package/dist/server/proxyServer.d.ts.map +1 -1
- package/dist/server/proxyServer.js +66 -303
- package/dist/server/proxyServer.js.map +1 -1
- package/dist/server/router.d.ts +49 -0
- package/dist/server/router.d.ts.map +1 -0
- package/dist/server/router.js +120 -0
- package/dist/server/router.js.map +1 -0
- package/dist/server/state.d.ts +30 -0
- package/dist/server/state.d.ts.map +1 -0
- package/dist/server/state.js +18 -0
- package/dist/server/state.js.map +1 -0
- package/dist/skills/__tests__/skill_manager.test.d.ts +3 -0
- package/dist/skills/__tests__/skill_manager.test.d.ts.map +1 -1
- package/dist/skills/__tests__/skill_manager.test.js +3 -6
- package/dist/skills/__tests__/skill_manager.test.js.map +1 -1
- package/dist/tui/dashboard.d.ts +1 -0
- package/dist/tui/index.d.ts +1 -0
- package/dist/utils/costUtils.d.ts +1 -0
- package/dist/utils/reliability.js +4 -18
- package/dist/utils/sorting.d.ts +1 -0
- package/dist/utils/tokenUtils.d.ts +1 -0
- package/docs/assets/chart-accuracy-by-tier.svg +63 -0
- package/docs/assets/chart-confusion-matrix.svg +98 -0
- package/docs/assets/chart-cost-comparison.svg +66 -0
- package/docs/assets/chart-latency-overhead.svg +102 -0
- package/docs/assets/chart-routerena-leaderboard.svg +76 -0
- package/docs/index.html +72 -80
- package/docs/llms-full.txt +156 -184
- package/docs/llms.txt +77 -42
- package/llms-full.txt +156 -184
- package/llms.txt +77 -42
- package/mcp-server/package.json +6 -0
- package/mcp-server/tsconfig.json +3 -2
- package/package.json +4 -3
- package/scripts/postinstall-nudge.js +3 -0
- package/src/server/handlers/chatHandler.ts +173 -0
- package/src/server/handlers/completionsHandler.ts +120 -0
- package/src/server/handlers/embeddingsHandler.ts +271 -0
- package/src/server/handlers/healthHandler.ts +57 -0
- package/src/server/handlers/metricsHandler.ts +30 -0
- package/src/server/handlers/modelsHandler.ts +25 -0
- package/src/server/metrics.ts +303 -0
- package/src/server/proxyServer.ts +71 -394
- package/src/server/router.ts +157 -0
- package/src/server/state.ts +34 -0
- package/src/skills/__tests__/skill_manager.test.ts +3 -3
- package/index.html +0 -667
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3M Router - Chat Completions Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles POST /v1/chat/completions
|
|
5
|
+
* Plug-and-play: register with router.registerRoute('POST', /^\/v1\/chat\/completions$/, handleChatCompletions);
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as http from 'http';
|
|
9
|
+
import { RouteContext } from '../router';
|
|
10
|
+
import { resolveModel } from '../modelMapper';
|
|
11
|
+
import { costTracker } from '../state';
|
|
12
|
+
import { recordRequest, recordProviderError, recordActiveRequest } from '../metrics';
|
|
13
|
+
|
|
14
|
+
// ============================================================
|
|
15
|
+
// TYPES (local, matching OpenAI API)
|
|
16
|
+
// ============================================================
|
|
17
|
+
|
|
18
|
+
interface ChatMessage {
|
|
19
|
+
role: 'system' | 'user' | 'assistant' | 'tool';
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ChatRequest {
|
|
24
|
+
model: string;
|
|
25
|
+
messages: ChatMessage[];
|
|
26
|
+
stream?: boolean;
|
|
27
|
+
temperature?: number;
|
|
28
|
+
max_tokens?: number;
|
|
29
|
+
top_p?: number;
|
|
30
|
+
frequency_penalty?: number;
|
|
31
|
+
presence_penalty?: number;
|
|
32
|
+
stop?: string | string[];
|
|
33
|
+
n?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================
|
|
37
|
+
// HELPERS
|
|
38
|
+
// ============================================================
|
|
39
|
+
|
|
40
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const chunks: Buffer[] = [];
|
|
43
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
44
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
45
|
+
req.on('error', reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function jsonResponse(res: http.ServerResponse, statusCode: number, body: object): void {
|
|
50
|
+
res.writeHead(statusCode, {
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
'Access-Control-Allow-Origin': '*',
|
|
53
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
54
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
55
|
+
});
|
|
56
|
+
res.end(JSON.stringify(body));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function errorResponse(res: http.ServerResponse, statusCode: number, message: string, errorType = 'server_error'): void {
|
|
60
|
+
jsonResponse(res, statusCode, { error: { message, type: errorType, code: statusCode } });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================
|
|
64
|
+
// HANDLER
|
|
65
|
+
// ============================================================
|
|
66
|
+
|
|
67
|
+
export async function handleChatCompletions(
|
|
68
|
+
req: http.IncomingMessage,
|
|
69
|
+
res: http.ServerResponse,
|
|
70
|
+
ctx: RouteContext
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
recordActiveRequest(1);
|
|
73
|
+
|
|
74
|
+
const body = await readBody(req);
|
|
75
|
+
let request: ChatRequest;
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
request = JSON.parse(body);
|
|
79
|
+
} catch {
|
|
80
|
+
recordActiveRequest(-1);
|
|
81
|
+
errorResponse(res, 400, 'Invalid JSON in request body', 'invalid_request_error');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!request.messages || !Array.isArray(request.messages) || request.messages.length === 0) {
|
|
86
|
+
recordActiveRequest(-1);
|
|
87
|
+
errorResponse(res, 400, 'messages is required and must be a non-empty array', 'invalid_request_error');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const model = request.model || 'auto';
|
|
92
|
+
const stream = request.stream || false;
|
|
93
|
+
const requestId = ctx.requestId;
|
|
94
|
+
const promptForRouting = request.messages.map((m) => m.content).join(' ');
|
|
95
|
+
|
|
96
|
+
const mapping = resolveModel(model, promptForRouting);
|
|
97
|
+
if (!mapping) {
|
|
98
|
+
recordActiveRequest(-1);
|
|
99
|
+
errorResponse(res, 503, `No provider available for model "${model}". Configure API keys.`, 'server_error');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const startTime = Date.now();
|
|
104
|
+
|
|
105
|
+
if (stream) {
|
|
106
|
+
try {
|
|
107
|
+
const { streamProviderResponse } = await import('../proxyServer');
|
|
108
|
+
await streamProviderResponse(res, mapping, request.messages,
|
|
109
|
+
{ temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
|
|
110
|
+
requestId);
|
|
111
|
+
const latencyMs = Date.now() - startTime;
|
|
112
|
+
recordRequest({ endpoint: '/v1/chat/completions', provider: mapping.providerId, status: 'success', durationMs: latencyMs, model });
|
|
113
|
+
recordActiveRequest(-1);
|
|
114
|
+
} catch (err: unknown) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
if (!res.headersSent) errorResponse(res, 500, message);
|
|
117
|
+
recordProviderError(mapping.providerId, 'stream_error');
|
|
118
|
+
recordActiveRequest(-1);
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Non-streaming path
|
|
124
|
+
try {
|
|
125
|
+
const { callWithFallback } = await import('../proxyServer');
|
|
126
|
+
const { result, mapping: usedMapping } = await callWithFallback(
|
|
127
|
+
model, request.messages,
|
|
128
|
+
{ temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
|
|
129
|
+
promptForRouting
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const latencyMs = Date.now() - startTime;
|
|
133
|
+
const inputCost = (result.usage.prompt_tokens / 1000) * usedMapping.costPerK.input;
|
|
134
|
+
const outputCost = (result.usage.completion_tokens / 1000) * usedMapping.costPerK.output;
|
|
135
|
+
const totalCost = inputCost + outputCost;
|
|
136
|
+
|
|
137
|
+
costTracker.record(usedMapping.providerId, usedMapping.model, result.usage.prompt_tokens, result.usage.completion_tokens);
|
|
138
|
+
|
|
139
|
+
const response = {
|
|
140
|
+
id: requestId,
|
|
141
|
+
object: 'chat.completion',
|
|
142
|
+
created: Math.floor(Date.now() / 1000),
|
|
143
|
+
model: result.model,
|
|
144
|
+
choices: [{ index: 0, message: { role: 'assistant' as const, content: result.content }, finish_reason: result.finish_reason }],
|
|
145
|
+
usage: result.usage,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
jsonResponse(res, 200, response);
|
|
149
|
+
recordRequest({
|
|
150
|
+
endpoint: '/v1/chat/completions',
|
|
151
|
+
provider: usedMapping.providerId,
|
|
152
|
+
status: 'success',
|
|
153
|
+
durationMs: latencyMs,
|
|
154
|
+
tokensIn: result.usage.prompt_tokens,
|
|
155
|
+
tokensOut: result.usage.completion_tokens,
|
|
156
|
+
cost: totalCost,
|
|
157
|
+
model,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
console.log(`[a3m-router] ${requestId} chat model=${model}→${usedMapping.providerId}/${usedMapping.model} latency=${latencyMs}ms tokens=${result.usage.total_tokens} cost=$${totalCost.toFixed(6)}`);
|
|
161
|
+
} catch (err: unknown) {
|
|
162
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
163
|
+
const latencyMs = Date.now() - startTime;
|
|
164
|
+
recordRequest({ endpoint: '/v1/chat/completions', provider: mapping.providerId, status: 'error', durationMs: latencyMs, model });
|
|
165
|
+
recordProviderError(mapping.providerId, message.slice(0, 50));
|
|
166
|
+
console.error(`[a3m-router] ${requestId} ERROR model=${model}→${mapping.providerId}/${mapping.model} latency=${latencyMs}ms error=${message}`);
|
|
167
|
+
if (!res.headersSent) errorResponse(res, 502, message, 'upstream_error');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
recordActiveRequest(-1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export default handleChatCompletions;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3M Router - Completions Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles POST /v1/completions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as http from 'http';
|
|
8
|
+
import { RouteContext } from '../router';
|
|
9
|
+
import { resolveModel } from '../modelMapper';
|
|
10
|
+
import { recordRequest, recordActiveRequest } from '../metrics';
|
|
11
|
+
|
|
12
|
+
interface CompletionRequest {
|
|
13
|
+
model: string;
|
|
14
|
+
prompt: string | string[];
|
|
15
|
+
stream?: boolean;
|
|
16
|
+
temperature?: number;
|
|
17
|
+
max_tokens?: number;
|
|
18
|
+
top_p?: number;
|
|
19
|
+
echo?: boolean;
|
|
20
|
+
stop?: string | string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const chunks: Buffer[] = [];
|
|
26
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
27
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
28
|
+
req.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function jsonResponse(res: http.ServerResponse, statusCode: number, body: object): void {
|
|
33
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
34
|
+
res.end(JSON.stringify(body));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function errorResponse(res: http.ServerResponse, statusCode: number, message: string, errorType = 'server_error'): void {
|
|
38
|
+
jsonResponse(res, statusCode, { error: { message, type: errorType, code: statusCode } });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function handleCompletions(
|
|
42
|
+
req: http.IncomingMessage,
|
|
43
|
+
res: http.ServerResponse,
|
|
44
|
+
ctx: RouteContext
|
|
45
|
+
): Promise<void> {
|
|
46
|
+
recordActiveRequest(1);
|
|
47
|
+
const body = await readBody(req);
|
|
48
|
+
let request: CompletionRequest;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
request = JSON.parse(body);
|
|
52
|
+
} catch {
|
|
53
|
+
recordActiveRequest(-1);
|
|
54
|
+
errorResponse(res, 400, 'Invalid JSON in request body', 'invalid_request_error');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const prompts = Array.isArray(request.prompt) ? request.prompt : [request.prompt || ''];
|
|
59
|
+
const messages = prompts.map((p) => ({ role: 'user' as const, content: p }));
|
|
60
|
+
const model = request.model || 'auto';
|
|
61
|
+
const stream = request.stream || false;
|
|
62
|
+
const requestId = ctx.requestId;
|
|
63
|
+
const promptForRouting = prompts.join(' ');
|
|
64
|
+
|
|
65
|
+
const mapping = resolveModel(model, promptForRouting);
|
|
66
|
+
if (!mapping) {
|
|
67
|
+
recordActiveRequest(-1);
|
|
68
|
+
errorResponse(res, 503, `No provider available for model "${model}".`, 'server_error');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const startTime = Date.now();
|
|
73
|
+
|
|
74
|
+
if (stream) {
|
|
75
|
+
try {
|
|
76
|
+
const { streamProviderResponse } = await import('../proxyServer');
|
|
77
|
+
await streamProviderResponse(res, mapping, messages,
|
|
78
|
+
{ temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
|
|
79
|
+
requestId);
|
|
80
|
+
const latencyMs = Date.now() - startTime;
|
|
81
|
+
recordRequest({ endpoint: '/v1/completions', provider: mapping.providerId, status: 'success', durationMs: latencyMs, model });
|
|
82
|
+
recordActiveRequest(-1);
|
|
83
|
+
} catch (err: unknown) {
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
85
|
+
if (!res.headersSent) errorResponse(res, 500, message);
|
|
86
|
+
recordActiveRequest(-1);
|
|
87
|
+
}
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const { callWithFallback } = await import('../proxyServer');
|
|
93
|
+
const { result, mapping: usedMapping } = await callWithFallback(
|
|
94
|
+
model, messages,
|
|
95
|
+
{ temperature: request.temperature, max_tokens: request.max_tokens, stop: request.stop },
|
|
96
|
+
promptForRouting
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const latencyMs = Date.now() - startTime;
|
|
100
|
+
const response = {
|
|
101
|
+
id: requestId,
|
|
102
|
+
object: 'text_completion',
|
|
103
|
+
created: Math.floor(Date.now() / 1000),
|
|
104
|
+
model: result.model,
|
|
105
|
+
choices: [{ text: result.content, index: 0, finish_reason: result.finish_reason }],
|
|
106
|
+
usage: result.usage,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
jsonResponse(res, 200, response);
|
|
110
|
+
recordRequest({ endpoint: '/v1/completions', provider: usedMapping.providerId, status: 'success', durationMs: latencyMs, tokensIn: result.usage.prompt_tokens, tokensOut: result.usage.completion_tokens, model });
|
|
111
|
+
console.log(`[a3m-router] ${requestId} completion model=${model}→${usedMapping.providerId}/${usedMapping.model} latency=${latencyMs}ms`);
|
|
112
|
+
} catch (err: unknown) {
|
|
113
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
114
|
+
if (!res.headersSent) errorResponse(res, 502, message, 'upstream_error');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
recordActiveRequest(-1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default handleCompletions;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3M Router - Embeddings Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles POST /v1/embeddings
|
|
5
|
+
* OpenAI-compatible embeddings endpoint.
|
|
6
|
+
*
|
|
7
|
+
* Routes embedding requests to the best available provider:
|
|
8
|
+
* - OpenAI (text-embedding-3-small, text-embedding-3-large, text-embedding-ada-002)
|
|
9
|
+
* - Cohere (embed-english-v3.0, embed-multilingual-v3.0)
|
|
10
|
+
* - Google (text-embedding-004)
|
|
11
|
+
* - Local Ollama (nomic-embed-text, etc.)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as http from 'http';
|
|
15
|
+
import { RouteContext } from '../router';
|
|
16
|
+
import { getAvailableProviders } from '../../providers/providerConfig';
|
|
17
|
+
import { recordRequest, recordProviderError, recordActiveRequest } from '../metrics';
|
|
18
|
+
|
|
19
|
+
interface EmbeddingsRequest {
|
|
20
|
+
model?: string;
|
|
21
|
+
input: string | string[];
|
|
22
|
+
encoding_format?: 'float' | 'base64';
|
|
23
|
+
dimensions?: number;
|
|
24
|
+
user?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ProviderAdapter {
|
|
28
|
+
call: (url: string, apiKey: string, model: string, input: string[], dimensions?: number) => Promise<number[][]>;
|
|
29
|
+
urlFor: (apiKey: string, model: string) => string;
|
|
30
|
+
modelMap: (model: string) => string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readBody(req: http.IncomingMessage): Promise<string> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const chunks: Buffer[] = [];
|
|
36
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
37
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
38
|
+
req.on('error', reject);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function jsonResponse(res: http.ServerResponse, statusCode: number, body: object): void {
|
|
43
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
44
|
+
res.end(JSON.stringify(body));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function errorResponse(res: http.ServerResponse, statusCode: number, message: string, errorType = 'server_error'): void {
|
|
48
|
+
jsonResponse(res, statusCode, { error: { message, type: errorType, code: statusCode } });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get the best available embeddings provider.
|
|
53
|
+
*/
|
|
54
|
+
function getEmbeddingsProvider(): { providerId: string; model: string; adapter: ProviderAdapter } | null {
|
|
55
|
+
const providers = getAvailableProviders();
|
|
56
|
+
|
|
57
|
+
// Priority: OpenAI > Cohere > Google > Ollama
|
|
58
|
+
const priority = ['openai', 'cohere', 'google', 'ollama'];
|
|
59
|
+
|
|
60
|
+
for (const id of priority) {
|
|
61
|
+
const provider = providers[id];
|
|
62
|
+
if (!provider || !provider.apiKey) continue;
|
|
63
|
+
|
|
64
|
+
if (id === 'openai') {
|
|
65
|
+
return {
|
|
66
|
+
providerId: 'openai',
|
|
67
|
+
model: 'text-embedding-3-small',
|
|
68
|
+
adapter: openAIAdapter,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (id === 'cohere') {
|
|
73
|
+
return {
|
|
74
|
+
providerId: 'cohere',
|
|
75
|
+
model: 'embed-english-v3.0',
|
|
76
|
+
adapter: cohereAdapter,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (id === 'google') {
|
|
81
|
+
return {
|
|
82
|
+
providerId: 'google',
|
|
83
|
+
model: 'text-embedding-004',
|
|
84
|
+
adapter: googleAdapter,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (id === 'ollama' && provider.baseUrl) {
|
|
89
|
+
return {
|
|
90
|
+
providerId: 'ollama',
|
|
91
|
+
model: 'nomic-embed-text:latest',
|
|
92
|
+
adapter: ollamaAdapter,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ============================================================
|
|
101
|
+
// PROVIDER ADAPTERS
|
|
102
|
+
// ============================================================
|
|
103
|
+
|
|
104
|
+
const openAIAdapter: ProviderAdapter = {
|
|
105
|
+
modelMap(model: string): string | null {
|
|
106
|
+
const valid = ['text-embedding-3-small', 'text-embedding-3-large', 'text-embedding-ada-002'];
|
|
107
|
+
return valid.includes(model) ? model : 'text-embedding-3-small';
|
|
108
|
+
},
|
|
109
|
+
urlFor(_apiKey: string, _model: string): string {
|
|
110
|
+
return 'https://api.openai.com/v1/embeddings';
|
|
111
|
+
},
|
|
112
|
+
async call(url, apiKey, model, input, dimensions): Promise<number[][]> {
|
|
113
|
+
const body: Record<string, unknown> = { model, input };
|
|
114
|
+
if (dimensions) body.dimensions = dimensions;
|
|
115
|
+
const resp = await fetch(url, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
});
|
|
120
|
+
const data = await resp.json() as any;
|
|
121
|
+
if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
|
|
122
|
+
return data.data.map((item: any) => item.embedding as number[]);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const cohereAdapter: ProviderAdapter = {
|
|
127
|
+
modelMap(model: string): string | null {
|
|
128
|
+
const valid = ['embed-english-v3.0', 'embed-multilingual-v3.0', 'embed-english-v2.0'];
|
|
129
|
+
return valid.includes(model) ? model : 'embed-english-v3.0';
|
|
130
|
+
},
|
|
131
|
+
urlFor(_apiKey: string, _model: string): string {
|
|
132
|
+
return 'https://api.cohere.ai/v2/embed';
|
|
133
|
+
},
|
|
134
|
+
async call(url, apiKey, model, input, _dimensions): Promise<number[][]> {
|
|
135
|
+
const resp = await fetch(url, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
|
138
|
+
body: JSON.stringify({ model, texts: input }),
|
|
139
|
+
});
|
|
140
|
+
const data = await resp.json() as any;
|
|
141
|
+
if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
|
|
142
|
+
return data.embeddings as number[][];
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const googleAdapter: ProviderAdapter = {
|
|
147
|
+
modelMap(model: string): string | null {
|
|
148
|
+
return 'text-embedding-004';
|
|
149
|
+
},
|
|
150
|
+
urlFor(apiKey: string, _model: string): string {
|
|
151
|
+
return `https://generativelanguage.googleapis.com/v1beta2/models/text-embedding-004:predict?key=${apiKey}`;
|
|
152
|
+
},
|
|
153
|
+
async call(url, _apiKey, model, input, _dimensions): Promise<number[][]> {
|
|
154
|
+
const resp = await fetch(url, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
instances: input.map((text) => ({ content: text })),
|
|
159
|
+
parameters: { outputDimensionality: 768 },
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
const data = await resp.json() as any;
|
|
163
|
+
if (data.error) throw new Error(data.error.message || JSON.stringify(data.error));
|
|
164
|
+
return data.predictions.map((p: any) => p.embeddings.values as number[]);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const ollamaAdapter: ProviderAdapter = {
|
|
169
|
+
modelMap(model: string): string | null {
|
|
170
|
+
return model || 'nomic-embed-text:latest';
|
|
171
|
+
},
|
|
172
|
+
urlFor(baseUrl: string): string {
|
|
173
|
+
return `${baseUrl}/api/embeddings`;
|
|
174
|
+
},
|
|
175
|
+
async call(url, _apiKey, model, input): Promise<number[][]> {
|
|
176
|
+
const results: number[][] = [];
|
|
177
|
+
for (const text of input) {
|
|
178
|
+
const resp = await fetch(url, {
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: { 'Content-Type': 'application/json' },
|
|
181
|
+
body: JSON.stringify({ model, prompt: text }),
|
|
182
|
+
});
|
|
183
|
+
const data = await resp.json() as any;
|
|
184
|
+
if (data.error) throw new Error(data.error);
|
|
185
|
+
results.push(data.embedding as number[]);
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// ============================================================
|
|
192
|
+
// HANDLER
|
|
193
|
+
// ============================================================
|
|
194
|
+
|
|
195
|
+
export async function handleEmbeddings(
|
|
196
|
+
req: http.IncomingMessage,
|
|
197
|
+
res: http.ServerResponse,
|
|
198
|
+
ctx: RouteContext
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
recordActiveRequest(1);
|
|
201
|
+
const body = await readBody(req);
|
|
202
|
+
let request: EmbeddingsRequest;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
request = JSON.parse(body);
|
|
206
|
+
} catch {
|
|
207
|
+
recordActiveRequest(-1);
|
|
208
|
+
errorResponse(res, 400, 'Invalid JSON in request body', 'invalid_request_error');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const inputs = Array.isArray(request.input) ? request.input : [request.input];
|
|
213
|
+
if (inputs.length === 0) {
|
|
214
|
+
recordActiveRequest(-1);
|
|
215
|
+
errorResponse(res, 400, 'input is required', 'invalid_request_error');
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const provider = getEmbeddingsProvider();
|
|
220
|
+
if (!provider) {
|
|
221
|
+
recordActiveRequest(-1);
|
|
222
|
+
errorResponse(res, 503, 'No embeddings provider available. Configure API keys for OpenAI, Cohere, or Google.', 'server_error');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const model = request.model || provider.model;
|
|
227
|
+
const adapter = provider.adapter;
|
|
228
|
+
const providers = getAvailableProviders();
|
|
229
|
+
const providerConfig = providers[provider.providerId];
|
|
230
|
+
const resolvedModel = adapter.modelMap(model) || provider.model;
|
|
231
|
+
const url = adapter.urlFor(providerConfig.apiKey || '', resolvedModel);
|
|
232
|
+
|
|
233
|
+
const startTime = Date.now();
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const embeddings = await adapter.call(url, providerConfig.apiKey || '', resolvedModel, inputs, request.dimensions);
|
|
237
|
+
const latencyMs = Date.now() - startTime;
|
|
238
|
+
|
|
239
|
+
const response = {
|
|
240
|
+
object: 'list',
|
|
241
|
+
data: embeddings.map((embedding, i) => ({
|
|
242
|
+
object: 'embedding',
|
|
243
|
+
embedding,
|
|
244
|
+
index: i,
|
|
245
|
+
})),
|
|
246
|
+
model: resolvedModel,
|
|
247
|
+
usage: {
|
|
248
|
+
prompt_tokens: 0, // Embedding APIs don't always return token counts
|
|
249
|
+
total_tokens: 0,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
jsonResponse(res, 200, response);
|
|
254
|
+
recordRequest({
|
|
255
|
+
endpoint: '/v1/embeddings',
|
|
256
|
+
provider: provider.providerId,
|
|
257
|
+
status: 'success',
|
|
258
|
+
durationMs: latencyMs,
|
|
259
|
+
model: resolvedModel,
|
|
260
|
+
});
|
|
261
|
+
} catch (err: unknown) {
|
|
262
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
263
|
+
if (!res.headersSent) errorResponse(res, 502, message, 'upstream_error');
|
|
264
|
+
recordProviderError(provider.providerId, 'embeddings_error');
|
|
265
|
+
console.error(`[a3m-router] ${ctx.requestId} embeddings ERROR ${provider.providerId}: ${message}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
recordActiveRequest(-1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export default handleEmbeddings;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3M Router - Health Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles GET /health
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as http from 'http';
|
|
8
|
+
import { RouteContext } from '../router';
|
|
9
|
+
import { getAvailableProviders } from '../../providers/providerConfig';
|
|
10
|
+
import { costTracker, requestLogs } from '../state';
|
|
11
|
+
|
|
12
|
+
function jsonResponse(res: http.ServerResponse, statusCode: number, body: object): void {
|
|
13
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
14
|
+
res.end(JSON.stringify(body));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function handleHealth(
|
|
18
|
+
_req: http.IncomingMessage,
|
|
19
|
+
res: http.ServerResponse,
|
|
20
|
+
_ctx: RouteContext
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const available = getAvailableProviders();
|
|
23
|
+
const providerStatus: Record<string, object> = {};
|
|
24
|
+
let healthyCount = 0;
|
|
25
|
+
|
|
26
|
+
for (const [id, provider] of Object.entries(available)) {
|
|
27
|
+
const hasKey = !!provider.apiKey;
|
|
28
|
+
const isAvailable = provider.type !== 'api' || hasKey;
|
|
29
|
+
providerStatus[id] = {
|
|
30
|
+
name: provider.name || id,
|
|
31
|
+
type: provider.type,
|
|
32
|
+
models: provider.models?.length || 0,
|
|
33
|
+
available: isAvailable,
|
|
34
|
+
};
|
|
35
|
+
if (isAvailable) healthyCount++;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const costSummary = costTracker.getSummary();
|
|
39
|
+
|
|
40
|
+
jsonResponse(res, 200, {
|
|
41
|
+
status: 'ok',
|
|
42
|
+
version: '2.14.60',
|
|
43
|
+
providers: {
|
|
44
|
+
total: Object.keys(available).length,
|
|
45
|
+
healthy: healthyCount,
|
|
46
|
+
details: providerStatus,
|
|
47
|
+
},
|
|
48
|
+
cost: {
|
|
49
|
+
total: costSummary.total_cost,
|
|
50
|
+
requests: costSummary.request_count,
|
|
51
|
+
},
|
|
52
|
+
uptime: process.uptime(),
|
|
53
|
+
recentRequests: requestLogs.slice(-20),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default handleHealth;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3M Router - Prometheus Metrics Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles GET /metrics
|
|
5
|
+
* Returns Prometheus-compatible metrics output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as http from 'http';
|
|
9
|
+
import { RouteContext } from '../router';
|
|
10
|
+
import { generatePrometheusMetrics } from '../metrics';
|
|
11
|
+
|
|
12
|
+
function textResponse(res: http.ServerResponse, statusCode: number, body: string): void {
|
|
13
|
+
res.writeHead(statusCode, {
|
|
14
|
+
'Content-Type': 'text/plain; version=0.0.4; charset=utf-8',
|
|
15
|
+
'Access-Control-Allow-Origin': '*',
|
|
16
|
+
'Cache-Control': 'no-cache',
|
|
17
|
+
});
|
|
18
|
+
res.end(body);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function handleMetrics(
|
|
22
|
+
_req: http.IncomingMessage,
|
|
23
|
+
res: http.ServerResponse,
|
|
24
|
+
_ctx: RouteContext
|
|
25
|
+
): void {
|
|
26
|
+
const output = generatePrometheusMetrics();
|
|
27
|
+
textResponse(res, 200, output);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default handleMetrics;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A3M Router - Models Handler
|
|
3
|
+
*
|
|
4
|
+
* Handles GET /v1/models
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as http from 'http';
|
|
8
|
+
import { RouteContext } from '../router';
|
|
9
|
+
import { listAvailableModels } from '../modelMapper';
|
|
10
|
+
|
|
11
|
+
function jsonResponse(res: http.ServerResponse, statusCode: number, body: object): void {
|
|
12
|
+
res.writeHead(statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
|
|
13
|
+
res.end(JSON.stringify(body));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function handleModels(
|
|
17
|
+
_req: http.IncomingMessage,
|
|
18
|
+
res: http.ServerResponse,
|
|
19
|
+
_ctx: RouteContext
|
|
20
|
+
): void {
|
|
21
|
+
const models = listAvailableModels();
|
|
22
|
+
jsonResponse(res, 200, { object: 'list', data: models });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default handleModels;
|