@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 +3 -3
- package/src/app/factory.test.ts +2 -2
- package/src/index.ts +1 -0
- package/src/router/provider-invoker.ts +1 -10
- package/src/router/provider-stream.ts +6 -14
- package/src/router/provider-telemetry.ts +13 -4
- package/src/telemetry/chat-normalization.ts +108 -0
- package/src/telemetry/index.ts +1 -0
- package/src/telemetry/router.ts +113 -234
- package/src/telemetry/timeline.ts +198 -0
- package/src/telemetry.test.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/open-ai-router",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
15
|
-
"@neutrome/lilsdk": "0.4.
|
|
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",
|
package/src/app/factory.test.ts
CHANGED
|
@@ -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.
|
|
221
|
-
(
|
|
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
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
+
}
|
package/src/telemetry/index.ts
CHANGED
package/src/telemetry/router.ts
CHANGED
|
@@ -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
|
|
53
|
+
const spanId = crypto.randomUUID().replaceAll("-", "");
|
|
90
54
|
const startedAt = new Date().toISOString();
|
|
91
55
|
const spans: ExecutionSummarySpan[] = [];
|
|
92
|
-
const
|
|
93
|
-
const
|
|
56
|
+
const active = new Map<string, ActiveSpan>();
|
|
57
|
+
const generations: ActiveSpan[] = [];
|
|
94
58
|
const errors = new Map<string, TelemetryError>();
|
|
95
|
-
let
|
|
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
|
|
66
|
+
const data = eventData(event);
|
|
101
67
|
if (isInvocationStarted(event.kind)) {
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
140
|
-
|
|
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 (!
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
const
|
|
121
|
+
traceError ??= input.message;
|
|
122
|
+
const error = { ...input, traceId: spanId };
|
|
162
123
|
errors.set(
|
|
163
|
-
`${
|
|
164
|
-
|
|
124
|
+
`${error.requestId}:${error.executionId}:${error.message}`,
|
|
125
|
+
error,
|
|
165
126
|
);
|
|
166
127
|
}
|
|
167
128
|
|
|
168
129
|
async function flush(): Promise<void> {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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((
|
|
182
|
-
traceDriver?.recordGeneration(
|
|
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
|
|
150
|
+
spanId,
|
|
194
151
|
startedAt,
|
|
195
152
|
finishedAt,
|
|
153
|
+
durationMs: elapsed(startedAt, finishedAt),
|
|
196
154
|
spans,
|
|
197
|
-
totalTokensInput:
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
214
|
-
|
|
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:
|
|
218
|
-
executionId:
|
|
219
|
-
...(
|
|
220
|
-
? { parentExecutionId:
|
|
171
|
+
traceId: spanId,
|
|
172
|
+
requestId: span.requestId,
|
|
173
|
+
executionId: span.executionId,
|
|
174
|
+
...(span.parentExecutionId
|
|
175
|
+
? { parentExecutionId: span.parentExecutionId }
|
|
221
176
|
: {}),
|
|
222
|
-
provider:
|
|
223
|
-
...(
|
|
224
|
-
startedAt:
|
|
225
|
-
finishedAt:
|
|
226
|
-
tokensInput:
|
|
227
|
-
tokensOutput:
|
|
228
|
-
cachedTokensInput:
|
|
229
|
-
requestBytes:
|
|
230
|
-
responseBytes:
|
|
231
|
-
...(
|
|
232
|
-
...(
|
|
233
|
-
...(
|
|
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:
|
|
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
|
|
308
|
-
|
|
309
|
-
|
|
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
|
+
}
|
package/src/telemetry.test.ts
CHANGED
|
@@ -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
|
|