@neutrome/open-ai-router 0.6.4 → 0.6.5
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 +1 -1
- package/src/router/error-codec.ts +19 -1
- package/src/router/errors.ts +10 -0
- package/src/router/execution-resolution.ts +28 -2
- package/src/router/provider-invoker.ts +3 -0
- package/src/router/provider-stream.ts +3 -0
- package/src/telemetry/posthog.ts +28 -2
- package/src/telemetry/router.ts +76 -4
- package/src/telemetry/timeline.ts +6 -0
- package/src/telemetry/types.ts +18 -0
- package/src/telemetry.test.ts +56 -0
package/package.json
CHANGED
|
@@ -23,7 +23,13 @@ export async function toProviderError(
|
|
|
23
23
|
const summary =
|
|
24
24
|
firstError(program)?.message ||
|
|
25
25
|
`Provider ${provider.name} returned ${response.status}`;
|
|
26
|
-
return new ProviderHttpError(
|
|
26
|
+
return new ProviderHttpError(
|
|
27
|
+
summary,
|
|
28
|
+
response.status,
|
|
29
|
+
program,
|
|
30
|
+
errorBody(body),
|
|
31
|
+
body.byteLength,
|
|
32
|
+
);
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export function errorResponse(
|
|
@@ -168,3 +174,15 @@ export function toBody(bytes: Uint8Array): ArrayBuffer {
|
|
|
168
174
|
bytes.byteOffset + bytes.byteLength,
|
|
169
175
|
) as ArrayBuffer;
|
|
170
176
|
}
|
|
177
|
+
|
|
178
|
+
function errorBody(body: Uint8Array): Record<string, unknown> {
|
|
179
|
+
const text = new TextDecoder().decode(body);
|
|
180
|
+
try {
|
|
181
|
+
const value: unknown = JSON.parse(text);
|
|
182
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
183
|
+
? { json: value }
|
|
184
|
+
: { text };
|
|
185
|
+
} catch {
|
|
186
|
+
return { text };
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/router/errors.ts
CHANGED
|
@@ -28,6 +28,14 @@ export type ExecutionErrorDetails = {
|
|
|
28
28
|
transform?: string;
|
|
29
29
|
target?: string;
|
|
30
30
|
source?: "provider" | "executor";
|
|
31
|
+
upstream?: {
|
|
32
|
+
status: number;
|
|
33
|
+
source?: string;
|
|
34
|
+
kind?: string;
|
|
35
|
+
code?: unknown;
|
|
36
|
+
responseBytes?: number;
|
|
37
|
+
body?: Record<string, unknown>;
|
|
38
|
+
};
|
|
31
39
|
};
|
|
32
40
|
|
|
33
41
|
export class ExecutionError extends Error {
|
|
@@ -74,6 +82,8 @@ export class ProviderHttpError extends Error {
|
|
|
74
82
|
message: string,
|
|
75
83
|
readonly status: number,
|
|
76
84
|
readonly program: import("@neutrome/lil-engine").Program,
|
|
85
|
+
readonly body: Record<string, unknown>,
|
|
86
|
+
readonly responseBytes: number,
|
|
77
87
|
) {
|
|
78
88
|
super(message);
|
|
79
89
|
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import {
|
|
1
|
+
import { firstError, type Program } from "@neutrome/lil-engine";
|
|
2
|
+
import {
|
|
3
|
+
ExecutionError,
|
|
4
|
+
isExecutionError,
|
|
5
|
+
ProviderHttpError,
|
|
6
|
+
} from "./errors.ts";
|
|
3
7
|
import { executionTargetId } from "./targets.ts";
|
|
4
8
|
import type {
|
|
5
9
|
ExecutionRuntimeOptions,
|
|
@@ -95,6 +99,28 @@ export function normalizeExecutionError(
|
|
|
95
99
|
maxDepth: number,
|
|
96
100
|
): ExecutionError {
|
|
97
101
|
if (isExecutionError(error)) return error;
|
|
102
|
+
if (error instanceof ProviderHttpError) {
|
|
103
|
+
const upstream = firstError(error.program);
|
|
104
|
+
return new ExecutionError("provider", error.message, {
|
|
105
|
+
stage: fallbackStage,
|
|
106
|
+
details: {
|
|
107
|
+
depth,
|
|
108
|
+
maxDepth,
|
|
109
|
+
target: executionTargetId(target),
|
|
110
|
+
upstream: {
|
|
111
|
+
status: error.status,
|
|
112
|
+
...(upstream?.source ? { source: upstream.source } : {}),
|
|
113
|
+
...(upstream?.kind ? { kind: upstream.kind } : {}),
|
|
114
|
+
...(upstream?.data.code === undefined
|
|
115
|
+
? {}
|
|
116
|
+
: { code: upstream.data.code }),
|
|
117
|
+
responseBytes: error.responseBytes,
|
|
118
|
+
body: error.body,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
cause: error,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
98
124
|
if (isAbortLike(error)) {
|
|
99
125
|
return new ExecutionError("cancellation", "Execution aborted", {
|
|
100
126
|
stage: fallbackStage,
|
|
@@ -17,6 +17,7 @@ import type { RequestContext } from "../upstream-auth/types.ts";
|
|
|
17
17
|
import { streamProvider } from "./provider-stream.ts";
|
|
18
18
|
import type { RouterRuntime } from "./runtime.ts";
|
|
19
19
|
import { observeTimedExecution } from "./execution-events.ts";
|
|
20
|
+
import { normalizeChatRequest } from "../telemetry/chat-normalization.ts";
|
|
20
21
|
|
|
21
22
|
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120_000;
|
|
22
23
|
|
|
@@ -54,6 +55,8 @@ export function createFetchProviderInvoker(
|
|
|
54
55
|
provider: provider.name,
|
|
55
56
|
model: providerCtx.target.model,
|
|
56
57
|
bytes: reqBody.byteLength,
|
|
58
|
+
requestBytes: reqBody.byteLength,
|
|
59
|
+
input: normalizeChatRequest(resolvedRequest),
|
|
57
60
|
},
|
|
58
61
|
});
|
|
59
62
|
const reqBytes = reqBody.byteLength;
|
|
@@ -21,6 +21,7 @@ import { isSse } from "../util/headers.ts";
|
|
|
21
21
|
import { eventDataLines, splitSseEvents } from "../util/sse.ts";
|
|
22
22
|
import type { RouterRuntime } from "./runtime.ts";
|
|
23
23
|
import { observeTimedExecution } from "./execution-events.ts";
|
|
24
|
+
import { normalizeChatRequest } from "../telemetry/chat-normalization.ts";
|
|
24
25
|
|
|
25
26
|
const encoder = new TextEncoder();
|
|
26
27
|
const decoder = new TextDecoder();
|
|
@@ -58,6 +59,8 @@ export async function* streamProvider(
|
|
|
58
59
|
provider: provider.name,
|
|
59
60
|
model: providerCtx.target.model,
|
|
60
61
|
bytes: reqBody.byteLength,
|
|
62
|
+
requestBytes: reqBody.byteLength,
|
|
63
|
+
input: normalizeChatRequest(resolvedRequest),
|
|
61
64
|
},
|
|
62
65
|
});
|
|
63
66
|
const reqBytes = reqBody.byteLength;
|
package/src/telemetry/posthog.ts
CHANGED
|
@@ -33,6 +33,14 @@ export class PosthogTraceTelemetry implements TraceTelemetryDriver {
|
|
|
33
33
|
...(generation.model ? { model: generation.model } : {}),
|
|
34
34
|
input: generation.input ?? null,
|
|
35
35
|
output: generation.output ?? null,
|
|
36
|
+
...(generation.httpStatus === undefined
|
|
37
|
+
? {}
|
|
38
|
+
: { httpStatus: generation.httpStatus }),
|
|
39
|
+
...(generation.error
|
|
40
|
+
? {
|
|
41
|
+
error: namedError(generation.error.name, generation.error.message),
|
|
42
|
+
}
|
|
43
|
+
: {}),
|
|
36
44
|
latency: elapsedSeconds(generation.startedAt, generation.finishedAt),
|
|
37
45
|
...(generation.timeToFirstTokenMs === undefined
|
|
38
46
|
? {}
|
|
@@ -48,6 +56,12 @@ export class PosthogTraceTelemetry implements TraceTelemetryDriver {
|
|
|
48
56
|
"lil.cached_input_tokens": generation.cachedTokensInput || undefined,
|
|
49
57
|
"lil.request_bytes": generation.requestBytes || undefined,
|
|
50
58
|
"lil.response_bytes": generation.responseBytes || undefined,
|
|
59
|
+
"lil.error_kind": generation.error?.kind,
|
|
60
|
+
"lil.error_stage": generation.error?.stage,
|
|
61
|
+
"lil.error_status": generation.error?.status,
|
|
62
|
+
"lil.error_code": generation.error?.code,
|
|
63
|
+
"lil.error_details": generation.error?.details,
|
|
64
|
+
"lil.upstream_error_body": generation.error?.details?.body,
|
|
51
65
|
}),
|
|
52
66
|
captureImmediate: true,
|
|
53
67
|
});
|
|
@@ -62,12 +76,18 @@ export class PosthogErrorTelemetry implements ErrorTelemetryDriver {
|
|
|
62
76
|
|
|
63
77
|
async recordError(error: TelemetryError): Promise<void> {
|
|
64
78
|
await this.client.captureExceptionImmediate(
|
|
65
|
-
|
|
79
|
+
namedError(error.name ?? "RouterError", error.message),
|
|
66
80
|
this.distinctId,
|
|
67
81
|
{
|
|
68
82
|
"lil.trace_id": error.traceId,
|
|
69
83
|
"lil.request_id": error.requestId,
|
|
70
84
|
"lil.execution_id": error.executionId,
|
|
85
|
+
"lil.error_kind": error.kind,
|
|
86
|
+
"lil.error_stage": error.stage,
|
|
87
|
+
"lil.error_status": error.status,
|
|
88
|
+
"lil.error_code": error.code,
|
|
89
|
+
"lil.error_details": error.details,
|
|
90
|
+
"lil.upstream_error_body": error.details?.body,
|
|
71
91
|
},
|
|
72
92
|
);
|
|
73
93
|
}
|
|
@@ -101,8 +121,14 @@ function elapsedSeconds(startedAt: string, finishedAt: string): number {
|
|
|
101
121
|
return Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)) / 1_000;
|
|
102
122
|
}
|
|
103
123
|
|
|
104
|
-
function compact(values: Record<string,
|
|
124
|
+
function compact(values: Record<string, unknown>) {
|
|
105
125
|
return Object.fromEntries(
|
|
106
126
|
Object.entries(values).filter(([, value]) => value !== undefined),
|
|
107
127
|
);
|
|
108
128
|
}
|
|
129
|
+
|
|
130
|
+
function namedError(name: string, message: string): Error {
|
|
131
|
+
const error = new Error(message);
|
|
132
|
+
error.name = name;
|
|
133
|
+
return error;
|
|
134
|
+
}
|
package/src/telemetry/router.ts
CHANGED
|
@@ -70,6 +70,7 @@ export function createRouterTelemetry(
|
|
|
70
70
|
spans.push(summary);
|
|
71
71
|
active.set(event.executionId, span);
|
|
72
72
|
appendEvent(summary, event, data);
|
|
73
|
+
mergeSpan(summary, data);
|
|
73
74
|
return;
|
|
74
75
|
}
|
|
75
76
|
const span = active.get(event.executionId);
|
|
@@ -84,10 +85,11 @@ export function createRouterTelemetry(
|
|
|
84
85
|
event.timestamp,
|
|
85
86
|
);
|
|
86
87
|
if (event.kind === "execution.failed") {
|
|
87
|
-
const
|
|
88
|
-
failSpan(span.summary, message, event.timestamp);
|
|
88
|
+
const error = telemetryError(event, data);
|
|
89
|
+
failSpan(span.summary, error.message, event.timestamp);
|
|
90
|
+
applyFailure(span.summary, error);
|
|
89
91
|
recordError({
|
|
90
|
-
|
|
92
|
+
...error,
|
|
91
93
|
requestId: event.requestId,
|
|
92
94
|
executionId: event.executionId,
|
|
93
95
|
});
|
|
@@ -104,7 +106,7 @@ export function createRouterTelemetry(
|
|
|
104
106
|
): void {
|
|
105
107
|
if (event.kind === "execution.failed") {
|
|
106
108
|
recordError({
|
|
107
|
-
|
|
109
|
+
...telemetryError(event, data),
|
|
108
110
|
requestId: event.requestId,
|
|
109
111
|
executionId: event.executionId,
|
|
110
112
|
});
|
|
@@ -185,6 +187,22 @@ export function createRouterTelemetry(
|
|
|
185
187
|
responseBytes: span.responseBytes,
|
|
186
188
|
...(span.request ? { input: span.request } : {}),
|
|
187
189
|
...(span.response ? { output: span.response } : {}),
|
|
190
|
+
...(span.error
|
|
191
|
+
? {
|
|
192
|
+
httpStatus: span.errorStatus,
|
|
193
|
+
error: {
|
|
194
|
+
name: span.errorName ?? "ExecutionError",
|
|
195
|
+
message: span.error,
|
|
196
|
+
...(span.errorKind ? { kind: span.errorKind } : {}),
|
|
197
|
+
...(span.errorStage ? { stage: span.errorStage } : {}),
|
|
198
|
+
...(span.errorStatus === undefined
|
|
199
|
+
? {}
|
|
200
|
+
: { status: span.errorStatus }),
|
|
201
|
+
...(span.errorCode === undefined ? {} : { code: span.errorCode }),
|
|
202
|
+
...(span.errorDetails ? { details: span.errorDetails } : {}),
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
: {}),
|
|
188
206
|
...(activeSpan.timeToFirstTokenMs === undefined
|
|
189
207
|
? {}
|
|
190
208
|
: { timeToFirstTokenMs: activeSpan.timeToFirstTokenMs }),
|
|
@@ -192,6 +210,60 @@ export function createRouterTelemetry(
|
|
|
192
210
|
}
|
|
193
211
|
}
|
|
194
212
|
|
|
213
|
+
function applyFailure(
|
|
214
|
+
span: ExecutionSummarySpan,
|
|
215
|
+
error: Omit<TelemetryError, "traceId" | "requestId" | "executionId">,
|
|
216
|
+
): void {
|
|
217
|
+
if (error.name) span.errorName = error.name;
|
|
218
|
+
if (error.kind) span.errorKind = error.kind;
|
|
219
|
+
if (error.stage) span.errorStage = error.stage;
|
|
220
|
+
if (error.status !== undefined) span.errorStatus = error.status;
|
|
221
|
+
if (error.code !== undefined) span.errorCode = error.code;
|
|
222
|
+
if (error.details) {
|
|
223
|
+
span.errorDetails = error.details;
|
|
224
|
+
const responseBytes = finiteNumber(error.details.responseBytes);
|
|
225
|
+
if (responseBytes !== undefined) span.responseBytes = responseBytes;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function telemetryError(
|
|
230
|
+
event: ExecutionEvent,
|
|
231
|
+
data: ReturnType<typeof eventData>,
|
|
232
|
+
): Omit<TelemetryError, "traceId" | "requestId" | "executionId"> {
|
|
233
|
+
const upstream = object(data.upstream);
|
|
234
|
+
const status = finiteNumber(upstream?.status);
|
|
235
|
+
const kind = text(upstream?.kind) ?? event.errorKind;
|
|
236
|
+
const code = upstream?.code;
|
|
237
|
+
const stage = text(data.stage);
|
|
238
|
+
return {
|
|
239
|
+
message: data.message ?? "Execution failed",
|
|
240
|
+
name: ["Router", event.errorKind ?? "error", kind, status]
|
|
241
|
+
.filter((part) => part !== undefined)
|
|
242
|
+
.join("."),
|
|
243
|
+
...(event.errorKind ? { kind: event.errorKind } : {}),
|
|
244
|
+
...(stage ? { stage } : {}),
|
|
245
|
+
...(status === undefined ? {} : { status }),
|
|
246
|
+
...(code === undefined ? {} : { code }),
|
|
247
|
+
...(upstream ? { details: upstream } : {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function object(value: unknown): Record<string, unknown> | undefined {
|
|
252
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
253
|
+
? (value as Record<string, unknown>)
|
|
254
|
+
: undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function text(value: unknown): string | undefined {
|
|
258
|
+
return typeof value === "string" && value ? value : undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
262
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
263
|
+
? value
|
|
264
|
+
: undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
195
267
|
async function reportAll(
|
|
196
268
|
reports: Array<Promise<void> | undefined>,
|
|
197
269
|
): Promise<void> {
|
|
@@ -32,6 +32,12 @@ export type ExecutionSummarySpan = {
|
|
|
32
32
|
request?: Record<string, unknown>;
|
|
33
33
|
response?: Record<string, unknown>;
|
|
34
34
|
error?: string;
|
|
35
|
+
errorName?: string;
|
|
36
|
+
errorKind?: string;
|
|
37
|
+
errorStage?: string;
|
|
38
|
+
errorStatus?: number;
|
|
39
|
+
errorCode?: unknown;
|
|
40
|
+
errorDetails?: Record<string, unknown>;
|
|
35
41
|
timeline: ExecutionTimelineEvent[];
|
|
36
42
|
};
|
|
37
43
|
|
package/src/telemetry/types.ts
CHANGED
|
@@ -15,6 +15,18 @@ export type TraceGeneration = {
|
|
|
15
15
|
input?: unknown;
|
|
16
16
|
output?: unknown;
|
|
17
17
|
timeToFirstTokenMs?: number;
|
|
18
|
+
httpStatus?: number;
|
|
19
|
+
error?: TelemetryFailure;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type TelemetryFailure = {
|
|
23
|
+
name: string;
|
|
24
|
+
message: string;
|
|
25
|
+
kind?: string;
|
|
26
|
+
stage?: string;
|
|
27
|
+
status?: number;
|
|
28
|
+
code?: unknown;
|
|
29
|
+
details?: Record<string, unknown>;
|
|
18
30
|
};
|
|
19
31
|
|
|
20
32
|
export type TelemetryError = {
|
|
@@ -22,6 +34,12 @@ export type TelemetryError = {
|
|
|
22
34
|
requestId: string;
|
|
23
35
|
executionId: string;
|
|
24
36
|
message: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
kind?: string;
|
|
39
|
+
stage?: string;
|
|
40
|
+
status?: number;
|
|
41
|
+
code?: unknown;
|
|
42
|
+
details?: Record<string, unknown>;
|
|
25
43
|
};
|
|
26
44
|
|
|
27
45
|
export interface TraceTelemetryDriver {
|
package/src/telemetry.test.ts
CHANGED
|
@@ -123,6 +123,62 @@ describe("createRouterTelemetry", () => {
|
|
|
123
123
|
status: "ok",
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
it("keeps failed provider calls in the trace with typed upstream details", async () => {
|
|
128
|
+
const traceDriver = { recordGeneration: vi.fn(async () => {}) };
|
|
129
|
+
const errorDriver = { recordError: vi.fn(async () => {}) };
|
|
130
|
+
const telemetry = createRouterTelemetry({ traceDriver, errorDriver });
|
|
131
|
+
telemetry.observe(
|
|
132
|
+
createExecutionEvent({
|
|
133
|
+
kind: "provider.started",
|
|
134
|
+
requestId: "request-1",
|
|
135
|
+
executionId: "provider-1",
|
|
136
|
+
data: {
|
|
137
|
+
startedAt: "2026-07-13T12:00:00.000Z",
|
|
138
|
+
provider: "cloudflare",
|
|
139
|
+
model: "@cf/model",
|
|
140
|
+
input: { messages: [{ role: "user", content: "hello" }] },
|
|
141
|
+
},
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
telemetry.observe(
|
|
145
|
+
createExecutionEvent({
|
|
146
|
+
kind: "execution.failed",
|
|
147
|
+
requestId: "request-1",
|
|
148
|
+
executionId: "provider-1",
|
|
149
|
+
errorKind: "provider",
|
|
150
|
+
data: {
|
|
151
|
+
message: "Provider returned error",
|
|
152
|
+
stage: "provider_invoke",
|
|
153
|
+
upstream: {
|
|
154
|
+
status: 400,
|
|
155
|
+
kind: "invalid_request_error",
|
|
156
|
+
code: "bad_request",
|
|
157
|
+
body: { error: "invalid model" },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
await telemetry.flush();
|
|
164
|
+
|
|
165
|
+
expect(traceDriver.recordGeneration).toHaveBeenCalledWith(
|
|
166
|
+
expect.objectContaining({
|
|
167
|
+
httpStatus: 400,
|
|
168
|
+
input: { messages: [{ role: "user", content: "hello" }] },
|
|
169
|
+
error: expect.objectContaining({
|
|
170
|
+
name: "Router.provider.invalid_request_error.400",
|
|
171
|
+
code: "bad_request",
|
|
172
|
+
}),
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
expect(errorDriver.recordError).toHaveBeenCalledWith(
|
|
176
|
+
expect.objectContaining({
|
|
177
|
+
name: "Router.provider.invalid_request_error.400",
|
|
178
|
+
status: 400,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
126
182
|
});
|
|
127
183
|
|
|
128
184
|
describe("trace chat normalization", () => {
|