@mtharrison/loupe 1.4.0 → 1.5.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 +27 -3
- package/dist/client/app.css +122 -117
- package/dist/client/app.js +257 -249
- package/dist/index.js +75 -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) {
|
|
@@ -180,6 +217,7 @@ function wrapOpenAIClient(client, getContext, config) {
|
|
|
180
217
|
class LocalLLMTracerImpl {
|
|
181
218
|
config;
|
|
182
219
|
loggedUrl;
|
|
220
|
+
openedBrowser;
|
|
183
221
|
portWasExplicit;
|
|
184
222
|
server;
|
|
185
223
|
serverFailed;
|
|
@@ -202,6 +240,7 @@ class LocalLLMTracerImpl {
|
|
|
202
240
|
this.serverStartPromise = null;
|
|
203
241
|
this.serverFailed = false;
|
|
204
242
|
this.loggedUrl = false;
|
|
243
|
+
this.openedBrowser = false;
|
|
205
244
|
this.uiWatcher = null;
|
|
206
245
|
}
|
|
207
246
|
configure(config = {}) {
|
|
@@ -278,6 +317,10 @@ class LocalLLMTracerImpl {
|
|
|
278
317
|
this.loggedUrl = true;
|
|
279
318
|
process.stdout.write(`[llm-trace] dashboard: ${this.serverInfo.url}\n`);
|
|
280
319
|
}
|
|
320
|
+
if (!this.openedBrowser && this.serverInfo && shouldAutoOpenDashboard()) {
|
|
321
|
+
this.openedBrowser = true;
|
|
322
|
+
openBrowser(this.serverInfo.url);
|
|
323
|
+
}
|
|
281
324
|
return this.serverInfo;
|
|
282
325
|
}
|
|
283
326
|
catch (error) {
|
|
@@ -292,6 +335,37 @@ class LocalLLMTracerImpl {
|
|
|
292
335
|
return this.serverStartPromise;
|
|
293
336
|
}
|
|
294
337
|
}
|
|
338
|
+
function shouldAutoOpenDashboard() {
|
|
339
|
+
if (process.env.LOUPE_OPEN_BROWSER === '0') {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
return (process.env.NODE_ENV === 'development'
|
|
343
|
+
&& !process.env.CI
|
|
344
|
+
&& !!process.stdout.isTTY);
|
|
345
|
+
}
|
|
346
|
+
function openBrowser(url) {
|
|
347
|
+
const command = process.platform === 'darwin'
|
|
348
|
+
? ['open', [url]]
|
|
349
|
+
: process.platform === 'win32'
|
|
350
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
351
|
+
: process.platform === 'linux'
|
|
352
|
+
? ['xdg-open', [url]]
|
|
353
|
+
: null;
|
|
354
|
+
if (!command) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
const child = childProcess.spawn(command[0], command[1], {
|
|
359
|
+
detached: true,
|
|
360
|
+
stdio: 'ignore',
|
|
361
|
+
});
|
|
362
|
+
child.on('error', () => { });
|
|
363
|
+
child.unref();
|
|
364
|
+
}
|
|
365
|
+
catch (_error) {
|
|
366
|
+
// Ignore browser launch failures. The dashboard URL is already printed.
|
|
367
|
+
}
|
|
368
|
+
}
|
|
295
369
|
function normaliseRequest(request) {
|
|
296
370
|
return {
|
|
297
371
|
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.5.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
|
},
|