@mtharrison/loupe 1.4.0 → 1.6.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.
- package/README.md +28 -4
- package/dist/client/app.css +122 -117
- package/dist/client/app.js +257 -249
- package/dist/index.js +81 -1
- package/dist/utils.js +3 -1
- package/examples/fully-featured.js +533 -0
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.isTraceEnabled = isTraceEnabled;
|
|
4
37
|
exports.getLocalLLMTracer = getLocalLLMTracer;
|
|
@@ -11,6 +44,7 @@ exports.__resetLocalLLMTracerForTests = __resetLocalLLMTracerForTests;
|
|
|
11
44
|
exports.wrapChatModel = wrapChatModel;
|
|
12
45
|
exports.wrapOpenAIClient = wrapOpenAIClient;
|
|
13
46
|
const node_async_hooks_1 = require("node:async_hooks");
|
|
47
|
+
const childProcess = __importStar(require("node:child_process"));
|
|
14
48
|
const server_1 = require("./server");
|
|
15
49
|
const store_1 = require("./store");
|
|
16
50
|
const ui_build_1 = require("./ui-build");
|
|
@@ -19,7 +53,10 @@ let singleton = null;
|
|
|
19
53
|
const DEFAULT_TRACE_PORT = 4319;
|
|
20
54
|
const activeSpanStorage = new node_async_hooks_1.AsyncLocalStorage();
|
|
21
55
|
function isTraceEnabled() {
|
|
22
|
-
|
|
56
|
+
if (process.env.LLM_TRACE_ENABLED !== undefined) {
|
|
57
|
+
return (0, utils_1.envFlag)('LLM_TRACE_ENABLED');
|
|
58
|
+
}
|
|
59
|
+
return process.env.NODE_ENV === 'development';
|
|
23
60
|
}
|
|
24
61
|
function getLocalLLMTracer(config = {}) {
|
|
25
62
|
if (!singleton) {
|
|
@@ -28,6 +65,9 @@ function getLocalLLMTracer(config = {}) {
|
|
|
28
65
|
else if (config && Object.keys(config).length > 0) {
|
|
29
66
|
singleton.configure(config);
|
|
30
67
|
}
|
|
68
|
+
if (shouldEagerStartDashboard()) {
|
|
69
|
+
void singleton.startServer();
|
|
70
|
+
}
|
|
31
71
|
return singleton;
|
|
32
72
|
}
|
|
33
73
|
function startTraceServer(config = {}) {
|
|
@@ -180,6 +220,7 @@ function wrapOpenAIClient(client, getContext, config) {
|
|
|
180
220
|
class LocalLLMTracerImpl {
|
|
181
221
|
config;
|
|
182
222
|
loggedUrl;
|
|
223
|
+
openedBrowser;
|
|
183
224
|
portWasExplicit;
|
|
184
225
|
server;
|
|
185
226
|
serverFailed;
|
|
@@ -202,6 +243,7 @@ class LocalLLMTracerImpl {
|
|
|
202
243
|
this.serverStartPromise = null;
|
|
203
244
|
this.serverFailed = false;
|
|
204
245
|
this.loggedUrl = false;
|
|
246
|
+
this.openedBrowser = false;
|
|
205
247
|
this.uiWatcher = null;
|
|
206
248
|
}
|
|
207
249
|
configure(config = {}) {
|
|
@@ -278,6 +320,10 @@ class LocalLLMTracerImpl {
|
|
|
278
320
|
this.loggedUrl = true;
|
|
279
321
|
process.stdout.write(`[llm-trace] dashboard: ${this.serverInfo.url}\n`);
|
|
280
322
|
}
|
|
323
|
+
if (!this.openedBrowser && this.serverInfo && shouldAutoOpenDashboard()) {
|
|
324
|
+
this.openedBrowser = true;
|
|
325
|
+
openBrowser(this.serverInfo.url);
|
|
326
|
+
}
|
|
281
327
|
return this.serverInfo;
|
|
282
328
|
}
|
|
283
329
|
catch (error) {
|
|
@@ -292,6 +338,40 @@ class LocalLLMTracerImpl {
|
|
|
292
338
|
return this.serverStartPromise;
|
|
293
339
|
}
|
|
294
340
|
}
|
|
341
|
+
function shouldAutoOpenDashboard() {
|
|
342
|
+
if (process.env.LOUPE_OPEN_BROWSER === '0') {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
return (process.env.NODE_ENV === 'development'
|
|
346
|
+
&& !process.env.CI
|
|
347
|
+
&& !!process.stdout.isTTY);
|
|
348
|
+
}
|
|
349
|
+
function shouldEagerStartDashboard() {
|
|
350
|
+
return process.env.NODE_ENV === 'development';
|
|
351
|
+
}
|
|
352
|
+
function openBrowser(url) {
|
|
353
|
+
const command = process.platform === 'darwin'
|
|
354
|
+
? ['open', [url]]
|
|
355
|
+
: process.platform === 'win32'
|
|
356
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
357
|
+
: process.platform === 'linux'
|
|
358
|
+
? ['xdg-open', [url]]
|
|
359
|
+
: null;
|
|
360
|
+
if (!command) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
const child = childProcess.spawn(command[0], command[1], {
|
|
365
|
+
detached: true,
|
|
366
|
+
stdio: 'ignore',
|
|
367
|
+
});
|
|
368
|
+
child.on('error', () => { });
|
|
369
|
+
child.unref();
|
|
370
|
+
}
|
|
371
|
+
catch (_error) {
|
|
372
|
+
// Ignore browser launch failures. The dashboard URL is already printed.
|
|
373
|
+
}
|
|
374
|
+
}
|
|
295
375
|
function normaliseRequest(request) {
|
|
296
376
|
return {
|
|
297
377
|
input: (0, utils_1.safeClone)(request?.input),
|
package/dist/utils.js
CHANGED
|
@@ -134,7 +134,9 @@ function normalizeTraceContext(context, mode) {
|
|
|
134
134
|
const explicitKind = normalizeKind(raw.kind);
|
|
135
135
|
let kind = explicitKind || 'actor';
|
|
136
136
|
if (!explicitKind) {
|
|
137
|
-
|
|
137
|
+
// Keep generic guardrail metadata from masking actual delegated-agent spans.
|
|
138
|
+
// Explicit guardrail spans or spans with a concrete phase still remain guardrails.
|
|
139
|
+
if (guardrailPhase || (guardrailType && !isChildActor)) {
|
|
138
140
|
kind = 'guardrail';
|
|
139
141
|
}
|
|
140
142
|
else if (stage) {
|
|
@@ -0,0 +1,533 @@
|
|
|
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 {
|
|
9
|
+
addSpanEvent,
|
|
10
|
+
endSpan,
|
|
11
|
+
getLocalLLMTracer,
|
|
12
|
+
recordException,
|
|
13
|
+
startSpan,
|
|
14
|
+
wrapChatModel,
|
|
15
|
+
} = require('../dist/index.js');
|
|
16
|
+
|
|
17
|
+
const DEMO_TIMEOUT_MS = 15000;
|
|
18
|
+
|
|
19
|
+
const GUIDE_DATA = {
|
|
20
|
+
kyoto: {
|
|
21
|
+
coffee: ['Weekenders Coffee Tominokoji', 'Kurasu Kyoto Stand'],
|
|
22
|
+
museum: 'Kyoto National Museum',
|
|
23
|
+
neighborhood: 'Gion',
|
|
24
|
+
pack: ['layers', 'comfortable walking shoes', 'a compact umbrella'],
|
|
25
|
+
walk: 'Philosopher\'s Path and Higashiyama backstreets',
|
|
26
|
+
},
|
|
27
|
+
lisbon: {
|
|
28
|
+
coffee: ['Hello, Kristof', 'The Folks'],
|
|
29
|
+
museum: 'MAAT',
|
|
30
|
+
neighborhood: 'Baixa and Chiado',
|
|
31
|
+
pack: ['layers', 'comfortable walking shoes', 'a light rain shell'],
|
|
32
|
+
walk: 'Alfama viewpoints loop',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
async function runFullyFeaturedExample(options = {}) {
|
|
37
|
+
process.env.LLM_TRACE_ENABLED ??= '1';
|
|
38
|
+
|
|
39
|
+
const destinationKey = normalizeDestinationKey(options.destination || 'Lisbon');
|
|
40
|
+
const guide = GUIDE_DATA[destinationKey];
|
|
41
|
+
if (!guide) {
|
|
42
|
+
throw new Error(`Unsupported destination "${String(options.destination)}". Try Lisbon or Kyoto.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const port = getPort(options.port);
|
|
46
|
+
const destination = formatDestination(destinationKey);
|
|
47
|
+
const sessionId = options.sessionId || `fully-featured-demo-${randomUUID().slice(0, 8)}`;
|
|
48
|
+
const keepAlive = options.keepAlive !== false;
|
|
49
|
+
const openBrowserEnabled = options.openBrowser !== false;
|
|
50
|
+
const tracer = getLocalLLMTracer({ port });
|
|
51
|
+
const serverInfo = await tracer.startServer();
|
|
52
|
+
|
|
53
|
+
if (!serverInfo?.url) {
|
|
54
|
+
throw new Error('Failed to start the Loupe dashboard.');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
log(`[demo] Loupe dashboard: ${serverInfo.url}`);
|
|
58
|
+
if (openBrowserEnabled) {
|
|
59
|
+
openBrowser(serverInfo.url);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
log(`[demo] Session: ${sessionId}`);
|
|
63
|
+
log(`[demo] Destination: ${destination}`);
|
|
64
|
+
|
|
65
|
+
const baseTags = {
|
|
66
|
+
destination,
|
|
67
|
+
example: 'fully-featured',
|
|
68
|
+
workflow: 'weekend-planner',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const buildContext = (overrides = {}) => {
|
|
72
|
+
const { tags = {}, ...rest } = overrides;
|
|
73
|
+
return {
|
|
74
|
+
sessionId,
|
|
75
|
+
rootSessionId: sessionId,
|
|
76
|
+
rootActorId: 'travel-assistant',
|
|
77
|
+
actorId: 'travel-assistant',
|
|
78
|
+
actorType: 'assistant',
|
|
79
|
+
model: 'trip-planner-v2',
|
|
80
|
+
provider: 'mock-llm',
|
|
81
|
+
tags: {
|
|
82
|
+
...baseTags,
|
|
83
|
+
...tags,
|
|
84
|
+
},
|
|
85
|
+
tenantId: 'demo-travel',
|
|
86
|
+
userId: 'traveler-01',
|
|
87
|
+
...rest,
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const researchModel = wrapChatModel(
|
|
92
|
+
{
|
|
93
|
+
async invoke(input) {
|
|
94
|
+
const requestedDestination = formatDestination(normalizeDestinationKey(input?.destination || destination));
|
|
95
|
+
const selectedGuide = GUIDE_DATA[normalizeDestinationKey(requestedDestination)] || guide;
|
|
96
|
+
return {
|
|
97
|
+
message: {
|
|
98
|
+
role: 'assistant',
|
|
99
|
+
content: [
|
|
100
|
+
`Base the trip around ${selectedGuide.neighborhood}.`,
|
|
101
|
+
`Coffee: ${selectedGuide.coffee.join(' and ')}.`,
|
|
102
|
+
`Walk: ${selectedGuide.walk}.`,
|
|
103
|
+
`Museum: ${selectedGuide.museum}.`,
|
|
104
|
+
].join(' '),
|
|
105
|
+
},
|
|
106
|
+
tool_calls: [],
|
|
107
|
+
usage: createUsage(18, 27),
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
async *stream(input) {
|
|
111
|
+
const requestedDestination = formatDestination(normalizeDestinationKey(input?.destination || destination));
|
|
112
|
+
const selectedGuide = GUIDE_DATA[normalizeDestinationKey(requestedDestination)] || guide;
|
|
113
|
+
const content = `Research highlights for ${requestedDestination}: ${selectedGuide.walk}, ${selectedGuide.museum}.`;
|
|
114
|
+
yield { type: 'begin', role: 'assistant' };
|
|
115
|
+
yield { type: 'chunk', content };
|
|
116
|
+
yield {
|
|
117
|
+
type: 'finish',
|
|
118
|
+
message: { role: 'assistant', content },
|
|
119
|
+
tool_calls: [],
|
|
120
|
+
usage: createUsage(10, 12),
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
() =>
|
|
125
|
+
buildContext({
|
|
126
|
+
actorId: 'city-researcher',
|
|
127
|
+
actorType: 'tool',
|
|
128
|
+
model: 'city-researcher-v1',
|
|
129
|
+
tags: {
|
|
130
|
+
surface: 'research',
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
{ port },
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
const plannerModel = wrapChatModel(
|
|
137
|
+
{
|
|
138
|
+
async invoke(input) {
|
|
139
|
+
const researchStageId = startSpan(
|
|
140
|
+
buildContext({
|
|
141
|
+
stage: 'research',
|
|
142
|
+
tags: {
|
|
143
|
+
surface: 'research-stage',
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
{
|
|
147
|
+
mode: 'invoke',
|
|
148
|
+
name: 'workflow.research',
|
|
149
|
+
request: {
|
|
150
|
+
input: {
|
|
151
|
+
destination: input.destination,
|
|
152
|
+
interests: input.interests,
|
|
153
|
+
},
|
|
154
|
+
options: {
|
|
155
|
+
sources: ['city-guide', 'weather-notes'],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{ port },
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
addSpanEvent(
|
|
163
|
+
researchStageId,
|
|
164
|
+
{
|
|
165
|
+
name: 'retrieval.hit',
|
|
166
|
+
attributes: {
|
|
167
|
+
guide: destination,
|
|
168
|
+
sources: 2,
|
|
169
|
+
},
|
|
170
|
+
payload: {
|
|
171
|
+
guide: destination,
|
|
172
|
+
sources: ['city-guide', 'weather-notes'],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{ port },
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
endSpan(
|
|
179
|
+
researchStageId,
|
|
180
|
+
{
|
|
181
|
+
summary: `Loaded local guide data for ${destination}.`,
|
|
182
|
+
usage: createUsage(6, 4, 0.0000005, 0.0000008),
|
|
183
|
+
},
|
|
184
|
+
{ port },
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const research = await researchModel.invoke(
|
|
188
|
+
{
|
|
189
|
+
destination: input.destination,
|
|
190
|
+
interests: input.interests,
|
|
191
|
+
messages: [
|
|
192
|
+
{
|
|
193
|
+
role: 'user',
|
|
194
|
+
content: `Research a two-day ${destination} itinerary for ${input.interests.join(', ')}.`,
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
channel: 'research',
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const availabilitySpanId = startSpan(
|
|
204
|
+
buildContext({
|
|
205
|
+
actorId: 'availability-service',
|
|
206
|
+
actorType: 'service',
|
|
207
|
+
tags: {
|
|
208
|
+
surface: 'availability',
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
{
|
|
212
|
+
mode: 'invoke',
|
|
213
|
+
name: 'tool.availability-check',
|
|
214
|
+
request: {
|
|
215
|
+
input: {
|
|
216
|
+
destination: input.destination,
|
|
217
|
+
hotel: `${destination} Riverside House`,
|
|
218
|
+
},
|
|
219
|
+
options: {
|
|
220
|
+
timeoutMs: 250,
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
{ port },
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
throw Object.assign(new Error('Supplier timed out during live availability lookup.'), {
|
|
229
|
+
code: 'SUPPLIER_TIMEOUT',
|
|
230
|
+
status: 504,
|
|
231
|
+
});
|
|
232
|
+
} catch (error) {
|
|
233
|
+
// Keep the demo moving while leaving one child span in the error state.
|
|
234
|
+
recordException(availabilitySpanId, error, { port });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const outputGuardrailId = startSpan(
|
|
238
|
+
buildContext({
|
|
239
|
+
guardrailType: 'output-policy',
|
|
240
|
+
tags: {
|
|
241
|
+
surface: 'output-guardrail',
|
|
242
|
+
},
|
|
243
|
+
}),
|
|
244
|
+
{
|
|
245
|
+
mode: 'invoke',
|
|
246
|
+
name: 'guardrail.output',
|
|
247
|
+
request: {
|
|
248
|
+
input: {
|
|
249
|
+
draft: research.message.content,
|
|
250
|
+
},
|
|
251
|
+
options: {
|
|
252
|
+
policy: 'travel-safe',
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
{ port },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
addSpanEvent(
|
|
260
|
+
outputGuardrailId,
|
|
261
|
+
{
|
|
262
|
+
name: 'guardrail.review',
|
|
263
|
+
attributes: {
|
|
264
|
+
outcome: 'pass',
|
|
265
|
+
policy: 'travel-safe',
|
|
266
|
+
},
|
|
267
|
+
payload: {
|
|
268
|
+
removedClaims: 0,
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{ port },
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
endSpan(
|
|
275
|
+
outputGuardrailId,
|
|
276
|
+
{
|
|
277
|
+
reason: 'Advice stays within local-demo safety rules.',
|
|
278
|
+
result: 'pass',
|
|
279
|
+
},
|
|
280
|
+
{ port },
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
message: {
|
|
285
|
+
role: 'assistant',
|
|
286
|
+
content: [
|
|
287
|
+
`Day 1: start in ${guide.neighborhood} with ${guide.coffee[0]}, then walk ${guide.walk}.`,
|
|
288
|
+
`Day 2: visit ${guide.museum}, then use ${guide.coffee[1]} as a reset stop.`,
|
|
289
|
+
research.message.content,
|
|
290
|
+
'One live availability lookup failed, so confirm bookings directly before you go.',
|
|
291
|
+
`Pack ${guide.pack.join(', ')}.`,
|
|
292
|
+
].join('\n'),
|
|
293
|
+
},
|
|
294
|
+
tool_calls: [],
|
|
295
|
+
usage: createUsage(42, 68),
|
|
296
|
+
};
|
|
297
|
+
},
|
|
298
|
+
async *stream(input) {
|
|
299
|
+
const parts = [
|
|
300
|
+
`Day 1 in ${destination}: ${guide.coffee[0]}, then ${guide.walk}. `,
|
|
301
|
+
`Day 2: ${guide.museum}, followed by time around ${guide.neighborhood}. `,
|
|
302
|
+
`Pack ${guide.pack.join(', ')}.`,
|
|
303
|
+
];
|
|
304
|
+
const finalContent = parts.join('');
|
|
305
|
+
|
|
306
|
+
yield { type: 'begin', role: 'assistant' };
|
|
307
|
+
for (const content of parts) {
|
|
308
|
+
yield { type: 'chunk', content };
|
|
309
|
+
}
|
|
310
|
+
yield {
|
|
311
|
+
type: 'finish',
|
|
312
|
+
message: { role: 'assistant', content: finalContent },
|
|
313
|
+
tool_calls: [],
|
|
314
|
+
usage: createUsage(24, 39),
|
|
315
|
+
};
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
() =>
|
|
319
|
+
buildContext({
|
|
320
|
+
tags: {
|
|
321
|
+
surface: 'planner',
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
{ port },
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const invokeInput = {
|
|
328
|
+
days: 2,
|
|
329
|
+
destination,
|
|
330
|
+
interests: ['coffee', 'walking', 'museum'],
|
|
331
|
+
messages: [
|
|
332
|
+
{
|
|
333
|
+
role: 'user',
|
|
334
|
+
content: `Plan a safe two-day ${destination} trip with coffee, walking, and one museum stop.`,
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const inputGuardrailId = startSpan(
|
|
340
|
+
buildContext({
|
|
341
|
+
guardrailType: 'input-policy',
|
|
342
|
+
tags: {
|
|
343
|
+
surface: 'input-guardrail',
|
|
344
|
+
},
|
|
345
|
+
}),
|
|
346
|
+
{
|
|
347
|
+
mode: 'invoke',
|
|
348
|
+
name: 'guardrail.input',
|
|
349
|
+
request: {
|
|
350
|
+
input: invokeInput,
|
|
351
|
+
options: {
|
|
352
|
+
policy: 'travel-safe',
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
{ port },
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
addSpanEvent(
|
|
360
|
+
inputGuardrailId,
|
|
361
|
+
{
|
|
362
|
+
name: 'guardrail.review',
|
|
363
|
+
attributes: {
|
|
364
|
+
outcome: 'pass',
|
|
365
|
+
policy: 'travel-safe',
|
|
366
|
+
},
|
|
367
|
+
payload: {
|
|
368
|
+
matchedRules: ['safe-travel'],
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
{ port },
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
endSpan(
|
|
375
|
+
inputGuardrailId,
|
|
376
|
+
{
|
|
377
|
+
reason: 'The prompt is safe to answer.',
|
|
378
|
+
result: 'pass',
|
|
379
|
+
},
|
|
380
|
+
{ port },
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const invokeResponse = await plannerModel.invoke(invokeInput, {
|
|
384
|
+
channel: 'planning',
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
log('');
|
|
388
|
+
log('[demo] Invoke answer:');
|
|
389
|
+
log(invokeResponse.message.content);
|
|
390
|
+
|
|
391
|
+
const streamInput = {
|
|
392
|
+
...invokeInput,
|
|
393
|
+
messages: [
|
|
394
|
+
{
|
|
395
|
+
role: 'user',
|
|
396
|
+
content: `Now stream the concise version of the ${destination} plan.`,
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
const streamChunks = [];
|
|
402
|
+
for await (const chunk of plannerModel.stream(streamInput, { channel: 'handoff' })) {
|
|
403
|
+
streamChunks.push(chunk);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const streamReply = extractStreamText(streamChunks);
|
|
407
|
+
log('');
|
|
408
|
+
log('[demo] Stream answer:');
|
|
409
|
+
log(streamReply);
|
|
410
|
+
|
|
411
|
+
const traceCount = tracer.store
|
|
412
|
+
.list()
|
|
413
|
+
.items.filter((item) => item.hierarchy.sessionId === sessionId && item.tags.example === 'fully-featured').length;
|
|
414
|
+
|
|
415
|
+
log('');
|
|
416
|
+
log(`[demo] Recorded ${traceCount} traces for one session across guardrails, nested calls, errors, and streaming.`);
|
|
417
|
+
|
|
418
|
+
if (keepAlive) {
|
|
419
|
+
log(`[demo] Keep this process alive while you inspect ${serverInfo.url}`);
|
|
420
|
+
await waitForDashboardExit(serverInfo.url);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
invokeReply: invokeResponse.message.content,
|
|
425
|
+
sessionId,
|
|
426
|
+
streamReply,
|
|
427
|
+
traceCount,
|
|
428
|
+
url: serverInfo.url,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function createUsage(promptTokens, completionTokens, promptPrice = 0.000001, completionPrice = 0.000002) {
|
|
433
|
+
return {
|
|
434
|
+
pricing: {
|
|
435
|
+
completion: completionPrice,
|
|
436
|
+
prompt: promptPrice,
|
|
437
|
+
},
|
|
438
|
+
tokens: {
|
|
439
|
+
completion: completionTokens,
|
|
440
|
+
prompt: promptTokens,
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function extractStreamText(chunks) {
|
|
446
|
+
return chunks
|
|
447
|
+
.filter((chunk) => chunk?.type === 'chunk' && typeof chunk.content === 'string')
|
|
448
|
+
.map((chunk) => chunk.content)
|
|
449
|
+
.join('');
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function normalizeDestinationKey(value) {
|
|
453
|
+
return String(value || '')
|
|
454
|
+
.trim()
|
|
455
|
+
.toLowerCase();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function formatDestination(value) {
|
|
459
|
+
return String(value)
|
|
460
|
+
.split(' ')
|
|
461
|
+
.filter(Boolean)
|
|
462
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
463
|
+
.join(' ');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function getPort(port) {
|
|
467
|
+
const explicit = Number(port ?? process.env.LLM_TRACE_PORT);
|
|
468
|
+
return Number.isFinite(explicit) && explicit > 0 ? explicit : 4319;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function openBrowser(url) {
|
|
472
|
+
if (!process.stdout.isTTY || process.env.CI || process.env.LOUPE_OPEN_BROWSER === '0') {
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const command =
|
|
477
|
+
process.platform === 'darwin'
|
|
478
|
+
? ['open', [url]]
|
|
479
|
+
: process.platform === 'win32'
|
|
480
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
481
|
+
: process.platform === 'linux'
|
|
482
|
+
? ['xdg-open', [url]]
|
|
483
|
+
: null;
|
|
484
|
+
|
|
485
|
+
if (!command) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const child = spawn(command[0], command[1], {
|
|
491
|
+
detached: true,
|
|
492
|
+
stdio: 'ignore',
|
|
493
|
+
});
|
|
494
|
+
child.on('error', () => {});
|
|
495
|
+
child.unref();
|
|
496
|
+
} catch (_error) {
|
|
497
|
+
// Ignore browser launch failures. The dashboard URL is already printed.
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function waitForDashboardExit(url) {
|
|
502
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
503
|
+
log(`[demo] Non-interactive terminal detected. Leaving the dashboard up for ${Math.round(DEMO_TIMEOUT_MS / 1000)} seconds: ${url}`);
|
|
504
|
+
await new Promise((resolve) => setTimeout(resolve, DEMO_TIMEOUT_MS));
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const rl = readline.createInterface({
|
|
509
|
+
input: process.stdin,
|
|
510
|
+
output: process.stdout,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
await rl.question('[demo] Press Enter to stop the demo and close the dashboard.\n');
|
|
515
|
+
} finally {
|
|
516
|
+
rl.close();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function log(message) {
|
|
521
|
+
process.stdout.write(`${message}\n`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
module.exports = {
|
|
525
|
+
runFullyFeaturedExample,
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
if (require.main === module) {
|
|
529
|
+
runFullyFeaturedExample().catch((error) => {
|
|
530
|
+
process.stderr.write(`[demo] ${error.message}\n`);
|
|
531
|
+
process.exitCode = 1;
|
|
532
|
+
});
|
|
533
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mtharrison/loupe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Lightweight local tracing dashboard for LLM calls",
|
|
5
5
|
"author": "Matt Harrison",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,13 +24,14 @@
|
|
|
24
24
|
],
|
|
25
25
|
"sideEffects": false,
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
27
|
+
"node": ">=20"
|
|
28
28
|
},
|
|
29
29
|
"publishConfig": {
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json && node scripts/build-ui.mjs",
|
|
34
|
+
"lint": "tsc --noEmit -p tsconfig.json && node --check examples/fully-featured.js && node --check examples/nested-tool-call.js && node --check examples/openai-multiturn-tools.js && node --check test/index.test.js",
|
|
34
35
|
"release": "npx semantic-release",
|
|
35
36
|
"test": "npm run build && node --test"
|
|
36
37
|
},
|