adaptive-memory-multi-model-router 2.8.0 → 2.9.1

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,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 };