@mtharrison/loupe 1.1.1 → 1.3.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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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';
1
+ import { type ChatModelLike, type LocalLLMTracer, type OpenAIChatCompletionCreateParamsLike, type OpenAIClientLike, type SpanEventInput, type SpanStartOptions, type TraceConfig, type TraceContext } from './types';
2
+ export type { ChatModelLike, HierarchyNode, HierarchyResponse, LocalLLMTracer, NormalizedTraceContext, OpenAIChatCompletionCreateParamsLike, OpenAIChatCompletionStreamLike, OpenAIClientLike, SpanAttributes, SpanContext, SpanEvent, SpanEventInput, SpanKind, SpanStartOptions, SpanStatus, SpanStatusCode, TraceConfig, TraceContext, TraceEvent, TraceFilters, TraceHierarchy, TraceListResponse, TraceMode, TraceRecord, TraceRequest, TraceServer, TraceStatus, TraceSummary, TraceTags, UIReloadEvent, } from './types';
3
3
  export declare function isTraceEnabled(): boolean;
4
4
  export declare function getLocalLLMTracer(config?: TraceConfig): LocalLLMTracer;
5
5
  export declare function startTraceServer(config?: TraceConfig): Promise<{
@@ -7,11 +7,10 @@ export declare function startTraceServer(config?: TraceConfig): Promise<{
7
7
  port: number;
8
8
  url: string;
9
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;
10
+ export declare function startSpan(context: TraceContext, options?: SpanStartOptions, config?: TraceConfig): string;
11
+ export declare function endSpan(spanId: string, response: unknown, config?: TraceConfig): void;
12
+ export declare function addSpanEvent(spanId: string, event: SpanEventInput, config?: TraceConfig): void;
13
+ export declare function recordException(spanId: string, error: unknown, config?: TraceConfig): void;
16
14
  export declare function __resetLocalLLMTracerForTests(): void;
17
15
  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;
16
+ export declare function wrapOpenAIClient<TClient extends OpenAIClientLike<TParams, TOptions, TResponse, TChunk>, TParams extends OpenAIChatCompletionCreateParamsLike = OpenAIChatCompletionCreateParamsLike, TOptions = Record<string, any>, TResponse = any, TChunk = any>(client: TClient, getContext: () => TraceContext, config?: TraceConfig): TClient;
package/dist/index.js CHANGED
@@ -3,19 +3,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isTraceEnabled = isTraceEnabled;
4
4
  exports.getLocalLLMTracer = getLocalLLMTracer;
5
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;
6
+ exports.startSpan = startSpan;
7
+ exports.endSpan = endSpan;
8
+ exports.addSpanEvent = addSpanEvent;
9
+ exports.recordException = recordException;
12
10
  exports.__resetLocalLLMTracerForTests = __resetLocalLLMTracerForTests;
13
11
  exports.wrapChatModel = wrapChatModel;
12
+ exports.wrapOpenAIClient = wrapOpenAIClient;
13
+ const node_async_hooks_1 = require("node:async_hooks");
14
14
  const server_1 = require("./server");
15
15
  const store_1 = require("./store");
16
16
  const ui_build_1 = require("./ui-build");
17
17
  const utils_1 = require("./utils");
18
18
  let singleton = null;
19
+ const DEFAULT_TRACE_PORT = 4319;
20
+ const activeSpanStorage = new node_async_hooks_1.AsyncLocalStorage();
19
21
  function isTraceEnabled() {
20
22
  return (0, utils_1.envFlag)('LLM_TRACE_ENABLED');
21
23
  }
@@ -31,23 +33,17 @@ function getLocalLLMTracer(config = {}) {
31
33
  function startTraceServer(config = {}) {
32
34
  return getLocalLLMTracer(config).startServer();
33
35
  }
34
- function recordInvokeStart(context, request, config = {}) {
35
- return getLocalLLMTracer(config).recordInvokeStart(context, request);
36
+ function startSpan(context, options = {}, config = {}) {
37
+ return getLocalLLMTracer(config).startSpan(context, options);
36
38
  }
37
- function recordInvokeFinish(traceId, response, config = {}) {
38
- getLocalLLMTracer(config).recordInvokeFinish(traceId, response);
39
+ function endSpan(spanId, response, config = {}) {
40
+ getLocalLLMTracer(config).endSpan(spanId, response);
39
41
  }
40
- function recordStreamStart(context, request, config = {}) {
41
- return getLocalLLMTracer(config).recordStreamStart(context, request);
42
+ function addSpanEvent(spanId, event, config = {}) {
43
+ getLocalLLMTracer(config).addSpanEvent(spanId, event);
42
44
  }
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);
45
+ function recordException(spanId, error, config = {}) {
46
+ getLocalLLMTracer(config).recordException(spanId, error);
51
47
  }
52
48
  function __resetLocalLLMTracerForTests() {
53
49
  if (singleton?.uiWatcher) {
@@ -68,14 +64,19 @@ function wrapChatModel(model, getContext, config) {
68
64
  if (!tracer.isEnabled()) {
69
65
  return model.invoke(input, options);
70
66
  }
71
- const traceId = tracer.recordInvokeStart(getContext ? getContext() : {}, { input: input, options: options });
67
+ const traceId = tracer.startSpan(getContext ? getContext() : {}, {
68
+ attributes: { 'gen_ai.operation.name': 'chat' },
69
+ mode: 'invoke',
70
+ name: 'llm.invoke',
71
+ request: { input: input, options: options },
72
+ });
72
73
  try {
73
- const response = await model.invoke(input, options);
74
- tracer.recordInvokeFinish(traceId, response);
74
+ const response = await tracer.runWithActiveSpan(traceId, () => model.invoke(input, options));
75
+ tracer.endSpan(traceId, response);
75
76
  return response;
76
77
  }
77
78
  catch (error) {
78
- tracer.recordError(traceId, error);
79
+ tracer.recordException(traceId, error);
79
80
  throw error;
80
81
  }
81
82
  },
@@ -85,29 +86,101 @@ function wrapChatModel(model, getContext, config) {
85
86
  yield* model.stream(input, options);
86
87
  return;
87
88
  }
88
- const traceId = tracer.recordStreamStart(getContext ? getContext() : {}, { input: input, options: options });
89
+ const traceId = tracer.startSpan(getContext ? getContext() : {}, {
90
+ attributes: { 'gen_ai.operation.name': 'chat' },
91
+ mode: 'stream',
92
+ name: 'llm.stream',
93
+ request: { input: input, options: options },
94
+ });
89
95
  try {
90
- const stream = model.stream(input, options);
96
+ const stream = tracer.runWithActiveSpan(traceId, () => model.stream(input, options));
91
97
  for await (const chunk of stream) {
92
98
  if (chunk?.type === 'finish') {
93
- tracer.recordStreamFinish(traceId, chunk);
99
+ tracer.endSpan(traceId, chunk);
94
100
  }
95
101
  else {
96
- tracer.recordStreamChunk(traceId, chunk);
102
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)(chunk));
97
103
  }
98
104
  yield chunk;
99
105
  }
100
106
  }
101
107
  catch (error) {
102
- tracer.recordError(traceId, error);
108
+ tracer.recordException(traceId, error);
103
109
  throw error;
104
110
  }
105
111
  },
106
112
  };
107
113
  }
114
+ function wrapOpenAIClient(client, getContext, config) {
115
+ if (!client || typeof client.chat?.completions?.create !== 'function') {
116
+ throw new TypeError('wrapOpenAIClient expects an OpenAI client with chat.completions.create().');
117
+ }
118
+ const wrappedCompletions = new Proxy(client.chat.completions, {
119
+ get(target, prop, receiver) {
120
+ if (prop === 'create') {
121
+ return async (params, options) => {
122
+ const tracer = getLocalLLMTracer(config);
123
+ if (!tracer.isEnabled()) {
124
+ return target.create.call(target, params, options);
125
+ }
126
+ const context = withOpenAITraceContext(getContext ? getContext() : {}, params);
127
+ if (params?.stream) {
128
+ const traceId = tracer.startSpan(context, {
129
+ attributes: { 'gen_ai.operation.name': 'chat' },
130
+ mode: 'stream',
131
+ name: 'openai.chat.completions',
132
+ request: { input: params, options: options },
133
+ });
134
+ try {
135
+ const stream = await tracer.runWithActiveSpan(traceId, () => target.create.call(target, params, options));
136
+ return wrapOpenAIChatCompletionsStream(stream, tracer, traceId);
137
+ }
138
+ catch (error) {
139
+ tracer.recordException(traceId, error);
140
+ throw error;
141
+ }
142
+ }
143
+ const traceId = tracer.startSpan(context, {
144
+ attributes: { 'gen_ai.operation.name': 'chat' },
145
+ mode: 'invoke',
146
+ name: 'openai.chat.completions',
147
+ request: { input: params, options: options },
148
+ });
149
+ try {
150
+ const response = await tracer.runWithActiveSpan(traceId, () => target.create.call(target, params, options));
151
+ tracer.endSpan(traceId, normalizeOpenAIChatCompletionResponse(response));
152
+ return response;
153
+ }
154
+ catch (error) {
155
+ tracer.recordException(traceId, error);
156
+ throw error;
157
+ }
158
+ };
159
+ }
160
+ return bindMethod(target, Reflect.get(target, prop, receiver));
161
+ },
162
+ });
163
+ const wrappedChat = new Proxy(client.chat, {
164
+ get(target, prop, receiver) {
165
+ if (prop === 'completions') {
166
+ return wrappedCompletions;
167
+ }
168
+ return bindMethod(target, Reflect.get(target, prop, receiver));
169
+ },
170
+ });
171
+ return new Proxy(client, {
172
+ get(target, prop, receiver) {
173
+ if (prop === 'chat') {
174
+ return wrappedChat;
175
+ }
176
+ return bindMethod(target, Reflect.get(target, prop, receiver));
177
+ },
178
+ });
179
+ }
108
180
  class LocalLLMTracerImpl {
109
181
  config;
110
182
  loggedUrl;
183
+ portWasExplicit;
111
184
  server;
112
185
  serverFailed;
113
186
  serverInfo;
@@ -121,6 +194,7 @@ class LocalLLMTracerImpl {
121
194
  port: 4319,
122
195
  uiHotReload: false,
123
196
  };
197
+ this.portWasExplicit = false;
124
198
  this.configure(config);
125
199
  this.store = new store_1.TraceStore({ maxTraces: this.config.maxTraces });
126
200
  this.server = null;
@@ -134,9 +208,11 @@ class LocalLLMTracerImpl {
134
208
  if (this.serverInfo && (config.host || config.port)) {
135
209
  return;
136
210
  }
211
+ const explicitPort = getConfiguredPort(config.port, process.env.LLM_TRACE_PORT, this.portWasExplicit ? this.config.port : undefined);
212
+ this.portWasExplicit = explicitPort !== undefined;
137
213
  this.config = {
138
214
  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,
215
+ port: explicitPort ?? DEFAULT_TRACE_PORT,
140
216
  maxTraces: Number(config.maxTraces || this.config.maxTraces || process.env.LLM_TRACE_MAX_TRACES) || 1000,
141
217
  uiHotReload: typeof config.uiHotReload === 'boolean'
142
218
  ? config.uiHotReload
@@ -151,6 +227,27 @@ class LocalLLMTracerImpl {
151
227
  isEnabled() {
152
228
  return isTraceEnabled();
153
229
  }
230
+ startSpan(context, options = {}) {
231
+ void this.startServer();
232
+ const parentSpanId = options.parentSpanId || activeSpanStorage.getStore() || null;
233
+ return this.store.startSpan(context, {
234
+ ...options,
235
+ parentSpanId,
236
+ request: normaliseRequest(options.request || {}),
237
+ });
238
+ }
239
+ runWithActiveSpan(spanId, callback) {
240
+ return activeSpanStorage.run(spanId, callback);
241
+ }
242
+ addSpanEvent(spanId, event) {
243
+ this.store.addSpanEvent(spanId, (0, utils_1.safeClone)(event));
244
+ }
245
+ endSpan(spanId, response) {
246
+ this.store.endSpan(spanId, (0, utils_1.safeClone)(response));
247
+ }
248
+ recordException(spanId, error) {
249
+ this.store.recordException(spanId, error);
250
+ }
154
251
  startServer() {
155
252
  if (!this.isEnabled() || this.serverFailed) {
156
253
  return Promise.resolve(this.serverInfo);
@@ -163,13 +260,16 @@ class LocalLLMTracerImpl {
163
260
  }
164
261
  this.serverStartPromise = (async () => {
165
262
  try {
166
- this.server = (0, server_1.createTraceServer)(this.store, this.config);
263
+ this.server = (0, server_1.createTraceServer)(this.store, {
264
+ ...this.config,
265
+ allowPortFallback: !this.portWasExplicit,
266
+ });
167
267
  this.serverInfo = await this.server.start();
168
268
  if (this.serverInfo && !this.uiWatcher) {
169
269
  this.uiWatcher = await (0, ui_build_1.maybeStartUIWatcher)(() => {
170
270
  this.server?.broadcast({
171
271
  timestamp: new Date().toISOString(),
172
- traceId: null,
272
+ spanId: null,
173
273
  type: 'ui:reload',
174
274
  });
175
275
  }, this.config.uiHotReload);
@@ -191,30 +291,273 @@ class LocalLLMTracerImpl {
191
291
  })();
192
292
  return this.serverStartPromise;
193
293
  }
194
- recordInvokeStart(context, request) {
195
- void this.startServer();
196
- return this.store.recordInvokeStart(context, normaliseRequest(request));
294
+ }
295
+ function normaliseRequest(request) {
296
+ return {
297
+ input: (0, utils_1.safeClone)(request?.input),
298
+ options: (0, utils_1.safeClone)(request?.options),
299
+ };
300
+ }
301
+ function bindMethod(target, value) {
302
+ return typeof value === 'function' ? value.bind(target) : value;
303
+ }
304
+ function getConfiguredPort(configPort, envPort, currentExplicitPort) {
305
+ if (typeof configPort === 'number' && Number.isFinite(configPort)) {
306
+ return configPort;
197
307
  }
198
- recordInvokeFinish(traceId, response) {
199
- this.store.recordInvokeFinish(traceId, (0, utils_1.safeClone)(response));
308
+ if (envPort !== undefined) {
309
+ const parsed = Number(envPort);
310
+ if (Number.isFinite(parsed)) {
311
+ return parsed;
312
+ }
200
313
  }
201
- recordStreamStart(context, request) {
202
- void this.startServer();
203
- return this.store.recordStreamStart(context, normaliseRequest(request));
314
+ return typeof currentExplicitPort === 'number' && Number.isFinite(currentExplicitPort) ? currentExplicitPort : undefined;
315
+ }
316
+ function withOpenAITraceContext(context, params) {
317
+ return {
318
+ ...(context || {}),
319
+ model: context?.model || (typeof params?.model === 'string' ? params.model : null),
320
+ provider: context?.provider || 'openai',
321
+ };
322
+ }
323
+ function wrapOpenAIChatCompletionsStream(stream, tracer, traceId) {
324
+ const state = {
325
+ content: '',
326
+ finished: false,
327
+ finishReasons: [],
328
+ began: false,
329
+ role: null,
330
+ toolCalls: new Map(),
331
+ usage: null,
332
+ };
333
+ const emitBegin = (role) => {
334
+ if (state.began) {
335
+ return;
336
+ }
337
+ state.began = true;
338
+ const nextRole = role || state.role || 'assistant';
339
+ state.role = nextRole;
340
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({ type: 'begin', role: nextRole }));
341
+ };
342
+ const emitFinish = () => {
343
+ if (state.finished) {
344
+ return;
345
+ }
346
+ emitBegin(state.role);
347
+ state.finished = true;
348
+ tracer.endSpan(traceId, {
349
+ type: 'finish',
350
+ finish_reasons: state.finishReasons,
351
+ message: {
352
+ role: state.role || 'assistant',
353
+ content: state.content || null,
354
+ },
355
+ tool_calls: serializeOpenAIToolCalls(state.toolCalls),
356
+ usage: (0, utils_1.safeClone)(state.usage),
357
+ });
358
+ };
359
+ const processChunk = (chunk) => {
360
+ const raw = (0, utils_1.safeClone)(chunk);
361
+ const chunkToolCalls = [];
362
+ const finishReasons = new Set();
363
+ const contentParts = [];
364
+ const choices = Array.isArray(chunk?.choices) ? chunk.choices : [];
365
+ let sawEvent = false;
366
+ for (const choice of choices) {
367
+ const delta = choice?.delta || {};
368
+ if (typeof delta?.role === 'string' && delta.role) {
369
+ state.role = delta.role;
370
+ sawEvent = true;
371
+ }
372
+ for (const part of extractOpenAITextParts(delta?.content)) {
373
+ state.content = `${state.content}${part}`;
374
+ contentParts.push(part);
375
+ sawEvent = true;
376
+ }
377
+ if (Array.isArray(delta?.tool_calls) && delta.tool_calls.length > 0) {
378
+ sawEvent = true;
379
+ for (const toolCall of delta.tool_calls) {
380
+ mergeOpenAIToolCallDelta(state.toolCalls, toolCall);
381
+ chunkToolCalls.push((0, utils_1.safeClone)(toolCall));
382
+ }
383
+ }
384
+ if (typeof choice?.finish_reason === 'string' && choice.finish_reason) {
385
+ finishReasons.add(choice.finish_reason);
386
+ }
387
+ }
388
+ const usage = normalizeOpenAIUsage(chunk?.usage);
389
+ if (usage) {
390
+ state.usage = usage;
391
+ sawEvent = true;
392
+ }
393
+ for (const finishReason of finishReasons) {
394
+ state.finishReasons.push(finishReason);
395
+ }
396
+ if (sawEvent) {
397
+ emitBegin(state.role);
398
+ }
399
+ if (contentParts.length > 0) {
400
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({
401
+ type: 'chunk',
402
+ content: contentParts.join(''),
403
+ finish_reasons: [...finishReasons],
404
+ raw,
405
+ tool_calls: chunkToolCalls,
406
+ usage,
407
+ }));
408
+ return;
409
+ }
410
+ tracer.addSpanEvent(traceId, (0, utils_1.toSpanEventInputFromChunk)({
411
+ type: 'event',
412
+ finish_reasons: [...finishReasons],
413
+ raw,
414
+ tool_calls: chunkToolCalls,
415
+ usage,
416
+ }));
417
+ };
418
+ const createWrappedIterator = (iterator) => ({
419
+ async next(...args) {
420
+ try {
421
+ const result = await iterator.next(...args);
422
+ if (result.done) {
423
+ emitFinish();
424
+ return result;
425
+ }
426
+ processChunk(result.value);
427
+ return result;
428
+ }
429
+ catch (error) {
430
+ tracer.recordException(traceId, error);
431
+ throw error;
432
+ }
433
+ },
434
+ async return(value) {
435
+ try {
436
+ const result = typeof iterator.return === 'function'
437
+ ? await iterator.return(value)
438
+ : {
439
+ done: true,
440
+ value,
441
+ };
442
+ emitFinish();
443
+ return result;
444
+ }
445
+ catch (error) {
446
+ tracer.recordException(traceId, error);
447
+ throw error;
448
+ }
449
+ },
450
+ async throw(error) {
451
+ tracer.recordException(traceId, error);
452
+ if (typeof iterator.throw === 'function') {
453
+ return iterator.throw(error);
454
+ }
455
+ throw error;
456
+ },
457
+ [Symbol.asyncIterator]() {
458
+ return this;
459
+ },
460
+ });
461
+ return new Proxy(stream, {
462
+ get(target, prop, receiver) {
463
+ if (prop === Symbol.asyncIterator) {
464
+ return () => createWrappedIterator(target[Symbol.asyncIterator]());
465
+ }
466
+ return bindMethod(target, Reflect.get(target, prop, receiver));
467
+ },
468
+ });
469
+ }
470
+ function normalizeOpenAIChatCompletionResponse(response) {
471
+ const message = response?.choices?.[0]?.message;
472
+ return {
473
+ finish_reason: response?.choices?.[0]?.finish_reason || null,
474
+ id: response?.id || null,
475
+ message: normalizeOpenAIMessage(message),
476
+ model: response?.model || null,
477
+ object: response?.object || null,
478
+ raw: (0, utils_1.safeClone)(response),
479
+ tool_calls: Array.isArray(message?.tool_calls) ? (0, utils_1.safeClone)(message.tool_calls) : [],
480
+ usage: normalizeOpenAIUsage(response?.usage),
481
+ };
482
+ }
483
+ function normalizeOpenAIMessage(message) {
484
+ return {
485
+ ...(0, utils_1.safeClone)(message),
486
+ content: normalizeOpenAIMessageContent(message),
487
+ role: typeof message?.role === 'string' ? message.role : 'assistant',
488
+ };
489
+ }
490
+ function normalizeOpenAIMessageContent(message) {
491
+ const content = extractOpenAITextParts(message?.content);
492
+ if (content.length > 0) {
493
+ return content.join('');
204
494
  }
205
- recordStreamChunk(traceId, chunk) {
206
- this.store.recordStreamChunk(traceId, (0, utils_1.safeClone)(chunk));
495
+ if (typeof message?.refusal === 'string' && message.refusal) {
496
+ return message.refusal;
207
497
  }
208
- recordStreamFinish(traceId, chunk) {
209
- this.store.recordStreamFinish(traceId, (0, utils_1.safeClone)(chunk));
498
+ return message?.content ?? null;
499
+ }
500
+ function extractOpenAITextParts(content) {
501
+ if (typeof content === 'string' && content) {
502
+ return [content];
503
+ }
504
+ if (!Array.isArray(content)) {
505
+ return [];
210
506
  }
211
- recordError(traceId, error) {
212
- this.store.recordError(traceId, error);
507
+ const parts = [];
508
+ for (const item of content) {
509
+ if (typeof item === 'string' && item) {
510
+ parts.push(item);
511
+ continue;
512
+ }
513
+ if (typeof item?.text === 'string' && item.text) {
514
+ parts.push(item.text);
515
+ continue;
516
+ }
517
+ if (typeof item?.content === 'string' && item.content) {
518
+ parts.push(item.content);
519
+ }
213
520
  }
521
+ return parts;
214
522
  }
215
- function normaliseRequest(request) {
523
+ function normalizeOpenAIUsage(usage) {
524
+ if (!usage || typeof usage !== 'object') {
525
+ return null;
526
+ }
216
527
  return {
217
- input: (0, utils_1.safeClone)(request?.input),
218
- options: (0, utils_1.safeClone)(request?.options),
528
+ raw: (0, utils_1.safeClone)(usage),
529
+ tokens: {
530
+ completion: typeof usage?.completion_tokens === 'number' ? usage.completion_tokens : null,
531
+ prompt: typeof usage?.prompt_tokens === 'number' ? usage.prompt_tokens : null,
532
+ total: typeof usage?.total_tokens === 'number' ? usage.total_tokens : null,
533
+ },
219
534
  };
220
535
  }
536
+ function mergeOpenAIToolCallDelta(target, delta) {
537
+ const index = Number.isInteger(delta?.index) ? delta.index : target.size;
538
+ const current = (0, utils_1.safeClone)(target.get(index) || { function: { arguments: '' } });
539
+ if (delta?.id) {
540
+ current.id = delta.id;
541
+ }
542
+ if (delta?.type) {
543
+ current.type = delta.type;
544
+ }
545
+ if (delta?.function?.name) {
546
+ current.function = {
547
+ ...(current.function || {}),
548
+ name: delta.function.name,
549
+ };
550
+ }
551
+ if (delta?.function?.arguments) {
552
+ current.function = {
553
+ ...(current.function || {}),
554
+ arguments: `${current.function?.arguments || ''}${delta.function.arguments}`,
555
+ };
556
+ }
557
+ target.set(index, current);
558
+ }
559
+ function serializeOpenAIToolCalls(toolCalls) {
560
+ return [...toolCalls.entries()]
561
+ .sort(([left], [right]) => left - right)
562
+ .map(([, value]) => (0, utils_1.safeClone)(value));
563
+ }
package/dist/server.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type TraceServer } from './types';
2
2
  import { TraceStore } from './store';
3
3
  export declare function createTraceServer(store: TraceStore, options?: {
4
+ allowPortFallback?: boolean;
4
5
  host?: string;
5
6
  port?: number;
6
7
  }): TraceServer;
package/dist/server.js CHANGED
@@ -11,11 +11,12 @@ const node_url_1 = require("node:url");
11
11
  const ui_1 = require("./ui");
12
12
  function createTraceServer(store, options = {}) {
13
13
  const host = options.host || '127.0.0.1';
14
- const port = Number(options.port) || 4319;
14
+ const requestedPort = toPortNumber(options.port, 4319);
15
+ let activePort = requestedPort;
15
16
  const clients = new Set();
16
17
  const server = node_http_1.default.createServer(async (req, res) => {
17
18
  try {
18
- const url = new node_url_1.URL(req.url || '/', `http://${req.headers.host || `${host}:${port}`}`);
19
+ const url = new node_url_1.URL(req.url || '/', `http://${req.headers.host || `${host}:${activePort}`}`);
19
20
  if (req.method === 'GET' && url.pathname === '/') {
20
21
  sendHtml(res, (0, ui_1.renderAppHtml)());
21
22
  return;
@@ -62,18 +63,13 @@ function createTraceServer(store, options = {}) {
62
63
  });
63
64
  return {
64
65
  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
- });
66
+ await listenWithFallback(server, host, requestedPort, options.allowPortFallback);
67
+ activePort = getBoundPort(server, requestedPort);
72
68
  server.unref();
73
69
  return {
74
70
  host,
75
- port,
76
- url: `http://${host}:${port}`,
71
+ port: activePort,
72
+ url: `http://${host}:${activePort}`,
77
73
  };
78
74
  },
79
75
  broadcast,
@@ -92,6 +88,41 @@ function createTraceServer(store, options = {}) {
92
88
  }
93
89
  }
94
90
  }
91
+ async function listenWithFallback(server, host, port, allowPortFallback = false) {
92
+ await new Promise((resolve, reject) => {
93
+ const tryListen = (nextPort, canFallback) => {
94
+ const onError = (error) => {
95
+ server.removeListener('listening', onListening);
96
+ if (canFallback && error?.code === 'EADDRINUSE') {
97
+ tryListen(0, false);
98
+ return;
99
+ }
100
+ reject(error);
101
+ };
102
+ const onListening = () => {
103
+ server.removeListener('error', onError);
104
+ resolve();
105
+ };
106
+ server.once('error', onError);
107
+ server.once('listening', onListening);
108
+ server.listen(nextPort, host);
109
+ };
110
+ tryListen(port, allowPortFallback);
111
+ });
112
+ }
113
+ function getBoundPort(server, fallbackPort) {
114
+ const address = server.address();
115
+ if (address && typeof address === 'object' && typeof address.port === 'number') {
116
+ return address.port;
117
+ }
118
+ return fallbackPort;
119
+ }
120
+ function toPortNumber(value, fallback) {
121
+ if (typeof value === 'number' && Number.isFinite(value)) {
122
+ return value;
123
+ }
124
+ return fallback;
125
+ }
95
126
  function parseFilters(url) {
96
127
  return {
97
128
  search: url.searchParams.get('search') || undefined,
@@ -31,4 +31,14 @@ export type SessionNavItem = {
31
31
  shortSessionId: string;
32
32
  status: "error" | "ok" | "pending";
33
33
  };
34
+ export type SessionTreeSelection = {
35
+ selectedNodeId: string | null;
36
+ selectedTraceId: string | null;
37
+ };
34
38
  export declare function deriveSessionNavItems(sessionNodes: SessionNavHierarchyNode[], traceById: Map<string, SessionNavTraceSummary>): SessionNavItem[];
39
+ export declare function sortSessionNodesForNav(sessionNodes: SessionNavHierarchyNode[], traceById: Map<string, SessionNavTraceSummary>): SessionNavHierarchyNode[];
40
+ export declare function findSessionNodePath(nodes: SessionNavHierarchyNode[], id: string, trail?: SessionNavHierarchyNode[]): SessionNavHierarchyNode[];
41
+ export declare function findSessionNodeById(nodes: SessionNavHierarchyNode[], id: string): SessionNavHierarchyNode | null;
42
+ export declare function getNewestTraceIdForNode(node: SessionNavHierarchyNode | null | undefined): string | null;
43
+ export declare function resolveSessionTreeSelection(sessionNodes: SessionNavHierarchyNode[], selectedNodeId: string | null, selectedTraceId: string | null): SessionTreeSelection;
44
+ export declare function getDefaultExpandedSessionTreeNodeIds(sessionNodes: SessionNavHierarchyNode[], activeSessionId: string | null, selectedNodeId: string | null): Set<string>;