@mtharrison/loupe 1.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.
@@ -0,0 +1,17 @@
1
+ import { type ChatModelLike, type LocalLLMTracer, type TraceConfig, type TraceContext, type TraceRequest } from './types';
2
+ export type { ChatModelLike, HierarchyNode, HierarchyResponse, LocalLLMTracer, NormalizedTraceContext, TraceConfig, TraceContext, TraceEvent, TraceFilters, TraceHierarchy, TraceListResponse, TraceMode, TraceRecord, TraceRequest, TraceServer, TraceStatus, TraceSummary, TraceTags, UIReloadEvent, } from './types';
3
+ export declare function isTraceEnabled(): boolean;
4
+ export declare function getLocalLLMTracer(config?: TraceConfig): LocalLLMTracer;
5
+ export declare function startTraceServer(config?: TraceConfig): Promise<{
6
+ host: string;
7
+ port: number;
8
+ url: string;
9
+ }>;
10
+ export declare function recordInvokeStart(context: TraceContext, request: TraceRequest, config?: TraceConfig): string;
11
+ export declare function recordInvokeFinish(traceId: string, response: unknown, config?: TraceConfig): void;
12
+ export declare function recordStreamStart(context: TraceContext, request: TraceRequest, config?: TraceConfig): string;
13
+ export declare function recordStreamChunk(traceId: string, chunk: unknown, config?: TraceConfig): void;
14
+ export declare function recordStreamFinish(traceId: string, chunk: unknown, config?: TraceConfig): void;
15
+ export declare function recordError(traceId: string, error: unknown, config?: TraceConfig): void;
16
+ export declare function __resetLocalLLMTracerForTests(): void;
17
+ export declare function wrapChatModel<TModel extends ChatModelLike<TInput, TOptions, TValue, TChunk>, TInput = any, TOptions = any, TValue = any, TChunk = any>(model: TModel, getContext: () => TraceContext, config?: TraceConfig): TModel;
package/dist/index.js ADDED
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isTraceEnabled = isTraceEnabled;
4
+ exports.getLocalLLMTracer = getLocalLLMTracer;
5
+ exports.startTraceServer = startTraceServer;
6
+ exports.recordInvokeStart = recordInvokeStart;
7
+ exports.recordInvokeFinish = recordInvokeFinish;
8
+ exports.recordStreamStart = recordStreamStart;
9
+ exports.recordStreamChunk = recordStreamChunk;
10
+ exports.recordStreamFinish = recordStreamFinish;
11
+ exports.recordError = recordError;
12
+ exports.__resetLocalLLMTracerForTests = __resetLocalLLMTracerForTests;
13
+ exports.wrapChatModel = wrapChatModel;
14
+ const server_1 = require("./server");
15
+ const store_1 = require("./store");
16
+ const ui_build_1 = require("./ui-build");
17
+ const utils_1 = require("./utils");
18
+ let singleton = null;
19
+ function isTraceEnabled() {
20
+ return (0, utils_1.envFlag)('LLM_TRACE_ENABLED');
21
+ }
22
+ function getLocalLLMTracer(config = {}) {
23
+ if (!singleton) {
24
+ singleton = new LocalLLMTracerImpl(config);
25
+ }
26
+ else if (config && Object.keys(config).length > 0) {
27
+ singleton.configure(config);
28
+ }
29
+ return singleton;
30
+ }
31
+ function startTraceServer(config = {}) {
32
+ return getLocalLLMTracer(config).startServer();
33
+ }
34
+ function recordInvokeStart(context, request, config = {}) {
35
+ return getLocalLLMTracer(config).recordInvokeStart(context, request);
36
+ }
37
+ function recordInvokeFinish(traceId, response, config = {}) {
38
+ getLocalLLMTracer(config).recordInvokeFinish(traceId, response);
39
+ }
40
+ function recordStreamStart(context, request, config = {}) {
41
+ return getLocalLLMTracer(config).recordStreamStart(context, request);
42
+ }
43
+ function recordStreamChunk(traceId, chunk, config = {}) {
44
+ getLocalLLMTracer(config).recordStreamChunk(traceId, chunk);
45
+ }
46
+ function recordStreamFinish(traceId, chunk, config = {}) {
47
+ getLocalLLMTracer(config).recordStreamFinish(traceId, chunk);
48
+ }
49
+ function recordError(traceId, error, config = {}) {
50
+ getLocalLLMTracer(config).recordError(traceId, error);
51
+ }
52
+ function __resetLocalLLMTracerForTests() {
53
+ if (singleton?.uiWatcher) {
54
+ void singleton.uiWatcher.stop();
55
+ }
56
+ if (singleton?.server) {
57
+ singleton.server.close();
58
+ }
59
+ singleton = null;
60
+ }
61
+ function wrapChatModel(model, getContext, config) {
62
+ if (!model || typeof model.invoke !== 'function' || typeof model.stream !== 'function') {
63
+ throw new TypeError('wrapChatModel expects a ChatModel-compatible object.');
64
+ }
65
+ return {
66
+ async invoke(input, options) {
67
+ const tracer = getLocalLLMTracer(config);
68
+ if (!tracer.isEnabled()) {
69
+ return model.invoke(input, options);
70
+ }
71
+ const traceId = tracer.recordInvokeStart(getContext ? getContext() : {}, { input: input, options: options });
72
+ try {
73
+ const response = await model.invoke(input, options);
74
+ tracer.recordInvokeFinish(traceId, response);
75
+ return response;
76
+ }
77
+ catch (error) {
78
+ tracer.recordError(traceId, error);
79
+ throw error;
80
+ }
81
+ },
82
+ async *stream(input, options) {
83
+ const tracer = getLocalLLMTracer(config);
84
+ if (!tracer.isEnabled()) {
85
+ yield* model.stream(input, options);
86
+ return;
87
+ }
88
+ const traceId = tracer.recordStreamStart(getContext ? getContext() : {}, { input: input, options: options });
89
+ try {
90
+ const stream = model.stream(input, options);
91
+ for await (const chunk of stream) {
92
+ if (chunk?.type === 'finish') {
93
+ tracer.recordStreamFinish(traceId, chunk);
94
+ }
95
+ else {
96
+ tracer.recordStreamChunk(traceId, chunk);
97
+ }
98
+ yield chunk;
99
+ }
100
+ }
101
+ catch (error) {
102
+ tracer.recordError(traceId, error);
103
+ throw error;
104
+ }
105
+ },
106
+ };
107
+ }
108
+ class LocalLLMTracerImpl {
109
+ config;
110
+ loggedUrl;
111
+ server;
112
+ serverFailed;
113
+ serverInfo;
114
+ serverStartPromise;
115
+ store;
116
+ uiWatcher;
117
+ constructor(config = {}) {
118
+ this.config = {
119
+ host: '127.0.0.1',
120
+ maxTraces: 1000,
121
+ port: 4319,
122
+ uiHotReload: false,
123
+ };
124
+ this.configure(config);
125
+ this.store = new store_1.TraceStore({ maxTraces: this.config.maxTraces });
126
+ this.server = null;
127
+ this.serverInfo = null;
128
+ this.serverStartPromise = null;
129
+ this.serverFailed = false;
130
+ this.loggedUrl = false;
131
+ this.uiWatcher = null;
132
+ }
133
+ configure(config = {}) {
134
+ if (this.serverInfo && (config.host || config.port)) {
135
+ return;
136
+ }
137
+ this.config = {
138
+ host: config.host || this.config.host || process.env.LLM_TRACE_HOST || '127.0.0.1',
139
+ port: Number(config.port || this.config.port || process.env.LLM_TRACE_PORT) || 4319,
140
+ maxTraces: Number(config.maxTraces || this.config.maxTraces || process.env.LLM_TRACE_MAX_TRACES) || 1000,
141
+ uiHotReload: typeof config.uiHotReload === 'boolean'
142
+ ? config.uiHotReload
143
+ : process.env.LLM_TRACE_UI_HOT_RELOAD
144
+ ? (0, utils_1.envFlag)('LLM_TRACE_UI_HOT_RELOAD')
145
+ : !process.env.CI && !!process.stdout.isTTY && process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test',
146
+ };
147
+ if (this.store) {
148
+ this.store.maxTraces = this.config.maxTraces;
149
+ }
150
+ }
151
+ isEnabled() {
152
+ return isTraceEnabled();
153
+ }
154
+ startServer() {
155
+ if (!this.isEnabled() || this.serverFailed) {
156
+ return Promise.resolve(this.serverInfo);
157
+ }
158
+ if (this.serverInfo) {
159
+ return Promise.resolve(this.serverInfo);
160
+ }
161
+ if (this.serverStartPromise) {
162
+ return this.serverStartPromise;
163
+ }
164
+ this.serverStartPromise = (async () => {
165
+ try {
166
+ this.server = (0, server_1.createTraceServer)(this.store, this.config);
167
+ this.serverInfo = await this.server.start();
168
+ if (this.serverInfo && !this.uiWatcher) {
169
+ this.uiWatcher = await (0, ui_build_1.maybeStartUIWatcher)(() => {
170
+ this.server?.broadcast({
171
+ timestamp: new Date().toISOString(),
172
+ traceId: null,
173
+ type: 'ui:reload',
174
+ });
175
+ }, this.config.uiHotReload);
176
+ }
177
+ if (!this.loggedUrl && this.serverInfo) {
178
+ this.loggedUrl = true;
179
+ process.stdout.write(`[llm-trace] dashboard: ${this.serverInfo.url}\n`);
180
+ }
181
+ return this.serverInfo;
182
+ }
183
+ catch (error) {
184
+ this.serverFailed = true;
185
+ process.stderr.write(`[llm-trace] failed to start dashboard: ${error.message}\n`);
186
+ return null;
187
+ }
188
+ finally {
189
+ this.serverStartPromise = null;
190
+ }
191
+ })();
192
+ return this.serverStartPromise;
193
+ }
194
+ recordInvokeStart(context, request) {
195
+ void this.startServer();
196
+ return this.store.recordInvokeStart(context, normaliseRequest(request));
197
+ }
198
+ recordInvokeFinish(traceId, response) {
199
+ this.store.recordInvokeFinish(traceId, (0, utils_1.safeClone)(response));
200
+ }
201
+ recordStreamStart(context, request) {
202
+ void this.startServer();
203
+ return this.store.recordStreamStart(context, normaliseRequest(request));
204
+ }
205
+ recordStreamChunk(traceId, chunk) {
206
+ this.store.recordStreamChunk(traceId, (0, utils_1.safeClone)(chunk));
207
+ }
208
+ recordStreamFinish(traceId, chunk) {
209
+ this.store.recordStreamFinish(traceId, (0, utils_1.safeClone)(chunk));
210
+ }
211
+ recordError(traceId, error) {
212
+ this.store.recordError(traceId, error);
213
+ }
214
+ }
215
+ function normaliseRequest(request) {
216
+ return {
217
+ input: (0, utils_1.safeClone)(request?.input),
218
+ options: (0, utils_1.safeClone)(request?.options),
219
+ };
220
+ }
@@ -0,0 +1,6 @@
1
+ import { type TraceServer } from './types';
2
+ import { TraceStore } from './store';
3
+ export declare function createTraceServer(store: TraceStore, options?: {
4
+ host?: string;
5
+ port?: number;
6
+ }): TraceServer;
package/dist/server.js ADDED
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.createTraceServer = createTraceServer;
7
+ const node_http_1 = __importDefault(require("node:http"));
8
+ const promises_1 = require("node:fs/promises");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const node_url_1 = require("node:url");
11
+ const ui_1 = require("./ui");
12
+ function createTraceServer(store, options = {}) {
13
+ const host = options.host || '127.0.0.1';
14
+ const port = Number(options.port) || 4319;
15
+ const clients = new Set();
16
+ const server = node_http_1.default.createServer(async (req, res) => {
17
+ try {
18
+ const url = new node_url_1.URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
19
+ if (req.method === 'GET' && url.pathname === '/') {
20
+ sendHtml(res, (0, ui_1.renderAppHtml)());
21
+ return;
22
+ }
23
+ if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
24
+ await sendAsset(res, url.pathname);
25
+ return;
26
+ }
27
+ if (req.method === 'GET' && url.pathname === '/api/traces') {
28
+ sendJson(res, 200, store.list(parseFilters(url)));
29
+ return;
30
+ }
31
+ if (req.method === 'GET' && url.pathname === '/api/hierarchy') {
32
+ sendJson(res, 200, store.hierarchy(parseFilters(url)));
33
+ return;
34
+ }
35
+ if (req.method === 'GET' && url.pathname === '/api/events') {
36
+ openSse(res, clients);
37
+ return;
38
+ }
39
+ if (req.method === 'DELETE' && url.pathname === '/api/traces') {
40
+ store.clear();
41
+ sendJson(res, 200, { ok: true });
42
+ return;
43
+ }
44
+ if (req.method === 'GET' && url.pathname.startsWith('/api/traces/')) {
45
+ const traceId = decodeURIComponent(url.pathname.slice('/api/traces/'.length));
46
+ const trace = store.get(traceId);
47
+ if (!trace) {
48
+ sendJson(res, 404, { error: 'Trace not found' });
49
+ return;
50
+ }
51
+ sendJson(res, 200, trace);
52
+ return;
53
+ }
54
+ sendJson(res, 404, { error: 'Not found' });
55
+ }
56
+ catch (error) {
57
+ sendJson(res, 500, { error: error.message });
58
+ }
59
+ });
60
+ store.on('event', (event) => {
61
+ broadcast(event);
62
+ });
63
+ return {
64
+ async start() {
65
+ await new Promise((resolve, reject) => {
66
+ server.once('error', reject);
67
+ server.listen(port, host, () => {
68
+ server.removeListener('error', reject);
69
+ resolve();
70
+ });
71
+ });
72
+ server.unref();
73
+ return {
74
+ host,
75
+ port,
76
+ url: `http://${host}:${port}`,
77
+ };
78
+ },
79
+ broadcast,
80
+ close() {
81
+ for (const client of clients) {
82
+ client.end();
83
+ }
84
+ clients.clear();
85
+ server.close();
86
+ },
87
+ };
88
+ function broadcast(event) {
89
+ const payload = `data: ${JSON.stringify(event)}\n\n`;
90
+ for (const client of clients) {
91
+ client.write(payload);
92
+ }
93
+ }
94
+ }
95
+ function parseFilters(url) {
96
+ return {
97
+ search: url.searchParams.get('search') || undefined,
98
+ status: url.searchParams.get('status') || undefined,
99
+ kind: url.searchParams.get('kind') || undefined,
100
+ provider: url.searchParams.get('provider') || undefined,
101
+ model: url.searchParams.get('model') || undefined,
102
+ groupBy: url.searchParams.get('groupBy') || undefined,
103
+ tags: url.searchParams.getAll('tag').concat(url.searchParams.get('tags') || []).filter(Boolean),
104
+ };
105
+ }
106
+ function sendHtml(res, body) {
107
+ res.writeHead(200, {
108
+ 'Content-Type': 'text/html; charset=utf-8',
109
+ 'Cache-Control': 'no-store',
110
+ });
111
+ res.end(body);
112
+ }
113
+ function sendJson(res, statusCode, body) {
114
+ res.writeHead(statusCode, {
115
+ 'Content-Type': 'application/json; charset=utf-8',
116
+ 'Cache-Control': 'no-store',
117
+ });
118
+ res.end(JSON.stringify(body));
119
+ }
120
+ function openSse(res, clients) {
121
+ res.writeHead(200, {
122
+ 'Content-Type': 'text/event-stream',
123
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
124
+ Connection: 'keep-alive',
125
+ });
126
+ res.write('\n');
127
+ clients.add(res);
128
+ res.on('close', () => clients.delete(res));
129
+ }
130
+ async function sendAsset(res, pathname) {
131
+ const assetName = pathname.replace(/^\/assets\//, '');
132
+ const filePath = node_path_1.default.join(__dirname, 'client', assetName);
133
+ try {
134
+ const body = await (0, promises_1.readFile)(filePath);
135
+ res.writeHead(200, {
136
+ 'Content-Type': getContentType(filePath),
137
+ 'Cache-Control': 'no-store',
138
+ });
139
+ res.end(body);
140
+ }
141
+ catch {
142
+ sendJson(res, 404, { error: 'Asset not found' });
143
+ }
144
+ }
145
+ function getContentType(filePath) {
146
+ if (filePath.endsWith('.css')) {
147
+ return 'text/css; charset=utf-8';
148
+ }
149
+ if (filePath.endsWith('.js')) {
150
+ return 'application/javascript; charset=utf-8';
151
+ }
152
+ if (filePath.endsWith('.svg')) {
153
+ return 'image/svg+xml';
154
+ }
155
+ return 'application/octet-stream';
156
+ }
@@ -0,0 +1,25 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { type HierarchyResponse, type NormalizedTraceContext, type TraceFilters, type TraceListResponse, type TraceRecord, type TraceRequest } from './types';
3
+ export declare class TraceStore extends EventEmitter {
4
+ maxTraces: number;
5
+ order: string[];
6
+ traces: Map<string, TraceRecord>;
7
+ constructor(options?: {
8
+ maxTraces?: number;
9
+ });
10
+ recordInvokeStart(context: NormalizedTraceContext | undefined, request: TraceRequest): string;
11
+ recordInvokeFinish(traceId: string, response: any): void;
12
+ recordStreamStart(context: NormalizedTraceContext | undefined, request: TraceRequest): string;
13
+ recordStreamChunk(traceId: string, chunk: any): void;
14
+ recordStreamFinish(traceId: string, chunk: any): void;
15
+ recordError(traceId: string, error: unknown): void;
16
+ list(filters?: TraceFilters): TraceListResponse;
17
+ get(traceId: string): TraceRecord | null;
18
+ clear(): void;
19
+ hierarchy(filters?: TraceFilters): HierarchyResponse;
20
+ private recordStart;
21
+ private evictIfNeeded;
22
+ private cloneTrace;
23
+ private filteredTraces;
24
+ private publish;
25
+ }