@neutrome/open-ai-router 0.1.4 → 0.1.6
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 +1 -1
- package/src/app/handlers.ts +4 -56
- package/src/example.test.ts +29 -59
- package/src/router/config.ts +0 -4
- package/src/router/execute.test.ts +54 -24
- package/src/router/execute.ts +58 -67
- package/src/router/resolve.test.ts +20 -20
- package/src/router/resolve.ts +0 -33
- package/src/router/runtime.ts +1 -7
- package/src/worker.ts +0 -1
package/package.json
CHANGED
package/src/app/handlers.ts
CHANGED
|
@@ -28,8 +28,8 @@ export type AnthropicMessagesHandlerOptions = ChatCompletionsHandlerOptions;
|
|
|
28
28
|
export type GoogleGenAIHandlerOptions = ChatCompletionsHandlerOptions;
|
|
29
29
|
|
|
30
30
|
export function listModelsHandler(runtime: RouterRuntime) {
|
|
31
|
-
return
|
|
32
|
-
const modelIds =
|
|
31
|
+
return (c: Context) => {
|
|
32
|
+
const modelIds = listAvailableModels(runtime);
|
|
33
33
|
const models: OpenAiModel[] = modelIds.map((id) => ({
|
|
34
34
|
id,
|
|
35
35
|
object: "model",
|
|
@@ -40,60 +40,8 @@ export function listModelsHandler(runtime: RouterRuntime) {
|
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const models: string[] = [];
|
|
46
|
-
|
|
47
|
-
for (const id of listConfiguredModels(runtime)) {
|
|
48
|
-
addModel(id);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
await Promise.all(runtime.providerOrder.map(async (namespace) => {
|
|
52
|
-
const provider = runtime.providers.get(namespace);
|
|
53
|
-
if (!provider || provider.catalog.length > 0) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
for (const model of await fetchProviderCatalog(runtime, provider.apiBaseUrl)) {
|
|
58
|
-
if (provider.exportsMatcher(model)) {
|
|
59
|
-
addModel(`${namespace}/${model}`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}));
|
|
63
|
-
|
|
64
|
-
return models;
|
|
65
|
-
|
|
66
|
-
function addModel(id: string): void {
|
|
67
|
-
if (seen.has(id)) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
seen.add(id);
|
|
71
|
-
models.push(id);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function fetchProviderCatalog(runtime: RouterRuntime, apiBaseUrl: string): Promise<string[]> {
|
|
76
|
-
try {
|
|
77
|
-
const response = await runtime.fetchImpl(`${apiBaseUrl}/models`, {
|
|
78
|
-
method: "GET",
|
|
79
|
-
headers: { accept: "application/json" },
|
|
80
|
-
});
|
|
81
|
-
if (!response.ok) {
|
|
82
|
-
return [];
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const raw = await response.json() as { data?: unknown };
|
|
86
|
-
const data = raw.data;
|
|
87
|
-
if (!Array.isArray(data)) {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return data
|
|
92
|
-
.map((model) => (model as { id?: unknown } | null | undefined)?.id)
|
|
93
|
-
.filter((id): id is string => typeof id === "string");
|
|
94
|
-
} catch {
|
|
95
|
-
return [];
|
|
96
|
-
}
|
|
43
|
+
function listAvailableModels(runtime: RouterRuntime): string[] {
|
|
44
|
+
return listConfiguredModels(runtime);
|
|
97
45
|
}
|
|
98
46
|
|
|
99
47
|
export function chatCompletionsHandler(
|
package/src/example.test.ts
CHANGED
|
@@ -9,15 +9,14 @@ import { createApp } from "./example.ts";
|
|
|
9
9
|
const decoder = new TextDecoder();
|
|
10
10
|
|
|
11
11
|
describe("open-ai-router createApp", () => {
|
|
12
|
-
it("lists configured
|
|
12
|
+
it("lists configured executor aliases", async () => {
|
|
13
13
|
const app = createApp({
|
|
14
14
|
config: {
|
|
15
15
|
providers: {
|
|
16
16
|
openrouter: {
|
|
17
17
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
18
18
|
style: "chat-completions",
|
|
19
|
-
exports: ["
|
|
20
|
-
catalog: ["gpt-4o"],
|
|
19
|
+
exports: ["*"],
|
|
21
20
|
},
|
|
22
21
|
},
|
|
23
22
|
executors: {
|
|
@@ -38,50 +37,6 @@ describe("open-ai-router createApp", () => {
|
|
|
38
37
|
object: "list",
|
|
39
38
|
data: [
|
|
40
39
|
{ id: "semantyka/enei-1", object: "model", created: 0, owned_by: "semantyka" },
|
|
41
|
-
{ id: "openrouter/gpt-4o", object: "model", created: 0, owned_by: "openrouter" },
|
|
42
|
-
],
|
|
43
|
-
});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("lists provider /models entries when provider catalog is not configured", async () => {
|
|
47
|
-
const app = createApp({
|
|
48
|
-
config: {
|
|
49
|
-
providers: {
|
|
50
|
-
openrouter: {
|
|
51
|
-
api_base_url: "https://openrouter.ai/api/v1",
|
|
52
|
-
style: "chat-completions",
|
|
53
|
-
exports: ["*:free"],
|
|
54
|
-
},
|
|
55
|
-
},
|
|
56
|
-
executors: {},
|
|
57
|
-
},
|
|
58
|
-
fetchImpl: vi.fn(async () =>
|
|
59
|
-
new Response(
|
|
60
|
-
JSON.stringify({
|
|
61
|
-
data: [
|
|
62
|
-
{ id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" },
|
|
63
|
-
{ id: "openai/gpt-4.1-mini" },
|
|
64
|
-
],
|
|
65
|
-
}),
|
|
66
|
-
{
|
|
67
|
-
headers: { "content-type": "application/json; charset=utf-8" },
|
|
68
|
-
},
|
|
69
|
-
)
|
|
70
|
-
),
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const response = await app.request("/v1/models");
|
|
74
|
-
|
|
75
|
-
expect(response.status).toBe(200);
|
|
76
|
-
expect(await response.json()).toEqual({
|
|
77
|
-
object: "list",
|
|
78
|
-
data: [
|
|
79
|
-
{
|
|
80
|
-
id: "openrouter/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
|
81
|
-
object: "model",
|
|
82
|
-
created: 0,
|
|
83
|
-
owned_by: "openrouter",
|
|
84
|
-
},
|
|
85
40
|
],
|
|
86
41
|
});
|
|
87
42
|
});
|
|
@@ -101,12 +56,17 @@ describe("open-ai-router createApp", () => {
|
|
|
101
56
|
openrouter: {
|
|
102
57
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
103
58
|
style: "chat-completions",
|
|
104
|
-
exports: ["
|
|
105
|
-
|
|
106
|
-
|
|
59
|
+
exports: ["*"],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
executors: {
|
|
63
|
+
default: {
|
|
64
|
+
exports: ["*"],
|
|
65
|
+
models: {
|
|
66
|
+
"gpt-4o": { provider: "openrouter", model: "gpt-4o" },
|
|
67
|
+
},
|
|
107
68
|
},
|
|
108
69
|
},
|
|
109
|
-
executors: {},
|
|
110
70
|
},
|
|
111
71
|
fetchImpl: vi.fn(async (_input, init) => {
|
|
112
72
|
capturedBody = JSON.parse(decoder.decode(new Uint8Array(init?.body as ArrayBuffer)));
|
|
@@ -172,12 +132,17 @@ describe("open-ai-router createApp", () => {
|
|
|
172
132
|
openrouter: {
|
|
173
133
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
174
134
|
style: "chat-completions",
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
135
|
+
exports: ["*"],
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
executors: {
|
|
139
|
+
default: {
|
|
140
|
+
exports: ["*"],
|
|
141
|
+
models: {
|
|
142
|
+
"gpt-4o": { provider: "openrouter", model: "gpt-4o" },
|
|
143
|
+
},
|
|
178
144
|
},
|
|
179
145
|
},
|
|
180
|
-
executors: {},
|
|
181
146
|
},
|
|
182
147
|
fetchImpl: vi.fn(async (_input, init) => {
|
|
183
148
|
capturedBody = JSON.parse(decoder.decode(new Uint8Array(init?.body as ArrayBuffer)));
|
|
@@ -241,12 +206,17 @@ describe("open-ai-router createApp", () => {
|
|
|
241
206
|
openrouter: {
|
|
242
207
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
243
208
|
style: "chat-completions",
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
209
|
+
exports: ["*"],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
executors: {
|
|
213
|
+
default: {
|
|
214
|
+
exports: ["*"],
|
|
215
|
+
models: {
|
|
216
|
+
"gpt-4o": { provider: "openrouter", model: "gpt-4o" },
|
|
217
|
+
},
|
|
247
218
|
},
|
|
248
219
|
},
|
|
249
|
-
executors: {},
|
|
250
220
|
},
|
|
251
221
|
fetchImpl: vi.fn(async (_input, init) => {
|
|
252
222
|
capturedBody = JSON.parse(decoder.decode(new Uint8Array(init?.body as ArrayBuffer)));
|
package/src/router/config.ts
CHANGED
|
@@ -9,11 +9,7 @@ export type ProviderTargetConfig = {
|
|
|
9
9
|
api_base_url: string;
|
|
10
10
|
style: ProviderApiStyle;
|
|
11
11
|
exports?: string[] | null;
|
|
12
|
-
endpoint_path?: string;
|
|
13
|
-
allow_anonymous?: boolean;
|
|
14
|
-
no_prefix?: boolean;
|
|
15
12
|
headers?: Record<string, string>;
|
|
16
|
-
catalog?: string[];
|
|
17
13
|
};
|
|
18
14
|
|
|
19
15
|
export type ExecutorRouteConfig =
|
|
@@ -19,12 +19,17 @@ describe("router execution", () => {
|
|
|
19
19
|
openrouter: {
|
|
20
20
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
21
21
|
style: "chat-completions",
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
exports: ["*"],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
executors: {
|
|
26
|
+
default: {
|
|
27
|
+
exports: ["*"],
|
|
28
|
+
models: {
|
|
29
|
+
"gpt-4o": { provider: "openrouter", model: "gpt-4o" },
|
|
30
|
+
},
|
|
25
31
|
},
|
|
26
32
|
},
|
|
27
|
-
executors: {},
|
|
28
33
|
},
|
|
29
34
|
fetchImpl: vi.fn(async (input, init) => {
|
|
30
35
|
capturedUrl = String(input);
|
|
@@ -98,12 +103,17 @@ describe("router execution", () => {
|
|
|
98
103
|
openai: {
|
|
99
104
|
api_base_url: "https://api.openai.com/v1",
|
|
100
105
|
style: "responses",
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
106
|
+
exports: ["*"],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
executors: {
|
|
110
|
+
default: {
|
|
111
|
+
exports: ["*"],
|
|
112
|
+
models: {
|
|
113
|
+
"gpt-5": { provider: "openai", model: "gpt-5" },
|
|
114
|
+
},
|
|
104
115
|
},
|
|
105
116
|
},
|
|
106
|
-
executors: {},
|
|
107
117
|
},
|
|
108
118
|
fetchImpl: vi.fn(async (input, init) => {
|
|
109
119
|
capturedUrl = String(input);
|
|
@@ -166,12 +176,17 @@ describe("router execution", () => {
|
|
|
166
176
|
anthropic: {
|
|
167
177
|
api_base_url: "https://api.anthropic.com",
|
|
168
178
|
style: "anthropic-messages",
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
179
|
+
exports: ["*"],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
executors: {
|
|
183
|
+
default: {
|
|
184
|
+
exports: ["*"],
|
|
185
|
+
models: {
|
|
186
|
+
"claude-sonnet-4": { provider: "anthropic", model: "claude-sonnet-4" },
|
|
187
|
+
},
|
|
172
188
|
},
|
|
173
189
|
},
|
|
174
|
-
executors: {},
|
|
175
190
|
},
|
|
176
191
|
fetchImpl: vi.fn(async (input, init) => {
|
|
177
192
|
capturedUrl = String(input);
|
|
@@ -236,12 +251,17 @@ describe("router execution", () => {
|
|
|
236
251
|
google: {
|
|
237
252
|
api_base_url: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
|
|
238
253
|
style: "google-genai",
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
254
|
+
exports: ["*"],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
executors: {
|
|
258
|
+
default: {
|
|
259
|
+
exports: ["*"],
|
|
260
|
+
models: {
|
|
261
|
+
"gemini-2.5-flash": { provider: "google", model: "gemini-2.5-flash" },
|
|
262
|
+
},
|
|
242
263
|
},
|
|
243
264
|
},
|
|
244
|
-
executors: {},
|
|
245
265
|
},
|
|
246
266
|
fetchImpl: vi.fn(async (input, init) => {
|
|
247
267
|
capturedUrl = String(input);
|
|
@@ -359,12 +379,17 @@ describe("router execution", () => {
|
|
|
359
379
|
openrouter: {
|
|
360
380
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
361
381
|
style: "chat-completions",
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
382
|
+
exports: ["*"],
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
executors: {
|
|
386
|
+
default: {
|
|
387
|
+
exports: ["*"],
|
|
388
|
+
models: {
|
|
389
|
+
"gpt-4o": { provider: "openrouter", model: "gpt-4o" },
|
|
390
|
+
},
|
|
365
391
|
},
|
|
366
392
|
},
|
|
367
|
-
executors: {},
|
|
368
393
|
},
|
|
369
394
|
fetchImpl: vi.fn(async () => {
|
|
370
395
|
const stream = new ReadableStream<Uint8Array>({
|
|
@@ -428,12 +453,17 @@ describe("router execution", () => {
|
|
|
428
453
|
openrouter: {
|
|
429
454
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
430
455
|
style: "chat-completions",
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
456
|
+
exports: ["*"],
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
executors: {
|
|
460
|
+
default: {
|
|
461
|
+
exports: ["*"],
|
|
462
|
+
models: {
|
|
463
|
+
"gpt-4o": { provider: "openrouter", model: "gpt-4o" },
|
|
464
|
+
},
|
|
434
465
|
},
|
|
435
466
|
},
|
|
436
|
-
executors: {},
|
|
437
467
|
},
|
|
438
468
|
fetchImpl: vi.fn(async (_input, init) => {
|
|
439
469
|
return await new Promise<Response>((_resolve, reject) => {
|
package/src/router/execute.ts
CHANGED
|
@@ -28,7 +28,7 @@ import type { RequestContext, TargetAuthContext } from "../auth/types.ts";
|
|
|
28
28
|
import { cloneForwardHeaders, isSse } from "../util/headers.ts";
|
|
29
29
|
import { eventDataLines, splitSseEvents } from "../util/sse.ts";
|
|
30
30
|
import { resolveInvocationTarget, type ResolvedTarget } from "./resolve.ts";
|
|
31
|
-
import type { RouterRuntime } from "./runtime.ts";
|
|
31
|
+
import type { ProviderRuntime, RouterRuntime } from "./runtime.ts";
|
|
32
32
|
|
|
33
33
|
const encoder = new TextEncoder();
|
|
34
34
|
const decoder = new TextDecoder();
|
|
@@ -114,9 +114,6 @@ export function createRouterExecutionRuntime(
|
|
|
114
114
|
if (options.executors) {
|
|
115
115
|
runtimeOptions.executors = options.executors;
|
|
116
116
|
}
|
|
117
|
-
if (options.resolveExecutor) {
|
|
118
|
-
runtimeOptions.resolveExecutor = options.resolveExecutor;
|
|
119
|
-
}
|
|
120
117
|
if (options.transforms) {
|
|
121
118
|
runtimeOptions.transforms = options.transforms;
|
|
122
119
|
}
|
|
@@ -130,9 +127,41 @@ export function createRouterExecutionRuntime(
|
|
|
130
127
|
runtimeOptions.executionIdFactory = options.executionIdFactory;
|
|
131
128
|
}
|
|
132
129
|
|
|
130
|
+
const userResolveExecutor = options.resolveExecutor;
|
|
131
|
+
runtimeOptions.resolveExecutor = (name) => {
|
|
132
|
+
if (userResolveExecutor) {
|
|
133
|
+
const result = userResolveExecutor(name);
|
|
134
|
+
if (result) return result;
|
|
135
|
+
}
|
|
136
|
+
return resolveNamespaceExecutor(runtime, name);
|
|
137
|
+
};
|
|
138
|
+
|
|
133
139
|
return createExecutionRuntime(runtimeOptions);
|
|
134
140
|
}
|
|
135
141
|
|
|
142
|
+
function resolveNamespaceExecutor(
|
|
143
|
+
runtime: RouterRuntime,
|
|
144
|
+
name: string,
|
|
145
|
+
): Executor | null {
|
|
146
|
+
const executor = runtime.executors.get(name);
|
|
147
|
+
if (!executor) return null;
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
async execute(request, ctx) {
|
|
151
|
+
const model = getModel(request);
|
|
152
|
+
if (!model) throw new ExecutionError("resolution", "Request is missing model for namespace executor resolution", { stage: "target_resolution" });
|
|
153
|
+
const resolved = resolveInvocationTarget(runtime, `${name}/${model}`);
|
|
154
|
+
return ctx.invoke(request, { target: resolved.target });
|
|
155
|
+
},
|
|
156
|
+
async *stream(request, ctx) {
|
|
157
|
+
const model = getModel(request);
|
|
158
|
+
if (!model) throw new ExecutionError("resolution", "Request is missing model for namespace executor resolution", { stage: "target_resolution" });
|
|
159
|
+
const resolved = resolveInvocationTarget(runtime, `${name}/${model}`);
|
|
160
|
+
yield* ctx.invokeStream(request, { target: resolved.target });
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
136
165
|
export async function handleChatCompletions(
|
|
137
166
|
options: HandleChatCompletionsOptions,
|
|
138
167
|
): Promise<Response> {
|
|
@@ -271,19 +300,14 @@ export function createFetchProviderInvoker(
|
|
|
271
300
|
): ProviderInvoker {
|
|
272
301
|
return {
|
|
273
302
|
async execute(request, providerCtx) {
|
|
274
|
-
const provider = getProviderOrThrow(runtime, providerCtx.target.provider
|
|
303
|
+
const provider = getProviderOrThrow(runtime, providerCtx.target.provider);
|
|
275
304
|
const timed = createUpstreamSignal(providerCtx.signal, upstreamTimeoutMs);
|
|
276
305
|
|
|
277
306
|
try {
|
|
278
307
|
const upstream = await fetchProvider(
|
|
279
308
|
runtime,
|
|
280
309
|
ctx,
|
|
281
|
-
provider
|
|
282
|
-
provider.allowAnonymous,
|
|
283
|
-
provider.headers,
|
|
284
|
-
provider.apiBaseUrl,
|
|
285
|
-
provider.endpointPath,
|
|
286
|
-
provider.style,
|
|
310
|
+
provider,
|
|
287
311
|
setModel(request, providerCtx.target.model),
|
|
288
312
|
timed.signal,
|
|
289
313
|
);
|
|
@@ -301,7 +325,7 @@ export function createFetchProviderInvoker(
|
|
|
301
325
|
},
|
|
302
326
|
|
|
303
327
|
async *stream(request, providerCtx) {
|
|
304
|
-
const provider = getProviderOrThrow(runtime, providerCtx.target.provider
|
|
328
|
+
const provider = getProviderOrThrow(runtime, providerCtx.target.provider);
|
|
305
329
|
const timed = createUpstreamSignal(providerCtx.signal, upstreamTimeoutMs);
|
|
306
330
|
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
307
331
|
let buffer = "";
|
|
@@ -310,12 +334,7 @@ export function createFetchProviderInvoker(
|
|
|
310
334
|
const upstream = await fetchProvider(
|
|
311
335
|
runtime,
|
|
312
336
|
ctx,
|
|
313
|
-
provider
|
|
314
|
-
provider.allowAnonymous,
|
|
315
|
-
provider.headers,
|
|
316
|
-
provider.apiBaseUrl,
|
|
317
|
-
provider.endpointPath,
|
|
318
|
-
provider.style,
|
|
337
|
+
provider,
|
|
319
338
|
setModel(request, providerCtx.target.model),
|
|
320
339
|
timed.signal,
|
|
321
340
|
);
|
|
@@ -487,50 +506,31 @@ function timingHeaders(userTimeMs: number): Record<string, string> {
|
|
|
487
506
|
async function fetchProvider(
|
|
488
507
|
runtime: RouterRuntime,
|
|
489
508
|
ctx: RequestContext,
|
|
490
|
-
|
|
491
|
-
allowAnonymous: boolean,
|
|
492
|
-
providerHeaders: Readonly<Record<string, string>>,
|
|
493
|
-
apiBaseUrl: string,
|
|
494
|
-
endpointPath: string,
|
|
495
|
-
providerStyle: RouterRuntime["providers"] extends ReadonlyMap<string, infer T>
|
|
496
|
-
? T extends { style: infer S }
|
|
497
|
-
? S
|
|
498
|
-
: never
|
|
499
|
-
: never,
|
|
509
|
+
provider: ProviderRuntime,
|
|
500
510
|
request: Program,
|
|
501
511
|
signal: AbortSignal,
|
|
502
512
|
): Promise<Response> {
|
|
503
513
|
const headers = cloneForwardHeaders(ctx.request.headers);
|
|
504
514
|
headers.set("content-type", "application/json");
|
|
505
|
-
for (const [key, value] of Object.entries(
|
|
515
|
+
for (const [key, value] of Object.entries(provider.headers)) {
|
|
506
516
|
headers.set(key, value);
|
|
507
517
|
}
|
|
508
518
|
|
|
509
|
-
let hasAuth = false;
|
|
510
519
|
for (const driver of runtime.authChain) {
|
|
511
|
-
const targetCtx: TargetAuthContext = { ...ctx, providerName };
|
|
520
|
+
const targetCtx: TargetAuthContext = { ...ctx, providerName: provider.name };
|
|
512
521
|
const auth = await driver.collectTarget(targetCtx);
|
|
513
522
|
if (!auth) {
|
|
514
523
|
continue;
|
|
515
524
|
}
|
|
516
525
|
new Headers(auth.headers).forEach((value, key) => headers.set(key, value));
|
|
517
|
-
hasAuth = true;
|
|
518
526
|
break;
|
|
519
527
|
}
|
|
520
528
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
`No auth available for provider ${providerName}`,
|
|
524
|
-
401,
|
|
525
|
-
"provider_auth_missing",
|
|
526
|
-
);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
let body = emitProviderRequest(providerStyle, request);
|
|
530
|
-
if (providerStyle === "chat-completions" && isStreaming(request)) {
|
|
529
|
+
let body = emitProviderRequest(provider.style, request);
|
|
530
|
+
if (provider.style === "chat-completions" && isStreaming(request)) {
|
|
531
531
|
body = injectStreamUsage(body);
|
|
532
532
|
}
|
|
533
|
-
return runtime.fetchImpl(`${apiBaseUrl}${endpointPath}`, {
|
|
533
|
+
return runtime.fetchImpl(`${provider.apiBaseUrl}${provider.endpointPath}`, {
|
|
534
534
|
method: "POST",
|
|
535
535
|
headers,
|
|
536
536
|
body: toBody(body),
|
|
@@ -538,32 +538,23 @@ async function fetchProvider(
|
|
|
538
538
|
});
|
|
539
539
|
}
|
|
540
540
|
|
|
541
|
-
function getProviderOrThrow(runtime: RouterRuntime, providerName: string | undefined
|
|
542
|
-
if (providerName) {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
"provider_not_configured",
|
|
549
|
-
);
|
|
550
|
-
}
|
|
551
|
-
return provider;
|
|
541
|
+
function getProviderOrThrow(runtime: RouterRuntime, providerName: string | undefined) {
|
|
542
|
+
if (!providerName) {
|
|
543
|
+
throw new RouterError(
|
|
544
|
+
"No provider specified on execution target",
|
|
545
|
+
500,
|
|
546
|
+
"provider_not_configured",
|
|
547
|
+
);
|
|
552
548
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
549
|
+
const provider = runtime.providers.get(providerName);
|
|
550
|
+
if (!provider) {
|
|
551
|
+
throw new RouterError(
|
|
552
|
+
`Provider ${providerName} is not configured`,
|
|
553
|
+
500,
|
|
554
|
+
"provider_not_configured",
|
|
555
|
+
);
|
|
560
556
|
}
|
|
561
|
-
|
|
562
|
-
throw new RouterError(
|
|
563
|
-
`No provider found for model \`${model ?? "(unknown)"}\``,
|
|
564
|
-
404,
|
|
565
|
-
"provider_not_found",
|
|
566
|
-
);
|
|
557
|
+
return provider;
|
|
567
558
|
}
|
|
568
559
|
|
|
569
560
|
async function toProviderError(
|
|
@@ -10,8 +10,7 @@ describe("router resolution", () => {
|
|
|
10
10
|
openrouter: {
|
|
11
11
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
12
12
|
style: "chat-completions",
|
|
13
|
-
exports: ["
|
|
14
|
-
catalog: ["gemma-4-31b-it"],
|
|
13
|
+
exports: ["*"],
|
|
15
14
|
},
|
|
16
15
|
},
|
|
17
16
|
executors: {
|
|
@@ -45,7 +44,6 @@ describe("router resolution", () => {
|
|
|
45
44
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
46
45
|
style: "chat-completions",
|
|
47
46
|
exports: ["google/*"],
|
|
48
|
-
catalog: ["google/gemma-4-31b-it"],
|
|
49
47
|
},
|
|
50
48
|
},
|
|
51
49
|
executors: {},
|
|
@@ -71,7 +69,6 @@ describe("router resolution", () => {
|
|
|
71
69
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
72
70
|
style: "chat-completions",
|
|
73
71
|
exports: ["openai/gpt-4.1-mini"],
|
|
74
|
-
catalog: ["openai/gpt-4.1-mini"],
|
|
75
72
|
},
|
|
76
73
|
},
|
|
77
74
|
executors: {
|
|
@@ -96,15 +93,14 @@ describe("router resolution", () => {
|
|
|
96
93
|
});
|
|
97
94
|
});
|
|
98
95
|
|
|
99
|
-
it("lists configured executor
|
|
96
|
+
it("lists configured executor models as qualified IDs", () => {
|
|
100
97
|
const runtime = createRouterRuntime({
|
|
101
98
|
config: {
|
|
102
99
|
providers: {
|
|
103
100
|
openrouter: {
|
|
104
101
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
105
102
|
style: "chat-completions",
|
|
106
|
-
exports: ["
|
|
107
|
-
catalog: ["google/gemma-4-31b-it", "openai/gpt-4o", "anthropic/claude-sonnet-4"],
|
|
103
|
+
exports: ["*"],
|
|
108
104
|
},
|
|
109
105
|
},
|
|
110
106
|
executors: {
|
|
@@ -122,34 +118,38 @@ describe("router resolution", () => {
|
|
|
122
118
|
expect(listConfiguredModels(runtime)).toEqual([
|
|
123
119
|
"enei/enei-1",
|
|
124
120
|
"enei/enei-1-pro",
|
|
125
|
-
"openrouter/google/gemma-4-31b-it",
|
|
126
|
-
"openrouter/openai/gpt-4o",
|
|
127
121
|
]);
|
|
128
122
|
});
|
|
129
123
|
|
|
130
|
-
it("
|
|
124
|
+
it("resolves unqualified model through executor routes", () => {
|
|
131
125
|
const runtime = createRouterRuntime({
|
|
132
126
|
config: {
|
|
133
127
|
providers: {
|
|
134
128
|
openrouter: {
|
|
135
129
|
api_base_url: "https://openrouter.ai/api/v1",
|
|
136
130
|
style: "chat-completions",
|
|
137
|
-
exports: ["
|
|
138
|
-
no_prefix: true,
|
|
131
|
+
exports: ["*"],
|
|
139
132
|
},
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
exports: ["
|
|
144
|
-
|
|
133
|
+
},
|
|
134
|
+
executors: {
|
|
135
|
+
default: {
|
|
136
|
+
exports: ["*"],
|
|
137
|
+
models: {
|
|
138
|
+
"gpt-4o": { provider: "openrouter", model: "openai/gpt-4o" },
|
|
139
|
+
},
|
|
145
140
|
},
|
|
146
141
|
},
|
|
147
|
-
executors: {},
|
|
148
142
|
},
|
|
149
143
|
});
|
|
150
144
|
|
|
151
145
|
const resolved = resolveInvocationTargets(runtime, "gpt-4o");
|
|
152
|
-
expect(resolved).toHaveLength(
|
|
153
|
-
expect(resolved
|
|
146
|
+
expect(resolved).toHaveLength(1);
|
|
147
|
+
expect(resolved[0]!.namespace).toBe("default");
|
|
148
|
+
expect(resolved[0]!.target).toEqual({
|
|
149
|
+
kind: "provider",
|
|
150
|
+
provider: "openrouter",
|
|
151
|
+
model: "openai/gpt-4o",
|
|
152
|
+
transforms: [],
|
|
153
|
+
});
|
|
154
154
|
});
|
|
155
155
|
});
|
package/src/router/resolve.ts
CHANGED
|
@@ -48,31 +48,11 @@ export function resolveInvocationTargets(
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
for (const namespace of runtime.providerOrder) {
|
|
52
|
-
const provider = runtime.providers.get(namespace)!;
|
|
53
|
-
if (!provider.noPrefix) continue;
|
|
54
|
-
if (provider.exportsMatcher(parsed.baseModel)) {
|
|
55
|
-
matches.push({
|
|
56
|
-
requestedModel,
|
|
57
|
-
namespace,
|
|
58
|
-
source: "provider",
|
|
59
|
-
suffixes: parsed.suffixes,
|
|
60
|
-
target: {
|
|
61
|
-
kind: "provider",
|
|
62
|
-
provider: namespace,
|
|
63
|
-
model: parsed.baseModel,
|
|
64
|
-
transforms: parsed.suffixes.map((suffix) => suffix.raw),
|
|
65
|
-
},
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
51
|
return matches;
|
|
71
52
|
}
|
|
72
53
|
|
|
73
54
|
export function listConfiguredModels(runtime: RouterRuntime): string[] {
|
|
74
55
|
const models: string[] = [];
|
|
75
|
-
const seen = new Set<string>();
|
|
76
56
|
|
|
77
57
|
for (const namespace of runtime.executorOrder) {
|
|
78
58
|
const executor = runtime.executors.get(namespace)!;
|
|
@@ -83,19 +63,6 @@ export function listConfiguredModels(runtime: RouterRuntime): string[] {
|
|
|
83
63
|
}
|
|
84
64
|
}
|
|
85
65
|
|
|
86
|
-
for (const namespace of runtime.providerOrder) {
|
|
87
|
-
const provider = runtime.providers.get(namespace)!;
|
|
88
|
-
for (const model of provider.catalog) {
|
|
89
|
-
if (provider.exportsMatcher(model)) {
|
|
90
|
-
models.push(`${namespace}/${model}`);
|
|
91
|
-
if (provider.noPrefix && !seen.has(model)) {
|
|
92
|
-
seen.add(model);
|
|
93
|
-
models.push(model);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
66
|
return models;
|
|
100
67
|
}
|
|
101
68
|
|
package/src/router/runtime.ts
CHANGED
|
@@ -14,11 +14,8 @@ export type ProviderRuntime = {
|
|
|
14
14
|
style: ProviderTargetConfig["style"];
|
|
15
15
|
apiBaseUrl: string;
|
|
16
16
|
endpointPath: string;
|
|
17
|
-
allowAnonymous: boolean;
|
|
18
|
-
noPrefix: boolean;
|
|
19
17
|
headers: Readonly<Record<string, string>>;
|
|
20
18
|
exportsMatcher: (model: string) => boolean;
|
|
21
|
-
catalog: readonly string[];
|
|
22
19
|
};
|
|
23
20
|
|
|
24
21
|
export type ExecutorRouteRuntime = {
|
|
@@ -84,12 +81,9 @@ function buildProviderRuntime(name: string, config: ProviderTargetConfig): Provi
|
|
|
84
81
|
name,
|
|
85
82
|
style: config.style,
|
|
86
83
|
apiBaseUrl: config.api_base_url,
|
|
87
|
-
endpointPath:
|
|
88
|
-
allowAnonymous: config.allow_anonymous ?? false,
|
|
89
|
-
noPrefix: config.no_prefix ?? false,
|
|
84
|
+
endpointPath: defaultEndpointPath(config.style),
|
|
90
85
|
headers: config.headers ?? {},
|
|
91
86
|
exportsMatcher: createExportsMatcher(config.exports),
|
|
92
|
-
catalog: config.catalog ?? [],
|
|
93
87
|
};
|
|
94
88
|
}
|
|
95
89
|
|