@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -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(summary, response.status, program);
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
+ }
@@ -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 { Program } from "@neutrome/lil-engine";
2
- import { ExecutionError, isExecutionError } from "./errors.ts";
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;
@@ -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
- new Error(error.message),
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, string | number | undefined>) {
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
+ }
@@ -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 message = data.message ?? "Execution failed";
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
- message,
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
- message: data.message ?? "Execution failed",
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
 
@@ -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 {
@@ -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", () => {