@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 +3 -3
- package/src/app/factory.test.ts +12 -2
- package/src/app/factory.ts +112 -20
- package/src/observer.test.ts +20 -0
- package/src/observer.ts +46 -0
- package/src/router/execute.test.ts +86 -1
- package/src/router/execute.ts +240 -49
- package/src/router/execution-runtime.ts +40 -0
- package/src/router/mcp.ts +30 -0
- package/src/timing.ts +36 -0
- package/src/model.example.ts +0 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/open-ai-router",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
12
|
-
"@neutrome/lilsdk-ts": "0.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",
|
package/src/app/factory.test.ts
CHANGED
|
@@ -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
|
|
package/src/app/factory.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/observer.test.ts
CHANGED
|
@@ -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({
|
|
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
|
});
|
package/src/router/execute.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
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
|
-
|
|
865
|
+
summary,
|
|
698
866
|
response.status,
|
|
699
|
-
|
|
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.
|
|
873
|
+
return new Response(toBody(emitProviderError(responseStyle, error.program)), {
|
|
707
874
|
status: error.status,
|
|
708
875
|
headers: {
|
|
709
|
-
"content-type":
|
|
876
|
+
"content-type": "application/json; charset=utf-8",
|
|
710
877
|
},
|
|
711
878
|
});
|
|
712
879
|
}
|
|
713
880
|
|
|
714
881
|
if (error instanceof RouterError) {
|
|
715
|
-
return
|
|
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
|
-
|
|
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
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/model.example.ts
DELETED
|
@@ -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;
|