@neutrome/open-ai-router 0.8.1 → 0.9.2

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.8.1",
3
+ "version": "0.9.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -17,7 +17,7 @@
17
17
  "posthog-node": "^5.41.0",
18
18
  "zod": "^4.2.1",
19
19
  "@neutrome/lil-engine": "0.6.1",
20
- "@neutrome/lilsdk": "0.6.1"
20
+ "@neutrome/lilsdk": "0.6.2"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/node": "^25.9.3",
@@ -150,8 +150,14 @@ describe("createRouterApp trace finalization", () => {
150
150
  expect.objectContaining({
151
151
  requestedModel: "gpt-4o",
152
152
  outcome: "ok",
153
- inputTokens: expect.any(Number),
154
- outputTokens: expect.any(Number),
153
+ invocations: expect.arrayContaining([
154
+ expect.objectContaining({
155
+ kind: "incoming",
156
+ model: "gpt-4o",
157
+ inputTokens: expect.any(Number),
158
+ outputTokens: expect.any(Number),
159
+ }),
160
+ ]),
155
161
  }),
156
162
  {},
157
163
  );
@@ -297,6 +297,9 @@ export function createRouterApp(options: RouterAppOptions) {
297
297
  onUserResponse(result) {
298
298
  usage.setResult(result);
299
299
  },
300
+ onNamedInvocation(result) {
301
+ usage.recordInvocation(result);
302
+ },
300
303
  ...(options.upstreamTimeoutMs !== undefined
301
304
  ? { upstreamTimeoutMs: options.upstreamTimeoutMs }
302
305
  : {}),
@@ -1,15 +1,17 @@
1
1
  import type { Program, ProviderStyle } from "@neutrome/lil-engine";
2
2
  import type { ExecutionSummary } from "../telemetry/index.ts";
3
+ import type { NamedInvocationResult } from "../router/execution-types.ts";
3
4
  import {
4
5
  countUserProgramTokens,
6
+ createStreamUsageAccumulator,
5
7
  toolUsageFromEvents,
6
8
  type UserUsageReport,
7
9
  } from "../usage/user-usage.ts";
8
10
 
9
11
  export type UserResponseResult = Pick<
10
12
  UserUsageReport,
11
- "outputTokens" | "outcome" | "httpStatus" | "error"
12
- >;
13
+ "outcome" | "httpStatus" | "error"
14
+ > & { outputTokens: number };
13
15
 
14
16
  export function createUserUsageCollector(input: {
15
17
  requestId: string;
@@ -19,6 +21,7 @@ export function createUserUsageCollector(input: {
19
21
  let inputTokens = 0;
20
22
  let requestedModel: string | null = null;
21
23
  let result: UserResponseResult | null = null;
24
+ const invocations: UserUsageReport["invocations"] = [];
22
25
  return {
23
26
  setRequest(program: Program, model: string | null) {
24
27
  inputTokens = countUserProgramTokens(program);
@@ -30,15 +33,42 @@ export function createUserUsageCollector(input: {
30
33
  inputTokens() {
31
34
  return inputTokens;
32
35
  },
36
+ recordInvocation(invocation: NamedInvocationResult) {
37
+ const accumulator = createStreamUsageAccumulator();
38
+ for (const chunk of invocation.stream ?? []) accumulator.add(chunk);
39
+ invocations.push({
40
+ executionId: invocation.executionId,
41
+ parentExecutionId: invocation.parentExecutionId ?? null,
42
+ kind: invocation.kind,
43
+ model: invocation.model,
44
+ inputTokens: countUserProgramTokens(invocation.input),
45
+ outputTokens: invocation.stream
46
+ ? accumulator.count()
47
+ : invocation.output
48
+ ? countUserProgramTokens(invocation.output)
49
+ : 0,
50
+ startedAt: new Date(invocation.startedAtMs).toISOString(),
51
+ finishedAt: new Date(invocation.finishedAtMs).toISOString(),
52
+ durationMs: Math.max(
53
+ 0,
54
+ invocation.finishedAtMs - invocation.startedAtMs,
55
+ ),
56
+ outcome: invocation.outcome,
57
+ error: invocation.error ?? null,
58
+ });
59
+ },
33
60
  report(summary: ExecutionSummary): UserUsageReport {
34
61
  const finishedAt = new Date().toISOString();
62
+ const response =
63
+ result ?? responseResult(null, "Request did not produce a response");
35
64
  return {
36
65
  requestId: input.requestId,
37
66
  protocol: input.protocol,
38
67
  requestedModel,
39
- inputTokens,
40
- ...(result ??
41
- responseResult(null, "Request did not produce a response")),
68
+ invocations: [...invocations],
69
+ outcome: response.outcome,
70
+ httpStatus: response.httpStatus,
71
+ error: response.error,
42
72
  tools: toolUsageFromEvents(
43
73
  summary.spans
44
74
  .filter((span) => span.kind === "tool" && span.toolName)
@@ -82,6 +82,7 @@ export type RouterExecutionOptions = Pick<
82
82
  remoteMcpClientFactory?: RemoteMcpClientFactory;
83
83
  incomingExecutionId?: string;
84
84
  userUsage?: { inputTokens: number };
85
+ onNamedInvocation?: ExecutionRuntimeOptions["onNamedInvocation"];
85
86
  onUserResponse?: (result: {
86
87
  outputTokens: number;
87
88
  outcome: "ok" | "error" | "cancelled";
@@ -156,6 +157,18 @@ export function createRouterExecutionRuntime(
156
157
  if (options.executionIdFactory) {
157
158
  runtimeOptions.executionIdFactory = options.executionIdFactory;
158
159
  }
160
+ runtimeOptions.isBillableModel = (model) => {
161
+ try {
162
+ return runtime.modelNamespaces.has(
163
+ resolveNestedInvocationTarget(runtime, model).namespace,
164
+ );
165
+ } catch {
166
+ return false;
167
+ }
168
+ };
169
+ if (options.onNamedInvocation) {
170
+ runtimeOptions.onNamedInvocation = options.onNamedInvocation;
171
+ }
159
172
 
160
173
  const userResolveExecutor = options.resolveExecutor;
161
174
  runtimeOptions.resolveExecutor = (name: string) => {
@@ -239,7 +252,15 @@ async function handleRequestStyle(
239
252
  if (isStreaming(request)) {
240
253
  try {
241
254
  const responseStartedMs = Date.now();
242
- const chunks = execution.stream(request, { target: remoteMcp.target });
255
+ const rootBillable = options.runtime.modelNamespaces.has(
256
+ resolved.namespace,
257
+ );
258
+ const chunks = execution.stream(request, {
259
+ target: remoteMcp.target,
260
+ requestId: incomingExecutionId,
261
+ executionId: incomingExecutionId,
262
+ ...(rootBillable ? { namedModel: resolved.requestedModel } : {}),
263
+ });
243
264
  return await streamExecutionResponse(
244
265
  style,
245
266
  chunks,
@@ -266,6 +287,11 @@ async function handleRequestStyle(
266
287
  try {
267
288
  const response = await execution.execute(request, {
268
289
  target: remoteMcp.target,
290
+ requestId: incomingExecutionId,
291
+ executionId: incomingExecutionId,
292
+ ...(options.runtime.modelNamespaces.has(resolved.namespace)
293
+ ? { namedModel: resolved.requestedModel }
294
+ : {}),
269
295
  });
270
296
  const userTime = Date.now() - startTime;
271
297
  const outputTokens = countUserProgramTokens(response);
@@ -13,6 +13,7 @@ import type {
13
13
  ProviderInvocationContext,
14
14
  ProviderInvoker,
15
15
  } from "./execution-types.ts";
16
+ import type { NamedInvocationResult } from "./execution-types.ts";
16
17
 
17
18
  type InvocationIdentity = {
18
19
  requestId: string;
@@ -39,6 +40,11 @@ export type InvocationOptions = {
39
40
  stage: "provider_result" | "executor_result" | "stream_chunk",
40
41
  mode: ValidationMode,
41
42
  ): void;
43
+ namedInvocation?: Omit<
44
+ NamedInvocationResult,
45
+ "output" | "stream" | "finishedAtMs" | "outcome" | "error"
46
+ >;
47
+ onNamedInvocation?(result: NamedInvocationResult): void;
42
48
  };
43
49
 
44
50
  export async function invokeExecution(
@@ -55,8 +61,10 @@ export async function invokeExecution(
55
61
  const result = await primitive.execute(options.program);
56
62
  options.validate(result, primitive.resultStage, "program");
57
63
  finishInvocation(options.identity, timing, false, options.observe);
64
+ finishNamed(options, { output: result, outcome: "ok" });
58
65
  return result;
59
66
  } catch (error) {
67
+ finishNamed(options, failureResult(error));
60
68
  throw observedFailure(
61
69
  options,
62
70
  error,
@@ -76,17 +84,51 @@ export async function* streamExecution(
76
84
  true,
77
85
  options.observe,
78
86
  );
87
+ const chunks: Program[] = [];
79
88
  try {
80
89
  for await (const chunk of primitive.stream(options.program)) {
81
90
  options.validate(chunk, "stream_chunk", "stream_chunk");
91
+ if (options.namedInvocation) chunks.push(chunk);
82
92
  yield chunk;
83
93
  }
84
94
  finishInvocation(options.identity, timing, true, options.observe);
95
+ finishNamed(options, { stream: chunks, outcome: "ok" });
85
96
  } catch (error) {
97
+ finishNamed(options, { ...failureResult(error), stream: chunks });
86
98
  throw observedFailure(options, error, "stream", "stream_chunk");
87
99
  }
88
100
  }
89
101
 
102
+ function finishNamed(
103
+ options: InvocationOptions,
104
+ result: Pick<NamedInvocationResult, "outcome"> &
105
+ Partial<Pick<NamedInvocationResult, "output" | "stream" | "error">>,
106
+ ): void {
107
+ if (!options.namedInvocation || !options.onNamedInvocation) return;
108
+ options.onNamedInvocation({
109
+ ...options.namedInvocation,
110
+ ...result,
111
+ finishedAtMs: Date.now(),
112
+ });
113
+ }
114
+
115
+ function failureResult(
116
+ error: unknown,
117
+ ): Pick<NamedInvocationResult, "outcome" | "error"> {
118
+ const cancelled =
119
+ error instanceof DOMException
120
+ ? error.name === "AbortError"
121
+ : error instanceof Error &&
122
+ (error.name === "AbortError" || error.message === "Execution aborted");
123
+ return {
124
+ outcome: cancelled ? "cancelled" : "error",
125
+ error: (error instanceof Error ? error.message : String(error)).slice(
126
+ 0,
127
+ 500,
128
+ ),
129
+ };
130
+ }
131
+
90
132
  function invocationPrimitive(options: InvocationOptions): {
91
133
  execute(program: Program): Promise<Program>;
92
134
  stream(program: Program): AsyncIterable<Program>;
@@ -20,6 +20,67 @@ import {
20
20
  import { observeExecutionStream } from "@neutrome/lilsdk/stream";
21
21
 
22
22
  describe("router execution runtime", () => {
23
+ it("records only named boundaries through anonymous executor fan-out", async () => {
24
+ const named: Array<{
25
+ executionId: string;
26
+ parentExecutionId?: string;
27
+ kind: "incoming" | "subrequest";
28
+ model: string;
29
+ }> = [];
30
+ const leaf = {
31
+ async execute(program: Program) {
32
+ return program;
33
+ },
34
+ async *stream() {},
35
+ };
36
+ const anonymousInner = {
37
+ async execute(program: Program, ctx: ExecutorContext) {
38
+ return ctx.invoke("namespace/model-b", program);
39
+ },
40
+ async *stream() {},
41
+ };
42
+ const anonymousOuter = {
43
+ async execute(program: Program, ctx: ExecutorContext) {
44
+ return ctx.invoke(anonymousInner, program);
45
+ },
46
+ async *stream() {},
47
+ };
48
+ const runtime = createExecutionRuntime({
49
+ executorImplementations: {
50
+ root: {
51
+ async execute(program, ctx) {
52
+ return ctx.invoke(anonymousOuter, program);
53
+ },
54
+ async *stream() {},
55
+ },
56
+ leaf,
57
+ },
58
+ resolveTarget(request) {
59
+ return getModel(request) === "namespace/model-b"
60
+ ? { kind: "executor", executorId: "leaf", alias: "model-b" }
61
+ : { kind: "executor", executorId: "root", alias: "model-a" };
62
+ },
63
+ onNamedInvocation(result) {
64
+ named.push(result);
65
+ },
66
+ });
67
+
68
+ await runtime.execute(createProgram(), {
69
+ executionId: "model-a-execution",
70
+ namedModel: "namespace/model-a",
71
+ });
72
+
73
+ expect(named).toHaveLength(2);
74
+ expect(named.find((item) => item.kind === "incoming")).toMatchObject({
75
+ executionId: "model-a-execution",
76
+ model: "namespace/model-a",
77
+ });
78
+ expect(named.find((item) => item.kind === "subrequest")).toMatchObject({
79
+ parentExecutionId: "model-a-execution",
80
+ model: "namespace/model-b",
81
+ });
82
+ });
83
+
23
84
  it("shares one cache with transforms and executors", async () => {
24
85
  const values = new Map<string, string>();
25
86
  const cache = {
@@ -146,6 +146,10 @@ export function createExecutionRuntime(
146
146
  depth: number,
147
147
  ): Promise<InvocationOptions> {
148
148
  const prepared = await prepareExecution(request, invokeOptions, depth);
149
+ const namedModel = invokeOptions.namedModel;
150
+ const billable =
151
+ namedModel !== undefined &&
152
+ (options.isBillableModel?.(namedModel) ?? true);
149
153
  const executor =
150
154
  prepared.target.kind === "executor"
151
155
  ? await resolveRuntimeExecutor(
@@ -172,6 +176,9 @@ export function createExecutionRuntime(
172
176
  prepared.parentExecutionId,
173
177
  prepared.signal,
174
178
  depth,
179
+ billable
180
+ ? prepared.executionId
181
+ : invokeOptions.namedParentExecutionId,
175
182
  ),
176
183
  createProviderContext: (target) =>
177
184
  createProviderInvocationContext(
@@ -192,6 +199,22 @@ export function createExecutionRuntime(
192
199
  depth,
193
200
  emitSuccess: stage !== "stream_chunk",
194
201
  }),
202
+ ...(billable
203
+ ? {
204
+ namedInvocation: {
205
+ executionId: prepared.executionId,
206
+ ...(invokeOptions.namedParentExecutionId
207
+ ? { parentExecutionId: invokeOptions.namedParentExecutionId }
208
+ : {}),
209
+ kind:
210
+ depth === 0 ? ("incoming" as const) : ("subrequest" as const),
211
+ model: namedModel,
212
+ input: cloneProgram(request),
213
+ startedAtMs: Date.now(),
214
+ },
215
+ onNamedInvocation: options.onNamedInvocation,
216
+ }
217
+ : {}),
195
218
  };
196
219
  }
197
220
 
@@ -201,6 +224,7 @@ export function createExecutionRuntime(
201
224
  parentExecutionId: string | undefined,
202
225
  signal: AbortSignal,
203
226
  depth: number,
227
+ namedParentExecutionId?: string,
204
228
  ): ExecutorContext {
205
229
  return {
206
230
  cache: options.cache ?? unavailableCache,
@@ -218,12 +242,18 @@ export function createExecutionRuntime(
218
242
  executionId,
219
243
  signal,
220
244
  depth + 1,
245
+ namedParentExecutionId,
221
246
  ),
222
247
  );
223
248
  }
224
249
  return executeTarget(
225
250
  setModel(cloneProgram(request), executor),
226
- { requestId, parentExecutionId: executionId },
251
+ {
252
+ requestId,
253
+ parentExecutionId: executionId,
254
+ namedModel: executor,
255
+ ...(namedParentExecutionId ? { namedParentExecutionId } : {}),
256
+ },
227
257
  depth + 1,
228
258
  );
229
259
  },
@@ -238,13 +268,19 @@ export function createExecutionRuntime(
238
268
  executionId,
239
269
  signal,
240
270
  depth + 1,
271
+ namedParentExecutionId,
241
272
  ),
242
273
  );
243
274
  return;
244
275
  }
245
276
  yield* streamTarget(
246
277
  setModel(cloneProgram(request), executor),
247
- { requestId, parentExecutionId: executionId },
278
+ {
279
+ requestId,
280
+ parentExecutionId: executionId,
281
+ namedModel: executor,
282
+ ...(namedParentExecutionId ? { namedParentExecutionId } : {}),
283
+ },
248
284
  depth + 1,
249
285
  );
250
286
  },
@@ -30,6 +30,22 @@ export type InvokeOptions = {
30
30
  requestId?: string;
31
31
  executionId?: string;
32
32
  parentExecutionId?: string;
33
+ namedModel?: string;
34
+ namedParentExecutionId?: string;
35
+ };
36
+
37
+ export type NamedInvocationResult = {
38
+ executionId: string;
39
+ parentExecutionId?: string;
40
+ kind: "incoming" | "subrequest";
41
+ model: string;
42
+ input: Program;
43
+ output?: Program;
44
+ stream?: readonly Program[];
45
+ startedAtMs: number;
46
+ finishedAtMs: number;
47
+ outcome: "ok" | "error" | "cancelled";
48
+ error?: string;
33
49
  };
34
50
 
35
51
  export type ProviderInvocationContext = {
@@ -71,6 +87,8 @@ export type ExecutionRuntimeOptions = {
71
87
  executionIdFactory?: () => string;
72
88
  maxDepth?: number;
73
89
  validatePrograms?: boolean;
90
+ isBillableModel?(model: string): boolean;
91
+ onNamedInvocation?(result: NamedInvocationResult): void;
74
92
  };
75
93
 
76
94
  export type ExecutionRuntime = {
@@ -16,8 +16,7 @@ export type UserUsageReport = {
16
16
  requestId: string;
17
17
  protocol: ProviderStyle;
18
18
  requestedModel: string | null;
19
- inputTokens: number;
20
- outputTokens: number;
19
+ invocations: UserModelInvocation[];
21
20
  tools: UserToolUsage[];
22
21
  startedAt: string;
23
22
  finishedAt: string;
@@ -27,6 +26,20 @@ export type UserUsageReport = {
27
26
  error: string | null;
28
27
  };
29
28
 
29
+ export type UserModelInvocation = {
30
+ executionId: string;
31
+ parentExecutionId: string | null;
32
+ kind: "incoming" | "subrequest";
33
+ model: string;
34
+ inputTokens: number;
35
+ outputTokens: number;
36
+ startedAt: string;
37
+ finishedAt: string;
38
+ durationMs: number;
39
+ outcome: UserUsageOutcome;
40
+ error: string | null;
41
+ };
42
+
30
43
  /** Counts semantic LIL content, never transport JSON, settings, model ids, or usage. */
31
44
  export function countUserProgramTokens(program: Program): number {
32
45
  return encode(semanticProgramText(program), { disallowedSpecial: "all" })