@neutrome/open-ai-router 0.6.10 → 0.8.0

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,18 +1,23 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.6.10",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
+ "files": [
6
+ "src",
7
+ "README.md"
8
+ ],
5
9
  "exports": {
6
10
  ".": "./src/index.ts"
7
11
  },
8
12
  "dependencies": {
9
- "@posthog/ai": "^8.3.0",
10
13
  "@modelcontextprotocol/sdk": "^1.29.0",
14
+ "@posthog/ai": "^8.3.0",
15
+ "gpt-tokenizer": "4.0.0",
11
16
  "hono": "^4.12.18",
12
17
  "posthog-node": "^5.41.0",
13
18
  "zod": "^4.2.1",
14
- "@neutrome/lil-engine": "0.5.1",
15
- "@neutrome/lilsdk": "0.5.1"
19
+ "@neutrome/lil-engine": "0.6.0",
20
+ "@neutrome/lilsdk": "0.6.0"
16
21
  },
17
22
  "devDependencies": {
18
23
  "@types/node": "^25.9.3",
@@ -100,6 +100,7 @@ describe("createRouterApp trace finalization", () => {
100
100
  it("keeps non-stream trace hooks alive with waitUntil", async () => {
101
101
  const waited: Promise<unknown>[] = [];
102
102
  const onExecutionSummary = vi.fn(async (_summary: ExecutionSummary) => {});
103
+ const onUserUsage = vi.fn(async () => {});
103
104
  const app = createRouterApp({
104
105
  config,
105
106
  executorImplementations: {},
@@ -108,7 +109,7 @@ describe("createRouterApp trace finalization", () => {
108
109
  return principal();
109
110
  },
110
111
  },
111
- hooks: { onExecutionSummary },
112
+ hooks: { onExecutionSummary, onUserUsage },
112
113
  fetchImpl: vi.fn(
113
114
  async () =>
114
115
  new Response(
@@ -145,11 +146,21 @@ describe("createRouterApp trace finalization", () => {
145
146
  expect(waited).toHaveLength(1);
146
147
  await waited[0];
147
148
  expect(onExecutionSummary).toHaveBeenCalledTimes(1);
149
+ expect(onUserUsage).toHaveBeenCalledWith(
150
+ expect.objectContaining({
151
+ requestedModel: "gpt-4o",
152
+ outcome: "ok",
153
+ inputTokens: expect.any(Number),
154
+ outputTokens: expect.any(Number),
155
+ }),
156
+ {},
157
+ );
148
158
  });
149
159
 
150
160
  it("keeps stream trace hooks alive until the stream closes", async () => {
151
161
  const waited: Promise<unknown>[] = [];
152
162
  const onExecutionSummary = vi.fn(async (_summary: ExecutionSummary) => {});
163
+ const onUserUsage = vi.fn(async () => {});
153
164
  const app = createRouterApp({
154
165
  config,
155
166
  executorImplementations: {},
@@ -158,7 +169,7 @@ describe("createRouterApp trace finalization", () => {
158
169
  return principal();
159
170
  },
160
171
  },
161
- hooks: { onExecutionSummary },
172
+ hooks: { onExecutionSummary, onUserUsage },
162
173
  fetchImpl: vi.fn(async () => {
163
174
  const stream = new ReadableStream<Uint8Array>({
164
175
  start(controller) {
@@ -217,6 +228,7 @@ describe("createRouterApp trace finalization", () => {
217
228
  expect(waited).toHaveLength(1);
218
229
  await waited[0];
219
230
  expect(onExecutionSummary).toHaveBeenCalledTimes(1);
231
+ expect(onUserUsage).toHaveBeenCalledTimes(1);
220
232
  const events = onExecutionSummary.mock.calls[0]?.[0].spans.flatMap((span) =>
221
233
  span.timeline.map((entry) => entry.event),
222
234
  );
@@ -35,6 +35,10 @@ import {
35
35
  import { createModelListingHandler } from "./model-listing.ts";
36
36
  import { honoEnv, unauthorizedResponse } from "./http-utils.ts";
37
37
  import type { RouterAppOptions } from "./types.ts";
38
+ import {
39
+ createUserUsageCollector,
40
+ responseResult,
41
+ } from "./user-usage-collector.ts";
38
42
 
39
43
  type ProtocolHandler = (
40
44
  options: Parameters<typeof handleChatCompletions>[0],
@@ -100,6 +104,13 @@ export function createRouterApp(options: RouterAppOptions) {
100
104
  : {}),
101
105
  });
102
106
  const onExecutionSummary = options.hooks?.onExecutionSummary;
107
+ const onUserUsage = options.hooks?.onUserUsage;
108
+ const requestStartedMs = Date.now();
109
+ const usage = createUserUsageCollector({
110
+ requestId: incomingExecutionId,
111
+ protocol: style,
112
+ startedAtMs: requestStartedMs,
113
+ });
103
114
  const observe = (
104
115
  event: Parameters<NonNullable<ExecutionRuntimeOptions["observe"]>>[0],
105
116
  ) => {
@@ -114,13 +125,14 @@ export function createRouterApp(options: RouterAppOptions) {
114
125
  c,
115
126
  async () => {
116
127
  await telemetry.flush();
117
- await onExecutionSummary?.(telemetry.summary(), env);
128
+ const summary = telemetry.summary();
129
+ await onExecutionSummary?.(summary, env);
130
+ await onUserUsage?.(usage.report(summary), env);
118
131
  },
119
132
  "onExecutionSummary hook",
120
133
  );
121
134
  };
122
135
 
123
- const requestStartedMs = Date.now();
124
136
  observeTimedExecution({
125
137
  observe,
126
138
  kind: "request.started",
@@ -142,6 +154,7 @@ export function createRouterApp(options: RouterAppOptions) {
142
154
  data: { authenticated: !!principal },
143
155
  });
144
156
  if (!principal) {
157
+ usage.setResult(responseResult(401, "Authentication failed"));
145
158
  telemetry.recordError({
146
159
  message: "Authentication failed",
147
160
  requestId: incomingExecutionId,
@@ -166,6 +179,7 @@ export function createRouterApp(options: RouterAppOptions) {
166
179
  data: { style, streaming: forceStreaming === true },
167
180
  });
168
181
  } catch {
182
+ usage.setResult(responseResult(400, "Invalid request body"));
169
183
  telemetry.recordError({
170
184
  message: "Invalid request body",
171
185
  requestId: incomingExecutionId,
@@ -189,6 +203,7 @@ export function createRouterApp(options: RouterAppOptions) {
189
203
  }
190
204
 
191
205
  const model = getModel(program) || null;
206
+ usage.setRequest(program, model);
192
207
  const accessStartedMs = Date.now();
193
208
  const hasModelAccess = !model || canAccessPrincipalModel(principal, model);
194
209
  observeTimedExecution({
@@ -201,6 +216,7 @@ export function createRouterApp(options: RouterAppOptions) {
201
216
  data: { model: model ?? undefined, allowed: hasModelAccess },
202
217
  });
203
218
  if (!hasModelAccess) {
219
+ usage.setResult(responseResult(404, "Model access denied"));
204
220
  telemetry.recordError({
205
221
  message: "Model access denied",
206
222
  requestId: incomingExecutionId,
@@ -232,6 +248,12 @@ export function createRouterApp(options: RouterAppOptions) {
232
248
  data: { returnedResponse: !!hookResponse },
233
249
  });
234
250
  if (hookResponse) {
251
+ usage.setResult(
252
+ responseResult(
253
+ hookResponse.status,
254
+ hookResponse.status >= 400 ? "Request rejected" : null,
255
+ ),
256
+ );
235
257
  if (hookResponse.status >= 400) {
236
258
  telemetry.recordError({
237
259
  message: `Request hook returned ${hookResponse.status}`,
@@ -245,6 +267,7 @@ export function createRouterApp(options: RouterAppOptions) {
245
267
  }
246
268
 
247
269
  if (style === "responses" && (await hasPreviousResponseId(request))) {
270
+ usage.setResult(responseResult(400, "Unsupported response state"));
248
271
  finalizeTrace();
249
272
  return errorResponse(
250
273
  new RouterError(
@@ -270,6 +293,10 @@ export function createRouterApp(options: RouterAppOptions) {
270
293
  ...(options.transforms ? { transforms: options.transforms } : {}),
271
294
  observe,
272
295
  incomingExecutionId,
296
+ userUsage: { inputTokens: usage.inputTokens() },
297
+ onUserResponse(result) {
298
+ usage.setResult(result);
299
+ },
273
300
  ...(options.upstreamTimeoutMs !== undefined
274
301
  ? { upstreamTimeoutMs: options.upstreamTimeoutMs }
275
302
  : {}),
@@ -296,6 +323,7 @@ export function createRouterApp(options: RouterAppOptions) {
296
323
  }
297
324
  return finalizeWhenStreamCloses(response, finalizeTrace);
298
325
  } catch (error) {
326
+ usage.setResult(responseResult(500, "Request execution failed"));
299
327
  telemetry.recordError({
300
328
  message:
301
329
  error instanceof Error ? error.message : "Request execution failed",
@@ -54,8 +54,8 @@ export function finalizeWhenStreamCloses(
54
54
  }
55
55
  },
56
56
  async cancel(reason) {
57
- finalize();
58
57
  await reader.cancel(reason).catch(() => {});
58
+ finalize();
59
59
  },
60
60
  });
61
61
  return new Response(body, {
package/src/app/types.ts CHANGED
@@ -7,6 +7,7 @@ import type { ExecutionSummary } from "../telemetry/index.ts";
7
7
  import type { RouterConfig } from "../router/config.ts";
8
8
  import type { ExecutionRuntimeOptions } from "../router/execution-types.ts";
9
9
  import type { RemoteMcpClientFactory } from "../router/mcp.ts";
10
+ import type { UserUsageReport } from "../usage/user-usage.ts";
10
11
 
11
12
  export type RouterAppAuth = RuntimeAuthenticator;
12
13
 
@@ -21,6 +22,10 @@ export type RouterAppHooks = {
21
22
  summary: ExecutionSummary,
22
23
  env: Record<string, unknown>,
23
24
  ): void | Promise<void>;
25
+ onUserUsage?(
26
+ report: UserUsageReport,
27
+ env: Record<string, unknown>,
28
+ ): void | Promise<void>;
24
29
  };
25
30
 
26
31
  export type RouterAppOptions = {
@@ -0,0 +1,68 @@
1
+ import type { Program, ProviderStyle } from "@neutrome/lil-engine";
2
+ import type { ExecutionSummary } from "../telemetry/index.ts";
3
+ import {
4
+ countUserProgramTokens,
5
+ toolUsageFromEvents,
6
+ type UserUsageReport,
7
+ } from "../usage/user-usage.ts";
8
+
9
+ export type UserResponseResult = Pick<
10
+ UserUsageReport,
11
+ "outputTokens" | "outcome" | "httpStatus" | "error"
12
+ >;
13
+
14
+ export function createUserUsageCollector(input: {
15
+ requestId: string;
16
+ protocol: ProviderStyle;
17
+ startedAtMs: number;
18
+ }) {
19
+ let inputTokens = 0;
20
+ let requestedModel: string | null = null;
21
+ let result: UserResponseResult | null = null;
22
+ return {
23
+ setRequest(program: Program, model: string | null) {
24
+ inputTokens = countUserProgramTokens(program);
25
+ requestedModel = model;
26
+ },
27
+ setResult(next: UserResponseResult) {
28
+ result = next;
29
+ },
30
+ inputTokens() {
31
+ return inputTokens;
32
+ },
33
+ report(summary: ExecutionSummary): UserUsageReport {
34
+ const finishedAt = new Date().toISOString();
35
+ return {
36
+ requestId: input.requestId,
37
+ protocol: input.protocol,
38
+ requestedModel,
39
+ inputTokens,
40
+ ...(result ??
41
+ responseResult(null, "Request did not produce a response")),
42
+ tools: toolUsageFromEvents(
43
+ summary.spans
44
+ .filter((span) => span.kind === "tool" && span.toolName)
45
+ .map((span) => ({
46
+ name: span.toolName!,
47
+ error: span.status === "error",
48
+ })),
49
+ ),
50
+ startedAt: new Date(input.startedAtMs).toISOString(),
51
+ finishedAt,
52
+ durationMs: Math.max(0, Date.parse(finishedAt) - input.startedAtMs),
53
+ };
54
+ },
55
+ };
56
+ }
57
+
58
+ export function responseResult(
59
+ status: number | null,
60
+ error: string | null,
61
+ ): UserResponseResult {
62
+ return {
63
+ outputTokens: 0,
64
+ outcome: status !== null && status < 400 ? "ok" : "error",
65
+ httpStatus: status,
66
+ error,
67
+ };
68
+ }
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  PosthogErrorTelemetry,
10
10
  PosthogTraceTelemetry,
11
11
  } from "./telemetry/index.ts";
12
+ export type { UserUsageReport, UserToolUsage } from "./usage/user-usage.ts";
12
13
  export type {
13
14
  ErrorTelemetryDriver,
14
15
  ExecutionSummary,
@@ -1,4 +1,4 @@
1
- import { appendAssistantMessage } from "@neutrome/lil-engine";
1
+ import { appendAssistantMessage, createProgram } from "@neutrome/lil-engine";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import type { RequestContext } from "../upstream-auth/types.ts";
4
4
  import { handleChatCompletions } from "./execute.ts";
@@ -88,6 +88,7 @@ describe("router execution", () => {
88
88
  object: "chat.completion",
89
89
  id: "resp_1",
90
90
  model: "gpt-4o",
91
+ usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
91
92
  choices: [
92
93
  {
93
94
  index: 0,
@@ -334,6 +335,7 @@ describe("router execution", () => {
334
335
  object: "chat.completion",
335
336
  id: "resp_1",
336
337
  model: "gpt-5",
338
+ usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
337
339
  choices: [
338
340
  {
339
341
  index: 0,
@@ -411,9 +413,9 @@ describe("router execution", () => {
411
413
  id: "msg_1",
412
414
  model: "claude-sonnet-4",
413
415
  usage: {
414
- prompt_tokens: 10,
415
- completion_tokens: 5,
416
- total_tokens: 15,
416
+ prompt_tokens: 0,
417
+ completion_tokens: 27,
418
+ total_tokens: 27,
417
419
  },
418
420
  choices: [
419
421
  {
@@ -494,6 +496,7 @@ describe("router execution", () => {
494
496
  expect(await response.json()).toEqual({
495
497
  object: "chat.completion",
496
498
  model: "gemini-2.5-flash",
499
+ usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
497
500
  choices: [
498
501
  {
499
502
  index: 0,
@@ -543,10 +546,10 @@ describe("router execution", () => {
543
546
  executorImplementations: {
544
547
  "enei-1": {
545
548
  async execute(request) {
546
- return appendAssistantMessage(request, "done");
549
+ return appendAssistantMessage(createProgram(), "done");
547
550
  },
548
551
  async *stream(request) {
549
- yield appendAssistantMessage(request, "done");
552
+ yield appendAssistantMessage(createProgram(), "done");
550
553
  },
551
554
  },
552
555
  },
@@ -556,6 +559,7 @@ describe("router execution", () => {
556
559
  expect(response.headers.get("x-openairouter-executor-id")).toBe("enei-1");
557
560
  expect(await response.json()).toEqual({
558
561
  object: "chat.completion",
562
+ usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
559
563
  choices: [
560
564
  {
561
565
  index: 0,
@@ -13,6 +13,7 @@ import {
13
13
  type ProviderStyle,
14
14
  setModel,
15
15
  setStreaming,
16
+ replaceUsage,
16
17
  } from "@neutrome/lil-engine";
17
18
  import { createExecutionRuntime } from "./execution-runtime.ts";
18
19
  import { createFetchProviderInvoker } from "./provider-invoker.ts";
@@ -61,6 +62,7 @@ import {
61
62
  import type { RemoteMcpClientFactory } from "./mcp.ts";
62
63
  import type { ProviderRuntime, RouterRuntime } from "./runtime.ts";
63
64
  import { observeTimedExecution } from "./execution-events.ts";
65
+ import { countUserProgramTokens, protocolUsage } from "../usage/user-usage.ts";
64
66
 
65
67
  const encoder = new TextEncoder();
66
68
  const decoder = new TextDecoder();
@@ -79,6 +81,13 @@ export type RouterExecutionOptions = Pick<
79
81
  upstreamTimeoutMs?: number;
80
82
  remoteMcpClientFactory?: RemoteMcpClientFactory;
81
83
  incomingExecutionId?: string;
84
+ userUsage?: { inputTokens: number };
85
+ onUserResponse?: (result: {
86
+ outputTokens: number;
87
+ outcome: "ok" | "error" | "cancelled";
88
+ httpStatus: number | null;
89
+ error: string | null;
90
+ }) => void | Promise<void>;
82
91
  };
83
92
 
84
93
  export type HandleChatCompletionsOptions = RouterExecutionOptions & {
@@ -240,6 +249,10 @@ async function handleRequestStyle(
240
249
  requestId: incomingExecutionId,
241
250
  executionId: incomingExecutionId,
242
251
  startedAtMs: responseStartedMs,
252
+ inputTokens: options.userUsage?.inputTokens ?? 0,
253
+ onFinish: async (result) => {
254
+ await options.onUserResponse?.({ ...result, httpStatus: 200 });
255
+ },
243
256
  },
244
257
  remoteMcp.close,
245
258
  );
@@ -255,12 +268,38 @@ async function handleRequestStyle(
255
268
  target: remoteMcp.target,
256
269
  });
257
270
  const userTime = Date.now() - startTime;
258
- return jsonExecutionResponse(emitResponse(response), resolved, userTime);
271
+ const outputTokens = countUserProgramTokens(response);
272
+ await options.onUserResponse?.({
273
+ outputTokens,
274
+ outcome: "ok",
275
+ httpStatus: 200,
276
+ error: null,
277
+ });
278
+ return jsonExecutionResponse(
279
+ emitResponse(
280
+ replaceUsage(
281
+ response,
282
+ protocolUsage(
283
+ style,
284
+ options.userUsage?.inputTokens ?? 0,
285
+ outputTokens,
286
+ ),
287
+ ),
288
+ ),
289
+ resolved,
290
+ userTime,
291
+ );
259
292
  } finally {
260
293
  await remoteMcp.close();
261
294
  }
262
295
  } catch (error) {
263
296
  const response = errorResponse(error, style);
297
+ await options.onUserResponse?.({
298
+ outputTokens: 0,
299
+ outcome: "error",
300
+ httpStatus: response.status,
301
+ error: "Request execution failed",
302
+ });
264
303
  return resolved ? applyRoutingHeaders(response, resolved) : response;
265
304
  }
266
305
  }
@@ -0,0 +1,150 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Opcode, createProgram, type Program } from "@neutrome/lil-engine";
3
+ import { streamExecutionResponse } from "./response-delivery.ts";
4
+ import type { ResolvedTarget } from "./resolve.ts";
5
+
6
+ const resolved: ResolvedTarget = {
7
+ requestedModel: "main/assistant",
8
+ namespace: "main",
9
+ source: "provider",
10
+ suffixes: [],
11
+ mcps: [],
12
+ target: {
13
+ kind: "provider",
14
+ provider: "core",
15
+ model: "actual",
16
+ transforms: [],
17
+ },
18
+ };
19
+
20
+ describe("user stream boundary", () => {
21
+ for (const [style, usageKey] of [
22
+ ["chat-completions", "prompt_tokens"],
23
+ ["responses", "input_tokens"],
24
+ ["anthropic-messages", "input_tokens"],
25
+ ["google-genai", "promptTokenCount"],
26
+ ] as const) {
27
+ it(`emits one internal usage block for ${style}`, async () => {
28
+ const response = await streamExecutionResponse(
29
+ style,
30
+ successfulChunks(),
31
+ resolved,
32
+ undefined,
33
+ {
34
+ requestId: "r1",
35
+ executionId: "r1",
36
+ startedAtMs: Date.now(),
37
+ inputTokens: 7,
38
+ },
39
+ );
40
+ const text = await response.text();
41
+ expect(text, text).toContain(usageKey);
42
+ expect(text.match(new RegExp(usageKey, "g"))).toHaveLength(1);
43
+ expect(text).not.toContain("999");
44
+ });
45
+ }
46
+
47
+ it("emits no usage after a failed partial stream", async () => {
48
+ async function* failed(): AsyncGenerator<Program> {
49
+ yield chunk(Opcode.STREAM_DELTA, { kind: "string", value: "partial" });
50
+ throw new Error("broken");
51
+ }
52
+ const response = await streamExecutionResponse(
53
+ "chat-completions",
54
+ failed(),
55
+ resolved,
56
+ undefined,
57
+ {
58
+ requestId: "r1",
59
+ executionId: "r1",
60
+ startedAtMs: Date.now(),
61
+ inputTokens: 7,
62
+ },
63
+ );
64
+ expect(await response.text()).not.toContain("usage");
65
+ });
66
+
67
+ it("does not trust a terminal chunk until the stream actually closes", async () => {
68
+ async function* failedAfterTerminal(): AsyncGenerator<Program> {
69
+ yield createProgram({
70
+ code: [
71
+ {
72
+ opcode: Opcode.RESP_DONE,
73
+ value: { kind: "string", value: "stop" },
74
+ },
75
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
76
+ ],
77
+ });
78
+ throw new Error("late failure");
79
+ }
80
+ const response = await streamExecutionResponse(
81
+ "chat-completions",
82
+ failedAfterTerminal(),
83
+ resolved,
84
+ undefined,
85
+ {
86
+ requestId: "r1",
87
+ executionId: "r1",
88
+ startedAtMs: Date.now(),
89
+ inputTokens: 7,
90
+ },
91
+ );
92
+ expect(await response.text()).not.toContain("usage");
93
+ });
94
+
95
+ it("reports cancellation with partial internal tokens and no usage block", async () => {
96
+ let outcome: unknown;
97
+ async function* chunks(): AsyncGenerator<Program> {
98
+ yield chunk(Opcode.STREAM_START, { kind: "none" });
99
+ yield chunk(Opcode.STREAM_DELTA, { kind: "string", value: "partial" });
100
+ }
101
+ const response = await streamExecutionResponse(
102
+ "chat-completions",
103
+ chunks(),
104
+ resolved,
105
+ undefined,
106
+ {
107
+ requestId: "r1",
108
+ executionId: "r1",
109
+ startedAtMs: Date.now(),
110
+ inputTokens: 7,
111
+ onFinish(result) {
112
+ outcome = result;
113
+ },
114
+ },
115
+ );
116
+ const reader = response.body!.getReader();
117
+ const first = await reader.read();
118
+ expect(new TextDecoder().decode(first.value)).not.toContain("usage");
119
+ await reader.cancel();
120
+ expect(outcome).toMatchObject({
121
+ outcome: "cancelled",
122
+ outputTokens: expect.any(Number),
123
+ });
124
+ });
125
+ });
126
+
127
+ async function* successfulChunks(): AsyncGenerator<Program> {
128
+ yield chunk(Opcode.STREAM_START, { kind: "none" });
129
+ yield chunk(Opcode.STREAM_DELTA, { kind: "string", value: "hello" });
130
+ yield createProgram({
131
+ code: [
132
+ {
133
+ opcode: Opcode.USAGE,
134
+ value: {
135
+ kind: "json",
136
+ value: new TextEncoder().encode('{"prompt_tokens":999}'),
137
+ },
138
+ },
139
+ { opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
140
+ { opcode: Opcode.STREAM_END, value: { kind: "none" } },
141
+ ],
142
+ });
143
+ }
144
+
145
+ function chunk(
146
+ opcode: Opcode,
147
+ value: Program["code"][number]["value"],
148
+ ): Program {
149
+ return createProgram({ code: [{ opcode, value }] });
150
+ }