@mtharrison/loupe 1.1.0 → 1.2.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,399 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const { randomUUID } = require('node:crypto');
5
+ const readline = require('node:readline/promises');
6
+ const process = require('node:process');
7
+
8
+ const { getLocalLLMTracer, wrapOpenAIClient } = require('../dist/index.js');
9
+
10
+ const MODEL = process.env.OPENAI_MODEL || 'gpt-4.1-mini';
11
+ const PORT = Number(process.env.LLM_TRACE_PORT) || 4319;
12
+ const SESSION_ID = `openai-tools-demo-${randomUUID().slice(0, 8)}`;
13
+
14
+ const TOOL_DEFINITIONS = [
15
+ {
16
+ type: 'function',
17
+ function: {
18
+ name: 'get_city_weather',
19
+ description: 'Returns a short local forecast and packing hints for a supported city.',
20
+ parameters: {
21
+ type: 'object',
22
+ additionalProperties: false,
23
+ properties: {
24
+ city: {
25
+ type: 'string',
26
+ description: 'City name. Supported examples: London, San Francisco.',
27
+ },
28
+ },
29
+ required: ['city'],
30
+ },
31
+ },
32
+ },
33
+ {
34
+ type: 'function',
35
+ function: {
36
+ name: 'search_city_guide',
37
+ description: 'Returns coffee, walking, and neighborhood suggestions for a supported city.',
38
+ parameters: {
39
+ type: 'object',
40
+ additionalProperties: false,
41
+ properties: {
42
+ city: {
43
+ type: 'string',
44
+ description: 'City name. Supported examples: London, San Francisco.',
45
+ },
46
+ interests: {
47
+ type: 'array',
48
+ items: {
49
+ type: 'string',
50
+ },
51
+ description: 'Optional list of interests like coffee, walking, museums, or food.',
52
+ },
53
+ },
54
+ required: ['city'],
55
+ },
56
+ },
57
+ },
58
+ ];
59
+
60
+ const WEATHER_DATA = {
61
+ london: {
62
+ conditions: 'cool with light rain bands and gusty wind',
63
+ highC: 13,
64
+ lowC: 8,
65
+ notes: ['pack a waterproof shell', 'bring shoes that can handle wet pavement'],
66
+ },
67
+ 'san francisco': {
68
+ conditions: 'mild mornings, sunny midday, windy evening',
69
+ highC: 18,
70
+ lowC: 11,
71
+ notes: ['dress in layers', 'carry a light sweater for the evening'],
72
+ },
73
+ };
74
+
75
+ const GUIDE_DATA = {
76
+ london: {
77
+ neighborhoods: ['South Bank', 'Covent Garden', 'Shoreditch'],
78
+ coffee: ['Monmouth Coffee', 'Nagare Coffee', 'Prufrock Coffee'],
79
+ walking: ['Thames Path from Westminster to Tower Bridge', 'Regent Canal walk to Broadway Market'],
80
+ },
81
+ 'san francisco': {
82
+ neighborhoods: ['North Beach', 'Mission District', 'Hayes Valley'],
83
+ coffee: ['Sightglass Coffee', 'Saint Frank Coffee', 'Andytown Coffee Roasters'],
84
+ walking: ['Ferry Building to North Beach waterfront loop', 'Mission murals and Dolores Park loop'],
85
+ },
86
+ };
87
+
88
+ async function main() {
89
+ process.env.LLM_TRACE_ENABLED ??= '1';
90
+
91
+ if (!process.env.OPENAI_API_KEY) {
92
+ throw new Error('Set OPENAI_API_KEY before running this example.');
93
+ }
94
+
95
+ const OpenAI = await loadOpenAI();
96
+
97
+ const tracer = getLocalLLMTracer({ port: PORT });
98
+ const serverInfo = await tracer.startServer();
99
+ if (!serverInfo?.url) {
100
+ throw new Error('Failed to start the Loupe dashboard.');
101
+ }
102
+
103
+ log(`[demo] Loupe dashboard: ${serverInfo.url}`);
104
+ openBrowser(serverInfo.url);
105
+
106
+ const traceState = {
107
+ phase: 'boot',
108
+ turn: 0,
109
+ };
110
+
111
+ // getContext runs for every traced create() call, so these mutable fields let
112
+ // the example tag each turn without changing the wrapped client.
113
+ const client = wrapOpenAIClient(
114
+ new OpenAI({ apiKey: process.env.OPENAI_API_KEY }),
115
+ () => ({
116
+ sessionId: SESSION_ID,
117
+ rootSessionId: SESSION_ID,
118
+ rootActorId: 'openai-tools-demo',
119
+ actorId: 'openai-tools-demo',
120
+ model: MODEL,
121
+ provider: 'openai',
122
+ stage: `turn-${traceState.turn}:${traceState.phase}`,
123
+ tags: {
124
+ example: 'wrapOpenAIClient',
125
+ phase: traceState.phase,
126
+ sessionId: SESSION_ID,
127
+ turn: String(traceState.turn),
128
+ },
129
+ }),
130
+ { port: PORT },
131
+ );
132
+
133
+ const messages = [
134
+ {
135
+ role: 'system',
136
+ content: [
137
+ 'You are a concise travel assistant.',
138
+ 'Use the provided tools for weather or city-guide questions instead of answering from memory.',
139
+ 'Keep each final answer short and directly useful.',
140
+ ].join(' '),
141
+ },
142
+ ];
143
+
144
+ const userTurns = [
145
+ 'I am flying from London to San Francisco for three days. Check the forecast for both cities and tell me what to pack.',
146
+ 'Now suggest a two-day walking and coffee itinerary in San Francisco. Use the city guide tool and keep it under eight bullets.',
147
+ ];
148
+
149
+ log(`[demo] Session: ${SESSION_ID}`);
150
+ log(`[demo] Model: ${MODEL}`);
151
+
152
+ for (const prompt of userTurns) {
153
+ traceState.turn += 1;
154
+ traceState.phase = 'user';
155
+
156
+ messages.push({
157
+ role: 'user',
158
+ content: prompt,
159
+ });
160
+
161
+ log('');
162
+ log(`User ${traceState.turn}: ${prompt}`);
163
+
164
+ const reply = await runConversationTurn(client, messages, traceState);
165
+ log(`Assistant ${traceState.turn}: ${reply}`);
166
+ }
167
+
168
+ log('');
169
+ log(`[demo] Conversation complete. Keep this process alive while you inspect ${serverInfo.url}`);
170
+ await waitForDashboardExit(serverInfo.url);
171
+ }
172
+
173
+ async function runConversationTurn(client, messages, traceState) {
174
+ for (let modelCall = 1; modelCall <= 6; modelCall += 1) {
175
+ traceState.phase = modelCall === 1 ? 'assistant' : 'tool-followup';
176
+
177
+ const response = await client.chat.completions.create({
178
+ model: MODEL,
179
+ messages,
180
+ tools: TOOL_DEFINITIONS,
181
+ tool_choice: 'auto',
182
+ temperature: 0.2,
183
+ });
184
+
185
+ const assistantMessage = response?.choices?.[0]?.message;
186
+ if (!assistantMessage) {
187
+ throw new Error('OpenAI returned no assistant message.');
188
+ }
189
+
190
+ messages.push(assistantMessage);
191
+
192
+ const toolCalls = Array.isArray(assistantMessage.tool_calls) ? assistantMessage.tool_calls : [];
193
+ if (toolCalls.length === 0) {
194
+ return extractAssistantText(assistantMessage);
195
+ }
196
+
197
+ for (const toolCall of toolCalls) {
198
+ const result = await executeToolCall(toolCall);
199
+ log(`[tool:${toolCall.function.name}] ${JSON.stringify(result)}`);
200
+
201
+ messages.push({
202
+ role: 'tool',
203
+ tool_call_id: toolCall.id,
204
+ content: JSON.stringify(result),
205
+ });
206
+ }
207
+ }
208
+
209
+ throw new Error('Exceeded the maximum number of tool/model round trips for one turn.');
210
+ }
211
+
212
+ async function executeToolCall(toolCall) {
213
+ const toolName = toolCall?.function?.name;
214
+ const args = parseJson(toolCall?.function?.arguments);
215
+
216
+ switch (toolName) {
217
+ case 'get_city_weather':
218
+ return getCityWeather(args);
219
+ case 'search_city_guide':
220
+ return searchCityGuide(args);
221
+ default:
222
+ return {
223
+ error: `Unknown tool: ${String(toolName)}`,
224
+ };
225
+ }
226
+ }
227
+
228
+ function getCityWeather(args) {
229
+ const cityKey = normalizeCityKey(args?.city);
230
+ const weather = WEATHER_DATA[cityKey];
231
+
232
+ if (!weather) {
233
+ return {
234
+ city: args?.city || null,
235
+ error: 'No weather data available for that city in this demo.',
236
+ supportedCities: Object.keys(WEATHER_DATA),
237
+ };
238
+ }
239
+
240
+ return {
241
+ city: titleCaseCity(cityKey),
242
+ conditions: weather.conditions,
243
+ temperatureC: {
244
+ high: weather.highC,
245
+ low: weather.lowC,
246
+ },
247
+ packingNotes: weather.notes,
248
+ };
249
+ }
250
+
251
+ function searchCityGuide(args) {
252
+ const cityKey = normalizeCityKey(args?.city);
253
+ const guide = GUIDE_DATA[cityKey];
254
+
255
+ if (!guide) {
256
+ return {
257
+ city: args?.city || null,
258
+ error: 'No guide data available for that city in this demo.',
259
+ supportedCities: Object.keys(GUIDE_DATA),
260
+ };
261
+ }
262
+
263
+ return {
264
+ city: titleCaseCity(cityKey),
265
+ interests: Array.isArray(args?.interests) ? args.interests : [],
266
+ neighborhoods: guide.neighborhoods,
267
+ coffee: guide.coffee,
268
+ walking: guide.walking,
269
+ };
270
+ }
271
+
272
+ function normalizeCityKey(value) {
273
+ return String(value || '')
274
+ .trim()
275
+ .toLowerCase();
276
+ }
277
+
278
+ function titleCaseCity(value) {
279
+ return String(value)
280
+ .split(' ')
281
+ .filter(Boolean)
282
+ .map((part) => part[0].toUpperCase() + part.slice(1))
283
+ .join(' ');
284
+ }
285
+
286
+ function parseJson(value) {
287
+ if (!value) {
288
+ return {};
289
+ }
290
+
291
+ try {
292
+ return JSON.parse(value);
293
+ } catch (_error) {
294
+ return {
295
+ raw: value,
296
+ };
297
+ }
298
+ }
299
+
300
+ function extractAssistantText(message) {
301
+ const content = message?.content;
302
+
303
+ if (typeof content === 'string') {
304
+ return content;
305
+ }
306
+
307
+ if (!Array.isArray(content)) {
308
+ return '';
309
+ }
310
+
311
+ return content
312
+ .map((part) => {
313
+ if (typeof part === 'string') {
314
+ return part;
315
+ }
316
+
317
+ if (typeof part?.text === 'string') {
318
+ return part.text;
319
+ }
320
+
321
+ if (typeof part?.content === 'string') {
322
+ return part.content;
323
+ }
324
+
325
+ return '';
326
+ })
327
+ .join('');
328
+ }
329
+
330
+ async function loadOpenAI() {
331
+ try {
332
+ const module = await import('openai');
333
+ return module.default || module;
334
+ } catch (error) {
335
+ if (error && error.code === 'ERR_MODULE_NOT_FOUND') {
336
+ throw new Error('This example requires the "openai" package. Install it in the project where you run the demo.');
337
+ }
338
+
339
+ throw error;
340
+ }
341
+ }
342
+
343
+ function openBrowser(url) {
344
+ if (!process.stdout.isTTY || process.env.CI || process.env.LOUPE_OPEN_BROWSER === '0') {
345
+ return;
346
+ }
347
+
348
+ const command =
349
+ process.platform === 'darwin'
350
+ ? ['open', [url]]
351
+ : process.platform === 'win32'
352
+ ? ['cmd', ['/c', 'start', '', url]]
353
+ : process.platform === 'linux'
354
+ ? ['xdg-open', [url]]
355
+ : null;
356
+
357
+ if (!command) {
358
+ return;
359
+ }
360
+
361
+ try {
362
+ const child = spawn(command[0], command[1], {
363
+ detached: true,
364
+ stdio: 'ignore',
365
+ });
366
+ child.on('error', () => {});
367
+ child.unref();
368
+ } catch (_error) {
369
+ // Ignore browser launch failures. The dashboard URL is already printed.
370
+ }
371
+ }
372
+
373
+ async function waitForDashboardExit(url) {
374
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
375
+ log(`[demo] Non-interactive terminal detected. Leaving the dashboard up for 60 seconds: ${url}`);
376
+ await new Promise((resolve) => setTimeout(resolve, 60000));
377
+ return;
378
+ }
379
+
380
+ const rl = readline.createInterface({
381
+ input: process.stdin,
382
+ output: process.stdout,
383
+ });
384
+
385
+ try {
386
+ await rl.question('[demo] Press Enter to stop the demo and close the dashboard.\n');
387
+ } finally {
388
+ rl.close();
389
+ }
390
+ }
391
+
392
+ function log(message) {
393
+ process.stdout.write(`${message}\n`);
394
+ }
395
+
396
+ main().catch((error) => {
397
+ process.stderr.write(`[demo] ${error.message}\n`);
398
+ process.exitCode = 1;
399
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mtharrison/loupe",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight local tracing dashboard for LLM calls",
5
5
  "author": "Matt Harrison",
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "dist",
20
20
  "assets",
21
+ "examples",
21
22
  "README.md",
22
23
  "LICENSE"
23
24
  ],
@@ -48,6 +49,7 @@
48
49
  "@types/node": "^22.0.0",
49
50
  "@types/react": "^19.2.14",
50
51
  "@types/react-dom": "^19.2.3",
52
+ "openai": "^6.29.0",
51
53
  "typescript": "^5.9.2"
52
54
  },
53
55
  "keywords": [