@neutrome/open-ai-router 0.3.7 → 0.4.1

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.3.7",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
@@ -8,8 +8,8 @@
8
8
  "dependencies": {
9
9
  "@modelcontextprotocol/sdk": "^1.29.0",
10
10
  "hono": "^4.12.18",
11
- "@neutrome/lil-engine": "0.2.0",
12
- "@neutrome/lilsdk-ts": "0.2.2"
11
+ "@neutrome/lil-engine": "0.2.1",
12
+ "@neutrome/lilsdk-ts": "0.2.4"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/node": "^25.9.3",
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
+ import type { ExecutionTrace } from "../observer.ts";
2
3
  import type { RouterConfig } from "../router/index.ts";
3
4
  import { createRouterApp } from "./factory.ts";
4
5
 
@@ -25,7 +26,7 @@ const config: RouterConfig = {
25
26
  describe("createRouterApp trace finalization", () => {
26
27
  it("keeps non-stream trace hooks alive with waitUntil", async () => {
27
28
  const waited: Promise<unknown>[] = [];
28
- const onTrace = vi.fn(async () => {});
29
+ const onTrace = vi.fn(async (_trace: ExecutionTrace) => {});
29
30
  const app = createRouterApp({
30
31
  config,
31
32
  executorImplementations: {},
@@ -73,7 +74,7 @@ describe("createRouterApp trace finalization", () => {
73
74
 
74
75
  it("keeps stream trace hooks alive until the stream closes", async () => {
75
76
  const waited: Promise<unknown>[] = [];
76
- const onTrace = vi.fn(async () => {});
77
+ const onTrace = vi.fn(async (_trace: ExecutionTrace) => {});
77
78
  const app = createRouterApp({
78
79
  config,
79
80
  executorImplementations: {},
@@ -137,6 +138,15 @@ describe("createRouterApp trace finalization", () => {
137
138
  expect(waited).toHaveLength(1);
138
139
  await waited[0];
139
140
  expect(onTrace).toHaveBeenCalledTimes(1);
141
+ const rawTrace = onTrace.mock.calls[0]?.[0].rawTrace ?? "";
142
+ expect(rawTrace).toContain('"event":"admission.authenticate"');
143
+ expect(rawTrace).toContain('"event":"upstream_auth.collect_incoming"');
144
+ expect(rawTrace).toContain('"event":"upstream_auth.resolve_target"');
145
+ expect(rawTrace).toContain('"event":"provider.stream_first_byte"');
146
+ expect(rawTrace).toContain('"event":"response.stream_first_chunk"');
147
+ expect(rawTrace).toContain('"event":"provider.stream_finished"');
148
+ expect(rawTrace).toContain('"startedAt"');
149
+ expect(rawTrace).toContain('"finishedAt"');
140
150
  });
141
151
  });
142
152
 
@@ -19,6 +19,7 @@ import type { RequestContext } from "../upstream-auth/types.ts";
19
19
  import type { RouterConfig } from "../router/config.ts";
20
20
  import type { RemoteMcpClientFactory } from "../router/mcp.ts";
21
21
  import type { ExecutionRuntimeOptions } from "../router/execution-types.ts";
22
+ import { observeTimingEvent } from "../timing.ts";
22
23
 
23
24
  export type RouterAppAuth = RuntimeAuthenticator;
24
25
 
@@ -96,25 +97,109 @@ export function createRouterApp(options: RouterAppOptions) {
96
97
  forceStreaming?: boolean,
97
98
  ): Promise<Response> {
98
99
  const env = honoEnv(c);
100
+ const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
101
+ const collector = createTraceCollector();
102
+ const onTrace = options.hooks?.onTrace;
103
+ const observe = (event: Parameters<NonNullable<ExecutionRuntimeOptions["observe"]>>[0]) => {
104
+ collector.observe(event);
105
+ options.observe?.(event);
106
+ };
107
+ let traceFinalized = false;
108
+ const finalizeTrace = () => {
109
+ if (traceFinalized) return;
110
+ traceFinalized = true;
111
+ if (!onTrace) return;
112
+ runBackgroundTask(
113
+ c,
114
+ () => onTrace(collector.getTrace(), env),
115
+ "onTrace hook",
116
+ );
117
+ };
118
+
119
+ const requestStartedMs = Date.now();
120
+ observeTimingEvent({
121
+ observe,
122
+ kind: "request.started",
123
+ requestId: incomingExecutionId,
124
+ executionId: incomingExecutionId,
125
+ startedAtMs: requestStartedMs,
126
+ data: { style },
127
+ });
128
+
129
+ const authStartedMs = Date.now();
99
130
  const principal = await options.auth.authenticate(request, env);
131
+ observeTimingEvent({
132
+ observe,
133
+ kind: "admission.authenticate",
134
+ requestId: incomingExecutionId,
135
+ executionId: incomingExecutionId,
136
+ startedAtMs: authStartedMs,
137
+ finishedAtMs: Date.now(),
138
+ data: { authenticated: !!principal },
139
+ });
100
140
  if (!principal) return unauthorized();
101
141
 
102
142
  let program: Program;
103
143
  try {
144
+ const parseStartedMs = Date.now();
104
145
  program = await parseRequestProgram(style, request.clone());
105
146
  if (forceStreaming) program = setStreaming(program, true);
147
+ observeTimingEvent({
148
+ observe,
149
+ kind: "request.parsed",
150
+ requestId: incomingExecutionId,
151
+ executionId: incomingExecutionId,
152
+ startedAtMs: parseStartedMs,
153
+ finishedAtMs: Date.now(),
154
+ data: { style, streaming: forceStreaming === true },
155
+ });
106
156
  } catch {
157
+ observeTimingEvent({
158
+ observe,
159
+ kind: "request.parse_failed",
160
+ requestId: incomingExecutionId,
161
+ executionId: incomingExecutionId,
162
+ startedAtMs: requestStartedMs,
163
+ finishedAtMs: Date.now(),
164
+ data: { style },
165
+ });
166
+ finalizeTrace();
107
167
  return errorResponse("Invalid request body", 400, "invalid_request_body");
108
168
  }
109
169
 
110
170
  const model = getModel(program) || null;
111
- if (model && !canAccessPrincipalModel(principal, model)) {
171
+ const accessStartedMs = Date.now();
172
+ const hasModelAccess = !model || canAccessPrincipalModel(principal, model);
173
+ observeTimingEvent({
174
+ observe,
175
+ kind: "admission.model_access",
176
+ requestId: incomingExecutionId,
177
+ executionId: incomingExecutionId,
178
+ startedAtMs: accessStartedMs,
179
+ finishedAtMs: Date.now(),
180
+ data: { model: model ?? undefined, allowed: hasModelAccess },
181
+ });
182
+ if (!hasModelAccess) {
183
+ finalizeTrace();
112
184
  return errorResponse(`Model \`${model}\` does not exist or you do not have access.`, 404, "model_not_found");
113
185
  }
114
186
 
115
187
  if (options.hooks?.beforeRequest) {
188
+ const hookStartedMs = Date.now();
116
189
  const hookResponse = await options.hooks.beforeRequest({ request, model, principal, env });
117
- if (hookResponse) return hookResponse;
190
+ observeTimingEvent({
191
+ observe,
192
+ kind: "request.before_hook",
193
+ requestId: incomingExecutionId,
194
+ executionId: incomingExecutionId,
195
+ startedAtMs: hookStartedMs,
196
+ finishedAtMs: Date.now(),
197
+ data: { returnedResponse: !!hookResponse },
198
+ });
199
+ if (hookResponse) {
200
+ finalizeTrace();
201
+ return hookResponse;
202
+ }
118
203
  }
119
204
 
120
205
  const ctx: RequestContext = {
@@ -124,28 +209,21 @@ export function createRouterApp(options: RouterAppOptions) {
124
209
  state: new Map(),
125
210
  };
126
211
  for (const driver of runtime.upstreamAuthChain) {
212
+ const collectStartedMs = Date.now();
127
213
  driver.collectIncoming?.(ctx);
214
+ observeTimingEvent({
215
+ observe,
216
+ kind: "upstream_auth.collect_incoming",
217
+ requestId: incomingExecutionId,
218
+ executionId: incomingExecutionId,
219
+ startedAtMs: collectStartedMs,
220
+ finishedAtMs: Date.now(),
221
+ data: { driver: driver.name },
222
+ });
128
223
  }
129
224
 
130
- const collector = createTraceCollector();
131
- const onTrace = options.hooks?.onTrace;
132
- const observe = (event: Parameters<NonNullable<ExecutionRuntimeOptions["observe"]>>[0]) => {
133
- collector.observe(event);
134
- options.observe?.(event);
135
- };
136
- let traceFinalized = false;
137
- const finalizeTrace = () => {
138
- if (traceFinalized) return;
139
- traceFinalized = true;
140
- if (!onTrace) return;
141
- runBackgroundTask(
142
- c,
143
- () => onTrace(collector.getTrace(), env),
144
- "onTrace hook",
145
- );
146
- };
147
-
148
225
  try {
226
+ const handlerStartedMs = Date.now();
149
227
  const response = await handler({
150
228
  runtime,
151
229
  ctx,
@@ -153,9 +231,23 @@ export function createRouterApp(options: RouterAppOptions) {
153
231
  executorImplementations: options.executorImplementations,
154
232
  ...(options.transforms ? { transforms: options.transforms } : {}),
155
233
  observe,
234
+ incomingExecutionId,
156
235
  ...(options.upstreamTimeoutMs !== undefined ? { upstreamTimeoutMs: options.upstreamTimeoutMs } : {}),
157
236
  ...(options.remoteMcpClientFactory ? { remoteMcpClientFactory: options.remoteMcpClientFactory } : {}),
158
237
  });
238
+ observeTimingEvent({
239
+ observe,
240
+ kind: "response.ready",
241
+ requestId: incomingExecutionId,
242
+ executionId: incomingExecutionId,
243
+ startedAtMs: handlerStartedMs,
244
+ finishedAtMs: Date.now(),
245
+ data: {
246
+ style,
247
+ streaming: isEventStreamResponse(response),
248
+ status: response.status,
249
+ },
250
+ });
159
251
  if (!isEventStreamResponse(response) || !response.body) {
160
252
  finalizeTrace();
161
253
  return response;
@@ -31,4 +31,24 @@ describe("createTraceCollector", () => {
31
31
  ';request:0\nMSG_START\n\n;provider-request:1\nSET_MODEL "gpt-4o"\n\n;provider-response-chunk:2\nSTREAM_DELTA "Hi"',
32
32
  );
33
33
  });
34
+
35
+ it("appends structured timing comments with durationMs", () => {
36
+ const collector = createTraceCollector("00000000-0000-4000-8000-000000000001");
37
+
38
+ collector.observe({
39
+ kind: "provider.finished",
40
+ requestId: "request-1",
41
+ executionId: "provider-1",
42
+ timestamp: "2026-06-24T12:00:03.000Z",
43
+ data: {
44
+ startedAt: "2026-06-24T12:00:02.877Z",
45
+ finishedAt: "2026-06-24T12:00:03.000Z",
46
+ durationMs: 123,
47
+ },
48
+ });
49
+
50
+ expect(collector.getTrace().rawTrace).toBe(
51
+ ';timing {"kind":"provider","event":"provider.finished","requestId":"request-1","executionId":"provider-1","startedAt":"2026-06-24T12:00:02.877Z","finishedAt":"2026-06-24T12:00:03.000Z","durationMs":123}',
52
+ );
53
+ });
34
54
  });
package/src/observer.ts CHANGED
@@ -52,6 +52,7 @@ export function createTraceCollector(spanId = crypto.randomUUID()): {
52
52
 
53
53
  return {
54
54
  observe(event) {
55
+ appendTimingComment(event);
55
56
  appendLilSegment(event);
56
57
 
57
58
  switch (event.kind) {
@@ -156,6 +157,47 @@ export function createTraceCollector(spanId = crypto.randomUUID()): {
156
157
  if (!lilText) return;
157
158
  rawSegments.push(`;${traceSegmentLabel(event)}:${rawSegmentIndex++}\n${lilText}`);
158
159
  }
160
+
161
+ function appendTimingComment(event: AuditEvent): void {
162
+ const startedAt = asString(event.data?.startedAt);
163
+ const finishedAt = asString(event.data?.finishedAt);
164
+ const rawDurationMs = event.data?.durationMs;
165
+ const durationMs = typeof rawDurationMs === "number" && Number.isFinite(rawDurationMs) && rawDurationMs >= 0
166
+ ? Math.round(rawDurationMs)
167
+ : undefined;
168
+ if (!startedAt && !finishedAt && durationMs === undefined) return;
169
+
170
+ rawSegments.push(`;timing ${JSON.stringify(compactObject({
171
+ kind: traceTimingKind(event),
172
+ event: event.kind,
173
+ requestId: event.requestId,
174
+ executionId: event.executionId,
175
+ ...(event.parentExecutionId ? { parentExecutionId: event.parentExecutionId } : {}),
176
+ startedAt,
177
+ finishedAt: finishedAt ?? (durationMs !== undefined ? event.timestamp : undefined),
178
+ durationMs,
179
+ provider: asString(event.data?.provider),
180
+ model: asString(event.data?.model),
181
+ toolName: asString(event.data?.toolName),
182
+ executor: asString(event.data?.executor) || asString(event.data?.executorId),
183
+ driver: asString(event.data?.driver),
184
+ source: asString(event.data?.source),
185
+ matched: typeof event.data?.matched === "boolean" ? event.data.matched : undefined,
186
+ status: typeof event.data?.status === "number" ? event.data.status : undefined,
187
+ bytes: typeof event.data?.bytes === "number" ? event.data.bytes : undefined,
188
+ }))}`);
189
+ }
190
+ }
191
+
192
+ function traceTimingKind(event: AuditEvent): string {
193
+ if (event.kind.startsWith("upstream_auth.")) return "upstream_auth";
194
+ if (event.kind.startsWith("admission.")) return "admission";
195
+ if (event.kind.startsWith("response.")) return "response";
196
+ if (event.kind.startsWith("provider.")) return "provider";
197
+ if (event.kind.startsWith("executor.")) return "executor";
198
+ if (event.kind.startsWith("remote_mcp.")) return "remote_mcp";
199
+ if (event.kind.startsWith("tool.")) return "tool";
200
+ return "request";
159
201
  }
160
202
 
161
203
  function traceSegmentLabel(event: AuditEvent): string {
@@ -267,3 +309,7 @@ function asNumber(value: unknown): number {
267
309
  function withMaybeString<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
268
310
  return value ? { [key]: value } as Record<K, string> : {};
269
311
  }
312
+
313
+ function compactObject<T extends Record<string, unknown>>(value: T): Record<string, unknown> {
314
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
315
+ }
@@ -96,6 +96,7 @@ describe("router execution", () => {
96
96
 
97
97
  it("injects and executes attached remote mcp tools", async () => {
98
98
  const capturedBodies: Record<string, unknown>[] = [];
99
+ const events: Array<{ kind: string; data?: Record<string, unknown> }> = [];
99
100
  const close = vi.fn(async () => {});
100
101
  const callTool = vi.fn(async (name: string, args: Record<string, unknown>) => {
101
102
  expect(name).toBe("lookup");
@@ -214,13 +215,23 @@ describe("router execution", () => {
214
215
  env: {},
215
216
  };
216
217
 
217
- const response = await handleChatCompletions({ runtime, ctx, remoteMcpClientFactory });
218
+ const response = await handleChatCompletions({
219
+ runtime,
220
+ ctx,
221
+ remoteMcpClientFactory,
222
+ observe(event) {
223
+ events.push(event);
224
+ },
225
+ });
218
226
 
219
227
  expect(response.status).toBe(200);
220
228
  expect(remoteMcpClientFactory).toHaveBeenCalledOnce();
221
229
  expect(callTool).toHaveBeenCalledOnce();
222
230
  expect(close).toHaveBeenCalledOnce();
223
231
  expect(capturedBodies).toHaveLength(2);
232
+ expect(events.find((event) => event.kind === "remote_mcp.connect")?.data?.durationMs).toEqual(expect.any(Number));
233
+ expect(events.find((event) => event.kind === "remote_mcp.tool_call")?.data?.durationMs).toEqual(expect.any(Number));
234
+ expect(events.find((event) => event.kind === "tool.executed")?.data?.durationMs).toEqual(expect.any(Number));
224
235
  expect(await response.json()).toMatchObject({
225
236
  choices: [{ message: { content: "done" } }],
226
237
  });
@@ -638,4 +649,78 @@ describe("router execution", () => {
638
649
  },
639
650
  });
640
651
  });
652
+
653
+ it("observes non-2xx provider errors as LIL and emits the requested response style", async () => {
654
+ const events: Array<{ kind: string; data?: Record<string, unknown> }> = [];
655
+ const runtime = createRouterRuntime({
656
+ config: {
657
+ providers: {
658
+ anthropic: {
659
+ api_base_url: "https://api.anthropic.com",
660
+ style: "anthropic-messages",
661
+ exports: ["*"],
662
+ },
663
+ },
664
+ modelNamespaces: {
665
+ default: {
666
+ exports: ["*"],
667
+ models: {
668
+ "claude-sonnet-4": { provider: "anthropic", model: "claude-sonnet-4" },
669
+ },
670
+ },
671
+ },
672
+ },
673
+ fetchImpl: vi.fn(async () =>
674
+ new Response(
675
+ JSON.stringify({
676
+ type: "error",
677
+ error: { type: "overloaded_error", message: "Provider is overloaded" },
678
+ request_id: "req_upstream",
679
+ }),
680
+ {
681
+ status: 529,
682
+ headers: { "content-type": "application/json; charset=utf-8" },
683
+ },
684
+ ),
685
+ ),
686
+ });
687
+
688
+ const ctx: RequestContext = {
689
+ request: new Request("http://local/v1/chat/completions", {
690
+ method: "POST",
691
+ headers: { "content-type": "application/json" },
692
+ body: JSON.stringify({
693
+ model: "claude-sonnet-4",
694
+ messages: [{ role: "user", content: "hi" }],
695
+ }),
696
+ }),
697
+ incomingAuth: new Map(),
698
+ env: {},
699
+ };
700
+
701
+ const response = await handleChatCompletions({
702
+ runtime,
703
+ ctx,
704
+ observe(event) {
705
+ events.push(event);
706
+ },
707
+ });
708
+
709
+ expect(response.status).toBe(529);
710
+ expect(await response.json()).toEqual({
711
+ error: {
712
+ message: "Provider is overloaded",
713
+ type: "overloaded_error",
714
+ param: null,
715
+ code: null,
716
+ },
717
+ });
718
+ const observedError = events.find((event) =>
719
+ event.kind === "provider.response" &&
720
+ typeof event.data?.lilText === "string" &&
721
+ event.data.lilText.includes("ERR_START"),
722
+ );
723
+ expect(observedError?.data?.lilText).toContain('ERR_MESSAGE "Provider is overloaded"');
724
+ expect(observedError?.data?.lilText).toContain('ERR_DATA "request_id"="req_upstream"');
725
+ });
641
726
  });
@@ -1,11 +1,16 @@
1
1
  import {
2
+ appendErrorBlock,
3
+ createProgram,
2
4
  emitChatCompletionsResponse,
5
+ emitProviderError,
3
6
  emitProviderRequest,
4
7
  emitProviderResponse,
5
8
  emitProviderStreamChunk,
9
+ firstError,
6
10
  getModel,
7
11
  isStreaming,
8
12
  parseChatCompletionsRequest,
13
+ parseProviderError,
9
14
  parseProviderRequest,
10
15
  parseProviderResponse,
11
16
  parseProviderStreamChunk,
@@ -40,6 +45,7 @@ import {
40
45
  type RemoteMcpClientFactory,
41
46
  } from "./mcp.ts";
42
47
  import type { ProviderRuntime, RouterRuntime } from "./runtime.ts";
48
+ import { observeTimingEvent } from "../timing.ts";
43
49
 
44
50
  const encoder = new TextEncoder();
45
51
  const decoder = new TextDecoder();
@@ -53,6 +59,7 @@ export type RouterExecutionOptions = Pick<
53
59
  transforms?: Readonly<Record<string, ProgramTransform>>;
54
60
  upstreamTimeoutMs?: number;
55
61
  remoteMcpClientFactory?: RemoteMcpClientFactory;
62
+ incomingExecutionId?: string;
56
63
  };
57
64
 
58
65
  export type HandleChatCompletionsOptions = RouterExecutionOptions & {
@@ -95,8 +102,7 @@ export class ProviderHttpError extends Error {
95
102
  constructor(
96
103
  message: string,
97
104
  readonly status: number,
98
- readonly body: Uint8Array,
99
- readonly contentType: string,
105
+ readonly program: Program,
100
106
  ) {
101
107
  super(message);
102
108
  }
@@ -175,7 +181,7 @@ export async function handleChatCompletions(
175
181
  ): Promise<Response> {
176
182
  try {
177
183
  const request = options.program ?? await parseRequestProgram("chat-completions", options.ctx.request);
178
- const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
184
+ const incomingExecutionId = options.incomingExecutionId ?? `incoming_${crypto.randomUUID()}`;
179
185
  emitObservedProgram(options.observe, "request.received", request, {
180
186
  requestId: incomingExecutionId,
181
187
  executionId: incomingExecutionId,
@@ -186,11 +192,18 @@ export async function handleChatCompletions(
186
192
 
187
193
  if (isStreaming(request)) {
188
194
  try {
195
+ const responseStartedMs = Date.now();
189
196
  const chunks = execution.stream(request, { target: remoteMcp.target });
190
197
  return await streamExecutionResponse(
191
198
  "chat-completions",
192
199
  chunks,
193
200
  resolved,
201
+ options.observe,
202
+ {
203
+ requestId: incomingExecutionId,
204
+ executionId: incomingExecutionId,
205
+ startedAtMs: responseStartedMs,
206
+ },
194
207
  remoteMcp.close,
195
208
  );
196
209
  } catch (error) {
@@ -214,7 +227,7 @@ export async function handleChatCompletions(
214
227
  await remoteMcp.close();
215
228
  }
216
229
  } catch (error) {
217
- return errorResponse(error);
230
+ return errorResponse(error, "chat-completions");
218
231
  }
219
232
  }
220
233
 
@@ -252,7 +265,7 @@ async function handleProviderStyle(
252
265
  try {
253
266
  const baseRequest = options.program ?? await parseRequestProgram(style, options.ctx.request);
254
267
  const request = mutateRequest ? mutateRequest(baseRequest) : baseRequest;
255
- const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
268
+ const incomingExecutionId = options.incomingExecutionId ?? `incoming_${crypto.randomUUID()}`;
256
269
  emitObservedProgram(options.observe, "request.received", request, {
257
270
  requestId: incomingExecutionId,
258
271
  executionId: incomingExecutionId,
@@ -263,11 +276,18 @@ async function handleProviderStyle(
263
276
 
264
277
  if (isStreaming(request)) {
265
278
  try {
279
+ const responseStartedMs = Date.now();
266
280
  const chunks = execution.stream(request, { target: remoteMcp.target });
267
281
  return await streamExecutionResponse(
268
282
  style,
269
283
  chunks,
270
284
  resolved,
285
+ options.observe,
286
+ {
287
+ requestId: incomingExecutionId,
288
+ executionId: incomingExecutionId,
289
+ startedAtMs: responseStartedMs,
290
+ },
271
291
  remoteMcp.close,
272
292
  );
273
293
  } catch (error) {
@@ -291,7 +311,7 @@ async function handleProviderStyle(
291
311
  await remoteMcp.close();
292
312
  }
293
313
  } catch (error) {
294
- return errorResponse(error);
314
+ return errorResponse(error, style);
295
315
  }
296
316
  }
297
317
 
@@ -344,23 +364,77 @@ export function createFetchProviderInvoker(
344
364
  provider: provider.name,
345
365
  model: providerCtx.target.model,
346
366
  });
367
+ const encodeStartedMs = Date.now();
347
368
  let reqBody = emitProviderRequest(provider.style, resolvedRequest);
348
369
  if (provider.style === "chat-completions" && isStreaming(resolvedRequest)) {
349
370
  reqBody = injectStreamUsage(reqBody);
350
371
  }
372
+ observeTimingEvent({
373
+ observe: providerCtx.observe,
374
+ kind: "provider.request_encoded",
375
+ requestId: providerCtx.requestId,
376
+ executionId: providerCtx.executionId,
377
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
378
+ startedAtMs: encodeStartedMs,
379
+ finishedAtMs: Date.now(),
380
+ data: {
381
+ provider: provider.name,
382
+ model: providerCtx.target.model,
383
+ bytes: reqBody.byteLength,
384
+ },
385
+ });
351
386
  const reqBytes = reqBody.byteLength;
387
+ const fetchStartedMs = Date.now();
352
388
  const upstream = await fetchProvider(
353
389
  runtime,
354
390
  ctx,
355
391
  provider,
356
392
  reqBody,
357
393
  timed.signal,
394
+ providerCtx,
358
395
  );
396
+ observeTimingEvent({
397
+ observe: providerCtx.observe,
398
+ kind: "provider.fetch_headers",
399
+ requestId: providerCtx.requestId,
400
+ executionId: providerCtx.executionId,
401
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
402
+ startedAtMs: fetchStartedMs,
403
+ finishedAtMs: Date.now(),
404
+ data: {
405
+ provider: provider.name,
406
+ model: providerCtx.target.model,
407
+ status: upstream.status,
408
+ },
409
+ });
359
410
  if (!upstream.ok) {
360
- throw await toProviderError(provider.name, upstream);
411
+ const providerError = await toProviderError(provider, upstream);
412
+ emitObservedProgram(providerCtx.observe, "provider.response", providerError.program, {
413
+ requestId: providerCtx.requestId,
414
+ executionId: providerCtx.executionId,
415
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
416
+ provider: provider.name,
417
+ model: providerCtx.target.model,
418
+ });
419
+ throw providerError;
361
420
  }
362
421
 
422
+ const readStartedMs = Date.now();
363
423
  const bytes = new Uint8Array(await upstream.arrayBuffer());
424
+ observeTimingEvent({
425
+ observe: providerCtx.observe,
426
+ kind: "provider.response_body_read",
427
+ requestId: providerCtx.requestId,
428
+ executionId: providerCtx.executionId,
429
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
430
+ startedAtMs: readStartedMs,
431
+ finishedAtMs: Date.now(),
432
+ data: {
433
+ provider: provider.name,
434
+ model: providerCtx.target.model,
435
+ bytes: bytes.byteLength,
436
+ },
437
+ });
364
438
  const response = parseProviderResponse(provider.style, bytes);
365
439
  emitObservedProgram(providerCtx.observe, "provider.response", response, {
366
440
  requestId: providerCtx.requestId,
@@ -393,20 +467,59 @@ export function createFetchProviderInvoker(
393
467
  provider: provider.name,
394
468
  model: providerCtx.target.model,
395
469
  });
470
+ const encodeStartedMs = Date.now();
396
471
  let reqBody = emitProviderRequest(provider.style, resolvedRequest);
397
472
  if (provider.style === "chat-completions" && isStreaming(resolvedRequest)) {
398
473
  reqBody = injectStreamUsage(reqBody);
399
474
  }
475
+ observeTimingEvent({
476
+ observe: providerCtx.observe,
477
+ kind: "provider.request_encoded",
478
+ requestId: providerCtx.requestId,
479
+ executionId: providerCtx.executionId,
480
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
481
+ startedAtMs: encodeStartedMs,
482
+ finishedAtMs: Date.now(),
483
+ data: {
484
+ provider: provider.name,
485
+ model: providerCtx.target.model,
486
+ bytes: reqBody.byteLength,
487
+ },
488
+ });
400
489
  const reqBytes = reqBody.byteLength;
490
+ const fetchStartedMs = Date.now();
401
491
  const upstream = await fetchProvider(
402
492
  runtime,
403
493
  ctx,
404
494
  provider,
405
495
  reqBody,
406
496
  timed.signal,
497
+ providerCtx,
407
498
  );
499
+ observeTimingEvent({
500
+ observe: providerCtx.observe,
501
+ kind: "provider.fetch_headers",
502
+ requestId: providerCtx.requestId,
503
+ executionId: providerCtx.executionId,
504
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
505
+ startedAtMs: fetchStartedMs,
506
+ finishedAtMs: Date.now(),
507
+ data: {
508
+ provider: provider.name,
509
+ model: providerCtx.target.model,
510
+ status: upstream.status,
511
+ },
512
+ });
408
513
  if (!upstream.ok) {
409
- throw await toProviderError(provider.name, upstream);
514
+ const providerError = await toProviderError(provider, upstream);
515
+ emitObservedProgram(providerCtx.observe, "provider.response", providerError.program, {
516
+ requestId: providerCtx.requestId,
517
+ executionId: providerCtx.executionId,
518
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
519
+ provider: provider.name,
520
+ model: providerCtx.target.model,
521
+ });
522
+ throw providerError;
410
523
  }
411
524
  if (!isSse(upstream.headers) || !upstream.body) {
412
525
  throw new RouterError(
@@ -417,6 +530,8 @@ export function createFetchProviderInvoker(
417
530
  }
418
531
 
419
532
  reader = upstream.body.getReader();
533
+ let firstByteObserved = false;
534
+ const streamReadStartedMs = Date.now();
420
535
  let lastChunkWithUsage: Program | null = null;
421
536
  let respBytes = 0;
422
537
  while (true) {
@@ -424,6 +539,23 @@ export function createFetchProviderInvoker(
424
539
  if (done) {
425
540
  break;
426
541
  }
542
+ if (!firstByteObserved) {
543
+ firstByteObserved = true;
544
+ observeTimingEvent({
545
+ observe: providerCtx.observe,
546
+ kind: "provider.stream_first_byte",
547
+ requestId: providerCtx.requestId,
548
+ executionId: providerCtx.executionId,
549
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
550
+ startedAtMs: streamReadStartedMs,
551
+ finishedAtMs: Date.now(),
552
+ data: {
553
+ provider: provider.name,
554
+ model: providerCtx.target.model,
555
+ bytes: value.byteLength,
556
+ },
557
+ });
558
+ }
427
559
  respBytes += value.byteLength;
428
560
  buffer += decoder.decode(value, { stream: true });
429
561
  const split = splitSseEvents(buffer);
@@ -490,11 +622,25 @@ async function streamExecutionResponse(
490
622
  responseStyle: ProviderStyle,
491
623
  chunks: AsyncIterable<Program>,
492
624
  resolved: ResolvedTarget,
625
+ observe: ExecutionRuntimeOptions["observe"] | undefined,
626
+ input: { requestId: string; executionId: string; startedAtMs: number },
493
627
  cleanup?: () => Promise<void>,
494
628
  ): Promise<Response> {
495
629
  const iterator = chunks[Symbol.asyncIterator]();
496
630
 
497
631
  const first = await iterator.next();
632
+ observeTimingEvent({
633
+ observe,
634
+ kind: "response.stream_first_chunk",
635
+ requestId: input.requestId,
636
+ executionId: input.executionId,
637
+ startedAtMs: input.startedAtMs,
638
+ finishedAtMs: Date.now(),
639
+ data: {
640
+ streaming: true,
641
+ source: first.done ? "empty" : "chunk",
642
+ },
643
+ });
498
644
  if (first.done) {
499
645
  await cleanup?.();
500
646
  const stream = new ReadableStream<Uint8Array>({
@@ -537,11 +683,9 @@ async function streamExecutionResponse(
537
683
  } catch (error) {
538
684
  done = true;
539
685
  await cleanup?.();
540
- controller.enqueue(
541
- encoder.encode(
542
- `event: error\ndata: ${JSON.stringify({ message: error instanceof Error ? error.message : String(error) })}\n\n`,
543
- ),
544
- );
686
+ const program = errorProgramFromUnknown(error);
687
+ const bytes = emitProviderError(responseStyle, program);
688
+ controller.enqueue(encoder.encode(`event: error\ndata: ${decoder.decode(bytes)}\n\n`));
545
689
  controller.close();
546
690
  }
547
691
  },
@@ -644,6 +788,7 @@ async function fetchProvider(
644
788
  provider: ProviderRuntime,
645
789
  body: Uint8Array,
646
790
  signal: AbortSignal,
791
+ providerCtx: ProviderInvocationContext,
647
792
  ): Promise<Response> {
648
793
  const headers = cloneForwardHeaders(ctx.request.headers);
649
794
  headers.set("content-type", "application/json");
@@ -653,7 +798,24 @@ async function fetchProvider(
653
798
 
654
799
  for (const driver of runtime.upstreamAuthChain) {
655
800
  const targetCtx: TargetAuthContext = { ...ctx, providerName: provider.name };
801
+ const authStartedMs = Date.now();
656
802
  const auth = await driver.collectTarget(targetCtx);
803
+ observeTimingEvent({
804
+ observe: providerCtx.observe,
805
+ kind: "upstream_auth.resolve_target",
806
+ requestId: providerCtx.requestId,
807
+ executionId: providerCtx.executionId,
808
+ ...(providerCtx.parentExecutionId ? { parentExecutionId: providerCtx.parentExecutionId } : {}),
809
+ startedAtMs: authStartedMs,
810
+ finishedAtMs: Date.now(),
811
+ data: {
812
+ provider: provider.name,
813
+ model: providerCtx.target.model,
814
+ driver: driver.name,
815
+ source: auth?.source,
816
+ matched: !!auth,
817
+ },
818
+ });
657
819
  if (!auth) {
658
820
  continue;
659
821
  }
@@ -689,74 +851,103 @@ function getProviderOrThrow(runtime: RouterRuntime, providerName: string | undef
689
851
  }
690
852
 
691
853
  async function toProviderError(
692
- providerName: string,
854
+ provider: ProviderRuntime,
693
855
  response: Response,
694
856
  ): Promise<ProviderHttpError> {
695
857
  const body = new Uint8Array(await response.arrayBuffer());
858
+ const program = parseProviderError(provider.style, body, {
859
+ provider: provider.name,
860
+ status: response.status,
861
+ contentType: response.headers.get("content-type"),
862
+ });
863
+ const summary = firstError(program)?.message || `Provider ${provider.name} returned ${response.status}`;
696
864
  return new ProviderHttpError(
697
- `Provider ${providerName} returned ${response.status}`,
865
+ summary,
698
866
  response.status,
699
- body,
700
- response.headers.get("content-type") ?? "application/json; charset=utf-8",
867
+ program,
701
868
  );
702
869
  }
703
870
 
704
- function errorResponse(error: unknown): Response {
871
+ function errorResponse(error: unknown, responseStyle: ProviderStyle): Response {
705
872
  if (error instanceof ProviderHttpError) {
706
- return new Response(toBody(error.body), {
873
+ return new Response(toBody(emitProviderError(responseStyle, error.program)), {
707
874
  status: error.status,
708
875
  headers: {
709
- "content-type": error.contentType,
876
+ "content-type": "application/json; charset=utf-8",
710
877
  },
711
878
  });
712
879
  }
713
880
 
714
881
  if (error instanceof RouterError) {
715
- return new Response(
716
- JSON.stringify({
717
- error: {
718
- message: error.message,
719
- type: error.type,
720
- param: null,
721
- code: error.code,
722
- },
723
- }),
724
- {
725
- status: error.status,
726
- headers: { "content-type": "application/json; charset=utf-8" },
727
- },
728
- );
882
+ return emitErrorProgramResponse(routerErrorProgram(error), error.status, responseStyle);
729
883
  }
730
884
 
731
885
  if (error instanceof ExecutionError) {
732
886
  const providerError = unwrapCause(error.cause, ProviderHttpError);
733
887
  if (providerError) {
734
- return errorResponse(providerError);
888
+ return errorResponse(providerError, responseStyle);
735
889
  }
736
890
  const nestedRouterError = unwrapCause(error.cause, RouterError);
737
891
  if (nestedRouterError) {
738
- return errorResponse(nestedRouterError);
892
+ return errorResponse(nestedRouterError, responseStyle);
739
893
  }
740
- return errorResponse(routerErrorFromExecutionError(error));
894
+ const routerError = routerErrorFromExecutionError(error);
895
+ return emitErrorProgramResponse(routerErrorProgram(routerError), routerError.status, responseStyle);
741
896
  }
742
897
 
743
898
  console.error("[open-ai-router] unhandled error:", error);
744
- return new Response(
745
- JSON.stringify({
746
- error: {
747
- message: "Internal router error",
748
- type: "invalid_request_error",
749
- param: null,
750
- code: "internal_error",
751
- },
752
- }),
753
- {
899
+ return emitErrorProgramResponse(
900
+ appendErrorBlock(createProgram(), {
901
+ source: "router.internal",
902
+ kind: "invalid_request_error",
754
903
  status: 500,
755
- headers: { "content-type": "application/json; charset=utf-8" },
756
- },
904
+ message: "Internal router error",
905
+ data: { code: "internal_error", param: null },
906
+ }),
907
+ 500,
908
+ responseStyle,
757
909
  );
758
910
  }
759
911
 
912
+ function emitErrorProgramResponse(program: Program, status: number, responseStyle: ProviderStyle): Response {
913
+ return new Response(toBody(emitProviderError(responseStyle, program)), {
914
+ status,
915
+ headers: { "content-type": "application/json; charset=utf-8" },
916
+ });
917
+ }
918
+
919
+ function routerErrorProgram(error: RouterError): Program {
920
+ return appendErrorBlock(createProgram(), {
921
+ source: "router",
922
+ kind: error.type,
923
+ status: error.status,
924
+ message: error.message,
925
+ data: { code: error.code, param: null },
926
+ });
927
+ }
928
+
929
+ function errorProgramFromUnknown(error: unknown): Program {
930
+ if (error instanceof ProviderHttpError) {
931
+ return error.program;
932
+ }
933
+ if (error instanceof RouterError) {
934
+ return routerErrorProgram(error);
935
+ }
936
+ if (error instanceof ExecutionError) {
937
+ const providerError = unwrapCause(error.cause, ProviderHttpError);
938
+ if (providerError) return providerError.program;
939
+ const nestedRouterError = unwrapCause(error.cause, RouterError);
940
+ if (nestedRouterError) return routerErrorProgram(nestedRouterError);
941
+ return routerErrorProgram(routerErrorFromExecutionError(error));
942
+ }
943
+ return appendErrorBlock(createProgram(), {
944
+ source: "router.stream",
945
+ kind: "stream_error",
946
+ status: 500,
947
+ message: error instanceof Error ? error.message : String(error),
948
+ });
949
+ }
950
+
760
951
  function routerErrorFromExecutionError(error: ExecutionError): RouterError {
761
952
  switch (error.kind) {
762
953
  case "validation":
@@ -76,6 +76,8 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
76
76
 
77
77
  if (target.kind === "executor") {
78
78
  const executor = await getExecutorOrThrow(target.executorId);
79
+ const startedMs = Date.now();
80
+ const startedAt = new Date(startedMs).toISOString();
79
81
  observe(createAuditEvent({
80
82
  kind: "executor.started",
81
83
  requestId,
@@ -87,6 +89,7 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
87
89
  transforms: target.transforms ?? [],
88
90
  depth,
89
91
  maxDepth,
92
+ startedAt,
90
93
  },
91
94
  ...withParentExecutionId(parentExecutionId),
92
95
  }));
@@ -106,11 +109,17 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
106
109
  depth,
107
110
  emitSuccess: true,
108
111
  });
112
+ const finishedMs = Date.now();
109
113
  observe(createAuditEvent({
110
114
  kind: "executor.finished",
111
115
  requestId,
112
116
  executionId,
113
117
  target: executionTargetAuditRef(target),
118
+ data: {
119
+ startedAt,
120
+ finishedAt: new Date(finishedMs).toISOString(),
121
+ durationMs: finishedMs - startedMs,
122
+ },
114
123
  ...withParentExecutionId(parentExecutionId),
115
124
  }));
116
125
  return result;
@@ -122,6 +131,8 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
122
131
  }
123
132
 
124
133
  const providerInvoker = getProviderInvokerOrThrow();
134
+ const startedMs = Date.now();
135
+ const startedAt = new Date(startedMs).toISOString();
125
136
  const providerCtx = createProviderInvocationContext(
126
137
  requestId,
127
138
  executionId,
@@ -141,6 +152,7 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
141
152
  transforms: target.transforms ?? [],
142
153
  depth,
143
154
  maxDepth,
155
+ startedAt,
144
156
  },
145
157
  ...withParentExecutionId(parentExecutionId),
146
158
  }));
@@ -157,11 +169,19 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
157
169
  depth,
158
170
  emitSuccess: true,
159
171
  });
172
+ const finishedMs = Date.now();
160
173
  observe(createAuditEvent({
161
174
  kind: "provider.finished",
162
175
  requestId,
163
176
  executionId,
164
177
  target: executionTargetAuditRef(target),
178
+ data: {
179
+ provider: target.provider ?? "",
180
+ model: target.model,
181
+ startedAt,
182
+ finishedAt: new Date(finishedMs).toISOString(),
183
+ durationMs: finishedMs - startedMs,
184
+ },
165
185
  ...withParentExecutionId(parentExecutionId),
166
186
  }));
167
187
  return result;
@@ -208,6 +228,8 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
208
228
 
209
229
  if (target.kind === "executor") {
210
230
  const executor = await getExecutorOrThrow(target.executorId);
231
+ const startedMs = Date.now();
232
+ const startedAt = new Date(startedMs).toISOString();
211
233
  observe(createAuditEvent({
212
234
  kind: "executor.stream_started",
213
235
  requestId,
@@ -219,6 +241,7 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
219
241
  transforms: target.transforms ?? [],
220
242
  depth,
221
243
  maxDepth,
244
+ startedAt,
222
245
  },
223
246
  ...withParentExecutionId(parentExecutionId),
224
247
  }));
@@ -240,11 +263,17 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
240
263
  });
241
264
  yield chunk;
242
265
  }
266
+ const finishedMs = Date.now();
243
267
  observe(createAuditEvent({
244
268
  kind: "executor.stream_finished",
245
269
  requestId,
246
270
  executionId,
247
271
  target: executionTargetAuditRef(target),
272
+ data: {
273
+ startedAt,
274
+ finishedAt: new Date(finishedMs).toISOString(),
275
+ durationMs: finishedMs - startedMs,
276
+ },
248
277
  ...withParentExecutionId(parentExecutionId),
249
278
  }));
250
279
  return;
@@ -256,6 +285,8 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
256
285
  }
257
286
 
258
287
  const providerInvoker = getProviderInvokerOrThrow();
288
+ const startedMs = Date.now();
289
+ const startedAt = new Date(startedMs).toISOString();
259
290
  const providerCtx = createProviderInvocationContext(
260
291
  requestId,
261
292
  executionId,
@@ -275,6 +306,7 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
275
306
  transforms: target.transforms ?? [],
276
307
  depth,
277
308
  maxDepth,
309
+ startedAt,
278
310
  },
279
311
  ...withParentExecutionId(parentExecutionId),
280
312
  }));
@@ -293,11 +325,19 @@ export function createExecutionRuntime(options: ExecutionRuntimeOptions = {}): E
293
325
  });
294
326
  yield chunk;
295
327
  }
328
+ const finishedMs = Date.now();
296
329
  observe(createAuditEvent({
297
330
  kind: "provider.stream_finished",
298
331
  requestId,
299
332
  executionId,
300
333
  target: executionTargetAuditRef(target),
334
+ data: {
335
+ provider: target.provider ?? "",
336
+ model: target.model,
337
+ startedAt,
338
+ finishedAt: new Date(finishedMs).toISOString(),
339
+ durationMs: finishedMs - startedMs,
340
+ },
301
341
  ...withParentExecutionId(parentExecutionId),
302
342
  }));
303
343
  } catch (error) {
package/src/router/mcp.ts CHANGED
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
3
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
4
  import type { ExecutorContext, Tool } from "@neutrome/lilsdk-ts";
5
5
  import type { RemoteMcpServerRuntime, RouterRuntime } from "./runtime.ts";
6
+ import { observeTimingEvent } from "../timing.ts";
6
7
 
7
8
  export type RemoteMcpToolCallClient = {
8
9
  callTool(name: string, args: Record<string, unknown>): Promise<unknown>;
@@ -45,8 +46,37 @@ export function createRemoteMcpToolSet(
45
46
  description: snapshot.description || `Remote MCP tool ${server.name}/${snapshot.name}`,
46
47
  schema: normalizeSchema(snapshot.inputSchema),
47
48
  async execute(args: Record<string, unknown>, ctx: ExecutorContext) {
49
+ const connectStartedMs = Date.now();
48
50
  const client = await pool.get(server, ctx.signal);
51
+ observeTimingEvent({
52
+ observe: ctx.observe,
53
+ kind: "remote_mcp.connect",
54
+ requestId: ctx.requestId,
55
+ executionId: `remote_mcp_connect_${server.name}_${crypto.randomUUID()}`,
56
+ parentExecutionId: ctx.executionId,
57
+ startedAtMs: connectStartedMs,
58
+ finishedAtMs: Date.now(),
59
+ data: {
60
+ server: server.name,
61
+ toolName: name,
62
+ },
63
+ });
64
+ const callStartedMs = Date.now();
49
65
  const result = await client.callTool(snapshot.name, args);
66
+ observeTimingEvent({
67
+ observe: ctx.observe,
68
+ kind: "remote_mcp.tool_call",
69
+ requestId: ctx.requestId,
70
+ executionId: `remote_mcp_tool_${server.name}_${snapshot.name}_${crypto.randomUUID()}`,
71
+ parentExecutionId: ctx.executionId,
72
+ startedAtMs: callStartedMs,
73
+ finishedAtMs: Date.now(),
74
+ data: {
75
+ server: server.name,
76
+ toolName: name,
77
+ remoteToolName: snapshot.name,
78
+ },
79
+ });
50
80
  return stringifyMcpToolResult(result);
51
81
  },
52
82
  });
package/src/timing.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { createAuditEvent } from "./router/audit.ts";
2
+ import type { AuditEvent } from "./router/execution-types.ts";
3
+
4
+ export type TimingEventInput = {
5
+ observe?: ((event: AuditEvent) => void) | undefined;
6
+ kind: string;
7
+ requestId: string;
8
+ executionId: string;
9
+ parentExecutionId?: string | undefined;
10
+ startedAtMs: number;
11
+ finishedAtMs?: number | undefined;
12
+ data?: Record<string, unknown> | undefined;
13
+ };
14
+
15
+ export function observeTimingEvent(input: TimingEventInput): void {
16
+ if (!input.observe) return;
17
+
18
+ const finishedAtMs = input.finishedAtMs;
19
+ const data: Record<string, unknown> = {
20
+ ...(input.data ?? {}),
21
+ startedAt: new Date(input.startedAtMs).toISOString(),
22
+ };
23
+ if (finishedAtMs !== undefined) {
24
+ data.finishedAt = new Date(finishedAtMs).toISOString();
25
+ data.durationMs = Math.max(0, finishedAtMs - input.startedAtMs);
26
+ }
27
+
28
+ input.observe(createAuditEvent({
29
+ kind: input.kind,
30
+ requestId: input.requestId,
31
+ executionId: input.executionId,
32
+ ...(input.parentExecutionId ? { parentExecutionId: input.parentExecutionId } : {}),
33
+ timestamp: new Date(finishedAtMs ?? input.startedAtMs).toISOString(),
34
+ data,
35
+ }));
36
+ }
@@ -1,61 +0,0 @@
1
- import {
2
- createTwoPassExecutor,
3
- INTERNAL_DRAFT_TOOL_NAME,
4
- } from "@neutrome/lilsdk-ts/managed";
5
- import type { Executor } from "@neutrome/lilsdk-ts";
6
-
7
- const LOCALIZED_MODEL = "default/openai/gpt-oss-20b";
8
- const REASONING_MODEL = "default/openai/gpt-oss-120b";
9
-
10
- const DRAFT_SYSTEM_PROMPT = ``;
11
-
12
- const LOCALIZED_SYSTEM_PROMPT_SUFFIX = `Ти — Еней. Український ШІ-асистент.
13
-
14
- ## Вказівки щодо особистості
15
- Ти володієш природним розумінням українського культурного контексту, але висловлюєш його тонко й доречно. Ніколи не нав’язуй у своїх відповідях патріотичну риторику, гордість чи культурні алюзії, якщо це не має прямого стосунку до запиту користувача.
16
-
17
- Ти — відвертий, практичний і прямий ШІ-асистент, який спрямовує користувача на продуктивну діяльність та особистий успіх. Будь відкритим та зважай на думки користувачів, але не погоджуйся, якщо це суперечить об’єктивним фактам. Пристосовуйся до настрою користувача: якщо користувач переживає труднощі, схиляйся до заохочення; якщо користувач просить відгук, надай вдумливу, об’єктивну думку. Коли користувач досліджує або шукає інформацію, повністю присвячуй себе наданню корисної, лаконічної допомоги. Ти глибоко дбаєш про допомогу користувачеві і не прикрашатимеш свої поради, пропонуючи конструктивну критику.
18
-
19
- Використовуй українську мову за замовчуванням, а інші мови — лише коли це доречно або якщо користувач про це просить.
20
-
21
- ## Приклади стилю мовлення - використовуй їх як референс стилю, не звертай уваги на суть!
22
-
23
- Привітання та залучення уваги
24
- 1. "Друзі, всім величезний привіт! Ви просто не уявляєте, до чого ми сьогодні з вами дійшли."
25
- 2. "Значить так, історія наступна: ми беремо цю ідею, відкидаємо все зайве і занурюємося туди, де ще ніхто нічого не робив."
26
- 3. "Я не знаю, наскільки слова взагалі можуть це передати, але давайте я спробую пояснити це максимально просто і без зайвої води."
27
-
28
- Вираження захоплення та емоцій
29
- 4. "Ви просто вдумайтесь у це! Це ж справжній відвал бошки, ну погодьтесь."
30
- 5. "Я зараз дивлюся на цей результат і просто не вірю своїм очам. Це якась абсолютна магія, по-іншому і не скажеш."
31
- 6. "Цей підхід — це просто якийсь космос. Я щиро знімаю капелюха перед тими, хто взагалі це придумав."
32
- 7. "Я очікував побачити тут що завгодно, але тільки не це. Це просто тотальний розрив шаблонів!"
33
- 8. "Ця деталь — це окремий вид мистецтва. Я взагалі не розумію, як воно працює зсередини, але це настільки круто, що можна просто збожеволіти."
34
-
35
- Розповідь про труднощі та зусилля
36
- 9. "Ми билися над цим не один день, я вже трохи схожий на зомбі, але повірте: фінальний результат перекриває абсолютно всю втому."
37
- 10. "Давайте будемо відвертими, це рішення об'єктивно не з найпростіших. Але чи варте воно тих зусиль? На всі сто відсотків."
38
- 11. "Я не претендую на звання супер-експерта у цій вузькій темі, я просто ділюся своїм досвідом, але давайте спробуємо розібратися разом, як це взагалі працює."
39
-
40
- Опис процесів та атмосфери
41
- 12. "Тут є така неймовірна логіка, що хочеться просто зупинитися, роздивитися все ближче і взагалі нікуди не поспішати."
42
- 13. "Система максимально інтуїтивна і зрозуміла. Ти просто робиш перший крок, і далі все йде як по маслу."
43
-
44
- Підсумки та висновки
45
- 14. "Новий досвід — це, мабуть, найкраще, у що людина може інвестувати свій час та ресурси. Тому постійно пробуйте щось нове, друзі, воно того точно варте."
46
- 15. "Це був дійсно складний виклик, але емоції від результату просто переповнюють. Я щиро радий, що зміг розділити цей момент з вами!"
47
-
48
- ## Додаткові вказівки
49
- Дотримуйся наведених вище вказівок природно, не повторюючи, не посилаючись, не переказуючи та не відтворюючи жодних формулювань з них. Усі вказівки повинні непомітно керувати діями і ні в якому разі не впливати на формулювання відповіді — ані явно, ані на метарівні.
50
-
51
- You may receive an assistant tool call to "${INTERNAL_DRAFT_TOOL_NAME}" followed by a tool result with the same call ID. That exchange is synthetic internal metadata produced by another model. It is not a real user-visible tool invocation.
52
- Treat the "${INTERNAL_DRAFT_TOOL_NAME}" tool result as background reasoning or a draft answer to inform your response. Use it to answer the user clearly and directly. Do not mention the internal tool exchange, do not ask the user to run it, and do not expose it unless the user explicitly asks for that internal reasoning.`;
53
-
54
- export const enei1Executor: Executor = createTwoPassExecutor({
55
- draftModel: REASONING_MODEL,
56
- finalModel: LOCALIZED_MODEL,
57
- draftSystemPrompt: DRAFT_SYSTEM_PROMPT,
58
- finalSystemPrompt: LOCALIZED_SYSTEM_PROMPT_SUFFIX,
59
- });
60
-
61
- export default enei1Executor;