adaptive-memory-multi-model-router 2.8.0 → 2.9.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.
@@ -0,0 +1,229 @@
1
+ import { EventEmitter } from 'events';
2
+ import { Span, RouteTrace, ObservabilityEvent } from './types';
3
+
4
+ // Langfuse integration interface
5
+ interface LangfuseConfig {
6
+ publicKey?: string;
7
+ secretKey?: string;
8
+ baseUrl?: string;
9
+ }
10
+
11
+ interface LangfuseClient {
12
+ trace: (data: any) => Promise<void>;
13
+ flush: () => Promise<void>;
14
+ }
15
+
16
+ function generateId(): string {
17
+ return Math.random().toString(36).substring(2, 15) +
18
+ Math.random().toString(36).substring(2, 15);
19
+ }
20
+
21
+ class Tracer extends EventEmitter {
22
+ private traces: Map<string, Span> = new Map();
23
+ private routeTraces: RouteTrace[] = [];
24
+ private langfuseClient?: LangfuseClient;
25
+ private langfuseConfig?: LangfuseConfig;
26
+
27
+ constructor() {
28
+ super();
29
+ }
30
+
31
+ /**
32
+ * Initialize Langfuse if configured
33
+ */
34
+ initLangfuse(config: LangfuseConfig): void {
35
+ if (!config.publicKey || !config.secretKey) {
36
+ console.warn('[Tracer] Langfuse keys not provided, running without Langfuse');
37
+ return;
38
+ }
39
+
40
+ this.langfuseConfig = config;
41
+
42
+ // Simple Langfuse REST client
43
+ this.langfuseClient = {
44
+ trace: async (data: any) => {
45
+ try {
46
+ const response = await fetch(`${config.baseUrl || 'https://cloud.langfuse.com'}/api/public/ingestion`, {
47
+ method: 'POST',
48
+ headers: {
49
+ 'Content-Type': 'application/json',
50
+ 'Authorization': `Bearer ${config.publicKey}`,
51
+ },
52
+ body: JSON.stringify(data),
53
+ });
54
+ if (!response.ok) {
55
+ console.warn('[Tracer] Langfuse ingestion failed:', response.status);
56
+ }
57
+ } catch (err) {
58
+ console.warn('[Tracer] Langfuse error:', err);
59
+ }
60
+ },
61
+ flush: async () => {
62
+ // Langfuse uses async ingestion, noop here
63
+ },
64
+ };
65
+
66
+ console.log('[Tracer] Langfuse initialized');
67
+ }
68
+
69
+ /**
70
+ * Create a new trace span
71
+ */
72
+ startSpan(operationName: string, parentSpanId?: string): Span {
73
+ const traceId = parentSpanId
74
+ ? this.traces.get(parentSpanId)?.traceId || this.generateTraceId()
75
+ : this.generateTraceId();
76
+
77
+ const span: Span = {
78
+ traceId,
79
+ spanId: this.generateTraceId(),
80
+ parentSpanId,
81
+ operationName,
82
+ startTime: Date.now(),
83
+ attributes: {},
84
+ status: 'started',
85
+ };
86
+
87
+ this.traces.set(span.spanId, span);
88
+ this.emit('span_started', span);
89
+ this.emit('observability_event', { type: 'span_started', span } as ObservabilityEvent);
90
+
91
+ return span;
92
+ }
93
+
94
+ /**
95
+ * End a span
96
+ */
97
+ endSpan(spanId: string, attributes?: Record<string, any>): void {
98
+ const span = this.traces.get(spanId);
99
+ if (!span) {
100
+ console.warn(`[Tracer] span ${spanId} not found`);
101
+ return;
102
+ }
103
+
104
+ span.endTime = Date.now();
105
+ span.duration = span.endTime - span.startTime;
106
+ span.status = 'completed';
107
+
108
+ if (attributes) {
109
+ span.attributes = { ...span.attributes, ...attributes };
110
+ }
111
+
112
+ this.emit('span_completed', span);
113
+ this.emit('observability_event', { type: 'span_completed', span } as ObservabilityEvent);
114
+ }
115
+
116
+ /**
117
+ * Record an error on a span
118
+ */
119
+ errorSpan(spanId: string, error: string, attributes?: Record<string, any>): void {
120
+ const span = this.traces.get(spanId);
121
+ if (!span) {
122
+ console.warn(`[Tracer] span ${spanId} not found`);
123
+ return;
124
+ }
125
+
126
+ span.endTime = Date.now();
127
+ span.duration = span.endTime - span.startTime;
128
+ span.status = 'error';
129
+ span.error = error;
130
+
131
+ if (attributes) {
132
+ span.attributes = { ...span.attributes, ...attributes };
133
+ }
134
+
135
+ this.emit('error', spanId, error);
136
+ this.emit('observability_event', { type: 'error', spanId, error } as ObservabilityEvent);
137
+ }
138
+
139
+ /**
140
+ * Record a route decision
141
+ */
142
+ recordRoute(trace: RouteTrace): void {
143
+ this.routeTraces.push(trace);
144
+
145
+ // Emit event
146
+ this.emit('route_complete', trace);
147
+ this.emit('observability_event', { type: 'route_complete', trace } as ObservabilityEvent);
148
+
149
+ // Export to Langfuse async
150
+ if (this.langfuseClient) {
151
+ this.langfuseClient.trace({
152
+ name: 'route_decision',
153
+ traceId: trace.traceId,
154
+ timestamp: new Date(trace.timestamp).toISOString(),
155
+ input: { query: trace.query },
156
+ output: {
157
+ model: trace.model,
158
+ provider: trace.provider,
159
+ tokens: trace.queryTokens + trace.responseTokens,
160
+ latencyMs: trace.latencyMs,
161
+ cost: trace.cost,
162
+ },
163
+ metadata: {
164
+ queryTokens: trace.queryTokens,
165
+ responseTokens: trace.responseTokens,
166
+ cacheHit: trace.cacheHit,
167
+ complexity: trace.complexity,
168
+ tier: trace.tier,
169
+ },
170
+ }).catch(() => {}); // Fire and forget
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Generate a unique trace ID
176
+ */
177
+ generateTraceId(): string {
178
+ return generateId();
179
+ }
180
+
181
+ /**
182
+ * Get all route traces
183
+ */
184
+ getTraces(limit?: number): RouteTrace[] {
185
+ if (limit) {
186
+ return this.routeTraces.slice(-limit);
187
+ }
188
+ return [...this.routeTraces];
189
+ }
190
+
191
+ /**
192
+ * Get all spans for a trace
193
+ */
194
+ getSpans(traceId: string): Span[] {
195
+ return Array.from(this.traces.values()).filter(s => s.traceId === traceId);
196
+ }
197
+
198
+ /**
199
+ * Export to Langfuse (async, non-blocking)
200
+ */
201
+ async flushToLangfuse(): Promise<void> {
202
+ if (this.langfuseClient) {
203
+ await this.langfuseClient.flush();
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Clear all traces (for testing)
209
+ */
210
+ reset(): void {
211
+ this.traces.clear();
212
+ this.routeTraces = [];
213
+ }
214
+ }
215
+
216
+ // Singleton instance
217
+ let tracerInstance: Tracer | null = null;
218
+
219
+ export function getTracer(): Tracer {
220
+ if (!tracerInstance) {
221
+ tracerInstance = new Tracer();
222
+ }
223
+ return tracerInstance;
224
+ }
225
+
226
+ export function createTracer(): Tracer {
227
+ tracerInstance = new Tracer();
228
+ return tracerInstance;
229
+ }
@@ -0,0 +1,45 @@
1
+ // Observability types for A3M Router
2
+
3
+ export interface Span {
4
+ traceId: string;
5
+ spanId: string;
6
+ parentSpanId?: string;
7
+ operationName: string;
8
+ startTime: number;
9
+ endTime?: number;
10
+ duration?: number;
11
+ attributes: Record<string, string | number | boolean>;
12
+ status: 'started' | 'completed' | 'error';
13
+ error?: string;
14
+ }
15
+
16
+ export interface Metric {
17
+ name: string;
18
+ value: number;
19
+ timestamp: number;
20
+ labels: Record<string, string>;
21
+ type: 'counter' | 'gauge' | 'histogram';
22
+ }
23
+
24
+ export interface RouteTrace {
25
+ traceId: string;
26
+ timestamp: number;
27
+ query: string;
28
+ queryTokens: number;
29
+ model: string;
30
+ provider: string;
31
+ responseTokens: number;
32
+ latencyMs: number;
33
+ cost: number;
34
+ cacheHit: boolean;
35
+ complexity: number;
36
+ tier: 'budget' | 'standard' | 'premium';
37
+ }
38
+
39
+ // Event types emitted by Tracer
40
+ export type ObservabilityEvent =
41
+ | { type: 'route_complete'; trace: RouteTrace }
42
+ | { type: 'span_started'; span: Span }
43
+ | { type: 'span_completed'; span: Span }
44
+ | { type: 'budget_warning'; apiKey: string; usedCents: number; thresholdCents: number }
45
+ | { type: 'error'; spanId: string; error: string };
@@ -0,0 +1,390 @@
1
+ // Observability tests
2
+
3
+ import {
4
+ getTracer,
5
+ createTracer,
6
+ getMetrics,
7
+ createMetricsCollector,
8
+ Tracer,
9
+ MetricsCollector,
10
+ RouteTrace,
11
+ } from '../src/observability';
12
+
13
+ // Use a separate tracer instance for tests
14
+ function createTestTracer(): Tracer {
15
+ const tracer = new Tracer();
16
+ return tracer;
17
+ }
18
+
19
+ function createTestMetrics(): MetricsCollector {
20
+ return new MetricsCollector();
21
+ }
22
+
23
+ describe('Tracer', () => {
24
+ let tracer: Tracer;
25
+
26
+ beforeEach(() => {
27
+ tracer = createTestTracer();
28
+ });
29
+
30
+ afterEach(() => {
31
+ tracer.reset();
32
+ });
33
+
34
+ describe('startSpan', () => {
35
+ it('should create a new span', () => {
36
+ const span = tracer.startSpan('test_operation');
37
+
38
+ expect(span).toBeDefined();
39
+ expect(span.spanId).toBeDefined();
40
+ expect(span.traceId).toBeDefined();
41
+ expect(span.operationName).toBe('test_operation');
42
+ expect(span.status).toBe('started');
43
+ expect(span.startTime).toBeDefined();
44
+ expect(span.attributes).toEqual({});
45
+ });
46
+
47
+ it('should create span with parent', () => {
48
+ const parent = tracer.startSpan('parent_op');
49
+ const child = tracer.startSpan('child_op', parent.spanId);
50
+
51
+ expect(child.parentSpanId).toBe(parent.spanId);
52
+ expect(child.traceId).toBe(parent.traceId);
53
+ });
54
+
55
+ it('should emit span_started event', (done) => {
56
+ tracer.on('span_started', (span) => {
57
+ expect(span.operationName).toBe('test_op');
58
+ done();
59
+ });
60
+ tracer.startSpan('test_op');
61
+ });
62
+ });
63
+
64
+ describe('endSpan', () => {
65
+ it('should end a span with duration', () => {
66
+ const span = tracer.startSpan('test_op');
67
+ tracer.endSpan(span.spanId);
68
+
69
+ const ended = tracer.getSpans(span.traceId)[0];
70
+ expect(ended.status).toBe('completed');
71
+ expect(ended.duration).toBeDefined();
72
+ expect(ended.endTime).toBeDefined();
73
+ });
74
+
75
+ it('should merge attributes on end', () => {
76
+ const span = tracer.startSpan('test_op');
77
+ tracer.endSpan(span.spanId, { custom_attr: 'value' });
78
+
79
+ const ended = tracer.getSpans(span.traceId)[0];
80
+ expect(ended.attributes.custom_attr).toBe('value');
81
+ });
82
+
83
+ it('should emit span_completed event', (done) => {
84
+ tracer.on('span_completed', (span) => {
85
+ expect(span.status).toBe('completed');
86
+ done();
87
+ });
88
+ const span = tracer.startSpan('test_op');
89
+ tracer.endSpan(span.spanId);
90
+ });
91
+ });
92
+
93
+ describe('errorSpan', () => {
94
+ it('should mark span as error', () => {
95
+ const span = tracer.startSpan('test_op');
96
+ tracer.errorSpan(span.spanId, 'something went wrong');
97
+
98
+ const errored = tracer.getSpans(span.traceId)[0];
99
+ expect(errored.status).toBe('error');
100
+ expect(errored.error).toBe('something went wrong');
101
+ });
102
+ });
103
+
104
+ describe('recordRoute', () => {
105
+ it('should record a route trace', () => {
106
+ const trace: RouteTrace = {
107
+ traceId: 'trace-123',
108
+ timestamp: Date.now(),
109
+ query: 'Hello world',
110
+ queryTokens: 5,
111
+ model: 'gpt-4o',
112
+ provider: 'openai',
113
+ responseTokens: 100,
114
+ latencyMs: 500,
115
+ cost: 0.03,
116
+ cacheHit: false,
117
+ complexity: 0.5,
118
+ tier: 'premium',
119
+ };
120
+
121
+ tracer.recordRoute(trace);
122
+
123
+ const traces = tracer.getTraces();
124
+ expect(traces).toHaveLength(1);
125
+ expect(traces[0].model).toBe('gpt-4o');
126
+ });
127
+
128
+ it('should emit route_complete event', (done) => {
129
+ tracer.on('route_complete', (trace) => {
130
+ expect(trace.model).toBe('test-model');
131
+ done();
132
+ });
133
+
134
+ tracer.recordRoute({
135
+ traceId: 'test',
136
+ timestamp: Date.now(),
137
+ query: 'test',
138
+ queryTokens: 1,
139
+ model: 'test-model',
140
+ provider: 'test-provider',
141
+ responseTokens: 1,
142
+ latencyMs: 100,
143
+ cost: 0.01,
144
+ cacheHit: false,
145
+ complexity: 0.1,
146
+ tier: 'standard',
147
+ });
148
+ });
149
+ });
150
+
151
+ describe('generateTraceId', () => {
152
+ it('should generate unique IDs', () => {
153
+ const id1 = tracer.generateTraceId();
154
+ const id2 = tracer.generateTraceId();
155
+ expect(id1).not.toBe(id2);
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('MetricsCollector', () => {
161
+ let metrics: MetricsCollector;
162
+
163
+ beforeEach(() => {
164
+ metrics = createTestMetrics();
165
+ });
166
+
167
+ afterEach(() => {
168
+ metrics.reset();
169
+ });
170
+
171
+ describe('incrementCounter', () => {
172
+ it('should increment a counter', () => {
173
+ metrics.incrementCounter('test_counter');
174
+ metrics.incrementCounter('test_counter');
175
+
176
+ const allMetrics = metrics.getMetrics();
177
+ const counter = allMetrics.find(m => m.name.includes('test_counter'));
178
+ expect(counter?.value).toBe(2);
179
+ });
180
+
181
+ it('should handle labels', () => {
182
+ metrics.incrementCounter('requests_total', { model: 'gpt-4', provider: 'openai' });
183
+ metrics.incrementCounter('requests_total', { model: 'gpt-4', provider: 'openai' });
184
+ metrics.incrementCounter('requests_total', { model: 'claude-3', provider: 'anthropic' });
185
+
186
+ const allMetrics = metrics.getMetrics();
187
+ const gpt4Metrics = allMetrics.filter(m =>
188
+ m.labels.model === 'gpt-4' && m.labels.provider === 'openai'
189
+ );
190
+ const claudeMetrics = allMetrics.filter(m => m.labels.model === 'claude-3');
191
+
192
+ expect(gpt4Metrics[0]?.value).toBe(2);
193
+ expect(claudeMetrics[0]?.value).toBe(1);
194
+ });
195
+ });
196
+
197
+ describe('setGauge', () => {
198
+ it('should set a gauge value', () => {
199
+ metrics.setGauge('active_providers', 5);
200
+ metrics.setGauge('active_providers', 6);
201
+
202
+ const allMetrics = metrics.getMetrics();
203
+ const gauge = allMetrics.find(m => m.name.includes('active_providers'));
204
+ expect(gauge?.value).toBe(6);
205
+ });
206
+ });
207
+
208
+ describe('recordHistogram', () => {
209
+ it('should record histogram values', () => {
210
+ metrics.recordHistogram('latency', 0.5);
211
+ metrics.recordHistogram('latency', 1.0);
212
+ metrics.recordHistogram('latency', 1.5);
213
+
214
+ const allMetrics = metrics.getMetrics();
215
+ const histogram = allMetrics.find(m => m.type === 'histogram');
216
+ expect(histogram).toBeDefined();
217
+ });
218
+ });
219
+
220
+ describe('getPrometheusMetrics', () => {
221
+ it('should output Prometheus format', () => {
222
+ metrics.setGauge('a3m_active_providers', 5, { provider: 'openai' });
223
+ metrics.incrementCounter('a3m_router_requests_total', { model: 'gpt-4', provider: 'openai' });
224
+ metrics.recordHistogram('a3m_request_latency_seconds', 0.5);
225
+
226
+ const output = metrics.getPrometheusMetrics();
227
+
228
+ expect(output).toContain('# HELP');
229
+ expect(output).toContain('# TYPE');
230
+ expect(output).toContain('a3m_active_providers');
231
+ expect(output).toContain('a3m_router_requests_total');
232
+ expect(output).toContain('a3m_request_latency_seconds');
233
+ });
234
+
235
+ it('should include router info', () => {
236
+ const output = metrics.getPrometheusMetrics();
237
+ expect(output).toContain('a3m_router_info');
238
+ });
239
+
240
+ it('should include timestamp', () => {
241
+ const output = metrics.getPrometheusMetrics();
242
+ // Should end with a timestamp number
243
+ const lines = output.trim().split('\n');
244
+ const lastLine = lines[lines.length - 1];
245
+ const parts = lastLine.split(' ');
246
+ const lastValue = parts[parts.length - 1];
247
+ expect(Number(lastValue)).toBeGreaterThan(0);
248
+ });
249
+ });
250
+
251
+ describe('getMetrics', () => {
252
+ it('should return all metrics as objects', () => {
253
+ metrics.incrementCounter('counter_test', { label: 'value' });
254
+ metrics.setGauge('gauge_test', 42);
255
+ metrics.recordHistogram('histogram_test', 1.0);
256
+
257
+ const allMetrics = metrics.getMetrics();
258
+
259
+ expect(allMetrics.length).toBeGreaterThan(0);
260
+ expect(allMetrics.some(m => m.type === 'counter')).toBe(true);
261
+ expect(allMetrics.some(m => m.type === 'gauge')).toBe(true);
262
+ expect(allMetrics.some(m => m.type === 'histogram')).toBe(true);
263
+ });
264
+
265
+ it('should include timestamp', () => {
266
+ metrics.incrementCounter('test_metric');
267
+ const allMetrics = metrics.getMetrics();
268
+
269
+ expect(allMetrics[0].timestamp).toBeGreaterThan(0);
270
+ });
271
+ });
272
+
273
+ describe('reset', () => {
274
+ it('should clear all metrics', () => {
275
+ metrics.incrementCounter('test_counter');
276
+ metrics.setGauge('test_gauge', 10);
277
+ metrics.recordHistogram('test_histogram', 1.0);
278
+
279
+ metrics.reset();
280
+
281
+ const allMetrics = metrics.getMetrics();
282
+ expect(allMetrics).toHaveLength(0);
283
+ });
284
+ });
285
+ });
286
+
287
+ describe('Integration', () => {
288
+ it('should track route metrics end-to-end', () => {
289
+ const tracer = createTestTracer();
290
+ const metrics = createTestMetrics();
291
+
292
+ const trace: RouteTrace = {
293
+ traceId: 'e2e-trace',
294
+ timestamp: Date.now(),
295
+ query: 'Hello AI',
296
+ queryTokens: 3,
297
+ model: 'gpt-4o',
298
+ provider: 'openai',
299
+ responseTokens: 50,
300
+ latencyMs: 250,
301
+ cost: 0.015,
302
+ cacheHit: false,
303
+ complexity: 0.3,
304
+ tier: 'premium',
305
+ };
306
+
307
+ // Record the route
308
+ tracer.recordRoute(trace);
309
+
310
+ // Record metrics for this route
311
+ metrics.incrementCounter('a3m_router_requests_total', {
312
+ model: trace.model,
313
+ provider: trace.provider,
314
+ tier: trace.tier,
315
+ cache_hit: String(trace.cacheHit),
316
+ });
317
+ metrics.recordHistogram('a3m_request_latency_seconds', trace.latencyMs / 1000, {
318
+ model: trace.model,
319
+ provider: trace.provider,
320
+ });
321
+ metrics.recordHistogram('a3m_request_cost_cents', trace.cost * 100, {
322
+ model: trace.model,
323
+ provider: trace.provider,
324
+ });
325
+
326
+ // Verify trace recorded
327
+ const traces = tracer.getTraces();
328
+ expect(traces).toHaveLength(1);
329
+ expect(traces[0].cost).toBe(0.015);
330
+
331
+ // Verify metrics recorded
332
+ const allMetrics = metrics.getMetrics();
333
+ const requestCounter = allMetrics.find(
334
+ m => m.name.includes('a3m_router_requests_total')
335
+ );
336
+ expect(requestCounter?.value).toBe(1);
337
+
338
+ const latencyHist = allMetrics.find(
339
+ m => m.name.includes('a3m_request_latency_seconds')
340
+ );
341
+ expect(latencyHist).toBeDefined();
342
+ });
343
+
344
+ it('should track cache hit/miss', () => {
345
+ const tracer = createTestTracer();
346
+ const metrics = createTestMetrics();
347
+
348
+ // Cache hit
349
+ tracer.recordRoute({
350
+ traceId: 'cache-hit',
351
+ timestamp: Date.now(),
352
+ query: 'cached query',
353
+ queryTokens: 10,
354
+ model: 'gpt-4o-mini',
355
+ provider: 'openai',
356
+ responseTokens: 20,
357
+ latencyMs: 50,
358
+ cost: 0,
359
+ cacheHit: true,
360
+ complexity: 0.2,
361
+ tier: 'budget',
362
+ });
363
+
364
+ // Cache miss
365
+ tracer.recordRoute({
366
+ traceId: 'cache-miss',
367
+ timestamp: Date.now(),
368
+ query: 'new query',
369
+ queryTokens: 10,
370
+ model: 'gpt-4o',
371
+ provider: 'openai',
372
+ responseTokens: 100,
373
+ latencyMs: 500,
374
+ cost: 0.02,
375
+ cacheHit: false,
376
+ complexity: 0.5,
377
+ tier: 'premium',
378
+ });
379
+
380
+ metrics.incrementCounter('a3m_cache_hits_total');
381
+ metrics.incrementCounter('a3m_cache_misses_total');
382
+
383
+ const allMetrics = metrics.getMetrics();
384
+ const hits = allMetrics.find(m => m.name === 'a3m_cache_hits_total');
385
+ const misses = allMetrics.find(m => m.name === 'a3m_cache_misses_total');
386
+
387
+ expect(hits?.value).toBe(1);
388
+ expect(misses?.value).toBe(1);
389
+ });
390
+ });