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