adaptive-memory-multi-model-router 2.7.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,215 @@
1
+ import { Metric } from './types';
2
+
3
+ interface HistogramBucket {
4
+ count: number;
5
+ sum: number;
6
+ min: number;
7
+ max: number;
8
+ }
9
+
10
+ function formatLabels(labels: Record<string, string>): string {
11
+ const parts: string[] = [];
12
+ for (const [key, value] of Object.entries(labels)) {
13
+ parts.push(`${key}="${value}"`);
14
+ }
15
+ return parts.join(',');
16
+ }
17
+
18
+ function metricKey(name: string, labels?: Record<string, string>): string {
19
+ if (!labels) return name;
20
+ return `${name}{${formatLabels(labels)}}`;
21
+ }
22
+
23
+ class MetricsCollector {
24
+ private counters: Map<string, number> = new Map();
25
+ private gauges: Map<string, number> = new Map();
26
+ private histograms: Map<string, Map<string, HistogramBucket>> = new Map();
27
+ private createdAt: number = Date.now();
28
+
29
+ /**
30
+ * Increment counter
31
+ */
32
+ incrementCounter(name: string, labels?: Record<string, string>, value: number = 1): void {
33
+ const key = metricKey(name, labels);
34
+ this.counters.set(key, (this.counters.get(key) || 0) + value);
35
+ }
36
+
37
+ /**
38
+ * Set gauge
39
+ */
40
+ setGauge(name: string, value: number, labels?: Record<string, string>): void {
41
+ const key = metricKey(name, labels);
42
+ this.gauges.set(key, value);
43
+ }
44
+
45
+ /**
46
+ * Record histogram value
47
+ */
48
+ recordHistogram(name: string, value: number, labels?: Record<string, string>): void {
49
+ const key = metricKey(name, labels);
50
+
51
+ if (!this.histograms.has(name)) {
52
+ this.histograms.set(name, new Map());
53
+ }
54
+
55
+ const buckets = this.histograms.get(name)!;
56
+ const bucket = buckets.get(key) || { count: 0, sum: 0, min: Infinity, max: -Infinity };
57
+
58
+ bucket.count++;
59
+ bucket.sum += value;
60
+ bucket.min = Math.min(bucket.min, value);
61
+ bucket.max = Math.max(bucket.max, value);
62
+
63
+ buckets.set(key, bucket);
64
+ }
65
+
66
+ /**
67
+ * Get metrics in Prometheus format
68
+ */
69
+ getPrometheusMetrics(): string {
70
+ const lines: string[] = [];
71
+ const timestamp = Date.now();
72
+
73
+ lines.push('# HELP a3m_router_info A3M Router info');
74
+ lines.push('# TYPE a3m_router_info gauge');
75
+ lines.push('a3m_router_info{router_version="2.0.0"} 1');
76
+
77
+ // Counters
78
+ lines.push('');
79
+ lines.push('# HELP a3m_router_requests_total Total number of router requests');
80
+ lines.push('# TYPE a3m_router_requests_total counter');
81
+
82
+ for (const [key, value] of this.counters) {
83
+ if (key.startsWith('a3m_router_') || key.startsWith('a3m_')) {
84
+ lines.push(`${key} ${value} ${timestamp}`);
85
+ }
86
+ }
87
+
88
+ // Cache counters
89
+ for (const [key, value] of this.counters) {
90
+ if (key.startsWith('cache_')) {
91
+ lines.push(`${key} ${value} ${timestamp}`);
92
+ }
93
+ }
94
+
95
+ // Gauges
96
+ lines.push('');
97
+ lines.push('# HELP a3m_active_providers Number of active providers');
98
+ lines.push('# TYPE a3m_active_providers gauge');
99
+
100
+ for (const [key, value] of this.gauges) {
101
+ lines.push(`${key} ${value} ${timestamp}`);
102
+ }
103
+
104
+ // Histograms
105
+ lines.push('');
106
+ lines.push('# HELP a3m_request_latency_seconds Request latency in seconds');
107
+ lines.push('# TYPE a3m_request_latency_seconds histogram');
108
+
109
+ for (const [name, buckets] of this.histograms) {
110
+ if (name.includes('latency')) {
111
+ for (const [bucketKey, bucket] of buckets) {
112
+ const labelStr = bucketKey.includes('{') ? bucketKey.slice(name.length) : '';
113
+ lines.push(`${name}_sum${labelStr} ${bucket.sum} ${timestamp}`);
114
+ lines.push(`${name}_count${labelStr} ${bucket.count} ${timestamp}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ lines.push('');
120
+ lines.push('# HELP a3m_request_cost_cents Request cost in cents');
121
+ lines.push('# TYPE a3m_request_cost_cents histogram');
122
+
123
+ for (const [name, buckets] of this.histograms) {
124
+ if (name.includes('cost')) {
125
+ for (const [bucketKey, bucket] of buckets) {
126
+ const labelStr = bucketKey.includes('{') ? bucketKey.slice(name.length) : '';
127
+ lines.push(`${name}_sum${labelStr} ${bucket.sum} ${timestamp}`);
128
+ lines.push(`${name}_count${labelStr} ${bucket.count} ${timestamp}`);
129
+ }
130
+ }
131
+ }
132
+
133
+ return lines.join('\n');
134
+ }
135
+
136
+ /**
137
+ * Get all metrics as Metric objects
138
+ */
139
+ getMetrics(): Metric[] {
140
+ const metrics: Metric[] = [];
141
+ const timestamp = Date.now();
142
+
143
+ for (const [key, value] of this.counters) {
144
+ metrics.push({
145
+ name: key,
146
+ value,
147
+ timestamp,
148
+ labels: this.extractLabels(key),
149
+ type: 'counter',
150
+ });
151
+ }
152
+
153
+ for (const [key, value] of this.gauges) {
154
+ metrics.push({
155
+ name: key,
156
+ value,
157
+ timestamp,
158
+ labels: this.extractLabels(key),
159
+ type: 'gauge',
160
+ });
161
+ }
162
+
163
+ for (const [name, buckets] of this.histograms) {
164
+ for (const [bucketKey, bucket] of buckets) {
165
+ metrics.push({
166
+ name: bucketKey,
167
+ value: bucket.count,
168
+ timestamp,
169
+ labels: this.extractLabels(bucketKey),
170
+ type: 'histogram',
171
+ });
172
+ }
173
+ }
174
+
175
+ return metrics;
176
+ }
177
+
178
+ private extractLabels(metricKey: string): Record<string, string> {
179
+ const labels: Record<string, string> = {};
180
+ const match = metricKey.match(/\{(.+)\}/);
181
+ if (match) {
182
+ const pairs = match[1].split(',');
183
+ for (const pair of pairs) {
184
+ const [key, value] = pair.split('=');
185
+ labels[key] = value.replace(/"/g, '');
186
+ }
187
+ }
188
+ return labels;
189
+ }
190
+
191
+ /**
192
+ * Clear metrics
193
+ */
194
+ reset(): void {
195
+ this.counters.clear();
196
+ this.gauges.clear();
197
+ this.histograms.clear();
198
+ this.createdAt = Date.now();
199
+ }
200
+ }
201
+
202
+ // Singleton instance
203
+ let metricsInstance: MetricsCollector | null = null;
204
+
205
+ export function getMetrics(): MetricsCollector {
206
+ if (!metricsInstance) {
207
+ metricsInstance = new MetricsCollector();
208
+ }
209
+ return metricsInstance;
210
+ }
211
+
212
+ export function createMetricsCollector(): MetricsCollector {
213
+ metricsInstance = new MetricsCollector();
214
+ return metricsInstance;
215
+ }
@@ -0,0 +1,164 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import { getTracer, createTracer } from './tracer';
3
+ import { getMetrics, createMetricsCollector } from './metrics';
4
+
5
+ /**
6
+ * Express middleware for observability
7
+ * - Adds trace ID to all requests
8
+ * - Records request/response metrics
9
+ * - Attaches span context
10
+ */
11
+ export function observabilityMiddleware(
12
+ req: Request,
13
+ res: Response,
14
+ next: NextFunction
15
+ ): void {
16
+ const tracer = getTracer();
17
+ const metrics = getMetrics();
18
+
19
+ // Generate or extract trace ID
20
+ const traceId = (req.headers['x-trace-id'] as string) || tracer.generateTraceId();
21
+
22
+ // Attach to request object
23
+ (req as any).traceId = traceId;
24
+ (req as any).spanId = null;
25
+
26
+ // Add trace ID to response headers
27
+ res.setHeader('X-Trace-ID', traceId);
28
+
29
+ // Start span for this request
30
+ const span = tracer.startSpan(`${req.method} ${req.path}`);
31
+ (req as any).spanId = span.spanId;
32
+ (req as any).span = span;
33
+
34
+ // Record start time for latency calculation
35
+ const startTime = Date.now();
36
+
37
+ // Hook into response finish
38
+ res.on('finish', () => {
39
+ const duration = Date.now() - startTime;
40
+
41
+ // End the span
42
+ tracer.endSpan(span.spanId, {
43
+ method: req.method,
44
+ path: req.path,
45
+ statusCode: res.statusCode,
46
+ durationMs: duration,
47
+ });
48
+
49
+ // Record metrics
50
+ const model = (req as any).model || 'unknown';
51
+ const provider = (req as any).provider || 'unknown';
52
+ const cacheHit = (req as any).cacheHit || false;
53
+ const tier = (req as any).tier || 'standard';
54
+
55
+ // Request counter
56
+ metrics.incrementCounter('a3m_router_requests_total', {
57
+ model,
58
+ provider,
59
+ tier,
60
+ cache_hit: String(cacheHit),
61
+ });
62
+
63
+ // Latency histogram (convert to seconds)
64
+ metrics.recordHistogram('a3m_request_latency_seconds', duration / 1000, {
65
+ model,
66
+ provider,
67
+ });
68
+
69
+ // Error counter
70
+ if (res.statusCode >= 400) {
71
+ metrics.incrementCounter('a3m_router_errors_total', {
72
+ provider,
73
+ error_type: res.statusCode >= 500 ? 'server_error' : 'client_error',
74
+ });
75
+ }
76
+ });
77
+
78
+ next();
79
+ }
80
+
81
+ /**
82
+ * Fastify plugin for observability middleware
83
+ */
84
+ export function observabilityPlugin(
85
+ instance: any,
86
+ options: any,
87
+ next: (err?: Error) => void
88
+ ): void {
89
+ instance.addHook('onRequest', async (request: any, reply: any) => {
90
+ const tracer = getTracer();
91
+ const metrics = getMetrics();
92
+
93
+ const traceId = request.headers['x-trace-id'] || tracer.generateTraceId();
94
+ request.traceId = traceId;
95
+
96
+ reply.header('X-Trace-ID', traceId);
97
+
98
+ const span = tracer.startSpan(`${request.method} ${request.url}`);
99
+ request.spanId = span.spanId;
100
+ request.span = span;
101
+ request.startTime = Date.now();
102
+ });
103
+
104
+ instance.addHook('onResponse', async (request: any, reply: any) => {
105
+ const tracer = getTracer();
106
+ const metrics = getMetrics();
107
+
108
+ const duration = Date.now() - request.startTime;
109
+
110
+ if (request.spanId) {
111
+ tracer.endSpan(request.spanId, {
112
+ method: request.method,
113
+ path: request.url,
114
+ statusCode: reply.statusCode,
115
+ durationMs: duration,
116
+ });
117
+ }
118
+
119
+ const model = request.model || 'unknown';
120
+ const provider = request.provider || 'unknown';
121
+ const cacheHit = request.cacheHit || false;
122
+ const tier = request.tier || 'standard';
123
+
124
+ metrics.incrementCounter('a3m_router_requests_total', {
125
+ model,
126
+ provider,
127
+ tier,
128
+ cache_hit: String(cacheHit),
129
+ });
130
+
131
+ metrics.recordHistogram('a3m_request_latency_seconds', duration / 1000, {
132
+ model,
133
+ provider,
134
+ });
135
+
136
+ if (reply.statusCode >= 400) {
137
+ metrics.incrementCounter('a3m_router_errors_total', {
138
+ provider,
139
+ error_type: reply.statusCode >= 500 ? 'server_error' : 'client_error',
140
+ });
141
+ }
142
+ });
143
+
144
+ next();
145
+ }
146
+
147
+ /**
148
+ * Middleware for budget warning alerts
149
+ */
150
+ export function budgetAlertMiddleware(
151
+ req: Request,
152
+ res: Response,
153
+ next: NextFunction
154
+ ): void {
155
+ const tracer = getTracer();
156
+
157
+ // Hook into cost tracking to emit budget warnings
158
+ tracer.on('route_complete', (trace: any) => {
159
+ // This would be connected to budget enforcement in practice
160
+ // For now just a placeholder for the event hook
161
+ });
162
+
163
+ next();
164
+ }
@@ -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 };