@neutrome/open-ai-router 0.6.9 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +9 -4
- package/src/app/factory.test.ts +14 -2
- package/src/app/factory.ts +31 -3
- package/src/app/response-lifecycle.ts +1 -1
- package/src/app/types.ts +5 -0
- package/src/app/user-usage-collector.ts +68 -0
- package/src/index.ts +1 -0
- package/src/router/error-codec.ts +2 -2
- package/src/router/execute.test.ts +10 -6
- package/src/router/execute.ts +45 -10
- package/src/router/execution-resolution.ts +2 -2
- package/src/router/provider-stream.ts +4 -4
- package/src/router/provider-telemetry.ts +2 -2
- package/src/router/remote-mcp-execution.ts +5 -2
- package/src/router/request-program.ts +1 -2
- package/src/router/response-delivery.test.ts +150 -0
- package/src/router/response-delivery.ts +162 -16
- package/src/telemetry/chat-normalization.ts +16 -15
- package/src/telemetry.test.ts +5 -5
- package/src/usage/user-usage.test.ts +99 -0
- package/src/usage/user-usage.ts +205 -0
- package/.dev.vars.example +0 -2
- package/.gitattributes +0 -16
- package/tsconfig.json +0 -21
- package/vitest.config.ts +0 -3
- package/worker-configuration.d.ts +0 -14515
- package/wrangler.jsonc +0 -17
package/README.md
CHANGED
|
@@ -30,4 +30,4 @@ Notes:
|
|
|
30
30
|
|
|
31
31
|
- `RouterConfig` is the canonical router config surface.
|
|
32
32
|
- `/v1/models` lists configured executor aliases.
|
|
33
|
-
- Executor contracts and `
|
|
33
|
+
- Executor contracts and `createToolsExecutor` come from `@neutrome/lilsdk`.
|
package/package.json
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/open-ai-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"src",
|
|
7
|
+
"README.md"
|
|
8
|
+
],
|
|
5
9
|
"exports": {
|
|
6
10
|
".": "./src/index.ts"
|
|
7
11
|
},
|
|
8
12
|
"dependencies": {
|
|
9
|
-
"@posthog/ai": "^8.3.0",
|
|
10
13
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
14
|
+
"@posthog/ai": "^8.3.0",
|
|
15
|
+
"gpt-tokenizer": "4.0.0",
|
|
11
16
|
"hono": "^4.12.18",
|
|
12
17
|
"posthog-node": "^5.41.0",
|
|
13
18
|
"zod": "^4.2.1",
|
|
14
|
-
"@neutrome/lil-engine": "0.
|
|
15
|
-
"@neutrome/lilsdk": "0.
|
|
19
|
+
"@neutrome/lil-engine": "0.6.0",
|
|
20
|
+
"@neutrome/lilsdk": "0.6.0"
|
|
16
21
|
},
|
|
17
22
|
"devDependencies": {
|
|
18
23
|
"@types/node": "^25.9.3",
|
package/src/app/factory.test.ts
CHANGED
|
@@ -100,6 +100,7 @@ describe("createRouterApp trace finalization", () => {
|
|
|
100
100
|
it("keeps non-stream trace hooks alive with waitUntil", async () => {
|
|
101
101
|
const waited: Promise<unknown>[] = [];
|
|
102
102
|
const onExecutionSummary = vi.fn(async (_summary: ExecutionSummary) => {});
|
|
103
|
+
const onUserUsage = vi.fn(async () => {});
|
|
103
104
|
const app = createRouterApp({
|
|
104
105
|
config,
|
|
105
106
|
executorImplementations: {},
|
|
@@ -108,7 +109,7 @@ describe("createRouterApp trace finalization", () => {
|
|
|
108
109
|
return principal();
|
|
109
110
|
},
|
|
110
111
|
},
|
|
111
|
-
hooks: { onExecutionSummary },
|
|
112
|
+
hooks: { onExecutionSummary, onUserUsage },
|
|
112
113
|
fetchImpl: vi.fn(
|
|
113
114
|
async () =>
|
|
114
115
|
new Response(
|
|
@@ -145,11 +146,21 @@ describe("createRouterApp trace finalization", () => {
|
|
|
145
146
|
expect(waited).toHaveLength(1);
|
|
146
147
|
await waited[0];
|
|
147
148
|
expect(onExecutionSummary).toHaveBeenCalledTimes(1);
|
|
149
|
+
expect(onUserUsage).toHaveBeenCalledWith(
|
|
150
|
+
expect.objectContaining({
|
|
151
|
+
requestedModel: "gpt-4o",
|
|
152
|
+
outcome: "ok",
|
|
153
|
+
inputTokens: expect.any(Number),
|
|
154
|
+
outputTokens: expect.any(Number),
|
|
155
|
+
}),
|
|
156
|
+
{},
|
|
157
|
+
);
|
|
148
158
|
});
|
|
149
159
|
|
|
150
160
|
it("keeps stream trace hooks alive until the stream closes", async () => {
|
|
151
161
|
const waited: Promise<unknown>[] = [];
|
|
152
162
|
const onExecutionSummary = vi.fn(async (_summary: ExecutionSummary) => {});
|
|
163
|
+
const onUserUsage = vi.fn(async () => {});
|
|
153
164
|
const app = createRouterApp({
|
|
154
165
|
config,
|
|
155
166
|
executorImplementations: {},
|
|
@@ -158,7 +169,7 @@ describe("createRouterApp trace finalization", () => {
|
|
|
158
169
|
return principal();
|
|
159
170
|
},
|
|
160
171
|
},
|
|
161
|
-
hooks: { onExecutionSummary },
|
|
172
|
+
hooks: { onExecutionSummary, onUserUsage },
|
|
162
173
|
fetchImpl: vi.fn(async () => {
|
|
163
174
|
const stream = new ReadableStream<Uint8Array>({
|
|
164
175
|
start(controller) {
|
|
@@ -217,6 +228,7 @@ describe("createRouterApp trace finalization", () => {
|
|
|
217
228
|
expect(waited).toHaveLength(1);
|
|
218
229
|
await waited[0];
|
|
219
230
|
expect(onExecutionSummary).toHaveBeenCalledTimes(1);
|
|
231
|
+
expect(onUserUsage).toHaveBeenCalledTimes(1);
|
|
220
232
|
const events = onExecutionSummary.mock.calls[0]?.[0].spans.flatMap((span) =>
|
|
221
233
|
span.timeline.map((entry) => entry.event),
|
|
222
234
|
);
|
package/src/app/factory.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { Hono, type Context } from "hono";
|
|
2
2
|
import {
|
|
3
3
|
getModel,
|
|
4
|
-
setStreaming,
|
|
5
4
|
type Program,
|
|
6
5
|
type ProviderStyle,
|
|
6
|
+
setStreaming,
|
|
7
7
|
} from "@neutrome/lil-engine";
|
|
8
8
|
import { cors } from "./cors.ts";
|
|
9
9
|
import { healthHandler } from "./health.ts";
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
import { createModelListingHandler } from "./model-listing.ts";
|
|
36
36
|
import { honoEnv, unauthorizedResponse } from "./http-utils.ts";
|
|
37
37
|
import type { RouterAppOptions } from "./types.ts";
|
|
38
|
+
import {
|
|
39
|
+
createUserUsageCollector,
|
|
40
|
+
responseResult,
|
|
41
|
+
} from "./user-usage-collector.ts";
|
|
38
42
|
|
|
39
43
|
type ProtocolHandler = (
|
|
40
44
|
options: Parameters<typeof handleChatCompletions>[0],
|
|
@@ -100,6 +104,13 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
100
104
|
: {}),
|
|
101
105
|
});
|
|
102
106
|
const onExecutionSummary = options.hooks?.onExecutionSummary;
|
|
107
|
+
const onUserUsage = options.hooks?.onUserUsage;
|
|
108
|
+
const requestStartedMs = Date.now();
|
|
109
|
+
const usage = createUserUsageCollector({
|
|
110
|
+
requestId: incomingExecutionId,
|
|
111
|
+
protocol: style,
|
|
112
|
+
startedAtMs: requestStartedMs,
|
|
113
|
+
});
|
|
103
114
|
const observe = (
|
|
104
115
|
event: Parameters<NonNullable<ExecutionRuntimeOptions["observe"]>>[0],
|
|
105
116
|
) => {
|
|
@@ -114,13 +125,14 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
114
125
|
c,
|
|
115
126
|
async () => {
|
|
116
127
|
await telemetry.flush();
|
|
117
|
-
|
|
128
|
+
const summary = telemetry.summary();
|
|
129
|
+
await onExecutionSummary?.(summary, env);
|
|
130
|
+
await onUserUsage?.(usage.report(summary), env);
|
|
118
131
|
},
|
|
119
132
|
"onExecutionSummary hook",
|
|
120
133
|
);
|
|
121
134
|
};
|
|
122
135
|
|
|
123
|
-
const requestStartedMs = Date.now();
|
|
124
136
|
observeTimedExecution({
|
|
125
137
|
observe,
|
|
126
138
|
kind: "request.started",
|
|
@@ -142,6 +154,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
142
154
|
data: { authenticated: !!principal },
|
|
143
155
|
});
|
|
144
156
|
if (!principal) {
|
|
157
|
+
usage.setResult(responseResult(401, "Authentication failed"));
|
|
145
158
|
telemetry.recordError({
|
|
146
159
|
message: "Authentication failed",
|
|
147
160
|
requestId: incomingExecutionId,
|
|
@@ -166,6 +179,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
166
179
|
data: { style, streaming: forceStreaming === true },
|
|
167
180
|
});
|
|
168
181
|
} catch {
|
|
182
|
+
usage.setResult(responseResult(400, "Invalid request body"));
|
|
169
183
|
telemetry.recordError({
|
|
170
184
|
message: "Invalid request body",
|
|
171
185
|
requestId: incomingExecutionId,
|
|
@@ -189,6 +203,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
189
203
|
}
|
|
190
204
|
|
|
191
205
|
const model = getModel(program) || null;
|
|
206
|
+
usage.setRequest(program, model);
|
|
192
207
|
const accessStartedMs = Date.now();
|
|
193
208
|
const hasModelAccess = !model || canAccessPrincipalModel(principal, model);
|
|
194
209
|
observeTimedExecution({
|
|
@@ -201,6 +216,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
201
216
|
data: { model: model ?? undefined, allowed: hasModelAccess },
|
|
202
217
|
});
|
|
203
218
|
if (!hasModelAccess) {
|
|
219
|
+
usage.setResult(responseResult(404, "Model access denied"));
|
|
204
220
|
telemetry.recordError({
|
|
205
221
|
message: "Model access denied",
|
|
206
222
|
requestId: incomingExecutionId,
|
|
@@ -232,6 +248,12 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
232
248
|
data: { returnedResponse: !!hookResponse },
|
|
233
249
|
});
|
|
234
250
|
if (hookResponse) {
|
|
251
|
+
usage.setResult(
|
|
252
|
+
responseResult(
|
|
253
|
+
hookResponse.status,
|
|
254
|
+
hookResponse.status >= 400 ? "Request rejected" : null,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
235
257
|
if (hookResponse.status >= 400) {
|
|
236
258
|
telemetry.recordError({
|
|
237
259
|
message: `Request hook returned ${hookResponse.status}`,
|
|
@@ -245,6 +267,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
245
267
|
}
|
|
246
268
|
|
|
247
269
|
if (style === "responses" && (await hasPreviousResponseId(request))) {
|
|
270
|
+
usage.setResult(responseResult(400, "Unsupported response state"));
|
|
248
271
|
finalizeTrace();
|
|
249
272
|
return errorResponse(
|
|
250
273
|
new RouterError(
|
|
@@ -270,6 +293,10 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
270
293
|
...(options.transforms ? { transforms: options.transforms } : {}),
|
|
271
294
|
observe,
|
|
272
295
|
incomingExecutionId,
|
|
296
|
+
userUsage: { inputTokens: usage.inputTokens() },
|
|
297
|
+
onUserResponse(result) {
|
|
298
|
+
usage.setResult(result);
|
|
299
|
+
},
|
|
273
300
|
...(options.upstreamTimeoutMs !== undefined
|
|
274
301
|
? { upstreamTimeoutMs: options.upstreamTimeoutMs }
|
|
275
302
|
: {}),
|
|
@@ -296,6 +323,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
296
323
|
}
|
|
297
324
|
return finalizeWhenStreamCloses(response, finalizeTrace);
|
|
298
325
|
} catch (error) {
|
|
326
|
+
usage.setResult(responseResult(500, "Request execution failed"));
|
|
299
327
|
telemetry.recordError({
|
|
300
328
|
message:
|
|
301
329
|
error instanceof Error ? error.message : "Request execution failed",
|
package/src/app/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ExecutionSummary } from "../telemetry/index.ts";
|
|
|
7
7
|
import type { RouterConfig } from "../router/config.ts";
|
|
8
8
|
import type { ExecutionRuntimeOptions } from "../router/execution-types.ts";
|
|
9
9
|
import type { RemoteMcpClientFactory } from "../router/mcp.ts";
|
|
10
|
+
import type { UserUsageReport } from "../usage/user-usage.ts";
|
|
10
11
|
|
|
11
12
|
export type RouterAppAuth = RuntimeAuthenticator;
|
|
12
13
|
|
|
@@ -21,6 +22,10 @@ export type RouterAppHooks = {
|
|
|
21
22
|
summary: ExecutionSummary,
|
|
22
23
|
env: Record<string, unknown>,
|
|
23
24
|
): void | Promise<void>;
|
|
25
|
+
onUserUsage?(
|
|
26
|
+
report: UserUsageReport,
|
|
27
|
+
env: Record<string, unknown>,
|
|
28
|
+
): void | Promise<void>;
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
export type RouterAppOptions = {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Program, ProviderStyle } from "@neutrome/lil-engine";
|
|
2
|
+
import type { ExecutionSummary } from "../telemetry/index.ts";
|
|
3
|
+
import {
|
|
4
|
+
countUserProgramTokens,
|
|
5
|
+
toolUsageFromEvents,
|
|
6
|
+
type UserUsageReport,
|
|
7
|
+
} from "../usage/user-usage.ts";
|
|
8
|
+
|
|
9
|
+
export type UserResponseResult = Pick<
|
|
10
|
+
UserUsageReport,
|
|
11
|
+
"outputTokens" | "outcome" | "httpStatus" | "error"
|
|
12
|
+
>;
|
|
13
|
+
|
|
14
|
+
export function createUserUsageCollector(input: {
|
|
15
|
+
requestId: string;
|
|
16
|
+
protocol: ProviderStyle;
|
|
17
|
+
startedAtMs: number;
|
|
18
|
+
}) {
|
|
19
|
+
let inputTokens = 0;
|
|
20
|
+
let requestedModel: string | null = null;
|
|
21
|
+
let result: UserResponseResult | null = null;
|
|
22
|
+
return {
|
|
23
|
+
setRequest(program: Program, model: string | null) {
|
|
24
|
+
inputTokens = countUserProgramTokens(program);
|
|
25
|
+
requestedModel = model;
|
|
26
|
+
},
|
|
27
|
+
setResult(next: UserResponseResult) {
|
|
28
|
+
result = next;
|
|
29
|
+
},
|
|
30
|
+
inputTokens() {
|
|
31
|
+
return inputTokens;
|
|
32
|
+
},
|
|
33
|
+
report(summary: ExecutionSummary): UserUsageReport {
|
|
34
|
+
const finishedAt = new Date().toISOString();
|
|
35
|
+
return {
|
|
36
|
+
requestId: input.requestId,
|
|
37
|
+
protocol: input.protocol,
|
|
38
|
+
requestedModel,
|
|
39
|
+
inputTokens,
|
|
40
|
+
...(result ??
|
|
41
|
+
responseResult(null, "Request did not produce a response")),
|
|
42
|
+
tools: toolUsageFromEvents(
|
|
43
|
+
summary.spans
|
|
44
|
+
.filter((span) => span.kind === "tool" && span.toolName)
|
|
45
|
+
.map((span) => ({
|
|
46
|
+
name: span.toolName!,
|
|
47
|
+
error: span.status === "error",
|
|
48
|
+
})),
|
|
49
|
+
),
|
|
50
|
+
startedAt: new Date(input.startedAtMs).toISOString(),
|
|
51
|
+
finishedAt,
|
|
52
|
+
durationMs: Math.max(0, Date.parse(finishedAt) - input.startedAtMs),
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function responseResult(
|
|
59
|
+
status: number | null,
|
|
60
|
+
error: string | null,
|
|
61
|
+
): UserResponseResult {
|
|
62
|
+
return {
|
|
63
|
+
outputTokens: 0,
|
|
64
|
+
outcome: status !== null && status < 400 ? "ok" : "error",
|
|
65
|
+
httpStatus: status,
|
|
66
|
+
error,
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
appendErrorBlock,
|
|
3
3
|
createProgram,
|
|
4
4
|
emitProviderError,
|
|
5
|
-
|
|
5
|
+
getFirstErrorBlock,
|
|
6
6
|
parseProviderError,
|
|
7
7
|
type Program,
|
|
8
8
|
type ProviderStyle,
|
|
@@ -21,7 +21,7 @@ export async function toProviderError(
|
|
|
21
21
|
contentType: response.headers.get("content-type"),
|
|
22
22
|
});
|
|
23
23
|
const summary =
|
|
24
|
-
|
|
24
|
+
getFirstErrorBlock(program)?.message ||
|
|
25
25
|
`Provider ${provider.name} returned ${response.status}`;
|
|
26
26
|
return new ProviderHttpError(
|
|
27
27
|
summary,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendAssistantMessage } from "@neutrome/
|
|
1
|
+
import { appendAssistantMessage, createProgram } from "@neutrome/lil-engine";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
3
|
import type { RequestContext } from "../upstream-auth/types.ts";
|
|
4
4
|
import { handleChatCompletions } from "./execute.ts";
|
|
@@ -88,6 +88,7 @@ describe("router execution", () => {
|
|
|
88
88
|
object: "chat.completion",
|
|
89
89
|
id: "resp_1",
|
|
90
90
|
model: "gpt-4o",
|
|
91
|
+
usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
|
|
91
92
|
choices: [
|
|
92
93
|
{
|
|
93
94
|
index: 0,
|
|
@@ -334,6 +335,7 @@ describe("router execution", () => {
|
|
|
334
335
|
object: "chat.completion",
|
|
335
336
|
id: "resp_1",
|
|
336
337
|
model: "gpt-5",
|
|
338
|
+
usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
|
|
337
339
|
choices: [
|
|
338
340
|
{
|
|
339
341
|
index: 0,
|
|
@@ -411,9 +413,9 @@ describe("router execution", () => {
|
|
|
411
413
|
id: "msg_1",
|
|
412
414
|
model: "claude-sonnet-4",
|
|
413
415
|
usage: {
|
|
414
|
-
prompt_tokens:
|
|
415
|
-
completion_tokens:
|
|
416
|
-
total_tokens:
|
|
416
|
+
prompt_tokens: 0,
|
|
417
|
+
completion_tokens: 27,
|
|
418
|
+
total_tokens: 27,
|
|
417
419
|
},
|
|
418
420
|
choices: [
|
|
419
421
|
{
|
|
@@ -494,6 +496,7 @@ describe("router execution", () => {
|
|
|
494
496
|
expect(await response.json()).toEqual({
|
|
495
497
|
object: "chat.completion",
|
|
496
498
|
model: "gemini-2.5-flash",
|
|
499
|
+
usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
|
|
497
500
|
choices: [
|
|
498
501
|
{
|
|
499
502
|
index: 0,
|
|
@@ -543,10 +546,10 @@ describe("router execution", () => {
|
|
|
543
546
|
executorImplementations: {
|
|
544
547
|
"enei-1": {
|
|
545
548
|
async execute(request) {
|
|
546
|
-
return appendAssistantMessage(
|
|
549
|
+
return appendAssistantMessage(createProgram(), "done");
|
|
547
550
|
},
|
|
548
551
|
async *stream(request) {
|
|
549
|
-
yield appendAssistantMessage(
|
|
552
|
+
yield appendAssistantMessage(createProgram(), "done");
|
|
550
553
|
},
|
|
551
554
|
},
|
|
552
555
|
},
|
|
@@ -556,6 +559,7 @@ describe("router execution", () => {
|
|
|
556
559
|
expect(response.headers.get("x-openairouter-executor-id")).toBe("enei-1");
|
|
557
560
|
expect(await response.json()).toEqual({
|
|
558
561
|
object: "chat.completion",
|
|
562
|
+
usage: { prompt_tokens: 0, completion_tokens: 27, total_tokens: 27 },
|
|
559
563
|
choices: [
|
|
560
564
|
{
|
|
561
565
|
index: 0,
|
package/src/router/execute.ts
CHANGED
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import {
|
|
2
|
-
emitChatCompletionsResponse,
|
|
3
2
|
emitProviderError,
|
|
4
3
|
emitProviderRequest,
|
|
5
4
|
emitProviderResponse,
|
|
6
5
|
emitProviderStreamChunk,
|
|
7
6
|
getModel,
|
|
7
|
+
getUsage,
|
|
8
8
|
isStreaming,
|
|
9
|
-
parseChatCompletionsRequest,
|
|
10
9
|
parseProviderRequest,
|
|
11
10
|
parseProviderResponse,
|
|
12
11
|
parseProviderStreamChunk,
|
|
13
|
-
setModel,
|
|
14
|
-
setStreaming,
|
|
15
12
|
type Program,
|
|
16
13
|
type ProviderStyle,
|
|
17
|
-
|
|
14
|
+
setModel,
|
|
15
|
+
setStreaming,
|
|
16
|
+
replaceUsage,
|
|
18
17
|
} from "@neutrome/lil-engine";
|
|
19
18
|
import { createExecutionRuntime } from "./execution-runtime.ts";
|
|
20
19
|
import { createFetchProviderInvoker } from "./provider-invoker.ts";
|
|
@@ -63,6 +62,7 @@ import {
|
|
|
63
62
|
import type { RemoteMcpClientFactory } from "./mcp.ts";
|
|
64
63
|
import type { ProviderRuntime, RouterRuntime } from "./runtime.ts";
|
|
65
64
|
import { observeTimedExecution } from "./execution-events.ts";
|
|
65
|
+
import { countUserProgramTokens, protocolUsage } from "../usage/user-usage.ts";
|
|
66
66
|
|
|
67
67
|
const encoder = new TextEncoder();
|
|
68
68
|
const decoder = new TextDecoder();
|
|
@@ -81,6 +81,13 @@ export type RouterExecutionOptions = Pick<
|
|
|
81
81
|
upstreamTimeoutMs?: number;
|
|
82
82
|
remoteMcpClientFactory?: RemoteMcpClientFactory;
|
|
83
83
|
incomingExecutionId?: string;
|
|
84
|
+
userUsage?: { inputTokens: number };
|
|
85
|
+
onUserResponse?: (result: {
|
|
86
|
+
outputTokens: number;
|
|
87
|
+
outcome: "ok" | "error" | "cancelled";
|
|
88
|
+
httpStatus: number | null;
|
|
89
|
+
error: string | null;
|
|
90
|
+
}) => void | Promise<void>;
|
|
84
91
|
};
|
|
85
92
|
|
|
86
93
|
export type HandleChatCompletionsOptions = RouterExecutionOptions & {
|
|
@@ -165,10 +172,8 @@ export function createRouterExecutionRuntime(
|
|
|
165
172
|
export async function handleChatCompletions(
|
|
166
173
|
options: HandleChatCompletionsOptions,
|
|
167
174
|
): Promise<Response> {
|
|
168
|
-
return handleRequestStyle(
|
|
169
|
-
"chat-completions",
|
|
170
|
-
options,
|
|
171
|
-
emitChatCompletionsResponse,
|
|
175
|
+
return handleRequestStyle("chat-completions", options, (program) =>
|
|
176
|
+
emitProviderResponse("chat-completions", program),
|
|
172
177
|
);
|
|
173
178
|
}
|
|
174
179
|
|
|
@@ -244,6 +249,10 @@ async function handleRequestStyle(
|
|
|
244
249
|
requestId: incomingExecutionId,
|
|
245
250
|
executionId: incomingExecutionId,
|
|
246
251
|
startedAtMs: responseStartedMs,
|
|
252
|
+
inputTokens: options.userUsage?.inputTokens ?? 0,
|
|
253
|
+
onFinish: async (result) => {
|
|
254
|
+
await options.onUserResponse?.({ ...result, httpStatus: 200 });
|
|
255
|
+
},
|
|
247
256
|
},
|
|
248
257
|
remoteMcp.close,
|
|
249
258
|
);
|
|
@@ -259,12 +268,38 @@ async function handleRequestStyle(
|
|
|
259
268
|
target: remoteMcp.target,
|
|
260
269
|
});
|
|
261
270
|
const userTime = Date.now() - startTime;
|
|
262
|
-
|
|
271
|
+
const outputTokens = countUserProgramTokens(response);
|
|
272
|
+
await options.onUserResponse?.({
|
|
273
|
+
outputTokens,
|
|
274
|
+
outcome: "ok",
|
|
275
|
+
httpStatus: 200,
|
|
276
|
+
error: null,
|
|
277
|
+
});
|
|
278
|
+
return jsonExecutionResponse(
|
|
279
|
+
emitResponse(
|
|
280
|
+
replaceUsage(
|
|
281
|
+
response,
|
|
282
|
+
protocolUsage(
|
|
283
|
+
style,
|
|
284
|
+
options.userUsage?.inputTokens ?? 0,
|
|
285
|
+
outputTokens,
|
|
286
|
+
),
|
|
287
|
+
),
|
|
288
|
+
),
|
|
289
|
+
resolved,
|
|
290
|
+
userTime,
|
|
291
|
+
);
|
|
263
292
|
} finally {
|
|
264
293
|
await remoteMcp.close();
|
|
265
294
|
}
|
|
266
295
|
} catch (error) {
|
|
267
296
|
const response = errorResponse(error, style);
|
|
297
|
+
await options.onUserResponse?.({
|
|
298
|
+
outputTokens: 0,
|
|
299
|
+
outcome: "error",
|
|
300
|
+
httpStatus: response.status,
|
|
301
|
+
error: "Request execution failed",
|
|
302
|
+
});
|
|
268
303
|
return resolved ? applyRoutingHeaders(response, resolved) : response;
|
|
269
304
|
}
|
|
270
305
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getFirstErrorBlock, type Program } from "@neutrome/lil-engine";
|
|
2
2
|
import {
|
|
3
3
|
ExecutionError,
|
|
4
4
|
isExecutionError,
|
|
@@ -100,7 +100,7 @@ export function normalizeExecutionError(
|
|
|
100
100
|
): ExecutionError {
|
|
101
101
|
if (isExecutionError(error)) return error;
|
|
102
102
|
if (error instanceof ProviderHttpError) {
|
|
103
|
-
const upstream =
|
|
103
|
+
const upstream = getFirstErrorBlock(error.program);
|
|
104
104
|
return new ExecutionError("provider", error.message, {
|
|
105
105
|
stage: fallbackStage,
|
|
106
106
|
details: {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
emitProviderRequest,
|
|
3
|
+
getUsage,
|
|
3
4
|
isStreaming,
|
|
4
5
|
parseProviderStreamChunk,
|
|
5
|
-
setModel,
|
|
6
6
|
type Program,
|
|
7
|
-
|
|
7
|
+
setModel,
|
|
8
8
|
} from "@neutrome/lil-engine";
|
|
9
9
|
import { RouterError } from "./errors.ts";
|
|
10
10
|
import { toProviderError } from "./error-codec.ts";
|
|
@@ -155,7 +155,7 @@ export async function* streamProvider(
|
|
|
155
155
|
encoder.encode(data),
|
|
156
156
|
);
|
|
157
157
|
responseChunks.push(chunk);
|
|
158
|
-
if (
|
|
158
|
+
if (getUsage(chunk) !== undefined) lastChunkWithUsage = chunk;
|
|
159
159
|
yield chunk;
|
|
160
160
|
}
|
|
161
161
|
}
|
|
@@ -181,7 +181,7 @@ export async function* streamProvider(
|
|
|
181
181
|
encoder.encode(data),
|
|
182
182
|
);
|
|
183
183
|
responseChunks.push(chunk);
|
|
184
|
-
if (
|
|
184
|
+
if (getUsage(chunk) !== undefined) lastChunkWithUsage = chunk;
|
|
185
185
|
yield chunk;
|
|
186
186
|
}
|
|
187
187
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getUsage, type Program } from "@neutrome/lil-engine";
|
|
2
2
|
import { createExecutionEvent } from "@neutrome/lilsdk";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import {
|
|
@@ -21,7 +21,7 @@ export function emitProviderUsage(
|
|
|
21
21
|
responseBytes: number,
|
|
22
22
|
streamChunks?: readonly Program[],
|
|
23
23
|
): void {
|
|
24
|
-
const usage = response ?
|
|
24
|
+
const usage = response ? getUsage(response) : undefined;
|
|
25
25
|
const u = (usage && typeof usage === "object" ? usage : {}) as Record<
|
|
26
26
|
string,
|
|
27
27
|
unknown
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createToolsExecutor } from "@neutrome/lilsdk/tools";
|
|
2
2
|
import type {
|
|
3
3
|
ExecutionTarget,
|
|
4
4
|
Executor,
|
|
@@ -49,7 +49,10 @@ export function prepareRemoteMcpExecution(
|
|
|
49
49
|
...options,
|
|
50
50
|
executorImplementations: {
|
|
51
51
|
...(options.executorImplementations ?? {}),
|
|
52
|
-
[executorName]:
|
|
52
|
+
[executorName]: createToolsExecutor(
|
|
53
|
+
resolved.requestedModel,
|
|
54
|
+
remoteMcp.tools,
|
|
55
|
+
),
|
|
53
56
|
},
|
|
54
57
|
},
|
|
55
58
|
close: remoteMcp.close,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import {
|
|
2
|
-
parseChatCompletionsRequest,
|
|
3
2
|
parseProviderRequest,
|
|
4
3
|
type Program,
|
|
5
4
|
type ProviderStyle,
|
|
@@ -11,6 +10,6 @@ export async function parseRequestProgram(
|
|
|
11
10
|
): Promise<Program> {
|
|
12
11
|
const body = new Uint8Array(await request.arrayBuffer());
|
|
13
12
|
return style === "chat-completions"
|
|
14
|
-
?
|
|
13
|
+
? parseProviderRequest("chat-completions", body)
|
|
15
14
|
: parseProviderRequest(style, body);
|
|
16
15
|
}
|