@neutrome/open-ai-router 0.4.2 → 0.5.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/.dev.vars.example +2 -0
- package/README.md +5 -1
- package/package.json +5 -4
- package/src/admission/access.ts +0 -1
- package/src/admission/types.ts +0 -1
- package/src/app/factory.test.ts +40 -34
- package/src/app/factory.ts +120 -173
- package/src/app/google-genai-route.ts +48 -0
- package/src/app/http-utils.ts +14 -0
- package/src/app/model-listing.ts +29 -0
- package/src/app/response-lifecycle.ts +66 -0
- package/src/app/types.ts +38 -0
- package/src/index.ts +11 -43
- package/src/otlp.ts +148 -0
- package/src/router/audit.ts +5 -12
- package/src/router/config.ts +4 -4
- package/src/router/error-codec.ts +170 -0
- package/src/router/errors.ts +38 -1
- package/src/router/execute.test.ts +177 -107
- package/src/router/execute.ts +68 -924
- package/src/router/execution-events.ts +39 -0
- package/src/router/execution-invocation.ts +144 -0
- package/src/router/execution-observation.ts +161 -0
- package/src/router/execution-resolution.ts +120 -0
- package/src/router/execution-runtime.test.ts +156 -83
- package/src/router/execution-runtime.ts +144 -542
- package/src/router/execution-transforms.ts +82 -0
- package/src/router/execution-types.ts +8 -19
- package/src/router/index.ts +6 -5
- package/src/router/mcp.ts +12 -6
- package/src/router/provider-invoker.ts +134 -0
- package/src/router/provider-stream.ts +192 -0
- package/src/router/provider-telemetry.ts +59 -0
- package/src/router/remote-mcp-execution.ts +61 -0
- package/src/router/request-program.ts +16 -0
- package/src/router/request-target.ts +56 -0
- package/src/router/resolve.test.ts +43 -10
- package/src/router/resolve.ts +50 -28
- package/src/router/response-delivery.ts +141 -0
- package/src/router/runtime.test.ts +56 -0
- package/src/router/runtime.ts +79 -9
- package/src/router/targets.ts +3 -1
- package/src/router/upstream-client.ts +137 -0
- package/src/telemetry.test.ts +50 -0
- package/src/telemetry.ts +316 -0
- package/src/trace-labels.ts +29 -0
- package/src/upstream-auth/env.ts +15 -3
- package/src/upstream-auth/proxy.ts +6 -1
- package/src/upstream-auth/registry.ts +3 -1
- package/src/util/glob.ts +1 -1
- package/src/util/model-syntax.ts +3 -3
- package/src/worker.ts +12 -6
- package/worker-configuration.d.ts +195 -164
- package/pnpm-workspace.yaml +0 -4
- package/src/admission/jwt.test.ts +0 -96
- package/src/admission/jwt.ts +0 -221
- package/src/app/handlers.ts +0 -198
- package/src/example.test.ts +0 -377
- package/src/example.ts +0 -73
- package/src/observer.test.ts +0 -54
- package/src/observer.ts +0 -315
- package/src/timing.ts +0 -36
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getModel, type Program } from "@neutrome/lil-engine";
|
|
2
|
+
import { ExecutionError, RouterError } from "./errors.ts";
|
|
3
|
+
import type { Executor } from "./execution-types.ts";
|
|
4
|
+
import { resolveInvocationTarget, type ResolvedTarget } from "./resolve.ts";
|
|
5
|
+
import type { RouterRuntime } from "./runtime.ts";
|
|
6
|
+
|
|
7
|
+
export function resolveModelNamespaceExecutor(
|
|
8
|
+
runtime: RouterRuntime,
|
|
9
|
+
name: string,
|
|
10
|
+
): Executor | null {
|
|
11
|
+
if (!runtime.modelNamespaces.has(name)) return null;
|
|
12
|
+
return {
|
|
13
|
+
async execute(request, ctx) {
|
|
14
|
+
const model = getModel(request);
|
|
15
|
+
if (!model)
|
|
16
|
+
throw new ExecutionError(
|
|
17
|
+
"resolution",
|
|
18
|
+
"Request is missing model for namespace executor resolution",
|
|
19
|
+
{ stage: "target_resolution" },
|
|
20
|
+
);
|
|
21
|
+
return ctx.invoke(request, {
|
|
22
|
+
target: resolveInvocationTarget(runtime, `${name}/${model}`).target,
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
async *stream(request, ctx) {
|
|
26
|
+
const model = getModel(request);
|
|
27
|
+
if (!model)
|
|
28
|
+
throw new ExecutionError(
|
|
29
|
+
"resolution",
|
|
30
|
+
"Request is missing model for namespace executor resolution",
|
|
31
|
+
{ stage: "target_resolution" },
|
|
32
|
+
);
|
|
33
|
+
yield* ctx.invokeStream(request, {
|
|
34
|
+
target: resolveInvocationTarget(runtime, `${name}/${model}`).target,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveRequestTarget(
|
|
41
|
+
runtime: RouterRuntime,
|
|
42
|
+
request: Program,
|
|
43
|
+
): ResolvedTarget {
|
|
44
|
+
const model = getModel(request);
|
|
45
|
+
if (!model)
|
|
46
|
+
throw new RouterError("Request is missing `model`", 400, "missing_model");
|
|
47
|
+
try {
|
|
48
|
+
return resolveInvocationTarget(runtime, model);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new RouterError(
|
|
51
|
+
error instanceof Error ? error.message : "Model resolution failed",
|
|
52
|
+
404,
|
|
53
|
+
"model_not_found",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { createRouterRuntime } from "./runtime.ts";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
listConfiguredModels,
|
|
5
|
+
resolveInvocationTarget,
|
|
6
|
+
resolveInvocationTargets,
|
|
7
|
+
} from "./resolve.ts";
|
|
4
8
|
|
|
5
9
|
describe("router resolution", () => {
|
|
6
10
|
it("resolves executor-backed aliases before provider-backed models", () => {
|
|
7
11
|
const runtime = createRouterRuntime({
|
|
8
12
|
config: {
|
|
13
|
+
modelSuffixes: ["slwin"],
|
|
9
14
|
providers: {
|
|
10
15
|
openrouter: {
|
|
11
|
-
|
|
16
|
+
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
12
17
|
style: "chat-completions",
|
|
13
18
|
exports: ["*"],
|
|
14
19
|
},
|
|
@@ -18,7 +23,10 @@ describe("router resolution", () => {
|
|
|
18
23
|
exports: ["enei-*"],
|
|
19
24
|
models: {
|
|
20
25
|
"enei-1": { executorId: "enei-1" },
|
|
21
|
-
"enei-1-pro": {
|
|
26
|
+
"enei-1-pro": {
|
|
27
|
+
executorId: "enei-1-pro",
|
|
28
|
+
transforms: ["reasoning:high"],
|
|
29
|
+
},
|
|
22
30
|
},
|
|
23
31
|
},
|
|
24
32
|
},
|
|
@@ -39,19 +47,22 @@ describe("router resolution", () => {
|
|
|
39
47
|
it("resolves qualified provider models and preserves suffix transforms", () => {
|
|
40
48
|
const runtime = createRouterRuntime({
|
|
41
49
|
config: {
|
|
50
|
+
modelSuffixes: ["slwin", "kvtools"],
|
|
42
51
|
providers: {
|
|
43
52
|
openrouter: {
|
|
44
|
-
|
|
53
|
+
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
45
54
|
style: "chat-completions",
|
|
46
55
|
exports: ["google/*"],
|
|
47
56
|
},
|
|
48
57
|
},
|
|
49
58
|
modelNamespaces: {},
|
|
50
59
|
},
|
|
51
|
-
knownSuffixes: ["slwin", "kvtools"],
|
|
52
60
|
});
|
|
53
61
|
|
|
54
|
-
const resolved = resolveInvocationTarget(
|
|
62
|
+
const resolved = resolveInvocationTarget(
|
|
63
|
+
runtime,
|
|
64
|
+
"openrouter/google/gemma-4-31b-it+kvtools",
|
|
65
|
+
);
|
|
55
66
|
expect(resolved.source).toBe("provider");
|
|
56
67
|
expect(resolved.target).toEqual({
|
|
57
68
|
kind: "provider",
|
|
@@ -61,12 +72,31 @@ describe("router resolution", () => {
|
|
|
61
72
|
});
|
|
62
73
|
});
|
|
63
74
|
|
|
75
|
+
it("requires configured suffixes", () => {
|
|
76
|
+
const runtime = createRouterRuntime({
|
|
77
|
+
config: {
|
|
78
|
+
providers: {
|
|
79
|
+
openrouter: {
|
|
80
|
+
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
81
|
+
style: "chat-completions",
|
|
82
|
+
exports: ["*"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
modelNamespaces: {},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(() =>
|
|
90
|
+
resolveInvocationTarget(runtime, "openrouter/openai/gpt-4.1+slwin:8"),
|
|
91
|
+
).toThrow("Unknown model suffix: slwin");
|
|
92
|
+
});
|
|
93
|
+
|
|
64
94
|
it("resolves namespace model routes that alias provider targets", () => {
|
|
65
95
|
const runtime = createRouterRuntime({
|
|
66
96
|
config: {
|
|
67
97
|
providers: {
|
|
68
98
|
openrouter: {
|
|
69
|
-
|
|
99
|
+
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
70
100
|
style: "chat-completions",
|
|
71
101
|
exports: ["openai/gpt-4.1-mini"],
|
|
72
102
|
},
|
|
@@ -75,7 +105,10 @@ describe("router resolution", () => {
|
|
|
75
105
|
semantyka: {
|
|
76
106
|
exports: ["enei-*"],
|
|
77
107
|
models: {
|
|
78
|
-
"enei-1": {
|
|
108
|
+
"enei-1": {
|
|
109
|
+
provider: "openrouter",
|
|
110
|
+
model: "openai/gpt-4.1-mini",
|
|
111
|
+
},
|
|
79
112
|
},
|
|
80
113
|
},
|
|
81
114
|
},
|
|
@@ -98,7 +131,7 @@ describe("router resolution", () => {
|
|
|
98
131
|
config: {
|
|
99
132
|
providers: {
|
|
100
133
|
openrouter: {
|
|
101
|
-
|
|
134
|
+
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
102
135
|
style: "chat-completions",
|
|
103
136
|
exports: ["*"],
|
|
104
137
|
},
|
|
@@ -126,7 +159,7 @@ describe("router resolution", () => {
|
|
|
126
159
|
config: {
|
|
127
160
|
providers: {
|
|
128
161
|
openrouter: {
|
|
129
|
-
|
|
162
|
+
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
130
163
|
style: "chat-completions",
|
|
131
164
|
exports: ["*"],
|
|
132
165
|
},
|
package/src/router/resolve.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { ExecutionTarget } from "@neutrome/lilsdk-ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
parseModelSyntax,
|
|
4
|
+
type ModelSuffixSpec,
|
|
5
|
+
} from "../util/model-syntax.ts";
|
|
3
6
|
import type { RouterRuntime } from "./runtime.ts";
|
|
4
7
|
|
|
5
8
|
export type ResolvedTarget = {
|
|
@@ -28,15 +31,22 @@ export function resolveInvocationTargets(
|
|
|
28
31
|
runtime: RouterRuntime,
|
|
29
32
|
requestedModel: string,
|
|
30
33
|
): ResolvedTarget[] {
|
|
31
|
-
const parsed = parseModelSyntax(requestedModel, runtime.
|
|
34
|
+
const parsed = parseModelSyntax(requestedModel, runtime.modelSuffixes);
|
|
32
35
|
const qualified = splitQualified(parsed.baseModel);
|
|
33
36
|
if (qualified) {
|
|
34
|
-
return resolveQualified(
|
|
37
|
+
return resolveQualified(
|
|
38
|
+
runtime,
|
|
39
|
+
requestedModel,
|
|
40
|
+
qualified.namespace,
|
|
41
|
+
qualified.model,
|
|
42
|
+
parsed.suffixes,
|
|
43
|
+
);
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
const matches: ResolvedTarget[] = [];
|
|
38
47
|
for (const namespace of runtime.modelNamespaceOrder) {
|
|
39
|
-
const modelNamespace = runtime.modelNamespaces.get(namespace)
|
|
48
|
+
const modelNamespace = runtime.modelNamespaces.get(namespace);
|
|
49
|
+
if (!modelNamespace) continue;
|
|
40
50
|
const route = modelNamespace.routes.get(parsed.baseModel);
|
|
41
51
|
if (route && modelNamespace.exportsMatcher(parsed.baseModel)) {
|
|
42
52
|
matches.push({
|
|
@@ -57,7 +67,8 @@ export function listConfiguredModels(runtime: RouterRuntime): string[] {
|
|
|
57
67
|
const models: string[] = [];
|
|
58
68
|
|
|
59
69
|
for (const namespace of runtime.modelNamespaceOrder) {
|
|
60
|
-
const modelNamespace = runtime.modelNamespaces.get(namespace)
|
|
70
|
+
const modelNamespace = runtime.modelNamespaces.get(namespace);
|
|
71
|
+
if (!modelNamespace) continue;
|
|
61
72
|
for (const alias of modelNamespace.routes.keys()) {
|
|
62
73
|
if (modelNamespace.exportsMatcher(alias)) {
|
|
63
74
|
models.push(`${namespace}/${alias}`);
|
|
@@ -79,7 +90,9 @@ function resolveQualified(
|
|
|
79
90
|
const modelNamespace = runtime.modelNamespaces.get(namespace);
|
|
80
91
|
|
|
81
92
|
if (provider && modelNamespace) {
|
|
82
|
-
throw new Error(
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Ambiguous namespace \`${namespace}\`: both provider and model namespace are registered`,
|
|
95
|
+
);
|
|
83
96
|
}
|
|
84
97
|
|
|
85
98
|
if (modelNamespace) {
|
|
@@ -87,33 +100,37 @@ function resolveQualified(
|
|
|
87
100
|
if (!route || !modelNamespace.exportsMatcher(model)) {
|
|
88
101
|
return [];
|
|
89
102
|
}
|
|
90
|
-
return [
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
requestedModel,
|
|
106
|
+
namespace,
|
|
107
|
+
source: route.target.kind,
|
|
108
|
+
suffixes,
|
|
109
|
+
mcps: route.mcps,
|
|
110
|
+
target: withSuffixTransforms(route.target, suffixes),
|
|
111
|
+
},
|
|
112
|
+
];
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
if (provider) {
|
|
101
116
|
if (!provider.exportsMatcher(model)) {
|
|
102
117
|
return [];
|
|
103
118
|
}
|
|
104
|
-
return [
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
requestedModel,
|
|
122
|
+
namespace,
|
|
123
|
+
source: "provider",
|
|
124
|
+
suffixes,
|
|
125
|
+
mcps: [],
|
|
126
|
+
target: {
|
|
127
|
+
kind: "provider",
|
|
128
|
+
provider: namespace,
|
|
129
|
+
model,
|
|
130
|
+
transforms: suffixes.map((suffix) => suffix.raw),
|
|
131
|
+
},
|
|
115
132
|
},
|
|
116
|
-
|
|
133
|
+
];
|
|
117
134
|
}
|
|
118
135
|
|
|
119
136
|
return [];
|
|
@@ -125,11 +142,16 @@ function withSuffixTransforms(
|
|
|
125
142
|
): ExecutionTarget {
|
|
126
143
|
return {
|
|
127
144
|
...target,
|
|
128
|
-
transforms: [
|
|
145
|
+
transforms: [
|
|
146
|
+
...(target.transforms ?? []),
|
|
147
|
+
...suffixes.map((suffix) => suffix.raw),
|
|
148
|
+
],
|
|
129
149
|
};
|
|
130
150
|
}
|
|
131
151
|
|
|
132
|
-
function splitQualified(
|
|
152
|
+
function splitQualified(
|
|
153
|
+
model: string,
|
|
154
|
+
): { namespace: string; model: string } | null {
|
|
133
155
|
const index = model.indexOf("/");
|
|
134
156
|
if (index <= 0) {
|
|
135
157
|
return null;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emitProviderError,
|
|
3
|
+
emitProviderStreamChunk,
|
|
4
|
+
type Program,
|
|
5
|
+
type ProviderStyle,
|
|
6
|
+
} from "@neutrome/lil-engine";
|
|
7
|
+
import { errorProgramFromUnknown, toBody } from "./error-codec.ts";
|
|
8
|
+
import type { ExecutionRuntimeOptions } from "./execution-types.ts";
|
|
9
|
+
import type { ResolvedTarget } from "./resolve.ts";
|
|
10
|
+
import { observeTimedExecution } from "./execution-events.ts";
|
|
11
|
+
|
|
12
|
+
const encoder = new TextEncoder();
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
|
|
15
|
+
export async function streamExecutionResponse(
|
|
16
|
+
responseStyle: ProviderStyle,
|
|
17
|
+
chunks: AsyncIterable<Program>,
|
|
18
|
+
resolved: ResolvedTarget,
|
|
19
|
+
observe: ExecutionRuntimeOptions["observe"] | undefined,
|
|
20
|
+
input: { requestId: string; executionId: string; startedAtMs: number },
|
|
21
|
+
cleanup?: () => Promise<void>,
|
|
22
|
+
): Promise<Response> {
|
|
23
|
+
const iterator = chunks[Symbol.asyncIterator]();
|
|
24
|
+
|
|
25
|
+
const first = await iterator.next();
|
|
26
|
+
observeTimedExecution({
|
|
27
|
+
observe,
|
|
28
|
+
kind: "response.stream_first_chunk",
|
|
29
|
+
requestId: input.requestId,
|
|
30
|
+
executionId: input.executionId,
|
|
31
|
+
startedAtMs: input.startedAtMs,
|
|
32
|
+
finishedAtMs: Date.now(),
|
|
33
|
+
data: {
|
|
34
|
+
streaming: true,
|
|
35
|
+
source: first.done ? "empty" : "chunk",
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (first.done) {
|
|
39
|
+
await cleanup?.();
|
|
40
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
41
|
+
start(controller) {
|
|
42
|
+
if (responseStyle === "chat-completions") {
|
|
43
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
44
|
+
}
|
|
45
|
+
controller.close();
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return new Response(stream, {
|
|
49
|
+
headers: {
|
|
50
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
51
|
+
...routingHeaders(resolved),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let done = false;
|
|
57
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
58
|
+
start(controller) {
|
|
59
|
+
const bytes = emitProviderStreamChunk(responseStyle, first.value);
|
|
60
|
+
controller.enqueue(encoder.encode(`data: ${decoder.decode(bytes)}\n\n`));
|
|
61
|
+
},
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
if (done) return;
|
|
64
|
+
try {
|
|
65
|
+
const result = await iterator.next();
|
|
66
|
+
if (result.done) {
|
|
67
|
+
done = true;
|
|
68
|
+
if (responseStyle === "chat-completions") {
|
|
69
|
+
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
70
|
+
}
|
|
71
|
+
await cleanup?.();
|
|
72
|
+
controller.close();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const bytes = emitProviderStreamChunk(responseStyle, result.value);
|
|
76
|
+
controller.enqueue(
|
|
77
|
+
encoder.encode(`data: ${decoder.decode(bytes)}\n\n`),
|
|
78
|
+
);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
done = true;
|
|
81
|
+
await cleanup?.();
|
|
82
|
+
const program = errorProgramFromUnknown(error);
|
|
83
|
+
const bytes = emitProviderError(responseStyle, program);
|
|
84
|
+
controller.enqueue(
|
|
85
|
+
encoder.encode(`event: error\ndata: ${decoder.decode(bytes)}\n\n`),
|
|
86
|
+
);
|
|
87
|
+
controller.close();
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
async cancel() {
|
|
91
|
+
await cleanup?.();
|
|
92
|
+
await iterator.return?.();
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return new Response(stream, {
|
|
97
|
+
headers: {
|
|
98
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
99
|
+
...routingHeaders(resolved),
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function jsonExecutionResponse(
|
|
105
|
+
body: Uint8Array,
|
|
106
|
+
resolved: ResolvedTarget,
|
|
107
|
+
userTime: number,
|
|
108
|
+
): Response {
|
|
109
|
+
return new Response(toBody(body), {
|
|
110
|
+
headers: {
|
|
111
|
+
"content-type": "application/json; charset=utf-8",
|
|
112
|
+
...routingHeaders(resolved),
|
|
113
|
+
...timingHeaders(userTime),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function routingHeaders(resolved: ResolvedTarget): Record<string, string> {
|
|
119
|
+
if (resolved.target.kind === "provider") {
|
|
120
|
+
return {
|
|
121
|
+
"x-openairouter-source": resolved.source,
|
|
122
|
+
"x-openairouter-namespace": resolved.namespace,
|
|
123
|
+
"x-openairouter-provider-id": resolved.target.provider ?? "",
|
|
124
|
+
"x-openairouter-model-id": resolved.target.model,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
"x-openairouter-engine": "open-ai-router",
|
|
130
|
+
"x-openairouter-source": resolved.source,
|
|
131
|
+
"x-openairouter-namespace": resolved.namespace,
|
|
132
|
+
"x-openairouter-executor-id": resolved.target.executorId,
|
|
133
|
+
"x-openairouter-model-id": resolved.target.alias,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function timingHeaders(userTimeMs: number): Record<string, string> {
|
|
138
|
+
return {
|
|
139
|
+
"x-openairouter-user-time": String(userTimeMs),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createRouterRuntime } from "./runtime.ts";
|
|
3
|
+
|
|
4
|
+
const provider = {
|
|
5
|
+
apiBaseUrl: "https://api.example.test/v1",
|
|
6
|
+
style: "chat-completions" as const,
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
describe("router runtime configuration", () => {
|
|
10
|
+
it("rejects invalid references and URLs at startup", () => {
|
|
11
|
+
expect(() =>
|
|
12
|
+
createRouterRuntime({
|
|
13
|
+
config: {
|
|
14
|
+
providers: {
|
|
15
|
+
broken: { ...provider, apiBaseUrl: "file:///tmp/provider" },
|
|
16
|
+
},
|
|
17
|
+
modelNamespaces: {},
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
).toThrow("Provider broken has an invalid HTTP URL");
|
|
21
|
+
|
|
22
|
+
expect(() =>
|
|
23
|
+
createRouterRuntime({
|
|
24
|
+
config: {
|
|
25
|
+
providers: { upstream: provider },
|
|
26
|
+
modelNamespaces: {
|
|
27
|
+
default: {
|
|
28
|
+
models: { chat: { provider: "missing", model: "chat" } },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
).toThrow("references unknown provider missing");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("accepts only distinct, usable configured suffix names", () => {
|
|
37
|
+
expect(() =>
|
|
38
|
+
createRouterRuntime({
|
|
39
|
+
config: {
|
|
40
|
+
modelSuffixes: ["trim", "TRIM"],
|
|
41
|
+
providers: { upstream: provider },
|
|
42
|
+
modelNamespaces: {},
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
).toThrow("Invalid model suffix: TRIM");
|
|
46
|
+
|
|
47
|
+
const runtime = createRouterRuntime({
|
|
48
|
+
config: {
|
|
49
|
+
modelSuffixes: ["trim"],
|
|
50
|
+
providers: { upstream: provider },
|
|
51
|
+
modelNamespaces: {},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
expect(runtime.modelSuffixes).toEqual(new Set(["trim"]));
|
|
55
|
+
});
|
|
56
|
+
});
|
package/src/router/runtime.ts
CHANGED
|
@@ -40,7 +40,7 @@ export type RouterRuntime = {
|
|
|
40
40
|
modelNamespaceOrder: readonly string[];
|
|
41
41
|
mcps: ReadonlyMap<string, RemoteMcpServerRuntime>;
|
|
42
42
|
upstreamAuthChain: readonly UpstreamAuthDriver[];
|
|
43
|
-
|
|
43
|
+
modelSuffixes: ReadonlySet<string>;
|
|
44
44
|
fetchImpl: typeof fetch;
|
|
45
45
|
};
|
|
46
46
|
|
|
@@ -51,11 +51,13 @@ export type RemoteMcpServerRuntime = RemoteMcpServerConfig & {
|
|
|
51
51
|
export type CreateRouterOptions = {
|
|
52
52
|
config: RouterConfig;
|
|
53
53
|
fetchImpl?: typeof fetch;
|
|
54
|
-
knownSuffixes?: string[];
|
|
55
54
|
upstreamAuthDrivers?: readonly UpstreamAuthDriver[];
|
|
56
55
|
};
|
|
57
56
|
|
|
58
|
-
export function createRouterRuntime(
|
|
57
|
+
export function createRouterRuntime(
|
|
58
|
+
options: CreateRouterOptions,
|
|
59
|
+
): RouterRuntime {
|
|
60
|
+
validateRouterConfig(options.config);
|
|
59
61
|
const providers = new Map<string, ProviderRuntime>();
|
|
60
62
|
const modelNamespaces = new Map<string, ModelNamespaceRuntime>();
|
|
61
63
|
const mcps = new Map<string, RemoteMcpServerRuntime>();
|
|
@@ -82,24 +84,89 @@ export function createRouterRuntime(options: CreateRouterOptions): RouterRuntime
|
|
|
82
84
|
modelNamespaces,
|
|
83
85
|
modelNamespaceOrder,
|
|
84
86
|
mcps,
|
|
85
|
-
upstreamAuthChain:
|
|
86
|
-
|
|
87
|
+
upstreamAuthChain:
|
|
88
|
+
options.upstreamAuthDrivers ??
|
|
89
|
+
buildUpstreamAuthChain(options.config.upstreamAuth),
|
|
90
|
+
modelSuffixes: new Set(options.config.modelSuffixes ?? []),
|
|
87
91
|
fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
|
|
88
92
|
};
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
function
|
|
95
|
+
function validateRouterConfig(config: RouterConfig): void {
|
|
96
|
+
validateModelSuffixes(config.modelSuffixes);
|
|
97
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
98
|
+
validateHttpUrl(provider.apiBaseUrl, `Provider ${name}`);
|
|
99
|
+
}
|
|
100
|
+
for (const [name, mcp] of Object.entries(config.mcps ?? {})) {
|
|
101
|
+
validateHttpUrl(mcp.url, `MCP ${name}`);
|
|
102
|
+
}
|
|
103
|
+
for (const [namespace, namespaceConfig] of Object.entries(
|
|
104
|
+
config.modelNamespaces,
|
|
105
|
+
)) {
|
|
106
|
+
if (Object.keys(namespaceConfig.models).length === 0) {
|
|
107
|
+
throw new Error(`Model namespace ${namespace} has no routes`);
|
|
108
|
+
}
|
|
109
|
+
for (const [alias, route] of Object.entries(namespaceConfig.models)) {
|
|
110
|
+
if ("provider" in route && !config.providers[route.provider]) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Model route ${namespace}/${alias} references unknown provider ${route.provider}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
for (const mcp of route.mcps ?? []) {
|
|
116
|
+
if (!config.mcps?.[mcp]) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Model route ${namespace}/${alias} references unknown MCP ${mcp}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateModelSuffixes(suffixes: readonly string[] | undefined): void {
|
|
127
|
+
if (!suffixes) return;
|
|
128
|
+
const names = new Set<string>();
|
|
129
|
+
for (const suffix of suffixes) {
|
|
130
|
+
if (
|
|
131
|
+
!suffix ||
|
|
132
|
+
suffix !== suffix.trim().toLowerCase() ||
|
|
133
|
+
/[+:]/.test(suffix) ||
|
|
134
|
+
names.has(suffix)
|
|
135
|
+
) {
|
|
136
|
+
throw new Error(`Invalid model suffix: ${suffix}`);
|
|
137
|
+
}
|
|
138
|
+
names.add(suffix);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateHttpUrl(value: string, label: string): void {
|
|
143
|
+
try {
|
|
144
|
+
const url = new URL(value);
|
|
145
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
146
|
+
throw new Error("unsupported protocol");
|
|
147
|
+
} catch {
|
|
148
|
+
throw new Error(`${label} has an invalid HTTP URL`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildProviderRuntime(
|
|
153
|
+
name: string,
|
|
154
|
+
config: ProviderTargetConfig,
|
|
155
|
+
): ProviderRuntime {
|
|
92
156
|
return {
|
|
93
157
|
name,
|
|
94
158
|
style: config.style,
|
|
95
|
-
apiBaseUrl: config.
|
|
159
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
96
160
|
endpointPath: defaultEndpointPath(config.style),
|
|
97
161
|
headers: config.headers ?? {},
|
|
98
162
|
exportsMatcher: createExportsMatcher(config.exports),
|
|
99
163
|
};
|
|
100
164
|
}
|
|
101
165
|
|
|
102
|
-
function buildModelNamespaceRuntime(
|
|
166
|
+
function buildModelNamespaceRuntime(
|
|
167
|
+
name: string,
|
|
168
|
+
config: ModelNamespaceConfig,
|
|
169
|
+
): ModelNamespaceRuntime {
|
|
103
170
|
const routes = new Map<string, ModelRouteRuntime>();
|
|
104
171
|
for (const [alias, route] of Object.entries(config.models)) {
|
|
105
172
|
routes.set(alias, buildModelRoute(alias, route));
|
|
@@ -112,7 +179,10 @@ function buildModelNamespaceRuntime(name: string, config: ModelNamespaceConfig):
|
|
|
112
179
|
};
|
|
113
180
|
}
|
|
114
181
|
|
|
115
|
-
function buildModelRoute(
|
|
182
|
+
function buildModelRoute(
|
|
183
|
+
alias: string,
|
|
184
|
+
route: ModelRouteConfig,
|
|
185
|
+
): ModelRouteRuntime {
|
|
116
186
|
if ("provider" in route) {
|
|
117
187
|
return {
|
|
118
188
|
requestedAlias: alias,
|
package/src/router/targets.ts
CHANGED
|
@@ -2,7 +2,9 @@ import type { ExecutionTarget } from "./execution-types.ts";
|
|
|
2
2
|
|
|
3
3
|
export function executionTargetId(target: ExecutionTarget): string {
|
|
4
4
|
return target.kind === "provider"
|
|
5
|
-
? target.provider
|
|
5
|
+
? target.provider
|
|
6
|
+
? `${target.provider}/${target.model}`
|
|
7
|
+
: target.model
|
|
6
8
|
: `${target.executorId}/${target.alias}`;
|
|
7
9
|
}
|
|
8
10
|
|