@neutrome/open-ai-router 0.1.11 → 0.1.12

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,13 +1,13 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
7
7
  },
8
8
  "dependencies": {
9
9
  "hono": "^4.12.18",
10
- "@neutrome/lil-engine": "0.1.3"
10
+ "@neutrome/lil-engine": "0.1.4"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/node": "^25.9.3",
@@ -3,6 +3,7 @@ import type { Executor, ProgramTransform } from "@neutrome/lil-engine";
3
3
  import type { ExecutionRuntimeOptions } from "@neutrome/lil-engine";
4
4
  import { cors } from "./cors.ts";
5
5
  import { healthHandler } from "./health.ts";
6
+ import { createTraceCollector, type ExecutionTrace } from "../observer.ts";
6
7
  import {
7
8
  createRouterRuntime,
8
9
  handleAnthropicMessages,
@@ -14,7 +15,6 @@ import {
14
15
  } from "../router/index.ts";
15
16
  import type { RequestContext } from "../auth/types.ts";
16
17
  import type { RouterConfig } from "../router/config.ts";
17
- import type { UsageReport, ProviderCallReport } from "../router/execute.ts";
18
18
 
19
19
  export type RouterAppAuth = {
20
20
  authenticate(request: Request, env: Record<string, unknown>): Promise<object | null>;
@@ -23,9 +23,7 @@ export type RouterAppAuth = {
23
23
 
24
24
  export type RouterAppHooks = {
25
25
  beforeRequest?(ctx: { request: Request; model: string | null; env: Record<string, unknown> }): Promise<Response | void>;
26
- onUsage?(report: UsageReport, env: Record<string, unknown>): void;
27
- onProviderCall?(report: ProviderCallReport, env: Record<string, unknown>): void;
28
- onToolCall?(report: { toolName: string }, env: Record<string, unknown>): void;
26
+ onTrace?(trace: ExecutionTrace, env: Record<string, unknown>): void | Promise<void>;
29
27
  };
30
28
 
31
29
  export type RouterAppOptions = {
@@ -40,7 +38,6 @@ export type RouterAppOptions = {
40
38
  requestIdFactory?: ExecutionRuntimeOptions["requestIdFactory"];
41
39
  executionIdFactory?: ExecutionRuntimeOptions["executionIdFactory"];
42
40
  upstreamTimeoutMs?: number;
43
- suppressUsageToClient?: boolean;
44
41
  };
45
42
 
46
43
  export function createRouterApp(options: RouterAppOptions) {
@@ -114,25 +111,39 @@ export function createRouterApp(options: RouterAppOptions) {
114
111
  driver.collectIncoming?.(ctx);
115
112
  }
116
113
 
117
- const onUsage = options.hooks?.onUsage
118
- ? (report: UsageReport) => options.hooks!.onUsage!(report, env)
119
- : undefined;
120
- const onProviderCall = options.hooks?.onProviderCall
121
- ? (report: ProviderCallReport) => options.hooks!.onProviderCall!(report, env)
122
- : undefined;
123
-
124
- return handler({
125
- runtime,
126
- ctx,
127
- executors: options.executors,
128
- ...(options.transforms ? { transforms: options.transforms } : {}),
129
- ...(options.observe ? { observe: options.observe } : {}),
130
- ...(options.upstreamTimeoutMs !== undefined ? { upstreamTimeoutMs: options.upstreamTimeoutMs } : {}),
131
- ...(options.suppressUsageToClient ? { suppressUsageToClient: true } : {}),
132
- ...(onUsage ? { onUsage } : {}),
133
- ...(onProviderCall ? { onProviderCall } : {}),
134
- ...(forceStreaming ? { forceStreaming: true } : {}),
135
- });
114
+ const collector = createTraceCollector();
115
+ const observe = (event: Parameters<NonNullable<ExecutionRuntimeOptions["observe"]>>[0]) => {
116
+ collector.observe(event);
117
+ options.observe?.(event);
118
+ };
119
+ let traceFinalized = false;
120
+ const finalizeTrace = () => {
121
+ if (traceFinalized) return;
122
+ traceFinalized = true;
123
+ Promise.resolve(options.hooks?.onTrace?.(collector.getTrace(), env)).catch((error) => {
124
+ console.error("[open-ai-router] onTrace hook failed:", error);
125
+ });
126
+ };
127
+
128
+ try {
129
+ const response = await handler({
130
+ runtime,
131
+ ctx,
132
+ executors: options.executors,
133
+ ...(options.transforms ? { transforms: options.transforms } : {}),
134
+ observe,
135
+ ...(options.upstreamTimeoutMs !== undefined ? { upstreamTimeoutMs: options.upstreamTimeoutMs } : {}),
136
+ ...(forceStreaming ? { forceStreaming: true } : {}),
137
+ });
138
+ if (!isEventStreamResponse(response) || !response.body) {
139
+ finalizeTrace();
140
+ return response;
141
+ }
142
+ return wrapResponseWithFinalize(response, finalizeTrace);
143
+ } catch (error) {
144
+ finalizeTrace();
145
+ throw error;
146
+ }
136
147
  }
137
148
 
138
149
  return app;
@@ -191,3 +202,37 @@ function errorResponse(message: string, status: number, code: string): Response
191
202
  { status, headers: { "content-type": "application/json; charset=utf-8" } },
192
203
  );
193
204
  }
205
+
206
+ function isEventStreamResponse(response: Response): boolean {
207
+ const contentType = response.headers.get("content-type") ?? "";
208
+ return contentType.startsWith("text/event-stream");
209
+ }
210
+
211
+ function wrapResponseWithFinalize(response: Response, finalize: () => void): Response {
212
+ const reader = response.body!.getReader();
213
+ const body = new ReadableStream<Uint8Array>({
214
+ async pull(controller) {
215
+ try {
216
+ const { done, value } = await reader.read();
217
+ if (done) {
218
+ finalize();
219
+ controller.close();
220
+ return;
221
+ }
222
+ controller.enqueue(value);
223
+ } catch (error) {
224
+ finalize();
225
+ controller.error(error);
226
+ }
227
+ },
228
+ async cancel(reason) {
229
+ finalize();
230
+ await reader.cancel(reason).catch(() => {});
231
+ },
232
+ });
233
+ return new Response(body, {
234
+ status: response.status,
235
+ statusText: response.statusText,
236
+ headers: new Headers(response.headers),
237
+ });
238
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  export { createRouterApp } from "./app/factory.ts";
2
2
  export type { RouterAppAuth, RouterAppHooks, RouterAppOptions } from "./app/factory.ts";
3
+ export {
4
+ createTraceCollector,
5
+ renderProgramAsLil,
6
+ } from "./observer.ts";
7
+ export type {
8
+ ExecutionTrace,
9
+ TraceSpan,
10
+ TraceSpanKind,
11
+ } from "./observer.ts";
3
12
 
4
13
  export { createApp } from "./example.ts";
5
14
  export type { CreateAppOptions } from "./example.ts";
@@ -32,15 +41,12 @@ export type {
32
41
  HandleResponsesOptions,
33
42
  ProviderApiStyle,
34
43
  ProviderApiStyle as ProviderStyle,
35
- ProviderCallReport,
36
- ProviderErrorReport,
37
44
  ProviderRuntime,
38
45
  ProviderTargetConfig,
39
46
  ResolvedTarget,
40
47
  RouterConfig,
41
48
  RouterExecutionOptions,
42
49
  RouterRuntime,
43
- UsageReport,
44
50
  } from "./router/index.ts";
45
51
 
46
52
  export type {
@@ -0,0 +1,250 @@
1
+ import { opcodeName, type AuditEvent, type Program } from "@neutrome/lil-engine";
2
+
3
+ const decoder = new TextDecoder();
4
+
5
+ export type TraceSpanKind = "request" | "provider" | "tool" | "executor";
6
+
7
+ export type TraceSpan = {
8
+ kind: TraceSpanKind;
9
+ event: string;
10
+ requestId: string;
11
+ executionId: string;
12
+ parentExecutionId?: string;
13
+ model?: string;
14
+ provider?: string;
15
+ toolName?: string;
16
+ executor?: string;
17
+ startedAt: string;
18
+ finishedAt?: string;
19
+ tokensInput: number;
20
+ tokensOutput: number;
21
+ cachedTokensInput: number;
22
+ requestBytes: number;
23
+ responseBytes: number;
24
+ error?: string;
25
+ };
26
+
27
+ export type ExecutionTrace = {
28
+ spanId: string;
29
+ startedAt: string;
30
+ finishedAt?: string;
31
+ spans: TraceSpan[];
32
+ totalTokensInput: number;
33
+ totalTokensOutput: number;
34
+ totalCachedTokensInput: number;
35
+ rawTrace: string;
36
+ error?: string;
37
+ };
38
+
39
+ export function createTraceCollector(spanId = crypto.randomUUID()): {
40
+ observe: (event: AuditEvent) => void;
41
+ getTrace: () => ExecutionTrace;
42
+ } {
43
+ const spans: TraceSpan[] = [];
44
+ const providerSpans = new Map<string, TraceSpan>();
45
+ const executorSpans = new Map<string, TraceSpan>();
46
+ const rawSegments: string[] = [];
47
+ const startedAt = new Date().toISOString();
48
+ let finishedAt: string | undefined;
49
+ let error: string | undefined;
50
+
51
+ return {
52
+ observe(event) {
53
+ appendLilSegment(event);
54
+
55
+ switch (event.kind) {
56
+ case "request.received":
57
+ spans.push(createSpan("request", event, {
58
+ finishedAt: event.timestamp,
59
+ }));
60
+ break;
61
+ case "provider.started":
62
+ case "provider.stream_started": {
63
+ const span = createSpan("provider", event, {
64
+ event: event.kind,
65
+ ...withMaybeString("model", asString(event.data?.model)),
66
+ ...withMaybeString("provider", asString(event.data?.provider)),
67
+ });
68
+ spans.push(span);
69
+ providerSpans.set(event.executionId, span);
70
+ break;
71
+ }
72
+ case "provider.usage": {
73
+ const span = providerSpans.get(event.executionId);
74
+ if (!span) break;
75
+ span.tokensInput = asNumber(event.data?.tokensInput);
76
+ span.tokensOutput = asNumber(event.data?.tokensOutput);
77
+ span.cachedTokensInput = asNumber(event.data?.cachedTokensInput);
78
+ span.requestBytes = asNumber(event.data?.requestBytes);
79
+ span.responseBytes = asNumber(event.data?.responseBytes);
80
+ const provider = asString(event.data?.provider);
81
+ if (!span.provider && provider) {
82
+ span.provider = provider;
83
+ }
84
+ const model = asString(event.data?.model);
85
+ if (!span.model && model) {
86
+ span.model = model;
87
+ }
88
+ break;
89
+ }
90
+ case "provider.finished":
91
+ case "provider.stream_finished": {
92
+ const span = providerSpans.get(event.executionId);
93
+ if (!span) break;
94
+ span.finishedAt = event.timestamp;
95
+ break;
96
+ }
97
+ case "executor.started":
98
+ case "executor.stream_started": {
99
+ const span = createSpan("executor", event, {
100
+ event: event.kind,
101
+ ...withMaybeString("executor", asString(event.data?.executor) || asString(event.target?.id)),
102
+ });
103
+ spans.push(span);
104
+ executorSpans.set(event.executionId, span);
105
+ break;
106
+ }
107
+ case "executor.finished":
108
+ case "executor.stream_finished": {
109
+ const span = executorSpans.get(event.executionId);
110
+ if (!span) break;
111
+ span.finishedAt = event.timestamp;
112
+ break;
113
+ }
114
+ case "tool.executed":
115
+ spans.push(createSpan("tool", event, {
116
+ finishedAt: event.timestamp,
117
+ ...withMaybeString("toolName", asString(event.data?.toolName)),
118
+ }));
119
+ break;
120
+ case "execution.failed": {
121
+ const message = asString(event.data?.message) || `Execution failed (${event.errorKind ?? "unknown"})`;
122
+ error = error ?? message;
123
+ const span = providerSpans.get(event.executionId) ?? executorSpans.get(event.executionId);
124
+ if (span) {
125
+ span.error = message;
126
+ span.finishedAt = span.finishedAt ?? event.timestamp;
127
+ }
128
+ break;
129
+ }
130
+ }
131
+ },
132
+
133
+ getTrace() {
134
+ const totalTokensInput = spans.reduce((sum, span) => sum + span.tokensInput, 0);
135
+ const totalTokensOutput = spans.reduce((sum, span) => sum + span.tokensOutput, 0);
136
+ const totalCachedTokensInput = spans.reduce((sum, span) => sum + span.cachedTokensInput, 0);
137
+ finishedAt ??= new Date().toISOString();
138
+ return {
139
+ spanId,
140
+ startedAt,
141
+ finishedAt,
142
+ spans,
143
+ totalTokensInput,
144
+ totalTokensOutput,
145
+ totalCachedTokensInput,
146
+ rawTrace: rawSegments.join("\n\n"),
147
+ ...(error ? { error } : {}),
148
+ };
149
+ },
150
+ };
151
+
152
+ function appendLilSegment(event: AuditEvent): void {
153
+ const lilText = asString(event.data?.lilText)?.trim();
154
+ if (!lilText) return;
155
+ rawSegments.push(lilText);
156
+ }
157
+ }
158
+
159
+ export function renderProgramAsLil(program: Program): string {
160
+ return program.code
161
+ .map((instruction) => {
162
+ const name = opcodeName(instruction.opcode) ?? `OP_${instruction.opcode}`;
163
+ const value = formatInstructionValue(program, instruction.value);
164
+ return value ? `${name} ${value}` : name;
165
+ })
166
+ .join("\n");
167
+ }
168
+
169
+ function createSpan(
170
+ kind: TraceSpanKind,
171
+ event: AuditEvent,
172
+ input: Partial<Omit<TraceSpan, "kind" | "requestId" | "executionId" | "startedAt">> = {},
173
+ ): TraceSpan {
174
+ return {
175
+ kind,
176
+ event: input.event ?? event.kind,
177
+ requestId: event.requestId,
178
+ executionId: event.executionId,
179
+ ...(event.parentExecutionId ? { parentExecutionId: event.parentExecutionId } : {}),
180
+ startedAt: event.timestamp,
181
+ ...withMaybeString("model", input.model),
182
+ ...withMaybeString("provider", input.provider),
183
+ ...withMaybeString("toolName", input.toolName),
184
+ ...withMaybeString("executor", input.executor),
185
+ ...(input.finishedAt ? { finishedAt: input.finishedAt } : {}),
186
+ tokensInput: input.tokensInput ?? 0,
187
+ tokensOutput: input.tokensOutput ?? 0,
188
+ cachedTokensInput: input.cachedTokensInput ?? 0,
189
+ requestBytes: input.requestBytes ?? 0,
190
+ responseBytes: input.responseBytes ?? 0,
191
+ ...withMaybeString("error", input.error),
192
+ };
193
+ }
194
+
195
+ function formatInstructionValue(program: Program, value: Program["code"][number]["value"]): string {
196
+ switch (value.kind) {
197
+ case "none":
198
+ return "";
199
+ case "string":
200
+ return JSON.stringify(value.value);
201
+ case "float":
202
+ case "int":
203
+ return String(value.value);
204
+ case "json":
205
+ return formatBytes(value.value);
206
+ case "ref": {
207
+ const bytes = program.buffers[value.value];
208
+ if (!bytes) {
209
+ return `@${value.value}`;
210
+ }
211
+ return `@${value.value}=${formatBytes(bytes)}`;
212
+ }
213
+ case "key_string":
214
+ return `${JSON.stringify(value.key)}=${JSON.stringify(value.value)}`;
215
+ case "key_json":
216
+ return `${JSON.stringify(value.key)}=${formatBytes(value.value)}`;
217
+ }
218
+ }
219
+
220
+ function formatBytes(bytes: Uint8Array): string {
221
+ const text = decoder.decode(bytes);
222
+ if (text.includes("\uFFFD")) {
223
+ return `base64:${toBase64(bytes)}`;
224
+ }
225
+ const trimmed = text.trim();
226
+ if (trimmed.startsWith("{") || trimmed.startsWith("[") || trimmed.startsWith("\"")) {
227
+ return trimmed;
228
+ }
229
+ return JSON.stringify(text);
230
+ }
231
+
232
+ function toBase64(bytes: Uint8Array): string {
233
+ let binary = "";
234
+ for (const byte of bytes) {
235
+ binary += String.fromCharCode(byte);
236
+ }
237
+ return btoa(binary);
238
+ }
239
+
240
+ function asString(value: unknown): string | undefined {
241
+ return typeof value === "string" && value.length > 0 ? value : undefined;
242
+ }
243
+
244
+ function asNumber(value: unknown): number {
245
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
246
+ }
247
+
248
+ function withMaybeString<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
249
+ return value ? { [key]: value } as Record<K, string> : {};
250
+ }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ createAuditEvent,
2
3
  emitChatCompletionsResponse,
3
4
  emitProviderRequest,
4
5
  emitProviderResponse,
@@ -27,6 +28,7 @@ import {
27
28
  import type { RequestContext, TargetAuthContext } from "../auth/types.ts";
28
29
  import { cloneForwardHeaders, isSse } from "../util/headers.ts";
29
30
  import { eventDataLines, splitSseEvents } from "../util/sse.ts";
31
+ import { renderProgramAsLil } from "../observer.ts";
30
32
  import { resolveInvocationTarget, type ResolvedTarget } from "./resolve.ts";
31
33
  import type { ProviderRuntime, RouterRuntime } from "./runtime.ts";
32
34
 
@@ -34,25 +36,6 @@ const encoder = new TextEncoder();
34
36
  const decoder = new TextDecoder();
35
37
  const DEFAULT_UPSTREAM_TIMEOUT_MS = 120_000;
36
38
 
37
- export type UsageReport = {
38
- model: string;
39
- provider: string;
40
- tokensInput: number;
41
- tokensOutput: number;
42
- cachedTokensInput: number;
43
- };
44
-
45
- export type ProviderErrorReport = {
46
- model: string;
47
- provider: string;
48
- status: number;
49
- message: string;
50
- };
51
-
52
- export type ProviderCallReport =
53
- | { kind: "usage"; requestBytes: number; responseBytes: number } & UsageReport
54
- | { kind: "error"; } & ProviderErrorReport;
55
-
56
39
  export type RouterExecutionOptions = Pick<
57
40
  ExecutionRuntimeOptions,
58
41
  "observe" | "requestIdFactory" | "executionIdFactory" | "resolveExecutor"
@@ -60,9 +43,6 @@ export type RouterExecutionOptions = Pick<
60
43
  executors?: Readonly<Record<string, Executor>>;
61
44
  transforms?: Readonly<Record<string, ProgramTransform>>;
62
45
  upstreamTimeoutMs?: number;
63
- suppressUsageToClient?: boolean;
64
- onUsage?: (report: UsageReport) => void;
65
- onProviderCall?: (report: ProviderCallReport) => void;
66
46
  };
67
47
 
68
48
  export type HandleChatCompletionsOptions = RouterExecutionOptions & {
@@ -122,7 +102,6 @@ export function createRouterExecutionRuntime(
122
102
  runtime,
123
103
  ctx,
124
104
  options.upstreamTimeoutMs,
125
- options.onProviderCall,
126
105
  ),
127
106
  };
128
107
 
@@ -183,6 +162,11 @@ export async function handleChatCompletions(
183
162
  try {
184
163
  const requestBody = new Uint8Array(await options.ctx.request.arrayBuffer());
185
164
  const request = parseChatCompletionsRequest(requestBody);
165
+ const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
166
+ emitObservedProgram(options.observe, "request.received", request, {
167
+ requestId: incomingExecutionId,
168
+ executionId: incomingExecutionId,
169
+ });
186
170
  const resolved = resolveRequestTarget(options.runtime, request);
187
171
  const execution = createRouterExecutionRuntime(
188
172
  options.runtime,
@@ -192,15 +176,12 @@ export async function handleChatCompletions(
192
176
 
193
177
  const startTime = Date.now();
194
178
  if (isStreaming(request)) {
195
- const userWantsUsage = options.suppressUsageToClient ? false : requestedStreamUsage(requestBody);
196
179
  const chunks = execution.stream(request, { target: resolved.target });
197
180
  return await streamExecutionResponse(
198
181
  "chat-completions",
199
182
  chunks,
200
183
  resolved,
201
184
  startTime,
202
- options.onUsage,
203
- userWantsUsage,
204
185
  );
205
186
  }
206
187
 
@@ -208,7 +189,6 @@ export async function handleChatCompletions(
208
189
  target: resolved.target,
209
190
  });
210
191
  const userTime = Date.now() - startTime;
211
- reportUsage(response, resolved, options.onUsage);
212
192
  return jsonExecutionResponse(
213
193
  emitChatCompletionsResponse(response),
214
194
  resolved,
@@ -254,6 +234,11 @@ async function handleProviderStyle(
254
234
  const request = mutateRequest
255
235
  ? mutateRequest(parseProviderRequest(style, requestBody))
256
236
  : parseProviderRequest(style, requestBody);
237
+ const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
238
+ emitObservedProgram(options.observe, "request.received", request, {
239
+ requestId: incomingExecutionId,
240
+ executionId: incomingExecutionId,
241
+ });
257
242
  const resolved = resolveRequestTarget(options.runtime, request);
258
243
  const execution = createRouterExecutionRuntime(
259
244
  options.runtime,
@@ -269,8 +254,6 @@ async function handleProviderStyle(
269
254
  chunks,
270
255
  resolved,
271
256
  startTime,
272
- options.onUsage,
273
- !options.suppressUsageToClient,
274
257
  );
275
258
  }
276
259
 
@@ -278,7 +261,6 @@ async function handleProviderStyle(
278
261
  target: resolved.target,
279
262
  });
280
263
  const userTime = Date.now() - startTime;
281
- reportUsage(response, resolved, options.onUsage);
282
264
  return jsonExecutionResponse(
283
265
  emitProviderResponse(style, response),
284
266
  resolved,
@@ -313,7 +295,6 @@ export function createFetchProviderInvoker(
313
295
  runtime: RouterRuntime,
314
296
  ctx: RequestContext,
315
297
  upstreamTimeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS,
316
- onProviderCall?: (report: ProviderCallReport) => void,
317
298
  ): ProviderInvoker {
318
299
  return {
319
300
  async execute(request, providerCtx) {
@@ -321,24 +302,37 @@ export function createFetchProviderInvoker(
321
302
  const timed = createUpstreamSignal(providerCtx.signal, upstreamTimeoutMs);
322
303
 
323
304
  try {
324
- const reqBody = emitProviderRequest(provider.style, setModel(request, providerCtx.target.model));
305
+ const resolvedRequest = setModel(request, providerCtx.target.model);
306
+ emitObservedProgram(providerCtx.observe, "provider.request", resolvedRequest, {
307
+ requestId: providerCtx.requestId,
308
+ executionId: providerCtx.executionId,
309
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
310
+ provider: provider.name,
311
+ model: providerCtx.target.model,
312
+ });
313
+ const reqBody = emitProviderRequest(provider.style, resolvedRequest);
325
314
  const reqBytes = reqBody.byteLength;
326
315
  const upstream = await fetchProvider(
327
316
  runtime,
328
317
  ctx,
329
318
  provider,
330
- setModel(request, providerCtx.target.model),
319
+ resolvedRequest,
331
320
  timed.signal,
332
321
  );
333
322
  if (!upstream.ok) {
334
- const err = await toProviderError(provider.name, upstream);
335
- reportProviderError(onProviderCall, providerCtx.target, err);
336
- throw err;
323
+ throw await toProviderError(provider.name, upstream);
337
324
  }
338
325
 
339
326
  const bytes = new Uint8Array(await upstream.arrayBuffer());
340
327
  const response = parseProviderResponse(provider.style, bytes);
341
- reportProviderUsage(onProviderCall, response, providerCtx.target, reqBytes, bytes.byteLength);
328
+ emitObservedProgram(providerCtx.observe, "provider.response", response, {
329
+ requestId: providerCtx.requestId,
330
+ executionId: providerCtx.executionId,
331
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
332
+ provider: provider.name,
333
+ model: providerCtx.target.model,
334
+ });
335
+ emitProviderUsage(providerCtx, response, provider.name, reqBytes, bytes.byteLength);
342
336
  return response;
343
337
  } catch (error) {
344
338
  throw normalizeUpstreamAbort(error, timed.signal);
@@ -354,19 +348,25 @@ export function createFetchProviderInvoker(
354
348
  let buffer = "";
355
349
 
356
350
  try {
357
- const reqBody = emitProviderRequest(provider.style, setModel(request, providerCtx.target.model));
351
+ const resolvedRequest = setModel(request, providerCtx.target.model);
352
+ emitObservedProgram(providerCtx.observe, "provider.request", resolvedRequest, {
353
+ requestId: providerCtx.requestId,
354
+ executionId: providerCtx.executionId,
355
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
356
+ provider: provider.name,
357
+ model: providerCtx.target.model,
358
+ });
359
+ const reqBody = emitProviderRequest(provider.style, resolvedRequest);
358
360
  const reqBytes = reqBody.byteLength;
359
361
  const upstream = await fetchProvider(
360
362
  runtime,
361
363
  ctx,
362
364
  provider,
363
- setModel(request, providerCtx.target.model),
365
+ resolvedRequest,
364
366
  timed.signal,
365
367
  );
366
368
  if (!upstream.ok) {
367
- const err = await toProviderError(provider.name, upstream);
368
- reportProviderError(onProviderCall, providerCtx.target, err);
369
- throw err;
369
+ throw await toProviderError(provider.name, upstream);
370
370
  }
371
371
  if (!isSse(upstream.headers) || !upstream.body) {
372
372
  throw new RouterError(
@@ -391,7 +391,7 @@ export function createFetchProviderInvoker(
391
391
  for (const event of split.events) {
392
392
  for (const data of eventDataLines(event)) {
393
393
  if (data === "[DONE]") {
394
- reportProviderUsage(onProviderCall, lastChunkWithUsage, providerCtx.target, reqBytes, respBytes);
394
+ emitProviderUsage(providerCtx, lastChunkWithUsage, provider.name, reqBytes, respBytes);
395
395
  return;
396
396
  }
397
397
  if (data[0] !== "{") continue;
@@ -400,6 +400,13 @@ export function createFetchProviderInvoker(
400
400
  encoder.encode(data),
401
401
  );
402
402
  if (usageObject(chunk) !== undefined) lastChunkWithUsage = chunk;
403
+ emitObservedProgram(providerCtx.observe, "provider.response_chunk", chunk, {
404
+ requestId: providerCtx.requestId,
405
+ executionId: providerCtx.executionId,
406
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
407
+ provider: provider.name,
408
+ model: providerCtx.target.model,
409
+ });
403
410
  yield chunk;
404
411
  }
405
412
  }
@@ -408,7 +415,7 @@ export function createFetchProviderInvoker(
408
415
  if (buffer.length > 0) {
409
416
  for (const data of eventDataLines(buffer)) {
410
417
  if (data === "[DONE]") {
411
- reportProviderUsage(onProviderCall, lastChunkWithUsage, providerCtx.target, reqBytes, respBytes);
418
+ emitProviderUsage(providerCtx, lastChunkWithUsage, provider.name, reqBytes, respBytes);
412
419
  return;
413
420
  }
414
421
  if (data[0] !== "{") continue;
@@ -417,10 +424,17 @@ export function createFetchProviderInvoker(
417
424
  encoder.encode(data),
418
425
  );
419
426
  if (usageObject(chunk) !== undefined) lastChunkWithUsage = chunk;
427
+ emitObservedProgram(providerCtx.observe, "provider.response_chunk", chunk, {
428
+ requestId: providerCtx.requestId,
429
+ executionId: providerCtx.executionId,
430
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
431
+ provider: provider.name,
432
+ model: providerCtx.target.model,
433
+ });
420
434
  yield chunk;
421
435
  }
422
436
  }
423
- reportProviderUsage(onProviderCall, lastChunkWithUsage, providerCtx.target, reqBytes, respBytes);
437
+ emitProviderUsage(providerCtx, lastChunkWithUsage, provider.name, reqBytes, respBytes);
424
438
  } catch (error) {
425
439
  throw normalizeUpstreamAbort(error, timed.signal);
426
440
  } finally {
@@ -437,8 +451,6 @@ async function streamExecutionResponse(
437
451
  chunks: AsyncIterable<Program>,
438
452
  resolved: ResolvedTarget,
439
453
  startTime: number,
440
- onUsage?: (report: UsageReport) => void,
441
- emitUsageToClient = true,
442
454
  ): Promise<Response> {
443
455
  const iterator = chunks[Symbol.asyncIterator]();
444
456
 
@@ -448,12 +460,7 @@ async function streamExecutionResponse(
448
460
  const stream = new ReadableStream<Uint8Array>({
449
461
  async start(controller) {
450
462
  try {
451
- let lastChunkWithUsage: Program | null = null;
452
-
453
463
  function emitChunk(chunk: Program): void {
454
- const hasUsage = usageObject(chunk) !== undefined;
455
- if (hasUsage) lastChunkWithUsage = chunk;
456
- if (hasUsage && !emitUsageToClient) return;
457
464
  const bytes = emitProviderStreamChunk(responseStyle, chunk);
458
465
  controller.enqueue(
459
466
  encoder.encode(`data: ${decoder.decode(bytes)}\n\n`),
@@ -474,9 +481,6 @@ async function streamExecutionResponse(
474
481
  if (responseStyle === "chat-completions") {
475
482
  controller.enqueue(encoder.encode("data: [DONE]\n\n"));
476
483
  }
477
- if (lastChunkWithUsage) {
478
- reportUsage(lastChunkWithUsage, resolved, onUsage);
479
- }
480
484
  controller.close();
481
485
  } catch (error) {
482
486
  controller.enqueue(
@@ -749,91 +753,40 @@ function createUpstreamSignal(
749
753
  };
750
754
  }
751
755
 
752
- function reportUsage(
753
- response: Program,
754
- resolved: ResolvedTarget,
755
- onUsage?: (report: UsageReport) => void,
756
- ): void {
757
- if (!onUsage) return;
758
- const usage = usageObject(response);
759
- if (!usage || typeof usage !== "object") return;
760
- const u = usage as Record<string, unknown>;
761
- const tokensInput = asNum(u.prompt_tokens) || asNum(u.input_tokens);
762
- const tokensOutput = asNum(u.completion_tokens) || asNum(u.output_tokens);
763
- if (tokensInput === 0 && tokensOutput === 0) return;
764
- const details = u.prompt_tokens_details as Record<string, unknown> | undefined;
765
- const cachedTokensInput = details ? asNum(details.cached_tokens) : 0;
766
- const model = resolved.target.kind === "provider" ? resolved.target.model : resolved.target.alias;
767
- const provider = resolved.target.kind === "provider" ? (resolved.target.provider ?? "") : "";
768
- try {
769
- onUsage({ model, provider, tokensInput, tokensOutput, cachedTokensInput });
770
- } catch {}
771
- }
772
-
773
- function reportProviderUsage(
774
- onProviderCall: ((report: ProviderCallReport) => void) | undefined,
756
+ function emitProviderUsage(
757
+ providerCtx: import("@neutrome/lil-engine").ProviderInvocationContext,
775
758
  response: Program | null,
776
- target: Extract<import("@neutrome/lil-engine").ExecutionTarget, { kind: "provider" }>,
759
+ provider: string,
777
760
  requestBytes: number,
778
761
  responseBytes: number,
779
762
  ): void {
780
- if (!onProviderCall) return;
781
763
  const usage = response ? usageObject(response) : undefined;
782
764
  const u = (usage && typeof usage === "object" ? usage : {}) as Record<string, unknown>;
783
765
  const tokensInput = asNum(u.prompt_tokens) || asNum(u.input_tokens);
784
766
  const tokensOutput = asNum(u.completion_tokens) || asNum(u.output_tokens);
785
767
  const details = u.prompt_tokens_details as Record<string, unknown> | undefined;
786
768
  const cachedTokensInput = details ? asNum(details.cached_tokens) : 0;
787
- try {
788
- onProviderCall({
789
- kind: "usage",
790
- model: target.model,
791
- provider: target.provider ?? "",
769
+ providerCtx.observe(createAuditEvent({
770
+ kind: "provider.usage",
771
+ requestId: providerCtx.requestId,
772
+ executionId: providerCtx.executionId,
773
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
774
+ data: {
775
+ provider,
776
+ model: providerCtx.target.model,
792
777
  tokensInput,
793
778
  tokensOutput,
794
779
  cachedTokensInput,
795
780
  requestBytes,
796
781
  responseBytes,
797
- });
798
- } catch {}
799
- }
800
-
801
- function reportProviderError(
802
- onProviderCall: ((report: ProviderCallReport) => void) | undefined,
803
- target: Extract<import("@neutrome/lil-engine").ExecutionTarget, { kind: "provider" }>,
804
- error: ProviderHttpError,
805
- ): void {
806
- if (!onProviderCall) return;
807
- let message = `HTTP ${error.status}`;
808
- try {
809
- const text = decoder.decode(error.body).slice(0, 500);
810
- message = text || message;
811
- } catch {}
812
- try {
813
- onProviderCall({
814
- kind: "error",
815
- model: target.model,
816
- provider: target.provider ?? "",
817
- status: error.status,
818
- message,
819
- });
820
- } catch {}
782
+ },
783
+ }));
821
784
  }
822
785
 
823
786
  function asNum(v: unknown): number {
824
787
  return typeof v === "number" && v >= 0 ? v : 0;
825
788
  }
826
789
 
827
- function requestedStreamUsage(body: Uint8Array): boolean {
828
- try {
829
- const obj = JSON.parse(decoder.decode(body)) as Record<string, unknown>;
830
- const opts = obj.stream_options as Record<string, unknown> | undefined;
831
- return opts?.include_usage === true;
832
- } catch {
833
- return false;
834
- }
835
- }
836
-
837
790
  function injectStreamUsage(body: Uint8Array): Uint8Array {
838
791
  try {
839
792
  const obj = JSON.parse(decoder.decode(body)) as Record<string, unknown>;
@@ -850,3 +803,29 @@ function normalizeUpstreamAbort(error: unknown, signal: AbortSignal): unknown {
850
803
  }
851
804
  return error;
852
805
  }
806
+
807
+ function emitObservedProgram(
808
+ observe: ExecutionRuntimeOptions["observe"],
809
+ kind: string,
810
+ program: Program,
811
+ input: {
812
+ requestId: string;
813
+ executionId: string;
814
+ parentExecutionId?: string;
815
+ provider?: string;
816
+ model?: string;
817
+ },
818
+ ): void {
819
+ if (!observe) return;
820
+ observe(createAuditEvent({
821
+ kind,
822
+ requestId: input.requestId,
823
+ executionId: input.executionId,
824
+ ...(input.parentExecutionId ? { parentExecutionId: input.parentExecutionId } : {}),
825
+ data: {
826
+ ...(input.provider ? { provider: input.provider } : {}),
827
+ ...(input.model ? { model: input.model } : {}),
828
+ lilText: renderProgramAsLil(program),
829
+ },
830
+ }));
831
+ }
@@ -32,8 +32,5 @@ export type {
32
32
  HandleGoogleGenAIOptions,
33
33
  HandleResponsesOptions,
34
34
  RouterExecutionOptions,
35
- UsageReport,
36
- ProviderCallReport,
37
- ProviderErrorReport,
38
35
  } from "./execute.ts";
39
36
  export type { ResolvedTarget } from "./resolve.ts";
package/src/worker.ts CHANGED
@@ -38,6 +38,6 @@ export default {
38
38
  throw error;
39
39
  });
40
40
  const app = await appPromise;
41
- return app.fetch(request, env, ctx);
41
+ return app.fetch(request, env, ctx as never);
42
42
  },
43
43
  };