@neutrome/open-ai-router 0.6.2 → 0.6.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -11,8 +11,8 @@
11
11
  "hono": "^4.12.18",
12
12
  "posthog-node": "^5.41.0",
13
13
  "zod": "^4.2.1",
14
- "@neutrome/lil-engine": "0.4.1",
15
- "@neutrome/lilsdk": "0.4.1"
14
+ "@neutrome/lil-engine": "0.4.2",
15
+ "@neutrome/lilsdk": "0.4.3"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/node": "^25.9.3",
@@ -217,8 +217,8 @@ describe("createRouterApp trace finalization", () => {
217
217
  expect(waited).toHaveLength(1);
218
218
  await waited[0];
219
219
  expect(onExecutionSummary).toHaveBeenCalledTimes(1);
220
- const events = onExecutionSummary.mock.calls[0]?.[0].spans.map(
221
- (span) => span.event,
220
+ const events = onExecutionSummary.mock.calls[0]?.[0].spans.flatMap((span) =>
221
+ span.timeline.map((entry) => entry.event),
222
222
  );
223
223
  expect(events).toContain("admission.authenticate");
224
224
  expect(events).toContain("upstream_auth.resolve_target");
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export type {
13
13
  ErrorTelemetryDriver,
14
14
  ExecutionSummary,
15
15
  ExecutionSummarySpan,
16
+ ExecutionTimelineEvent,
16
17
  RouterTelemetry,
17
18
  TraceTelemetryDriver,
18
19
  } from "./telemetry/index.ts";
@@ -108,12 +108,11 @@ export function createFetchProviderInvoker(
108
108
  const response = parseProviderResponse(provider.style, bytes);
109
109
  emitProviderUsage(
110
110
  providerCtx,
111
+ resolvedRequest,
111
112
  response,
112
113
  provider.name,
113
114
  reqBytes,
114
115
  bytes.byteLength,
115
- jsonBody(reqBody),
116
- jsonBody(bytes),
117
116
  );
118
117
  return response;
119
118
  } catch (error) {
@@ -134,11 +133,3 @@ export function createFetchProviderInvoker(
134
133
  },
135
134
  };
136
135
  }
137
-
138
- function jsonBody(body: Uint8Array): unknown {
139
- try {
140
- return JSON.parse(new TextDecoder().decode(body));
141
- } catch {
142
- return undefined;
143
- }
144
- }
@@ -102,7 +102,7 @@ export async function* streamProvider(
102
102
  let firstByteObserved = false;
103
103
  const streamReadStartedMs = Date.now();
104
104
  let lastChunkWithUsage: Program | null = null;
105
- const responseChunks: unknown[] = [];
105
+ const responseChunks: Program[] = [];
106
106
  let respBytes = 0;
107
107
  while (true) {
108
108
  const { done, value } = await reader.read();
@@ -137,21 +137,21 @@ export async function* streamProvider(
137
137
  if (data === "[DONE]") {
138
138
  emitProviderUsage(
139
139
  providerCtx,
140
+ resolvedRequest,
140
141
  lastChunkWithUsage,
141
142
  provider.name,
142
143
  reqBytes,
143
144
  respBytes,
144
- jsonBody(reqBody),
145
145
  responseChunks,
146
146
  );
147
147
  return;
148
148
  }
149
149
  if (data[0] !== "{") continue;
150
- responseChunks.push(jsonBody(encoder.encode(data)));
151
150
  const chunk = parseProviderStreamChunk(
152
151
  provider.style,
153
152
  encoder.encode(data),
154
153
  );
154
+ responseChunks.push(chunk);
155
155
  if (usageObject(chunk) !== undefined) lastChunkWithUsage = chunk;
156
156
  yield chunk;
157
157
  }
@@ -163,32 +163,32 @@ export async function* streamProvider(
163
163
  if (data === "[DONE]") {
164
164
  emitProviderUsage(
165
165
  providerCtx,
166
+ resolvedRequest,
166
167
  lastChunkWithUsage,
167
168
  provider.name,
168
169
  reqBytes,
169
170
  respBytes,
170
- jsonBody(reqBody),
171
171
  responseChunks,
172
172
  );
173
173
  return;
174
174
  }
175
175
  if (data[0] !== "{") continue;
176
- responseChunks.push(jsonBody(encoder.encode(data)));
177
176
  const chunk = parseProviderStreamChunk(
178
177
  provider.style,
179
178
  encoder.encode(data),
180
179
  );
180
+ responseChunks.push(chunk);
181
181
  if (usageObject(chunk) !== undefined) lastChunkWithUsage = chunk;
182
182
  yield chunk;
183
183
  }
184
184
  }
185
185
  emitProviderUsage(
186
186
  providerCtx,
187
+ resolvedRequest,
187
188
  lastChunkWithUsage,
188
189
  provider.name,
189
190
  reqBytes,
190
191
  respBytes,
191
- jsonBody(reqBody),
192
192
  responseChunks,
193
193
  );
194
194
  } catch (error) {
@@ -199,11 +199,3 @@ export async function* streamProvider(
199
199
  timed.cleanup();
200
200
  }
201
201
  }
202
-
203
- function jsonBody(body: Uint8Array): unknown {
204
- try {
205
- return JSON.parse(new TextDecoder().decode(body));
206
- } catch {
207
- return undefined;
208
- }
209
- }
@@ -1,6 +1,11 @@
1
1
  import { usageObject, type Program } from "@neutrome/lil-engine";
2
2
  import { createExecutionEvent } from "@neutrome/lilsdk";
3
3
  import { z } from "zod";
4
+ import {
5
+ normalizeChatRequest,
6
+ normalizeChatResponse,
7
+ normalizeChatStream,
8
+ } from "../telemetry/chat-normalization.ts";
4
9
  import type { ProviderInvocationContext } from "./execution-types.ts";
5
10
 
6
11
  const encoder = new TextEncoder();
@@ -9,12 +14,12 @@ const TokenCountSchema = z.number().finite().nonnegative();
9
14
 
10
15
  export function emitProviderUsage(
11
16
  providerCtx: ProviderInvocationContext,
17
+ request: Program,
12
18
  response: Program | null,
13
19
  provider: string,
14
20
  requestBytes: number,
15
21
  responseBytes: number,
16
- input?: unknown,
17
- output?: unknown,
22
+ streamChunks?: readonly Program[],
18
23
  ): void {
19
24
  const usage = response ? usageObject(response) : undefined;
20
25
  const u = (usage && typeof usage === "object" ? usage : {}) as Record<
@@ -43,8 +48,12 @@ export function emitProviderUsage(
43
48
  cachedTokensInput,
44
49
  requestBytes,
45
50
  responseBytes,
46
- ...(input === undefined ? {} : { input }),
47
- ...(output === undefined ? {} : { output }),
51
+ input: normalizeChatRequest(request),
52
+ output: streamChunks
53
+ ? normalizeChatStream(streamChunks)
54
+ : response
55
+ ? normalizeChatResponse(response)
56
+ : undefined,
48
57
  },
49
58
  }),
50
59
  );
@@ -0,0 +1,108 @@
1
+ import {
2
+ decodeStreamToolDelta,
3
+ emitChatCompletionsRequest,
4
+ emitChatCompletionsResponse,
5
+ type Program,
6
+ Opcode,
7
+ usageObject,
8
+ } from "@neutrome/lil-engine";
9
+
10
+ const decoder = new TextDecoder();
11
+
12
+ /** The trace wire format is always a non-streaming Chat Completions payload. */
13
+ export function normalizeChatRequest(
14
+ program: Program,
15
+ ): Record<string, unknown> {
16
+ const request = json(emitChatCompletionsRequest(program));
17
+ delete request.stream;
18
+ return request;
19
+ }
20
+
21
+ export function normalizeChatResponse(
22
+ program: Program,
23
+ ): Record<string, unknown> {
24
+ return json(emitChatCompletionsResponse(program));
25
+ }
26
+
27
+ export function normalizeChatStream(
28
+ chunks: readonly Program[],
29
+ ): Record<string, unknown> {
30
+ const content: string[] = [];
31
+ const reasoning: string[] = [];
32
+ const tools = new Map<
33
+ number,
34
+ { id?: string; name?: string; arguments: string }
35
+ >();
36
+ let id: string | undefined;
37
+ let model: string | undefined;
38
+ let finishReason = "stop";
39
+ let usage: unknown;
40
+
41
+ for (const chunk of chunks) {
42
+ const chunkUsage = usageObject(chunk);
43
+ if (chunkUsage !== undefined) usage = chunkUsage;
44
+ for (const instruction of chunk.code) {
45
+ if (
46
+ instruction.opcode === Opcode.RESP_ID &&
47
+ instruction.value.kind === "string"
48
+ )
49
+ id = instruction.value.value;
50
+ if (
51
+ instruction.opcode === Opcode.RESP_MODEL &&
52
+ instruction.value.kind === "string"
53
+ )
54
+ model = instruction.value.value;
55
+ if (
56
+ instruction.opcode === Opcode.RESP_DONE &&
57
+ instruction.value.kind === "string"
58
+ )
59
+ finishReason = instruction.value.value;
60
+ if (
61
+ instruction.opcode === Opcode.STREAM_DELTA &&
62
+ instruction.value.kind === "string"
63
+ )
64
+ content.push(instruction.value.value);
65
+ if (
66
+ instruction.opcode === Opcode.STREAM_THINK_DELTA &&
67
+ instruction.value.kind === "string"
68
+ )
69
+ reasoning.push(instruction.value.value);
70
+ if (instruction.opcode !== Opcode.STREAM_TOOL_DELTA) continue;
71
+ const delta = decodeStreamToolDelta(instruction);
72
+ if (!delta) continue;
73
+ const tool = tools.get(delta.index) ?? { arguments: "" };
74
+ if (delta.id !== undefined) tool.id = delta.id;
75
+ if (delta.name !== undefined) tool.name = delta.name;
76
+ if (delta.arguments !== undefined) tool.arguments += delta.arguments;
77
+ tools.set(delta.index, tool);
78
+ }
79
+ }
80
+
81
+ const message: Record<string, unknown> = {
82
+ role: "assistant",
83
+ content: content.join("") || null,
84
+ };
85
+ if (reasoning.length > 0) message.reasoning_content = reasoning.join("");
86
+ if (tools.size > 0) {
87
+ message.tool_calls = [...tools.entries()].map(([index, tool]) => ({
88
+ index,
89
+ ...(tool.id ? { id: tool.id } : {}),
90
+ type: "function",
91
+ function: {
92
+ ...(tool.name ? { name: tool.name } : {}),
93
+ arguments: tool.arguments,
94
+ },
95
+ }));
96
+ }
97
+ return {
98
+ object: "chat.completion",
99
+ ...(id ? { id } : {}),
100
+ ...(model ? { model } : {}),
101
+ choices: [{ index: 0, message, finish_reason: finishReason }],
102
+ ...(usage === undefined ? {} : { usage }),
103
+ };
104
+ }
105
+
106
+ function json(bytes: Uint8Array): Record<string, unknown> {
107
+ return JSON.parse(decoder.decode(bytes)) as Record<string, unknown>;
108
+ }
@@ -11,3 +11,4 @@ export type {
11
11
  ExecutionSummarySpan,
12
12
  RouterTelemetry,
13
13
  } from "./router.ts";
14
+ export type { ExecutionTimelineEvent } from "./timeline.ts";
@@ -1,6 +1,19 @@
1
1
  import type { ExecutionEvent } from "@neutrome/lilsdk";
2
- import { z } from "zod";
3
2
  import { createPosthogTelemetryDrivers } from "./posthog.ts";
3
+ import {
4
+ appendEvent,
5
+ createSpan,
6
+ elapsed,
7
+ eventData,
8
+ failSpan,
9
+ finishSpan,
10
+ isInvocationFinished,
11
+ isInvocationStarted,
12
+ mergeSpan,
13
+ setUsage,
14
+ type ExecutionSummary,
15
+ type ExecutionSummarySpan,
16
+ } from "./timeline.ts";
4
17
  import type {
5
18
  ErrorTelemetryDriver,
6
19
  TelemetryError,
@@ -8,36 +21,7 @@ import type {
8
21
  TraceTelemetryDriver,
9
22
  } from "./types.ts";
10
23
 
11
- export type ExecutionSummary = {
12
- spanId: string;
13
- startedAt: string;
14
- finishedAt: string;
15
- spans: ExecutionSummarySpan[];
16
- totalTokensInput: number;
17
- totalTokensOutput: number;
18
- totalCachedTokensInput: number;
19
- error?: string;
20
- };
21
-
22
- export type ExecutionSummarySpan = {
23
- kind: "request" | "provider" | "tool" | "executor";
24
- event: string;
25
- requestId: string;
26
- executionId: string;
27
- parentExecutionId?: string;
28
- model?: string;
29
- provider?: string;
30
- toolName?: string;
31
- executor?: string;
32
- startedAt: string;
33
- finishedAt?: string;
34
- tokensInput: number;
35
- tokensOutput: number;
36
- cachedTokensInput: number;
37
- requestBytes: number;
38
- responseBytes: number;
39
- error?: string;
40
- };
24
+ export type { ExecutionSummary, ExecutionSummarySpan } from "./timeline.ts";
41
25
 
42
26
  export type RouterTelemetry = {
43
27
  observe(event: ExecutionEvent): void;
@@ -48,29 +32,9 @@ export type RouterTelemetry = {
48
32
 
49
33
  type ActiveSpan = {
50
34
  summary: ExecutionSummarySpan;
51
- input?: unknown;
52
- output?: unknown;
53
35
  timeToFirstTokenMs?: number;
54
36
  };
55
37
 
56
- const EventDataSchema = z
57
- .object({
58
- startedAt: z.string().min(1).optional(),
59
- finishedAt: z.string().min(1).optional(),
60
- message: z.string().min(1).optional(),
61
- model: z.string().min(1).optional(),
62
- provider: z.string().min(1).optional(),
63
- toolName: z.string().min(1).optional(),
64
- executor: z.string().min(1).optional(),
65
- executorId: z.string().min(1).optional(),
66
- tokensInput: z.number().finite().nonnegative().optional(),
67
- tokensOutput: z.number().finite().nonnegative().optional(),
68
- cachedTokensInput: z.number().finite().nonnegative().optional(),
69
- requestBytes: z.number().finite().nonnegative().optional(),
70
- responseBytes: z.number().finite().nonnegative().optional(),
71
- })
72
- .passthrough();
73
-
74
38
  export function createRouterTelemetry(
75
39
  input: {
76
40
  env?: Record<string, unknown>;
@@ -86,238 +50,153 @@ export function createRouterTelemetry(
86
50
  });
87
51
  const traceDriver = input.traceDriver ?? drivers.traceDriver;
88
52
  const errorDriver = input.errorDriver ?? drivers.errorDriver;
89
- const traceId = crypto.randomUUID().replaceAll("-", "");
53
+ const spanId = crypto.randomUUID().replaceAll("-", "");
90
54
  const startedAt = new Date().toISOString();
91
55
  const spans: ExecutionSummarySpan[] = [];
92
- const activeSpans = new Map<string, ActiveSpan>();
93
- const providerGenerations: ActiveSpan[] = [];
56
+ const active = new Map<string, ActiveSpan>();
57
+ const generations: ActiveSpan[] = [];
94
58
  const errors = new Map<string, TelemetryError>();
95
- let error: string | undefined;
59
+ let traceError: string | undefined;
60
+ let finalizedAt: string | undefined;
61
+ let flushed = false;
96
62
 
97
63
  return { observe, recordError, flush, summary };
98
64
 
99
65
  function observe(event: ExecutionEvent): void {
100
- const timing = eventTiming(event);
66
+ const data = eventData(event);
101
67
  if (isInvocationStarted(event.kind)) {
102
- const active = {
103
- summary: createSpan(event, timing.startedAt ?? event.timestamp),
104
- };
105
- spans.push(active.summary);
106
- activeSpans.set(event.executionId, active);
107
- return;
108
- }
109
- if (isInvocationFinished(event.kind)) {
110
- const active = activeSpans.get(event.executionId);
111
- if (active) {
112
- active.summary.finishedAt = timing.finishedAt ?? event.timestamp;
113
- activeSpans.delete(event.executionId);
114
- if (active.summary.kind === "provider")
115
- providerGenerations.push(active);
116
- }
117
- return;
118
- }
119
- if (event.kind === "provider.usage") {
120
- const active = activeSpans.get(event.executionId);
121
- if (active) applyUsage(active, event);
122
- return;
123
- }
124
- if (event.kind === "provider.stream_first_byte") {
125
- const active = activeSpans.get(event.executionId);
126
- if (active) {
127
- active.timeToFirstTokenMs = Math.max(
128
- 0,
129
- Date.parse(timing.finishedAt ?? event.timestamp) -
130
- Date.parse(active.summary.startedAt),
131
- );
132
- }
133
- const span = createSpan(event, timing.startedAt ?? event.timestamp);
134
- span.finishedAt = timing.finishedAt ?? event.timestamp;
135
- spans.push(span);
68
+ const summary = createSpan(event, data.startedAt ?? event.timestamp);
69
+ const span = { summary };
70
+ spans.push(summary);
71
+ active.set(event.executionId, span);
72
+ appendEvent(summary, event, data);
136
73
  return;
137
74
  }
75
+ const span = active.get(event.executionId);
76
+ if (!span) return observeDetached(event, data);
77
+
78
+ appendEvent(span.summary, event, data);
79
+ mergeSpan(span.summary, data);
80
+ if (event.kind === "provider.usage") setUsage(span.summary, data);
81
+ if (event.kind === "provider.stream_first_byte")
82
+ span.timeToFirstTokenMs = elapsed(
83
+ span.summary.startedAt,
84
+ event.timestamp,
85
+ );
138
86
  if (event.kind === "execution.failed") {
139
- const message = eventData(event).message ?? "Execution failed";
140
- error ??= message;
141
- const active = activeSpans.get(event.executionId);
142
- if (active) {
143
- active.summary.error = message;
144
- active.summary.finishedAt ??= event.timestamp;
145
- }
87
+ const message = data.message ?? "Execution failed";
88
+ failSpan(span.summary, message, event.timestamp);
146
89
  recordError({
147
90
  message,
148
91
  requestId: event.requestId,
149
92
  executionId: event.executionId,
150
93
  });
94
+ }
95
+ if (!isInvocationFinished(event.kind)) return;
96
+ finishSpan(span.summary, data.finishedAt ?? event.timestamp);
97
+ active.delete(event.executionId);
98
+ if (span.summary.kind === "provider") generations.push(span);
99
+ }
100
+
101
+ function observeDetached(
102
+ event: ExecutionEvent,
103
+ data: ReturnType<typeof eventData>,
104
+ ): void {
105
+ if (event.kind === "execution.failed") {
106
+ recordError({
107
+ message: data.message ?? "Execution failed",
108
+ requestId: event.requestId,
109
+ executionId: event.executionId,
110
+ });
151
111
  return;
152
112
  }
153
- if (!timing.startedAt) return;
154
- const span = createSpan(event, timing.startedAt);
155
- span.finishedAt = timing.finishedAt ?? event.timestamp;
156
- spans.push(span);
113
+ if (event.kind !== "tool.executed" && !data.startedAt) return;
114
+ const summary = createSpan(event, data.startedAt ?? event.timestamp);
115
+ finishSpan(summary, data.finishedAt ?? event.timestamp);
116
+ appendEvent(summary, event, data);
117
+ spans.push(summary);
157
118
  }
158
119
 
159
120
  function recordError(input: Omit<TelemetryError, "traceId">): void {
160
- error ??= input.message;
161
- const telemetryError = { ...input, traceId };
121
+ traceError ??= input.message;
122
+ const error = { ...input, traceId: spanId };
162
123
  errors.set(
163
- `${telemetryError.requestId}:${telemetryError.executionId}:${telemetryError.message}`,
164
- telemetryError,
124
+ `${error.requestId}:${error.executionId}:${error.message}`,
125
+ error,
165
126
  );
166
127
  }
167
128
 
168
129
  async function flush(): Promise<void> {
169
- const finishedAt = new Date().toISOString();
170
- for (const active of activeSpans.values()) {
171
- active.summary.finishedAt ??= finishedAt;
130
+ if (flushed) return;
131
+ flushed = true;
132
+ const finishedAt = finalizedAt ?? new Date().toISOString();
133
+ finalizedAt = finishedAt;
134
+ for (const span of active.values()) {
135
+ finishSpan(span.summary, finishedAt, "incomplete");
136
+ if (span.summary.kind === "provider") generations.push(span);
172
137
  }
173
- const generations = [
174
- ...providerGenerations,
175
- ...[...activeSpans.values()].filter(
176
- ({ summary }) => summary.kind === "provider",
177
- ),
178
- ].map(toGeneration);
179
- activeSpans.clear();
138
+ active.clear();
180
139
  await reportAll([
181
- ...generations.map((generation) =>
182
- traceDriver?.recordGeneration(generation),
183
- ),
184
- ...[...errors.values()].map((telemetryError) =>
185
- errorDriver?.recordError(telemetryError),
140
+ ...generations.map((span) =>
141
+ traceDriver?.recordGeneration(toGeneration(span)),
186
142
  ),
143
+ ...[...errors.values()].map((error) => errorDriver?.recordError(error)),
187
144
  ]);
188
145
  }
189
146
 
190
147
  function summary(): ExecutionSummary {
191
- const finishedAt = new Date().toISOString();
148
+ const finishedAt = finalizedAt ?? new Date().toISOString();
192
149
  return {
193
- spanId: traceId,
150
+ spanId,
194
151
  startedAt,
195
152
  finishedAt,
153
+ durationMs: elapsed(startedAt, finishedAt),
196
154
  spans,
197
- totalTokensInput: spans.reduce(
198
- (total, span) => total + span.tokensInput,
199
- 0,
200
- ),
201
- totalTokensOutput: spans.reduce(
202
- (total, span) => total + span.tokensOutput,
203
- 0,
204
- ),
205
- totalCachedTokensInput: spans.reduce(
206
- (total, span) => total + span.cachedTokensInput,
207
- 0,
208
- ),
209
- ...(error ? { error } : {}),
155
+ totalTokensInput: sum("tokensInput"),
156
+ totalTokensOutput: sum("tokensOutput"),
157
+ totalCachedTokensInput: sum("cachedTokensInput"),
158
+ ...(traceError ? { error: traceError } : {}),
210
159
  };
211
160
  }
212
161
 
213
- function toGeneration(active: ActiveSpan): TraceGeneration {
214
- const { summary } = active;
162
+ function sum(
163
+ field: "tokensInput" | "tokensOutput" | "cachedTokensInput",
164
+ ): number {
165
+ return spans.reduce((total, span) => total + span[field], 0);
166
+ }
167
+
168
+ function toGeneration(activeSpan: ActiveSpan): TraceGeneration {
169
+ const span = activeSpan.summary;
215
170
  return {
216
- traceId,
217
- requestId: summary.requestId,
218
- executionId: summary.executionId,
219
- ...(summary.parentExecutionId
220
- ? { parentExecutionId: summary.parentExecutionId }
171
+ traceId: spanId,
172
+ requestId: span.requestId,
173
+ executionId: span.executionId,
174
+ ...(span.parentExecutionId
175
+ ? { parentExecutionId: span.parentExecutionId }
221
176
  : {}),
222
- provider: summary.provider ?? "unknown",
223
- ...(summary.model ? { model: summary.model } : {}),
224
- startedAt: summary.startedAt,
225
- finishedAt: summary.finishedAt ?? new Date().toISOString(),
226
- tokensInput: summary.tokensInput,
227
- tokensOutput: summary.tokensOutput,
228
- cachedTokensInput: summary.cachedTokensInput,
229
- requestBytes: summary.requestBytes,
230
- responseBytes: summary.responseBytes,
231
- ...(active.input === undefined ? {} : { input: active.input }),
232
- ...(active.output === undefined ? {} : { output: active.output }),
233
- ...(active.timeToFirstTokenMs === undefined
177
+ provider: span.provider ?? "unknown",
178
+ ...(span.model ? { model: span.model } : {}),
179
+ startedAt: span.startedAt,
180
+ finishedAt: span.finishedAt ?? finalizedAt ?? new Date().toISOString(),
181
+ tokensInput: span.tokensInput,
182
+ tokensOutput: span.tokensOutput,
183
+ cachedTokensInput: span.cachedTokensInput,
184
+ requestBytes: span.requestBytes,
185
+ responseBytes: span.responseBytes,
186
+ ...(span.request ? { input: span.request } : {}),
187
+ ...(span.response ? { output: span.response } : {}),
188
+ ...(activeSpan.timeToFirstTokenMs === undefined
234
189
  ? {}
235
- : { timeToFirstTokenMs: active.timeToFirstTokenMs }),
190
+ : { timeToFirstTokenMs: activeSpan.timeToFirstTokenMs }),
236
191
  };
237
192
  }
238
193
  }
239
194
 
240
- function createSpan(
241
- event: ExecutionEvent,
242
- startedAt: string,
243
- ): ExecutionSummarySpan {
244
- return {
245
- kind: spanKind(event.kind),
246
- event: event.kind,
247
- requestId: event.requestId,
248
- executionId: event.executionId,
249
- ...(event.parentExecutionId
250
- ? { parentExecutionId: event.parentExecutionId }
251
- : {}),
252
- ...eventDetails(event),
253
- startedAt,
254
- tokensInput: 0,
255
- tokensOutput: 0,
256
- cachedTokensInput: 0,
257
- requestBytes: 0,
258
- responseBytes: 0,
259
- };
260
- }
261
-
262
- function applyUsage(active: ActiveSpan, event: ExecutionEvent): void {
263
- const { summary } = active;
264
- const data = eventData(event);
265
- summary.tokensInput = data.tokensInput ?? 0;
266
- summary.tokensOutput = data.tokensOutput ?? 0;
267
- summary.cachedTokensInput = data.cachedTokensInput ?? 0;
268
- summary.requestBytes = data.requestBytes ?? 0;
269
- summary.responseBytes = data.responseBytes ?? 0;
270
- active.input = event.data?.input;
271
- active.output = event.data?.output;
272
- }
273
-
274
- function eventTiming(event: ExecutionEvent) {
275
- return {
276
- startedAt: eventData(event).startedAt,
277
- finishedAt: eventData(event).finishedAt,
278
- };
279
- }
280
-
281
- function isInvocationStarted(kind: string): boolean {
282
- return kind.endsWith(".started") || kind.endsWith(".stream_started");
283
- }
284
- function isInvocationFinished(kind: string): boolean {
285
- return kind.endsWith(".finished") || kind.endsWith(".stream_finished");
286
- }
287
- function spanKind(kind: string): ExecutionSummarySpan["kind"] {
288
- if (kind.startsWith("provider.")) return "provider";
289
- if (kind.startsWith("tool.")) return "tool";
290
- if (kind.startsWith("executor.")) return "executor";
291
- return "request";
292
- }
293
- function eventDetails(event: ExecutionEvent): Partial<ExecutionSummarySpan> {
294
- return {
295
- ...optional("model", eventData(event).model),
296
- ...optional("provider", eventData(event).provider),
297
- ...optional("toolName", eventData(event).toolName),
298
- ...optional(
299
- "executor",
300
- eventData(event).executor ?? eventData(event).executorId,
301
- ),
302
- };
303
- }
304
195
  async function reportAll(
305
196
  reports: Array<Promise<void> | undefined>,
306
197
  ): Promise<void> {
307
- const results = await Promise.allSettled(reports.filter(Boolean));
308
- for (const result of results) {
309
- if (result.status === "rejected") {
310
- console.error("[telemetry] PostHog capture failed", result.reason);
311
- }
198
+ for (const result of await Promise.allSettled(reports.filter(Boolean))) {
199
+ if (result.status === "rejected")
200
+ console.error("[telemetry] report failed", result.reason);
312
201
  }
313
202
  }
314
- function eventData(event: ExecutionEvent): z.infer<typeof EventDataSchema> {
315
- const parsed = EventDataSchema.safeParse(event.data ?? {});
316
- return parsed.success ? parsed.data : {};
317
- }
318
- function optional<K extends string>(
319
- key: K,
320
- value: string | undefined,
321
- ): Partial<Record<K, string>> {
322
- return value ? ({ [key]: value } as Record<K, string>) : {};
323
- }
@@ -0,0 +1,198 @@
1
+ import type { ExecutionEvent } from "@neutrome/lilsdk";
2
+ import { z } from "zod";
3
+
4
+ export type ExecutionTimelineEvent = {
5
+ event: string;
6
+ at: string;
7
+ startedAt?: string;
8
+ finishedAt?: string;
9
+ durationMs?: number;
10
+ data?: Record<string, unknown>;
11
+ };
12
+
13
+ export type ExecutionSummarySpan = {
14
+ kind: "request" | "provider" | "tool" | "executor";
15
+ event: string;
16
+ requestId: string;
17
+ executionId: string;
18
+ parentExecutionId?: string;
19
+ model?: string;
20
+ provider?: string;
21
+ toolName?: string;
22
+ executor?: string;
23
+ startedAt: string;
24
+ finishedAt?: string;
25
+ durationMs?: number;
26
+ status: "ok" | "error" | "incomplete";
27
+ tokensInput: number;
28
+ tokensOutput: number;
29
+ cachedTokensInput: number;
30
+ requestBytes: number;
31
+ responseBytes: number;
32
+ request?: Record<string, unknown>;
33
+ response?: Record<string, unknown>;
34
+ error?: string;
35
+ timeline: ExecutionTimelineEvent[];
36
+ };
37
+
38
+ export type ExecutionSummary = {
39
+ spanId: string;
40
+ startedAt: string;
41
+ finishedAt: string;
42
+ durationMs: number;
43
+ spans: ExecutionSummarySpan[];
44
+ totalTokensInput: number;
45
+ totalTokensOutput: number;
46
+ totalCachedTokensInput: number;
47
+ error?: string;
48
+ };
49
+
50
+ export const EventDataSchema = z
51
+ .object({
52
+ startedAt: z.string().min(1).optional(),
53
+ finishedAt: z.string().min(1).optional(),
54
+ durationMs: z.number().finite().nonnegative().optional(),
55
+ message: z.string().min(1).optional(),
56
+ model: z.string().min(1).optional(),
57
+ provider: z.string().min(1).optional(),
58
+ toolName: z.string().min(1).optional(),
59
+ executor: z.string().min(1).optional(),
60
+ executorId: z.string().min(1).optional(),
61
+ tokensInput: z.number().finite().nonnegative().optional(),
62
+ tokensOutput: z.number().finite().nonnegative().optional(),
63
+ cachedTokensInput: z.number().finite().nonnegative().optional(),
64
+ requestBytes: z.number().finite().nonnegative().optional(),
65
+ responseBytes: z.number().finite().nonnegative().optional(),
66
+ input: z.record(z.string(), z.unknown()).optional(),
67
+ output: z.record(z.string(), z.unknown()).optional(),
68
+ })
69
+ .passthrough();
70
+
71
+ export type EventData = z.infer<typeof EventDataSchema>;
72
+
73
+ export function eventData(event: ExecutionEvent): EventData {
74
+ const parsed = EventDataSchema.safeParse(event.data ?? {});
75
+ return parsed.success ? parsed.data : {};
76
+ }
77
+
78
+ export function createSpan(
79
+ event: ExecutionEvent,
80
+ startedAt: string,
81
+ ): ExecutionSummarySpan {
82
+ return {
83
+ kind: spanKind(event.kind),
84
+ event: event.kind,
85
+ requestId: event.requestId,
86
+ executionId: event.executionId,
87
+ ...(event.parentExecutionId
88
+ ? { parentExecutionId: event.parentExecutionId }
89
+ : {}),
90
+ ...details(eventData(event)),
91
+ startedAt,
92
+ status: "incomplete",
93
+ tokensInput: 0,
94
+ tokensOutput: 0,
95
+ cachedTokensInput: 0,
96
+ requestBytes: 0,
97
+ responseBytes: 0,
98
+ timeline: [],
99
+ };
100
+ }
101
+
102
+ export function appendEvent(
103
+ span: ExecutionSummarySpan,
104
+ event: ExecutionEvent,
105
+ data: EventData,
106
+ ): void {
107
+ span.timeline.push({
108
+ event: event.kind,
109
+ at: event.timestamp,
110
+ ...(data.startedAt ? { startedAt: data.startedAt } : {}),
111
+ ...(data.finishedAt ? { finishedAt: data.finishedAt } : {}),
112
+ ...(data.durationMs === undefined ? {} : { durationMs: data.durationMs }),
113
+ ...(Object.keys(data).length ? { data: scrub(data) } : {}),
114
+ });
115
+ }
116
+
117
+ export function mergeSpan(span: ExecutionSummarySpan, data: EventData): void {
118
+ Object.assign(span, details(data));
119
+ if (data.tokensInput !== undefined) span.tokensInput = data.tokensInput;
120
+ if (data.tokensOutput !== undefined) span.tokensOutput = data.tokensOutput;
121
+ if (data.cachedTokensInput !== undefined)
122
+ span.cachedTokensInput = data.cachedTokensInput;
123
+ if (data.requestBytes !== undefined) span.requestBytes = data.requestBytes;
124
+ if (data.responseBytes !== undefined) span.responseBytes = data.responseBytes;
125
+ if (data.input) span.request = data.input;
126
+ if (data.output) span.response = data.output;
127
+ }
128
+
129
+ export function setUsage(span: ExecutionSummarySpan, data: EventData): void {
130
+ span.tokensInput = data.tokensInput ?? 0;
131
+ span.tokensOutput = data.tokensOutput ?? 0;
132
+ span.cachedTokensInput = data.cachedTokensInput ?? 0;
133
+ span.requestBytes = data.requestBytes ?? 0;
134
+ span.responseBytes = data.responseBytes ?? 0;
135
+ if (data.input) span.request = data.input;
136
+ if (data.output) span.response = data.output;
137
+ }
138
+
139
+ export function finishSpan(
140
+ span: ExecutionSummarySpan,
141
+ finishedAt: string,
142
+ status: ExecutionSummarySpan["status"] = "ok",
143
+ ): void {
144
+ span.finishedAt = finishedAt;
145
+ span.durationMs = elapsed(span.startedAt, finishedAt);
146
+ if (span.status !== "error") span.status = status;
147
+ }
148
+
149
+ export function failSpan(
150
+ span: ExecutionSummarySpan,
151
+ message: string,
152
+ at: string,
153
+ ): void {
154
+ span.error = message;
155
+ span.status = "error";
156
+ finishSpan(span, span.finishedAt ?? at, "error");
157
+ }
158
+
159
+ export function elapsed(startedAt: string, finishedAt: string): number {
160
+ return Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
161
+ }
162
+
163
+ export function isInvocationStarted(kind: string): boolean {
164
+ return kind.endsWith(".started") || kind.endsWith(".stream_started");
165
+ }
166
+
167
+ export function isInvocationFinished(kind: string): boolean {
168
+ return kind.endsWith(".finished") || kind.endsWith(".stream_finished");
169
+ }
170
+
171
+ function scrub(data: EventData): Record<string, unknown> {
172
+ const {
173
+ input: _input,
174
+ output: _output,
175
+ startedAt: _start,
176
+ finishedAt: _end,
177
+ durationMs: _duration,
178
+ ...rest
179
+ } = data;
180
+ return rest;
181
+ }
182
+
183
+ function details(data: EventData): Partial<ExecutionSummarySpan> {
184
+ const executor = data.executor ?? data.executorId;
185
+ return {
186
+ ...(data.model ? { model: data.model } : {}),
187
+ ...(data.provider ? { provider: data.provider } : {}),
188
+ ...(data.toolName ? { toolName: data.toolName } : {}),
189
+ ...(executor ? { executor } : {}),
190
+ };
191
+ }
192
+
193
+ function spanKind(kind: string): ExecutionSummarySpan["kind"] {
194
+ if (kind.startsWith("provider.")) return "provider";
195
+ if (kind.startsWith("tool.")) return "tool";
196
+ if (kind.startsWith("executor.")) return "executor";
197
+ return "request";
198
+ }
@@ -1,10 +1,12 @@
1
1
  import { createExecutionEvent } from "@neutrome/lilsdk";
2
+ import { streamEnd, streamStart, streamTextDelta } from "@neutrome/lil-engine";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import {
4
5
  createRouterTelemetry,
5
6
  PosthogErrorTelemetry,
6
7
  PosthogTraceTelemetry,
7
8
  } from "./telemetry/index.ts";
9
+ import { normalizeChatStream } from "./telemetry/chat-normalization.ts";
8
10
 
9
11
  describe("createRouterTelemetry", () => {
10
12
  it("reports provider generations and exceptions through separate drivers", async () => {
@@ -65,6 +67,83 @@ describe("createRouterTelemetry", () => {
65
67
  expect(errorDriver.recordError).toHaveBeenCalledWith(
66
68
  expect.objectContaining({ message: "tool unavailable" }),
67
69
  );
70
+ expect(telemetry.summary().spans[0]).toMatchObject({
71
+ status: "ok",
72
+ durationMs: 1000,
73
+ request: { messages: [{ role: "user", content: "hello" }] },
74
+ timeline: expect.arrayContaining([
75
+ expect.objectContaining({ event: "provider.usage" }),
76
+ ]),
77
+ });
78
+ });
79
+
80
+ it("converts streamed programs into one non-streaming chat completion", async () => {
81
+ const telemetry = createRouterTelemetry();
82
+ telemetry.observe(
83
+ createExecutionEvent({
84
+ kind: "provider.stream_started",
85
+ requestId: "request-1",
86
+ executionId: "provider-1",
87
+ data: {
88
+ startedAt: "2026-07-13T12:00:00.000Z",
89
+ provider: "openrouter",
90
+ model: "gpt-5-chat",
91
+ },
92
+ }),
93
+ );
94
+ telemetry.observe(
95
+ createExecutionEvent({
96
+ kind: "provider.usage",
97
+ requestId: "request-1",
98
+ executionId: "provider-1",
99
+ data: {
100
+ input: {
101
+ model: "gpt-5-chat",
102
+ messages: [{ role: "user", content: "hello" }],
103
+ },
104
+ output: {
105
+ object: "chat.completion",
106
+ choices: [{ message: { role: "assistant", content: "hello" } }],
107
+ },
108
+ },
109
+ }),
110
+ );
111
+ telemetry.observe(
112
+ createExecutionEvent({
113
+ kind: "provider.stream_finished",
114
+ requestId: "request-1",
115
+ executionId: "provider-1",
116
+ data: { finishedAt: "2026-07-13T12:00:01.000Z" },
117
+ }),
118
+ );
119
+
120
+ expect(telemetry.summary().spans[0]).toMatchObject({
121
+ request: { model: "gpt-5-chat" },
122
+ response: { object: "chat.completion" },
123
+ status: "ok",
124
+ });
125
+ });
126
+ });
127
+
128
+ describe("trace chat normalization", () => {
129
+ it("concatenates stream chunks into a chat completion response", () => {
130
+ expect(
131
+ normalizeChatStream([
132
+ streamStart(),
133
+ streamTextDelta("hello "),
134
+ streamTextDelta("world"),
135
+ streamEnd("length"),
136
+ ]),
137
+ ).toEqual({
138
+ object: "chat.completion",
139
+ choices: [
140
+ {
141
+ index: 0,
142
+ message: { role: "assistant", content: "hello world" },
143
+ finish_reason: "length",
144
+ },
145
+ ],
146
+ });
68
147
  });
69
148
  });
70
149