@neutrome/open-ai-router 0.1.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 +25 -0
- package/package.json +26 -0
- package/src/app/cors.ts +8 -0
- package/src/app/handlers.ts +192 -0
- package/src/app/health.ts +5 -0
- package/src/auth/config.ts +10 -0
- package/src/auth/env.ts +16 -0
- package/src/auth/proxy.ts +21 -0
- package/src/auth/registry.ts +25 -0
- package/src/auth/types.ts +23 -0
- package/src/example.test.ts +229 -0
- package/src/example.ts +62 -0
- package/src/index.ts +50 -0
- package/src/router/config.ts +41 -0
- package/src/router/execute.test.ts +478 -0
- package/src/router/execute.ts +735 -0
- package/src/router/index.ts +33 -0
- package/src/router/resolve.test.ts +155 -0
- package/src/router/resolve.ts +171 -0
- package/src/router/runtime.ts +146 -0
- package/src/util/glob.ts +15 -0
- package/src/util/headers.ts +27 -0
- package/src/util/model-syntax.ts +26 -0
- package/src/util/sse.test.ts +18 -0
- package/src/util/sse.ts +23 -0
- package/src/worker.ts +42 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +3 -0
- package/worker-configuration.d.ts +14484 -0
- package/wrangler.jsonc +17 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emitChatCompletionsResponse,
|
|
3
|
+
emitChatCompletionsStreamChunk,
|
|
4
|
+
emitProviderResponse,
|
|
5
|
+
emitProviderRequest,
|
|
6
|
+
emitProviderStreamChunk,
|
|
7
|
+
getModel,
|
|
8
|
+
isStreaming,
|
|
9
|
+
parseChatCompletionsRequest,
|
|
10
|
+
parseProviderRequest,
|
|
11
|
+
parseProviderResponse,
|
|
12
|
+
parseProviderStreamChunk,
|
|
13
|
+
setModel,
|
|
14
|
+
usageObject,
|
|
15
|
+
type Program,
|
|
16
|
+
} from "@neutrome/lil-engine";
|
|
17
|
+
import {
|
|
18
|
+
createExecutionRuntime,
|
|
19
|
+
ExecutionError,
|
|
20
|
+
type ExecutionRuntime,
|
|
21
|
+
type ExecutionRuntimeOptions,
|
|
22
|
+
type Executor,
|
|
23
|
+
type ProgramTransform,
|
|
24
|
+
type ProviderInvoker,
|
|
25
|
+
} from "@neutrome/lil-engine";
|
|
26
|
+
import type { RequestContext, TargetAuthContext } from "../auth/types.ts";
|
|
27
|
+
import { cloneForwardHeaders, isSse } from "../util/headers.ts";
|
|
28
|
+
import { eventDataLines, splitSseEvents } from "../util/sse.ts";
|
|
29
|
+
import { resolveInvocationTarget, type ResolvedTarget } from "./resolve.ts";
|
|
30
|
+
import type { RouterRuntime } from "./runtime.ts";
|
|
31
|
+
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120_000;
|
|
35
|
+
|
|
36
|
+
export type UsageReport = {
|
|
37
|
+
model: string;
|
|
38
|
+
tokensInput: number;
|
|
39
|
+
tokensOutput: number;
|
|
40
|
+
cachedTokensInput: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type RouterExecutionOptions = Pick<
|
|
44
|
+
ExecutionRuntimeOptions,
|
|
45
|
+
"observe" | "requestIdFactory" | "executionIdFactory" | "resolveExecutor"
|
|
46
|
+
> & {
|
|
47
|
+
executors?: Readonly<Record<string, Executor>>;
|
|
48
|
+
transforms?: Readonly<Record<string, ProgramTransform>>;
|
|
49
|
+
upstreamTimeoutMs?: number;
|
|
50
|
+
onUsage?: (report: UsageReport) => void;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type HandleChatCompletionsOptions = RouterExecutionOptions & {
|
|
54
|
+
runtime: RouterRuntime;
|
|
55
|
+
ctx: RequestContext;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type HandleResponsesOptions = RouterExecutionOptions & {
|
|
59
|
+
runtime: RouterRuntime;
|
|
60
|
+
ctx: RequestContext;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export class RouterError extends Error {
|
|
64
|
+
constructor(
|
|
65
|
+
message: string,
|
|
66
|
+
readonly status: number,
|
|
67
|
+
readonly code: string,
|
|
68
|
+
readonly type = "invalid_request_error",
|
|
69
|
+
) {
|
|
70
|
+
super(message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ProviderHttpError extends Error {
|
|
75
|
+
constructor(
|
|
76
|
+
message: string,
|
|
77
|
+
readonly status: number,
|
|
78
|
+
readonly body: Uint8Array,
|
|
79
|
+
readonly contentType: string,
|
|
80
|
+
) {
|
|
81
|
+
super(message);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createRouterExecutionRuntime(
|
|
86
|
+
runtime: RouterRuntime,
|
|
87
|
+
ctx: RequestContext,
|
|
88
|
+
options: RouterExecutionOptions = {},
|
|
89
|
+
): ExecutionRuntime {
|
|
90
|
+
const runtimeOptions: ExecutionRuntimeOptions = {
|
|
91
|
+
signal: ctx.request.signal,
|
|
92
|
+
resolveTarget(request) {
|
|
93
|
+
return resolveRequestTarget(runtime, request).target;
|
|
94
|
+
},
|
|
95
|
+
providerInvoker: createFetchProviderInvoker(
|
|
96
|
+
runtime,
|
|
97
|
+
ctx,
|
|
98
|
+
options.upstreamTimeoutMs,
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (options.executors) {
|
|
103
|
+
runtimeOptions.executors = options.executors;
|
|
104
|
+
}
|
|
105
|
+
if (options.resolveExecutor) {
|
|
106
|
+
runtimeOptions.resolveExecutor = options.resolveExecutor;
|
|
107
|
+
}
|
|
108
|
+
if (options.transforms) {
|
|
109
|
+
runtimeOptions.transforms = options.transforms;
|
|
110
|
+
}
|
|
111
|
+
if (options.observe) {
|
|
112
|
+
runtimeOptions.observe = options.observe;
|
|
113
|
+
}
|
|
114
|
+
if (options.requestIdFactory) {
|
|
115
|
+
runtimeOptions.requestIdFactory = options.requestIdFactory;
|
|
116
|
+
}
|
|
117
|
+
if (options.executionIdFactory) {
|
|
118
|
+
runtimeOptions.executionIdFactory = options.executionIdFactory;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return createExecutionRuntime(runtimeOptions);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function handleChatCompletions(
|
|
125
|
+
options: HandleChatCompletionsOptions,
|
|
126
|
+
): Promise<Response> {
|
|
127
|
+
try {
|
|
128
|
+
const requestBody = new Uint8Array(await options.ctx.request.arrayBuffer());
|
|
129
|
+
const request = parseChatCompletionsRequest(requestBody);
|
|
130
|
+
const resolved = resolveRequestTarget(options.runtime, request);
|
|
131
|
+
const execution = createRouterExecutionRuntime(
|
|
132
|
+
options.runtime,
|
|
133
|
+
options.ctx,
|
|
134
|
+
options,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const startTime = Date.now();
|
|
138
|
+
if (isStreaming(request)) {
|
|
139
|
+
const userWantsUsage = requestedStreamUsage(requestBody);
|
|
140
|
+
const chunks = execution.stream(request, { target: resolved.target });
|
|
141
|
+
return await streamExecutionResponse(
|
|
142
|
+
"chat-completions",
|
|
143
|
+
chunks,
|
|
144
|
+
resolved,
|
|
145
|
+
startTime,
|
|
146
|
+
options.onUsage,
|
|
147
|
+
userWantsUsage,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const response = await execution.execute(request, {
|
|
152
|
+
target: resolved.target,
|
|
153
|
+
});
|
|
154
|
+
const userTime = Date.now() - startTime;
|
|
155
|
+
reportUsage(response, resolved, options.onUsage);
|
|
156
|
+
return jsonExecutionResponse(
|
|
157
|
+
emitChatCompletionsResponse(response),
|
|
158
|
+
resolved,
|
|
159
|
+
userTime,
|
|
160
|
+
);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return errorResponse(error);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function handleResponses(
|
|
167
|
+
options: HandleResponsesOptions,
|
|
168
|
+
): Promise<Response> {
|
|
169
|
+
try {
|
|
170
|
+
const requestBody = new Uint8Array(await options.ctx.request.arrayBuffer());
|
|
171
|
+
const request = parseProviderRequest("responses", requestBody);
|
|
172
|
+
const resolved = resolveRequestTarget(options.runtime, request);
|
|
173
|
+
const execution = createRouterExecutionRuntime(
|
|
174
|
+
options.runtime,
|
|
175
|
+
options.ctx,
|
|
176
|
+
options,
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const startTime = Date.now();
|
|
180
|
+
if (isStreaming(request)) {
|
|
181
|
+
const chunks = execution.stream(request, { target: resolved.target });
|
|
182
|
+
return await streamExecutionResponse(
|
|
183
|
+
"responses",
|
|
184
|
+
chunks,
|
|
185
|
+
resolved,
|
|
186
|
+
startTime,
|
|
187
|
+
options.onUsage,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const response = await execution.execute(request, {
|
|
192
|
+
target: resolved.target,
|
|
193
|
+
});
|
|
194
|
+
const userTime = Date.now() - startTime;
|
|
195
|
+
reportUsage(response, resolved, options.onUsage);
|
|
196
|
+
return jsonExecutionResponse(
|
|
197
|
+
emitProviderResponse("responses", response),
|
|
198
|
+
resolved,
|
|
199
|
+
userTime,
|
|
200
|
+
);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
return errorResponse(error);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function resolveRequestTarget(
|
|
207
|
+
runtime: RouterRuntime,
|
|
208
|
+
request: Program,
|
|
209
|
+
): ResolvedTarget {
|
|
210
|
+
const model = getModel(request);
|
|
211
|
+
if (!model) {
|
|
212
|
+
throw new RouterError("Request is missing `model`", 400, "missing_model");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
return resolveInvocationTarget(runtime, model);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new RouterError(
|
|
219
|
+
error instanceof Error ? error.message : "Model resolution failed",
|
|
220
|
+
404,
|
|
221
|
+
"model_not_found",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function createFetchProviderInvoker(
|
|
227
|
+
runtime: RouterRuntime,
|
|
228
|
+
ctx: RequestContext,
|
|
229
|
+
upstreamTimeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS,
|
|
230
|
+
): ProviderInvoker {
|
|
231
|
+
return {
|
|
232
|
+
async execute(request, providerCtx) {
|
|
233
|
+
const provider = getProviderOrThrow(runtime, providerCtx.target.provider, providerCtx.target.model);
|
|
234
|
+
const timed = createUpstreamSignal(providerCtx.signal, upstreamTimeoutMs);
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const upstream = await fetchProvider(
|
|
238
|
+
runtime,
|
|
239
|
+
ctx,
|
|
240
|
+
provider.name,
|
|
241
|
+
provider.allowAnonymous,
|
|
242
|
+
provider.headers,
|
|
243
|
+
provider.apiBaseUrl,
|
|
244
|
+
provider.endpointPath,
|
|
245
|
+
provider.style,
|
|
246
|
+
setModel(request, providerCtx.target.model),
|
|
247
|
+
timed.signal,
|
|
248
|
+
);
|
|
249
|
+
if (!upstream.ok) {
|
|
250
|
+
throw await toProviderError(provider.name, upstream);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const bytes = new Uint8Array(await upstream.arrayBuffer());
|
|
254
|
+
return parseProviderResponse(provider.style, bytes);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
throw normalizeUpstreamAbort(error, timed.signal);
|
|
257
|
+
} finally {
|
|
258
|
+
timed.cleanup();
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async *stream(request, providerCtx) {
|
|
263
|
+
const provider = getProviderOrThrow(runtime, providerCtx.target.provider, providerCtx.target.model);
|
|
264
|
+
const timed = createUpstreamSignal(providerCtx.signal, upstreamTimeoutMs);
|
|
265
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
266
|
+
let buffer = "";
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const upstream = await fetchProvider(
|
|
270
|
+
runtime,
|
|
271
|
+
ctx,
|
|
272
|
+
provider.name,
|
|
273
|
+
provider.allowAnonymous,
|
|
274
|
+
provider.headers,
|
|
275
|
+
provider.apiBaseUrl,
|
|
276
|
+
provider.endpointPath,
|
|
277
|
+
provider.style,
|
|
278
|
+
setModel(request, providerCtx.target.model),
|
|
279
|
+
timed.signal,
|
|
280
|
+
);
|
|
281
|
+
if (!upstream.ok) {
|
|
282
|
+
throw await toProviderError(provider.name, upstream);
|
|
283
|
+
}
|
|
284
|
+
if (!isSse(upstream.headers) || !upstream.body) {
|
|
285
|
+
throw new RouterError(
|
|
286
|
+
`Provider ${provider.name} did not return an event stream`,
|
|
287
|
+
502,
|
|
288
|
+
"invalid_upstream_stream",
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
reader = upstream.body.getReader();
|
|
293
|
+
while (true) {
|
|
294
|
+
const { done, value } = await reader.read();
|
|
295
|
+
if (done) {
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
buffer += decoder.decode(value, { stream: true });
|
|
299
|
+
const split = splitSseEvents(buffer);
|
|
300
|
+
buffer = split.remainder;
|
|
301
|
+
for (const event of split.events) {
|
|
302
|
+
for (const data of eventDataLines(event)) {
|
|
303
|
+
if (data === "[DONE]") {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (data[0] !== "{") continue;
|
|
307
|
+
yield parseProviderStreamChunk(
|
|
308
|
+
provider.style,
|
|
309
|
+
encoder.encode(data),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (buffer.length > 0) {
|
|
316
|
+
for (const data of eventDataLines(buffer)) {
|
|
317
|
+
if (data === "[DONE]") {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (data[0] !== "{") continue;
|
|
321
|
+
yield parseProviderStreamChunk(
|
|
322
|
+
provider.style,
|
|
323
|
+
encoder.encode(data),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
throw normalizeUpstreamAbort(error, timed.signal);
|
|
329
|
+
} finally {
|
|
330
|
+
await reader?.cancel().catch(() => {});
|
|
331
|
+
reader?.releaseLock();
|
|
332
|
+
timed.cleanup();
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function streamExecutionResponse(
|
|
339
|
+
responseStyle: "chat-completions" | "responses",
|
|
340
|
+
chunks: AsyncIterable<Program>,
|
|
341
|
+
resolved: ResolvedTarget,
|
|
342
|
+
startTime: number,
|
|
343
|
+
onUsage?: (report: UsageReport) => void,
|
|
344
|
+
emitUsageToClient = true,
|
|
345
|
+
): Promise<Response> {
|
|
346
|
+
const iterator = chunks[Symbol.asyncIterator]();
|
|
347
|
+
|
|
348
|
+
const first = await iterator.next();
|
|
349
|
+
const userTime = Date.now() - startTime;
|
|
350
|
+
|
|
351
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
352
|
+
async start(controller) {
|
|
353
|
+
try {
|
|
354
|
+
let lastChunkWithUsage: Program | null = null;
|
|
355
|
+
|
|
356
|
+
function emitChunk(chunk: Program): void {
|
|
357
|
+
const hasUsage = usageObject(chunk) !== undefined;
|
|
358
|
+
if (hasUsage) lastChunkWithUsage = chunk;
|
|
359
|
+
if (hasUsage && !emitUsageToClient) return;
|
|
360
|
+
const bytes =
|
|
361
|
+
responseStyle === "chat-completions"
|
|
362
|
+
? emitChatCompletionsStreamChunk(chunk)
|
|
363
|
+
: emitProviderStreamChunk("responses", chunk);
|
|
364
|
+
controller.enqueue(
|
|
365
|
+
encoder.encode(`data: ${decoder.decode(bytes)}\n\n`),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!first.done) {
|
|
370
|
+
emitChunk(first.value);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
while (true) {
|
|
374
|
+
const { done, value } = await iterator.next();
|
|
375
|
+
if (done) {
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
emitChunk(value);
|
|
379
|
+
}
|
|
380
|
+
if (responseStyle === "chat-completions") {
|
|
381
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
382
|
+
}
|
|
383
|
+
if (lastChunkWithUsage) {
|
|
384
|
+
reportUsage(lastChunkWithUsage, resolved, onUsage);
|
|
385
|
+
}
|
|
386
|
+
controller.close();
|
|
387
|
+
} catch (error) {
|
|
388
|
+
controller.enqueue(
|
|
389
|
+
encoder.encode(
|
|
390
|
+
`event: error\ndata: ${JSON.stringify({ message: error instanceof Error ? error.message : String(error) })}\n\n`,
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
controller.close();
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
async cancel() {
|
|
397
|
+
await iterator.return?.();
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return new Response(stream, {
|
|
402
|
+
headers: {
|
|
403
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
404
|
+
...routingHeaders(resolved),
|
|
405
|
+
...timingHeaders(userTime),
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function jsonExecutionResponse(
|
|
411
|
+
body: Uint8Array,
|
|
412
|
+
resolved: ResolvedTarget,
|
|
413
|
+
userTime: number,
|
|
414
|
+
): Response {
|
|
415
|
+
return new Response(toBody(body), {
|
|
416
|
+
headers: {
|
|
417
|
+
"content-type": "application/json; charset=utf-8",
|
|
418
|
+
...routingHeaders(resolved),
|
|
419
|
+
...timingHeaders(userTime),
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function routingHeaders(resolved: ResolvedTarget): Record<string, string> {
|
|
425
|
+
if (resolved.target.kind === "provider") {
|
|
426
|
+
return {
|
|
427
|
+
"x-openairouter-source": resolved.source,
|
|
428
|
+
"x-openairouter-namespace": resolved.namespace,
|
|
429
|
+
"x-openairouter-provider-id": resolved.target.provider ?? "",
|
|
430
|
+
"x-openairouter-model-id": resolved.target.model,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
"x-openairouter-engine": "open-ai-router",
|
|
436
|
+
"x-openairouter-source": resolved.source,
|
|
437
|
+
"x-openairouter-namespace": resolved.namespace,
|
|
438
|
+
"x-openairouter-executor-id": resolved.target.executor,
|
|
439
|
+
"x-openairouter-model-id": resolved.target.alias,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function timingHeaders(userTimeMs: number): Record<string, string> {
|
|
444
|
+
return {
|
|
445
|
+
"x-openairouter-user-time": String(userTimeMs),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function fetchProvider(
|
|
450
|
+
runtime: RouterRuntime,
|
|
451
|
+
ctx: RequestContext,
|
|
452
|
+
providerName: string,
|
|
453
|
+
allowAnonymous: boolean,
|
|
454
|
+
providerHeaders: Readonly<Record<string, string>>,
|
|
455
|
+
apiBaseUrl: string,
|
|
456
|
+
endpointPath: string,
|
|
457
|
+
providerStyle: RouterRuntime["providers"] extends ReadonlyMap<string, infer T>
|
|
458
|
+
? T extends { style: infer S }
|
|
459
|
+
? S
|
|
460
|
+
: never
|
|
461
|
+
: never,
|
|
462
|
+
request: Program,
|
|
463
|
+
signal: AbortSignal,
|
|
464
|
+
): Promise<Response> {
|
|
465
|
+
const headers = cloneForwardHeaders(ctx.request.headers);
|
|
466
|
+
headers.set("content-type", "application/json");
|
|
467
|
+
for (const [key, value] of Object.entries(providerHeaders)) {
|
|
468
|
+
headers.set(key, value);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
let hasAuth = false;
|
|
472
|
+
for (const driver of runtime.authChain) {
|
|
473
|
+
const targetCtx: TargetAuthContext = { ...ctx, providerName };
|
|
474
|
+
const auth = await driver.collectTarget(targetCtx);
|
|
475
|
+
if (!auth) {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
new Headers(auth.headers).forEach((value, key) => headers.set(key, value));
|
|
479
|
+
hasAuth = true;
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!hasAuth && !allowAnonymous) {
|
|
484
|
+
throw new RouterError(
|
|
485
|
+
`No auth available for provider ${providerName}`,
|
|
486
|
+
401,
|
|
487
|
+
"provider_auth_missing",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let body = emitProviderRequest(providerStyle, request);
|
|
492
|
+
if (providerStyle === "chat-completions" && isStreaming(request)) {
|
|
493
|
+
body = injectStreamUsage(body);
|
|
494
|
+
}
|
|
495
|
+
return runtime.fetchImpl(`${apiBaseUrl}${endpointPath}`, {
|
|
496
|
+
method: "POST",
|
|
497
|
+
headers,
|
|
498
|
+
body: toBody(body),
|
|
499
|
+
signal,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function getProviderOrThrow(runtime: RouterRuntime, providerName: string | undefined, model?: string) {
|
|
504
|
+
if (providerName) {
|
|
505
|
+
const provider = runtime.providers.get(providerName);
|
|
506
|
+
if (!provider) {
|
|
507
|
+
throw new RouterError(
|
|
508
|
+
`Provider ${providerName} is not configured`,
|
|
509
|
+
500,
|
|
510
|
+
"provider_not_configured",
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
return provider;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
for (const name of runtime.providerOrder) {
|
|
517
|
+
const provider = runtime.providers.get(name)!;
|
|
518
|
+
if (!provider.noPrefix) continue;
|
|
519
|
+
if (provider.exportsMatcher(model ?? "")) {
|
|
520
|
+
return provider;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
throw new RouterError(
|
|
525
|
+
`No provider found for model \`${model ?? "(unknown)"}\``,
|
|
526
|
+
404,
|
|
527
|
+
"provider_not_found",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function toProviderError(
|
|
532
|
+
providerName: string,
|
|
533
|
+
response: Response,
|
|
534
|
+
): Promise<ProviderHttpError> {
|
|
535
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
536
|
+
return new ProviderHttpError(
|
|
537
|
+
`Provider ${providerName} returned ${response.status}`,
|
|
538
|
+
response.status,
|
|
539
|
+
body,
|
|
540
|
+
response.headers.get("content-type") ?? "application/json; charset=utf-8",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function errorResponse(error: unknown): Response {
|
|
545
|
+
if (error instanceof ProviderHttpError) {
|
|
546
|
+
return new Response(toBody(error.body), {
|
|
547
|
+
status: error.status,
|
|
548
|
+
headers: {
|
|
549
|
+
"content-type": error.contentType,
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (error instanceof RouterError) {
|
|
555
|
+
return new Response(
|
|
556
|
+
JSON.stringify({
|
|
557
|
+
error: {
|
|
558
|
+
message: error.message,
|
|
559
|
+
type: error.type,
|
|
560
|
+
param: null,
|
|
561
|
+
code: error.code,
|
|
562
|
+
},
|
|
563
|
+
}),
|
|
564
|
+
{
|
|
565
|
+
status: error.status,
|
|
566
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
567
|
+
},
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (error instanceof ExecutionError) {
|
|
572
|
+
const providerError = unwrapCause(error.cause, ProviderHttpError);
|
|
573
|
+
if (providerError) {
|
|
574
|
+
return errorResponse(providerError);
|
|
575
|
+
}
|
|
576
|
+
const nestedRouterError = unwrapCause(error.cause, RouterError);
|
|
577
|
+
if (nestedRouterError) {
|
|
578
|
+
return errorResponse(nestedRouterError);
|
|
579
|
+
}
|
|
580
|
+
return errorResponse(routerErrorFromExecutionError(error));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
console.error("[open-ai-router] unhandled error:", error);
|
|
584
|
+
return new Response(
|
|
585
|
+
JSON.stringify({
|
|
586
|
+
error: {
|
|
587
|
+
message: "Internal router error",
|
|
588
|
+
type: "invalid_request_error",
|
|
589
|
+
param: null,
|
|
590
|
+
code: "internal_error",
|
|
591
|
+
},
|
|
592
|
+
}),
|
|
593
|
+
{
|
|
594
|
+
status: 500,
|
|
595
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function routerErrorFromExecutionError(error: ExecutionError): RouterError {
|
|
601
|
+
switch (error.kind) {
|
|
602
|
+
case "validation":
|
|
603
|
+
return new RouterError(error.message, 400, "invalid_program");
|
|
604
|
+
case "resolution":
|
|
605
|
+
return new RouterError(error.message, 400, "target_resolution_failed");
|
|
606
|
+
case "cancellation":
|
|
607
|
+
return new RouterError(error.message, 499, "request_cancelled");
|
|
608
|
+
case "provider":
|
|
609
|
+
return new RouterError(error.message, 502, "provider_execution_failed");
|
|
610
|
+
case "transform":
|
|
611
|
+
return new RouterError(error.message, 500, "transform_failed");
|
|
612
|
+
case "executor":
|
|
613
|
+
return new RouterError(error.message, 500, "executor_failed");
|
|
614
|
+
case "stream":
|
|
615
|
+
return new RouterError(error.message, 500, "stream_failed");
|
|
616
|
+
case "recursion_limit":
|
|
617
|
+
return new RouterError(error.message, 500, "recursion_limit");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function unwrapCause<T extends Error>(
|
|
622
|
+
error: unknown,
|
|
623
|
+
ctor: new (...args: any[]) => T,
|
|
624
|
+
): T | null {
|
|
625
|
+
let current = error;
|
|
626
|
+
while (current && typeof current === "object") {
|
|
627
|
+
if (current instanceof ctor) {
|
|
628
|
+
return current;
|
|
629
|
+
}
|
|
630
|
+
if (!("cause" in current)) {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
current = (current as { cause?: unknown }).cause;
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function toBody(bytes: Uint8Array): ArrayBuffer {
|
|
639
|
+
return bytes.buffer.slice(
|
|
640
|
+
bytes.byteOffset,
|
|
641
|
+
bytes.byteOffset + bytes.byteLength,
|
|
642
|
+
) as ArrayBuffer;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function createUpstreamSignal(
|
|
646
|
+
signal: AbortSignal,
|
|
647
|
+
timeoutMs: number,
|
|
648
|
+
): {
|
|
649
|
+
signal: AbortSignal;
|
|
650
|
+
cleanup(): void;
|
|
651
|
+
} {
|
|
652
|
+
if (timeoutMs <= 0) {
|
|
653
|
+
return {
|
|
654
|
+
signal,
|
|
655
|
+
cleanup() {},
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const controller = new AbortController();
|
|
660
|
+
const onAbort = () => controller.abort(signal.reason);
|
|
661
|
+
if (signal.aborted) {
|
|
662
|
+
onAbort();
|
|
663
|
+
} else {
|
|
664
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const timer = setTimeout(() => {
|
|
668
|
+
controller.abort(
|
|
669
|
+
new RouterError(
|
|
670
|
+
`Upstream provider timed out after ${timeoutMs}ms`,
|
|
671
|
+
504,
|
|
672
|
+
"upstream_timeout",
|
|
673
|
+
),
|
|
674
|
+
);
|
|
675
|
+
}, timeoutMs);
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
signal: controller.signal,
|
|
679
|
+
cleanup() {
|
|
680
|
+
clearTimeout(timer);
|
|
681
|
+
signal.removeEventListener("abort", onAbort);
|
|
682
|
+
},
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function reportUsage(
|
|
687
|
+
response: Program,
|
|
688
|
+
resolved: ResolvedTarget,
|
|
689
|
+
onUsage?: (report: UsageReport) => void,
|
|
690
|
+
): void {
|
|
691
|
+
if (!onUsage) return;
|
|
692
|
+
const usage = usageObject(response);
|
|
693
|
+
if (!usage || typeof usage !== "object") return;
|
|
694
|
+
const u = usage as Record<string, unknown>;
|
|
695
|
+
const tokensInput = asNum(u.prompt_tokens) || asNum(u.input_tokens);
|
|
696
|
+
const tokensOutput = asNum(u.completion_tokens) || asNum(u.output_tokens);
|
|
697
|
+
if (tokensInput === 0 && tokensOutput === 0) return;
|
|
698
|
+
const details = u.prompt_tokens_details as Record<string, unknown> | undefined;
|
|
699
|
+
const cachedTokensInput = details ? asNum(details.cached_tokens) : 0;
|
|
700
|
+
const model = resolved.target.kind === "provider" ? resolved.target.model : resolved.target.alias;
|
|
701
|
+
try {
|
|
702
|
+
onUsage({ model, tokensInput, tokensOutput, cachedTokensInput });
|
|
703
|
+
} catch {}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function asNum(v: unknown): number {
|
|
707
|
+
return typeof v === "number" && v >= 0 ? v : 0;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function requestedStreamUsage(body: Uint8Array): boolean {
|
|
711
|
+
try {
|
|
712
|
+
const obj = JSON.parse(decoder.decode(body)) as Record<string, unknown>;
|
|
713
|
+
const opts = obj.stream_options as Record<string, unknown> | undefined;
|
|
714
|
+
return opts?.include_usage === true;
|
|
715
|
+
} catch {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function injectStreamUsage(body: Uint8Array): Uint8Array {
|
|
721
|
+
try {
|
|
722
|
+
const obj = JSON.parse(decoder.decode(body)) as Record<string, unknown>;
|
|
723
|
+
obj.stream_options = { include_usage: true };
|
|
724
|
+
return encoder.encode(JSON.stringify(obj));
|
|
725
|
+
} catch {
|
|
726
|
+
return body;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function normalizeUpstreamAbort(error: unknown, signal: AbortSignal): unknown {
|
|
731
|
+
if (signal.aborted && signal.reason instanceof RouterError) {
|
|
732
|
+
return signal.reason;
|
|
733
|
+
}
|
|
734
|
+
return error;
|
|
735
|
+
}
|