@neutrome/open-ai-router 0.6.10 → 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/package.json +9 -4
- package/src/app/factory.test.ts +14 -2
- package/src/app/factory.ts +30 -2
- 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/execute.test.ts +10 -6
- package/src/router/execute.ts +40 -1
- package/src/router/response-delivery.test.ts +150 -0
- package/src/router/response-delivery.ts +158 -12
- 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/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
|
@@ -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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendAssistantMessage } from "@neutrome/lil-engine";
|
|
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
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ProviderStyle,
|
|
14
14
|
setModel,
|
|
15
15
|
setStreaming,
|
|
16
|
+
replaceUsage,
|
|
16
17
|
} from "@neutrome/lil-engine";
|
|
17
18
|
import { createExecutionRuntime } from "./execution-runtime.ts";
|
|
18
19
|
import { createFetchProviderInvoker } from "./provider-invoker.ts";
|
|
@@ -61,6 +62,7 @@ import {
|
|
|
61
62
|
import type { RemoteMcpClientFactory } from "./mcp.ts";
|
|
62
63
|
import type { ProviderRuntime, RouterRuntime } from "./runtime.ts";
|
|
63
64
|
import { observeTimedExecution } from "./execution-events.ts";
|
|
65
|
+
import { countUserProgramTokens, protocolUsage } from "../usage/user-usage.ts";
|
|
64
66
|
|
|
65
67
|
const encoder = new TextEncoder();
|
|
66
68
|
const decoder = new TextDecoder();
|
|
@@ -79,6 +81,13 @@ export type RouterExecutionOptions = Pick<
|
|
|
79
81
|
upstreamTimeoutMs?: number;
|
|
80
82
|
remoteMcpClientFactory?: RemoteMcpClientFactory;
|
|
81
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>;
|
|
82
91
|
};
|
|
83
92
|
|
|
84
93
|
export type HandleChatCompletionsOptions = RouterExecutionOptions & {
|
|
@@ -240,6 +249,10 @@ async function handleRequestStyle(
|
|
|
240
249
|
requestId: incomingExecutionId,
|
|
241
250
|
executionId: incomingExecutionId,
|
|
242
251
|
startedAtMs: responseStartedMs,
|
|
252
|
+
inputTokens: options.userUsage?.inputTokens ?? 0,
|
|
253
|
+
onFinish: async (result) => {
|
|
254
|
+
await options.onUserResponse?.({ ...result, httpStatus: 200 });
|
|
255
|
+
},
|
|
243
256
|
},
|
|
244
257
|
remoteMcp.close,
|
|
245
258
|
);
|
|
@@ -255,12 +268,38 @@ async function handleRequestStyle(
|
|
|
255
268
|
target: remoteMcp.target,
|
|
256
269
|
});
|
|
257
270
|
const userTime = Date.now() - startTime;
|
|
258
|
-
|
|
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
|
+
);
|
|
259
292
|
} finally {
|
|
260
293
|
await remoteMcp.close();
|
|
261
294
|
}
|
|
262
295
|
} catch (error) {
|
|
263
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
|
+
});
|
|
264
303
|
return resolved ? applyRoutingHeaders(response, resolved) : response;
|
|
265
304
|
}
|
|
266
305
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Opcode, createProgram, type Program } from "@neutrome/lil-engine";
|
|
3
|
+
import { streamExecutionResponse } from "./response-delivery.ts";
|
|
4
|
+
import type { ResolvedTarget } from "./resolve.ts";
|
|
5
|
+
|
|
6
|
+
const resolved: ResolvedTarget = {
|
|
7
|
+
requestedModel: "main/assistant",
|
|
8
|
+
namespace: "main",
|
|
9
|
+
source: "provider",
|
|
10
|
+
suffixes: [],
|
|
11
|
+
mcps: [],
|
|
12
|
+
target: {
|
|
13
|
+
kind: "provider",
|
|
14
|
+
provider: "core",
|
|
15
|
+
model: "actual",
|
|
16
|
+
transforms: [],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("user stream boundary", () => {
|
|
21
|
+
for (const [style, usageKey] of [
|
|
22
|
+
["chat-completions", "prompt_tokens"],
|
|
23
|
+
["responses", "input_tokens"],
|
|
24
|
+
["anthropic-messages", "input_tokens"],
|
|
25
|
+
["google-genai", "promptTokenCount"],
|
|
26
|
+
] as const) {
|
|
27
|
+
it(`emits one internal usage block for ${style}`, async () => {
|
|
28
|
+
const response = await streamExecutionResponse(
|
|
29
|
+
style,
|
|
30
|
+
successfulChunks(),
|
|
31
|
+
resolved,
|
|
32
|
+
undefined,
|
|
33
|
+
{
|
|
34
|
+
requestId: "r1",
|
|
35
|
+
executionId: "r1",
|
|
36
|
+
startedAtMs: Date.now(),
|
|
37
|
+
inputTokens: 7,
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
expect(text, text).toContain(usageKey);
|
|
42
|
+
expect(text.match(new RegExp(usageKey, "g"))).toHaveLength(1);
|
|
43
|
+
expect(text).not.toContain("999");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it("emits no usage after a failed partial stream", async () => {
|
|
48
|
+
async function* failed(): AsyncGenerator<Program> {
|
|
49
|
+
yield chunk(Opcode.STREAM_DELTA, { kind: "string", value: "partial" });
|
|
50
|
+
throw new Error("broken");
|
|
51
|
+
}
|
|
52
|
+
const response = await streamExecutionResponse(
|
|
53
|
+
"chat-completions",
|
|
54
|
+
failed(),
|
|
55
|
+
resolved,
|
|
56
|
+
undefined,
|
|
57
|
+
{
|
|
58
|
+
requestId: "r1",
|
|
59
|
+
executionId: "r1",
|
|
60
|
+
startedAtMs: Date.now(),
|
|
61
|
+
inputTokens: 7,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
expect(await response.text()).not.toContain("usage");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does not trust a terminal chunk until the stream actually closes", async () => {
|
|
68
|
+
async function* failedAfterTerminal(): AsyncGenerator<Program> {
|
|
69
|
+
yield createProgram({
|
|
70
|
+
code: [
|
|
71
|
+
{
|
|
72
|
+
opcode: Opcode.RESP_DONE,
|
|
73
|
+
value: { kind: "string", value: "stop" },
|
|
74
|
+
},
|
|
75
|
+
{ opcode: Opcode.STREAM_END, value: { kind: "none" } },
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
throw new Error("late failure");
|
|
79
|
+
}
|
|
80
|
+
const response = await streamExecutionResponse(
|
|
81
|
+
"chat-completions",
|
|
82
|
+
failedAfterTerminal(),
|
|
83
|
+
resolved,
|
|
84
|
+
undefined,
|
|
85
|
+
{
|
|
86
|
+
requestId: "r1",
|
|
87
|
+
executionId: "r1",
|
|
88
|
+
startedAtMs: Date.now(),
|
|
89
|
+
inputTokens: 7,
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
expect(await response.text()).not.toContain("usage");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reports cancellation with partial internal tokens and no usage block", async () => {
|
|
96
|
+
let outcome: unknown;
|
|
97
|
+
async function* chunks(): AsyncGenerator<Program> {
|
|
98
|
+
yield chunk(Opcode.STREAM_START, { kind: "none" });
|
|
99
|
+
yield chunk(Opcode.STREAM_DELTA, { kind: "string", value: "partial" });
|
|
100
|
+
}
|
|
101
|
+
const response = await streamExecutionResponse(
|
|
102
|
+
"chat-completions",
|
|
103
|
+
chunks(),
|
|
104
|
+
resolved,
|
|
105
|
+
undefined,
|
|
106
|
+
{
|
|
107
|
+
requestId: "r1",
|
|
108
|
+
executionId: "r1",
|
|
109
|
+
startedAtMs: Date.now(),
|
|
110
|
+
inputTokens: 7,
|
|
111
|
+
onFinish(result) {
|
|
112
|
+
outcome = result;
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
const reader = response.body!.getReader();
|
|
117
|
+
const first = await reader.read();
|
|
118
|
+
expect(new TextDecoder().decode(first.value)).not.toContain("usage");
|
|
119
|
+
await reader.cancel();
|
|
120
|
+
expect(outcome).toMatchObject({
|
|
121
|
+
outcome: "cancelled",
|
|
122
|
+
outputTokens: expect.any(Number),
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
async function* successfulChunks(): AsyncGenerator<Program> {
|
|
128
|
+
yield chunk(Opcode.STREAM_START, { kind: "none" });
|
|
129
|
+
yield chunk(Opcode.STREAM_DELTA, { kind: "string", value: "hello" });
|
|
130
|
+
yield createProgram({
|
|
131
|
+
code: [
|
|
132
|
+
{
|
|
133
|
+
opcode: Opcode.USAGE,
|
|
134
|
+
value: {
|
|
135
|
+
kind: "json",
|
|
136
|
+
value: new TextEncoder().encode('{"prompt_tokens":999}'),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{ opcode: Opcode.RESP_DONE, value: { kind: "string", value: "stop" } },
|
|
140
|
+
{ opcode: Opcode.STREAM_END, value: { kind: "none" } },
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function chunk(
|
|
146
|
+
opcode: Opcode,
|
|
147
|
+
value: Program["code"][number]["value"],
|
|
148
|
+
): Program {
|
|
149
|
+
return createProgram({ code: [{ opcode, value }] });
|
|
150
|
+
}
|