@neutrome/open-ai-router 0.6.3 → 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.3",
3
+ "version": "0.6.5",
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.2"
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";
@@ -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;
@@ -108,12 +111,11 @@ export function createFetchProviderInvoker(
108
111
  const response = parseProviderResponse(provider.style, bytes);
109
112
  emitProviderUsage(
110
113
  providerCtx,
114
+ resolvedRequest,
111
115
  response,
112
116
  provider.name,
113
117
  reqBytes,
114
118
  bytes.byteLength,
115
- jsonBody(reqBody),
116
- jsonBody(bytes),
117
119
  );
118
120
  return response;
119
121
  } catch (error) {
@@ -134,11 +136,3 @@ export function createFetchProviderInvoker(
134
136
  },
135
137
  };
136
138
  }
137
-
138
- function jsonBody(body: Uint8Array): unknown {
139
- try {
140
- return JSON.parse(new TextDecoder().decode(body));
141
- } catch {
142
- return undefined;
143
- }
144
- }
@@ -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;
@@ -102,7 +105,7 @@ export async function* streamProvider(
102
105
  let firstByteObserved = false;
103
106
  const streamReadStartedMs = Date.now();
104
107
  let lastChunkWithUsage: Program | null = null;
105
- const responseChunks: unknown[] = [];
108
+ const responseChunks: Program[] = [];
106
109
  let respBytes = 0;
107
110
  while (true) {
108
111
  const { done, value } = await reader.read();
@@ -137,21 +140,21 @@ export async function* streamProvider(
137
140
  if (data === "[DONE]") {
138
141
  emitProviderUsage(
139
142
  providerCtx,
143
+ resolvedRequest,
140
144
  lastChunkWithUsage,
141
145
  provider.name,
142
146
  reqBytes,
143
147
  respBytes,
144
- jsonBody(reqBody),
145
148
  responseChunks,
146
149
  );
147
150
  return;
148
151
  }
149
152
  if (data[0] !== "{") continue;
150
- responseChunks.push(jsonBody(encoder.encode(data)));
151
153
  const chunk = parseProviderStreamChunk(
152
154
  provider.style,
153
155
  encoder.encode(data),
154
156
  );
157
+ responseChunks.push(chunk);
155
158
  if (usageObject(chunk) !== undefined) lastChunkWithUsage = chunk;
156
159
  yield chunk;
157
160
  }
@@ -163,32 +166,32 @@ export async function* streamProvider(
163
166
  if (data === "[DONE]") {
164
167
  emitProviderUsage(
165
168
  providerCtx,
169
+ resolvedRequest,
166
170
  lastChunkWithUsage,
167
171
  provider.name,
168
172
  reqBytes,
169
173
  respBytes,
170
- jsonBody(reqBody),
171
174
  responseChunks,
172
175
  );
173
176
  return;
174
177
  }
175
178
  if (data[0] !== "{") continue;
176
- responseChunks.push(jsonBody(encoder.encode(data)));
177
179
  const chunk = parseProviderStreamChunk(
178
180
  provider.style,
179
181
  encoder.encode(data),
180
182
  );
183
+ responseChunks.push(chunk);
181
184
  if (usageObject(chunk) !== undefined) lastChunkWithUsage = chunk;
182
185
  yield chunk;
183
186
  }
184
187
  }
185
188
  emitProviderUsage(
186
189
  providerCtx,
190
+ resolvedRequest,
187
191
  lastChunkWithUsage,
188
192
  provider.name,
189
193
  reqBytes,
190
194
  respBytes,
191
- jsonBody(reqBody),
192
195
  responseChunks,
193
196
  );
194
197
  } catch (error) {
@@ -199,11 +202,3 @@ export async function* streamProvider(
199
202
  timed.cleanup();
200
203
  }
201
204
  }
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";
@@ -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
+ }