@neutrome/open-ai-router 0.1.18 → 0.3.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 +8 -4
- package/package.json +3 -2
- package/src/admission/access.ts +14 -0
- package/src/admission/jwt.test.ts +96 -0
- package/src/admission/jwt.ts +221 -0
- package/src/admission/types.ts +14 -0
- package/src/app/factory.test.ts +14 -11
- package/src/app/factory.ts +41 -31
- package/src/app/handlers.ts +5 -9
- package/src/example.test.ts +14 -14
- package/src/example.ts +5 -2
- package/src/index.ts +45 -12
- package/src/model.example.ts +61 -0
- package/src/observer.ts +2 -1
- package/src/router/audit.ts +21 -0
- package/src/router/config.ts +8 -8
- package/src/router/errors.ts +60 -0
- package/src/router/execute.test.ts +12 -12
- package/src/router/execute.ts +47 -32
- package/src/router/execution-runtime.test.ts +327 -0
- package/src/router/execution-runtime.ts +632 -0
- package/src/router/execution-types.ts +80 -0
- package/src/router/index.ts +28 -6
- package/src/router/mcp.ts +2 -2
- package/src/router/resolve.test.ts +10 -10
- package/src/router/resolve.ts +15 -15
- package/src/router/runtime.ts +29 -29
- package/src/router/targets.ts +17 -0
- package/src/{auth → upstream-auth}/config.ts +1 -1
- package/src/{auth → upstream-auth}/env.ts +2 -2
- package/src/{auth → upstream-auth}/proxy.ts +2 -2
- package/src/{auth → upstream-auth}/registry.ts +4 -4
- package/src/{auth → upstream-auth}/types.ts +1 -1
- package/src/worker.ts +2 -2
- package/src/auth/apikey.ts +0 -51
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# `@neutrome/open-ai-router`
|
|
2
2
|
|
|
3
|
-
Deployable router app and reusable router/runtime library
|
|
3
|
+
Deployable router app and reusable router/runtime library built on
|
|
4
|
+
`@neutrome/lil-engine` for protocol conversion and `@neutrome/lilsdk-ts` for
|
|
5
|
+
executor/tool contracts.
|
|
4
6
|
|
|
5
7
|
```ts
|
|
6
8
|
import { createApp, createRouterRuntime, type RouterConfig } from "@neutrome/open-ai-router";
|
|
@@ -8,8 +10,9 @@ import { createApp, createRouterRuntime, type RouterConfig } from "@neutrome/ope
|
|
|
8
10
|
|
|
9
11
|
Primary runtime model:
|
|
10
12
|
|
|
11
|
-
- client JSON/SSE ->
|
|
12
|
-
- target resolution ->
|
|
13
|
+
- client JSON/SSE -> `@neutrome/lil-engine` IR
|
|
14
|
+
- target resolution -> router execution runtime
|
|
15
|
+
- SDK executor or upstream provider invocation
|
|
13
16
|
- provider transport -> router host
|
|
14
17
|
|
|
15
18
|
Development commands:
|
|
@@ -22,4 +25,5 @@ pnpm --filter @neutrome/open-ai-router typecheck
|
|
|
22
25
|
Notes:
|
|
23
26
|
|
|
24
27
|
- `RouterConfig` is the canonical router config surface.
|
|
25
|
-
- `/v1/models` lists configured executor aliases
|
|
28
|
+
- `/v1/models` lists configured executor aliases.
|
|
29
|
+
- Executor contracts and `connectTools` come from `@neutrome/lilsdk-ts`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/open-ai-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts"
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
10
10
|
"hono": "^4.12.18",
|
|
11
|
-
"@neutrome/lil-engine": "0.
|
|
11
|
+
"@neutrome/lil-engine": "0.2.0",
|
|
12
|
+
"@neutrome/lilsdk-ts": "0.2.0"
|
|
12
13
|
},
|
|
13
14
|
"devDependencies": {
|
|
14
15
|
"@types/node": "^25.9.3",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthPrincipal } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function canAccessPrincipalModel(
|
|
4
|
+
principal: AuthPrincipal,
|
|
5
|
+
modelId: string,
|
|
6
|
+
): boolean {
|
|
7
|
+
const hasModelRestriction = principal.allowedModels !== null;
|
|
8
|
+
const hasNamespaceRestriction = principal.allowedNamespaces !== null;
|
|
9
|
+
if (!hasModelRestriction && !hasNamespaceRestriction) return true;
|
|
10
|
+
if (principal.allowedModels?.includes(modelId)) return true;
|
|
11
|
+
const namespace = modelId.split("/")[0] ?? "";
|
|
12
|
+
return principal.allowedNamespaces?.includes(namespace) ?? false;
|
|
13
|
+
}
|
|
14
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createRuntimeJwtAuthenticator } from "./jwt.ts";
|
|
3
|
+
|
|
4
|
+
describe("runtime JWT admission", () => {
|
|
5
|
+
it("accepts a valid ES256 runtime token", async () => {
|
|
6
|
+
const auth = await testAuth();
|
|
7
|
+
const principal = await createRuntimeJwtAuthenticator({
|
|
8
|
+
workspaceId: "workspace_1",
|
|
9
|
+
}).authenticate(request(auth.token), env(auth.jwksJson));
|
|
10
|
+
|
|
11
|
+
expect(principal).toEqual({
|
|
12
|
+
workspaceId: "workspace_1",
|
|
13
|
+
keyId: "key_1",
|
|
14
|
+
allowedModels: ["support/router"],
|
|
15
|
+
allowedNamespaces: null,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("rejects revoked key ids", async () => {
|
|
20
|
+
const auth = await testAuth();
|
|
21
|
+
const principal = await createRuntimeJwtAuthenticator({
|
|
22
|
+
workspaceId: "workspace_1",
|
|
23
|
+
}).authenticate(
|
|
24
|
+
request(auth.token),
|
|
25
|
+
env(auth.jwksJson, JSON.stringify(["key_1"])),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
expect(principal).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("rejects wrong workspace tokens", async () => {
|
|
32
|
+
const auth = await testAuth({ workspaceId: "workspace_2" });
|
|
33
|
+
const principal = await createRuntimeJwtAuthenticator({
|
|
34
|
+
workspaceId: "workspace_1",
|
|
35
|
+
}).authenticate(request(auth.token), env(auth.jwksJson));
|
|
36
|
+
|
|
37
|
+
expect(principal).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
async function testAuth(
|
|
42
|
+
options: { workspaceId?: string } = {},
|
|
43
|
+
): Promise<{ token: string; jwksJson: string }> {
|
|
44
|
+
const keyPair = await crypto.subtle.generateKey(
|
|
45
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
46
|
+
true,
|
|
47
|
+
["sign", "verify"],
|
|
48
|
+
);
|
|
49
|
+
const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
50
|
+
const header = { alg: "ES256", typ: "JWT", kid: "test-key" };
|
|
51
|
+
const claims = {
|
|
52
|
+
iss: "neutrome-platform",
|
|
53
|
+
aud: "workspace-runtime",
|
|
54
|
+
sub: options.workspaceId ?? "workspace_1",
|
|
55
|
+
jti: "key_1",
|
|
56
|
+
models: ["support/router"],
|
|
57
|
+
iat: Math.floor(Date.now() / 1000),
|
|
58
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
59
|
+
};
|
|
60
|
+
const signingInput = `${base64UrlJson(header)}.${base64UrlJson(claims)}`;
|
|
61
|
+
const signature = new Uint8Array(await crypto.subtle.sign(
|
|
62
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
63
|
+
keyPair.privateKey,
|
|
64
|
+
new TextEncoder().encode(signingInput),
|
|
65
|
+
));
|
|
66
|
+
return {
|
|
67
|
+
token: `${signingInput}.${base64UrlBytes(signature)}`,
|
|
68
|
+
jwksJson: JSON.stringify({ keys: [{ ...publicJwk, kid: "test-key", alg: "ES256", use: "sig" }] }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function request(token: string): Request {
|
|
73
|
+
return new Request("http://runtime.test/v1/models", {
|
|
74
|
+
headers: { authorization: `Bearer ${token}` },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function env(jwksJson: string, revoked = "[]"): Record<string, unknown> {
|
|
79
|
+
return {
|
|
80
|
+
LIL_RUNTIME_AUTH_JWKS_JSON: jwksJson,
|
|
81
|
+
LIL_RUNTIME_AUTH_ISSUER: "neutrome-platform",
|
|
82
|
+
LIL_RUNTIME_AUTH_AUDIENCE: "workspace-runtime",
|
|
83
|
+
LIL_RUNTIME_REVOKED_KEY_IDS_JSON: revoked,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function base64UrlJson(value: unknown): string {
|
|
88
|
+
return base64UrlBytes(new TextEncoder().encode(JSON.stringify(value)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function base64UrlBytes(value: Uint8Array): string {
|
|
92
|
+
let binary = "";
|
|
93
|
+
for (const byte of value) binary += String.fromCharCode(byte);
|
|
94
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import type { AuthPrincipal, RuntimeAuthenticator } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_RUNTIME_AUTH_ISSUER = "neutrome-platform";
|
|
4
|
+
export const DEFAULT_RUNTIME_AUTH_AUDIENCE = "workspace-runtime";
|
|
5
|
+
|
|
6
|
+
export type RuntimeJwtAuthConfig = {
|
|
7
|
+
jwksJson?: string;
|
|
8
|
+
issuer?: string;
|
|
9
|
+
audience?: string;
|
|
10
|
+
revokedKeyIdsJson?: string;
|
|
11
|
+
workspaceId?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type JsonWebKeySet = {
|
|
15
|
+
keys?: JsonWebKey[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type JwtHeader = {
|
|
19
|
+
alg?: unknown;
|
|
20
|
+
kid?: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type RuntimeJwtClaims = {
|
|
24
|
+
iss?: unknown;
|
|
25
|
+
aud?: unknown;
|
|
26
|
+
sub?: unknown;
|
|
27
|
+
jti?: unknown;
|
|
28
|
+
models?: unknown;
|
|
29
|
+
namespaces?: unknown;
|
|
30
|
+
exp?: unknown;
|
|
31
|
+
nbf?: unknown;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RuntimeJwtEnv = Record<string, unknown> & {
|
|
35
|
+
LIL_RUNTIME_AUTH_JWKS_JSON?: unknown;
|
|
36
|
+
LIL_RUNTIME_AUTH_ISSUER?: unknown;
|
|
37
|
+
LIL_RUNTIME_AUTH_AUDIENCE?: unknown;
|
|
38
|
+
LIL_RUNTIME_REVOKED_KEY_IDS_JSON?: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const encoder = new TextEncoder();
|
|
42
|
+
const importedKeys = new Map<string, Promise<CryptoKey>>();
|
|
43
|
+
|
|
44
|
+
export function createRuntimeJwtAuthenticator(
|
|
45
|
+
config: RuntimeJwtAuthConfig = {},
|
|
46
|
+
): RuntimeAuthenticator {
|
|
47
|
+
return {
|
|
48
|
+
async authenticate(request, env) {
|
|
49
|
+
const token = readRuntimeToken(request);
|
|
50
|
+
if (!token) return null;
|
|
51
|
+
return verifyRuntimeJwt(token, readConfig(config, env));
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function verifyRuntimeJwt(
|
|
57
|
+
token: string,
|
|
58
|
+
config: Required<RuntimeJwtAuthConfig>,
|
|
59
|
+
): Promise<AuthPrincipal | null> {
|
|
60
|
+
const parts = token.split(".");
|
|
61
|
+
if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) return null;
|
|
62
|
+
|
|
63
|
+
const header = parseJsonPart<JwtHeader>(parts[0]);
|
|
64
|
+
const claims = parseJsonPart<RuntimeJwtClaims>(parts[1]);
|
|
65
|
+
if (!header || !claims || header.alg !== "ES256" || typeof header.kid !== "string") return null;
|
|
66
|
+
|
|
67
|
+
const key = findJwk(config.jwksJson, header.kid);
|
|
68
|
+
if (!key) return null;
|
|
69
|
+
|
|
70
|
+
const signature = base64UrlToBytes(parts[2]);
|
|
71
|
+
const signingInput = `${parts[0]}.${parts[1]}`;
|
|
72
|
+
const verified = await crypto.subtle.verify(
|
|
73
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
74
|
+
await importVerifyKey(key),
|
|
75
|
+
toArrayBuffer(derToJose(signature)),
|
|
76
|
+
toArrayBuffer(encoder.encode(signingInput)),
|
|
77
|
+
).catch(() => false);
|
|
78
|
+
if (!verified) return null;
|
|
79
|
+
|
|
80
|
+
const now = Math.floor(Date.now() / 1000);
|
|
81
|
+
if (claims.iss !== config.issuer) return null;
|
|
82
|
+
if (!audienceMatches(claims.aud, config.audience)) return null;
|
|
83
|
+
if (typeof claims.sub !== "string" || !claims.sub) return null;
|
|
84
|
+
if (typeof claims.jti !== "string" || !claims.jti) return null;
|
|
85
|
+
if (typeof claims.exp !== "number" || claims.exp <= now) return null;
|
|
86
|
+
if (typeof claims.nbf === "number" && claims.nbf > now) return null;
|
|
87
|
+
if (config.workspaceId && claims.sub !== config.workspaceId) return null;
|
|
88
|
+
if (revokedKeyIds(config.revokedKeyIdsJson).has(claims.jti)) return null;
|
|
89
|
+
|
|
90
|
+
const allowedModels = readStringArrayClaim(claims.models);
|
|
91
|
+
const allowedNamespaces = readStringArrayClaim(claims.namespaces);
|
|
92
|
+
if (allowedModels === undefined || allowedNamespaces === undefined) return null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
workspaceId: claims.sub,
|
|
96
|
+
keyId: claims.jti,
|
|
97
|
+
allowedModels,
|
|
98
|
+
allowedNamespaces,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function readRuntimeToken(request: Request): string | null {
|
|
103
|
+
const authHeader = request.headers.get("authorization");
|
|
104
|
+
const bearerMatch = authHeader?.match(/^Bearer\s+(.+)$/i);
|
|
105
|
+
if (bearerMatch?.[1]) return bearerMatch[1];
|
|
106
|
+
|
|
107
|
+
const headerToken = request.headers.get("x-api-key")
|
|
108
|
+
?? request.headers.get("x-goog-api-key");
|
|
109
|
+
if (headerToken) return headerToken;
|
|
110
|
+
|
|
111
|
+
const url = new URL(request.url);
|
|
112
|
+
return url.searchParams.get("key") ?? url.searchParams.get("api_key");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readConfig(
|
|
116
|
+
config: RuntimeJwtAuthConfig,
|
|
117
|
+
env: Record<string, unknown>,
|
|
118
|
+
): Required<RuntimeJwtAuthConfig> {
|
|
119
|
+
const runtimeEnv = env as RuntimeJwtEnv;
|
|
120
|
+
return {
|
|
121
|
+
jwksJson: config.jwksJson ?? stringBinding(runtimeEnv.LIL_RUNTIME_AUTH_JWKS_JSON) ?? "",
|
|
122
|
+
issuer: config.issuer ?? stringBinding(runtimeEnv.LIL_RUNTIME_AUTH_ISSUER) ?? DEFAULT_RUNTIME_AUTH_ISSUER,
|
|
123
|
+
audience: config.audience ?? stringBinding(runtimeEnv.LIL_RUNTIME_AUTH_AUDIENCE) ?? DEFAULT_RUNTIME_AUTH_AUDIENCE,
|
|
124
|
+
revokedKeyIdsJson: config.revokedKeyIdsJson ?? stringBinding(runtimeEnv.LIL_RUNTIME_REVOKED_KEY_IDS_JSON) ?? "",
|
|
125
|
+
workspaceId: config.workspaceId ?? "",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stringBinding(value: unknown): string | undefined {
|
|
130
|
+
return typeof value === "string" && value ? value : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findJwk(jwksJson: string, kid: string): JsonWebKey | null {
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(jwksJson) as JsonWebKeySet;
|
|
136
|
+
const key = parsed.keys?.find((item) => (item as { kid?: unknown }).kid === kid);
|
|
137
|
+
return key ?? null;
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function revokedKeyIds(value: string): Set<string> {
|
|
144
|
+
try {
|
|
145
|
+
const parsed = JSON.parse(value) as unknown;
|
|
146
|
+
return new Set(Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : []);
|
|
147
|
+
} catch {
|
|
148
|
+
return new Set();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function audienceMatches(value: unknown, expected: string): boolean {
|
|
153
|
+
return value === expected || (Array.isArray(value) && value.includes(expected));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function readStringArrayClaim(value: unknown): readonly string[] | null | undefined {
|
|
157
|
+
if (value === undefined || value === null) return null;
|
|
158
|
+
if (!Array.isArray(value)) return undefined;
|
|
159
|
+
return value.every((item) => typeof item === "string") ? value : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseJsonPart<T>(part: string): T | null {
|
|
163
|
+
try {
|
|
164
|
+
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(part))) as T;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function importVerifyKey(jwk: JsonWebKey): Promise<CryptoKey> {
|
|
171
|
+
const cacheKey = JSON.stringify(jwk);
|
|
172
|
+
let imported = importedKeys.get(cacheKey);
|
|
173
|
+
if (!imported) {
|
|
174
|
+
imported = crypto.subtle.importKey(
|
|
175
|
+
"jwk",
|
|
176
|
+
jwk,
|
|
177
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
178
|
+
false,
|
|
179
|
+
["verify"],
|
|
180
|
+
);
|
|
181
|
+
importedKeys.set(cacheKey, imported);
|
|
182
|
+
}
|
|
183
|
+
return imported;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function base64UrlToBytes(value: string): Uint8Array {
|
|
187
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
188
|
+
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
|
189
|
+
const binary = atob(padded);
|
|
190
|
+
const bytes = new Uint8Array(binary.length);
|
|
191
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
192
|
+
return bytes;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
196
|
+
return bytes.buffer.slice(
|
|
197
|
+
bytes.byteOffset,
|
|
198
|
+
bytes.byteOffset + bytes.byteLength,
|
|
199
|
+
) as ArrayBuffer;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function derToJose(signature: Uint8Array): Uint8Array {
|
|
203
|
+
if (signature.byteLength === 64) return signature;
|
|
204
|
+
const bytes = [...signature];
|
|
205
|
+
if (bytes[0] !== 0x30) return signature;
|
|
206
|
+
let offset = 2;
|
|
207
|
+
if (bytes[1] === 0x81) offset = 3;
|
|
208
|
+
if (bytes[offset] !== 0x02) return signature;
|
|
209
|
+
const rLength = bytes[offset + 1] ?? 0;
|
|
210
|
+
const r = bytes.slice(offset + 2, offset + 2 + rLength);
|
|
211
|
+
offset += 2 + rLength;
|
|
212
|
+
if (bytes[offset] !== 0x02) return signature;
|
|
213
|
+
const sLength = bytes[offset + 1] ?? 0;
|
|
214
|
+
const s = bytes.slice(offset + 2, offset + 2 + sLength);
|
|
215
|
+
return new Uint8Array([...normalizeInteger(r), ...normalizeInteger(s)]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeInteger(value: number[]): number[] {
|
|
219
|
+
const trimmed = value[0] === 0 ? value.slice(1) : value;
|
|
220
|
+
return [...new Array(Math.max(0, 32 - trimmed.length)).fill(0), ...trimmed].slice(-32);
|
|
221
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type AuthPrincipal = {
|
|
2
|
+
workspaceId: string;
|
|
3
|
+
keyId: string;
|
|
4
|
+
allowedModels: readonly string[] | null;
|
|
5
|
+
allowedNamespaces: readonly string[] | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type RuntimeAuthenticator = {
|
|
9
|
+
authenticate(
|
|
10
|
+
request: Request,
|
|
11
|
+
env: Record<string, unknown>,
|
|
12
|
+
): Promise<AuthPrincipal | null>;
|
|
13
|
+
};
|
|
14
|
+
|
package/src/app/factory.test.ts
CHANGED
|
@@ -12,7 +12,7 @@ const config: RouterConfig = {
|
|
|
12
12
|
exports: ["*"],
|
|
13
13
|
},
|
|
14
14
|
},
|
|
15
|
-
|
|
15
|
+
modelNamespaces: {
|
|
16
16
|
default: {
|
|
17
17
|
exports: ["*"],
|
|
18
18
|
models: {
|
|
@@ -28,13 +28,10 @@ describe("createRouterApp trace finalization", () => {
|
|
|
28
28
|
const onTrace = vi.fn(async () => {});
|
|
29
29
|
const app = createRouterApp({
|
|
30
30
|
config,
|
|
31
|
-
|
|
31
|
+
executorImplementations: {},
|
|
32
32
|
auth: {
|
|
33
33
|
async authenticate() {
|
|
34
|
-
return
|
|
35
|
-
},
|
|
36
|
-
canAccess() {
|
|
37
|
-
return true;
|
|
34
|
+
return principal();
|
|
38
35
|
},
|
|
39
36
|
},
|
|
40
37
|
hooks: { onTrace },
|
|
@@ -79,13 +76,10 @@ describe("createRouterApp trace finalization", () => {
|
|
|
79
76
|
const onTrace = vi.fn(async () => {});
|
|
80
77
|
const app = createRouterApp({
|
|
81
78
|
config,
|
|
82
|
-
|
|
79
|
+
executorImplementations: {},
|
|
83
80
|
auth: {
|
|
84
81
|
async authenticate() {
|
|
85
|
-
return
|
|
86
|
-
},
|
|
87
|
-
canAccess() {
|
|
88
|
-
return true;
|
|
82
|
+
return principal();
|
|
89
83
|
},
|
|
90
84
|
},
|
|
91
85
|
hooks: { onTrace },
|
|
@@ -154,3 +148,12 @@ function createExecutionContext(waited: Promise<unknown>[]) {
|
|
|
154
148
|
passThroughOnException() {},
|
|
155
149
|
};
|
|
156
150
|
}
|
|
151
|
+
|
|
152
|
+
function principal() {
|
|
153
|
+
return {
|
|
154
|
+
workspaceId: "workspace_1",
|
|
155
|
+
keyId: "key_1",
|
|
156
|
+
allowedModels: null,
|
|
157
|
+
allowedNamespaces: null,
|
|
158
|
+
};
|
|
159
|
+
}
|
package/src/app/factory.ts
CHANGED
|
@@ -1,35 +1,40 @@
|
|
|
1
1
|
import { Hono, type Context } from "hono";
|
|
2
|
-
import type { Executor, ProgramTransform } from "@neutrome/
|
|
3
|
-
import type
|
|
2
|
+
import type { Executor, ProgramTransform } from "@neutrome/lilsdk-ts";
|
|
3
|
+
import { getModel, setStreaming, type Program, type ProviderStyle } from "@neutrome/lil-engine";
|
|
4
4
|
import { cors } from "./cors.ts";
|
|
5
5
|
import { healthHandler } from "./health.ts";
|
|
6
6
|
import { createTraceCollector, type ExecutionTrace } from "../observer.ts";
|
|
7
|
+
import { canAccessPrincipalModel } from "../admission/access.ts";
|
|
7
8
|
import {
|
|
8
9
|
createRouterRuntime,
|
|
9
10
|
handleAnthropicMessages,
|
|
10
11
|
handleChatCompletions,
|
|
11
12
|
handleGoogleGenAI,
|
|
13
|
+
parseRequestProgram,
|
|
12
14
|
handleResponses,
|
|
13
15
|
listConfiguredModels,
|
|
14
|
-
type RouterRuntime,
|
|
15
16
|
} from "../router/index.ts";
|
|
16
|
-
import type {
|
|
17
|
+
import type { AuthPrincipal, RuntimeAuthenticator } from "../admission/types.ts";
|
|
18
|
+
import type { RequestContext } from "../upstream-auth/types.ts";
|
|
17
19
|
import type { RouterConfig } from "../router/config.ts";
|
|
18
20
|
import type { RemoteMcpClientFactory } from "../router/mcp.ts";
|
|
21
|
+
import type { ExecutionRuntimeOptions } from "../router/execution-types.ts";
|
|
19
22
|
|
|
20
|
-
export type RouterAppAuth =
|
|
21
|
-
authenticate(request: Request, env: Record<string, unknown>): Promise<object | null>;
|
|
22
|
-
canAccess(principal: object, modelId: string): boolean;
|
|
23
|
-
};
|
|
23
|
+
export type RouterAppAuth = RuntimeAuthenticator;
|
|
24
24
|
|
|
25
25
|
export type RouterAppHooks = {
|
|
26
|
-
beforeRequest?(ctx: {
|
|
26
|
+
beforeRequest?(ctx: {
|
|
27
|
+
request: Request;
|
|
28
|
+
model: string | null;
|
|
29
|
+
principal: AuthPrincipal;
|
|
30
|
+
env: Record<string, unknown>;
|
|
31
|
+
}): Promise<Response | void>;
|
|
27
32
|
onTrace?(trace: ExecutionTrace, env: Record<string, unknown>): void | Promise<void>;
|
|
28
33
|
};
|
|
29
34
|
|
|
30
35
|
export type RouterAppOptions = {
|
|
31
36
|
config: RouterConfig;
|
|
32
|
-
|
|
37
|
+
executorImplementations: Readonly<Record<string, Executor>>;
|
|
33
38
|
transforms?: Readonly<Record<string, ProgramTransform>>;
|
|
34
39
|
auth: RouterAppAuth;
|
|
35
40
|
hooks?: RouterAppHooks;
|
|
@@ -60,7 +65,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
60
65
|
if (!principal) return unauthorized();
|
|
61
66
|
|
|
62
67
|
const models = listConfiguredModels(runtime)
|
|
63
|
-
.filter((id) =>
|
|
68
|
+
.filter((id) => canAccessPrincipalModel(principal, id))
|
|
64
69
|
.map((id) => ({
|
|
65
70
|
id,
|
|
66
71
|
object: "model" as const,
|
|
@@ -70,15 +75,15 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
70
75
|
return c.json({ object: "list", data: models });
|
|
71
76
|
});
|
|
72
77
|
|
|
73
|
-
app.post("/v1/chat/completions", (c) => runAuthorized(c, c.req.raw, handleChatCompletions));
|
|
74
|
-
app.post("/v1/responses", (c) => runAuthorized(c, c.req.raw, handleResponses));
|
|
75
|
-
app.post("/v1/messages", (c) => runAuthorized(c, c.req.raw, handleAnthropicMessages));
|
|
78
|
+
app.post("/v1/chat/completions", (c) => runAuthorized(c, c.req.raw, "chat-completions", handleChatCompletions));
|
|
79
|
+
app.post("/v1/responses", (c) => runAuthorized(c, c.req.raw, "responses", handleResponses));
|
|
80
|
+
app.post("/v1/messages", (c) => runAuthorized(c, c.req.raw, "anthropic-messages", handleAnthropicMessages));
|
|
76
81
|
|
|
77
82
|
app.post("/v1/models/*", async (c) => {
|
|
78
83
|
const route = parseGoogleGenAIRoute(c.req.raw);
|
|
79
84
|
if (!route) return c.text("Not Found", 404);
|
|
80
85
|
const request = await rewriteGoogleGenAIRequest(c.req.raw, route);
|
|
81
|
-
return runAuthorized(c, request, handleGoogleGenAI, route.stream);
|
|
86
|
+
return runAuthorized(c, request, "google-genai", handleGoogleGenAI, route.stream);
|
|
82
87
|
});
|
|
83
88
|
|
|
84
89
|
app.all("*", (c) => c.text("Not Found", 404));
|
|
@@ -86,6 +91,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
86
91
|
async function runAuthorized(
|
|
87
92
|
c: Context,
|
|
88
93
|
request: Request,
|
|
94
|
+
style: ProviderStyle,
|
|
89
95
|
handler: (opts: any) => Promise<Response>,
|
|
90
96
|
forceStreaming?: boolean,
|
|
91
97
|
): Promise<Response> {
|
|
@@ -93,13 +99,21 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
93
99
|
const principal = await options.auth.authenticate(request, env);
|
|
94
100
|
if (!principal) return unauthorized();
|
|
95
101
|
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
let program: Program;
|
|
103
|
+
try {
|
|
104
|
+
program = await parseRequestProgram(style, request.clone());
|
|
105
|
+
if (forceStreaming) program = setStreaming(program, true);
|
|
106
|
+
} catch {
|
|
107
|
+
return errorResponse("Invalid request body", 400, "invalid_request_body");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const model = getModel(program) || null;
|
|
111
|
+
if (model && !canAccessPrincipalModel(principal, model)) {
|
|
98
112
|
return errorResponse(`Model \`${model}\` does not exist or you do not have access.`, 404, "model_not_found");
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
if (options.hooks?.beforeRequest) {
|
|
102
|
-
const hookResponse = await options.hooks.beforeRequest({ request, model, env });
|
|
116
|
+
const hookResponse = await options.hooks.beforeRequest({ request, model, principal, env });
|
|
103
117
|
if (hookResponse) return hookResponse;
|
|
104
118
|
}
|
|
105
119
|
|
|
@@ -109,7 +123,7 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
109
123
|
env,
|
|
110
124
|
state: new Map(),
|
|
111
125
|
};
|
|
112
|
-
for (const driver of runtime.
|
|
126
|
+
for (const driver of runtime.upstreamAuthChain) {
|
|
113
127
|
driver.collectIncoming?.(ctx);
|
|
114
128
|
}
|
|
115
129
|
|
|
@@ -135,12 +149,12 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
135
149
|
const response = await handler({
|
|
136
150
|
runtime,
|
|
137
151
|
ctx,
|
|
138
|
-
|
|
152
|
+
program,
|
|
153
|
+
executorImplementations: options.executorImplementations,
|
|
139
154
|
...(options.transforms ? { transforms: options.transforms } : {}),
|
|
140
155
|
observe,
|
|
141
156
|
...(options.upstreamTimeoutMs !== undefined ? { upstreamTimeoutMs: options.upstreamTimeoutMs } : {}),
|
|
142
157
|
...(options.remoteMcpClientFactory ? { remoteMcpClientFactory: options.remoteMcpClientFactory } : {}),
|
|
143
|
-
...(forceStreaming ? { forceStreaming: true } : {}),
|
|
144
158
|
});
|
|
145
159
|
if (!isEventStreamResponse(response) || !response.body) {
|
|
146
160
|
finalizeTrace();
|
|
@@ -190,15 +204,6 @@ async function rewriteGoogleGenAIRequest(
|
|
|
190
204
|
return new Request(request.url, { method: request.method, headers, body: bodyText });
|
|
191
205
|
}
|
|
192
206
|
|
|
193
|
-
async function readModel(request: Request): Promise<string | null> {
|
|
194
|
-
try {
|
|
195
|
-
const body = (await request.json()) as { model?: unknown };
|
|
196
|
-
return typeof body.model === "string" ? body.model : null;
|
|
197
|
-
} catch {
|
|
198
|
-
return null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
207
|
function unauthorized(): Response {
|
|
203
208
|
return errorResponse("Authentication required", 401, "auth_required");
|
|
204
209
|
}
|
|
@@ -221,7 +226,12 @@ function runBackgroundTask(c: Context, task: () => unknown, label: string): void
|
|
|
221
226
|
.catch((error) => {
|
|
222
227
|
console.error(`[open-ai-router] ${label} failed:`, error);
|
|
223
228
|
});
|
|
224
|
-
|
|
229
|
+
let executionCtx: { waitUntil?(promise: Promise<unknown>): void } | undefined;
|
|
230
|
+
try {
|
|
231
|
+
executionCtx = c.executionCtx as { waitUntil?(promise: Promise<unknown>): void } | undefined;
|
|
232
|
+
} catch {
|
|
233
|
+
executionCtx = undefined;
|
|
234
|
+
}
|
|
225
235
|
if (executionCtx?.waitUntil) {
|
|
226
236
|
executionCtx.waitUntil(tracked);
|
|
227
237
|
return;
|
package/src/app/handlers.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
|
-
import type {
|
|
3
|
-
import type { RequestContext } from "../auth/types.ts";
|
|
2
|
+
import type { RequestContext } from "../upstream-auth/types.ts";
|
|
4
3
|
import {
|
|
5
4
|
handleAnthropicMessages,
|
|
6
5
|
handleChatCompletions,
|
|
@@ -18,10 +17,7 @@ type OpenAiModel = {
|
|
|
18
17
|
owned_by: string;
|
|
19
18
|
};
|
|
20
19
|
|
|
21
|
-
export type ChatCompletionsHandlerOptions = RouterExecutionOptions
|
|
22
|
-
executors?: Readonly<Record<string, Executor>>;
|
|
23
|
-
transforms?: Readonly<Record<string, ProgramTransform>>;
|
|
24
|
-
};
|
|
20
|
+
export type ChatCompletionsHandlerOptions = RouterExecutionOptions;
|
|
25
21
|
|
|
26
22
|
export type ResponsesHandlerOptions = ChatCompletionsHandlerOptions;
|
|
27
23
|
export type AnthropicMessagesHandlerOptions = ChatCompletionsHandlerOptions;
|
|
@@ -111,8 +107,8 @@ function createExecutionHandlerOptions(
|
|
|
111
107
|
ctx,
|
|
112
108
|
} as Parameters<typeof handleChatCompletions>[0];
|
|
113
109
|
|
|
114
|
-
if (options.
|
|
115
|
-
handlerOptions.
|
|
110
|
+
if (options.executorImplementations) {
|
|
111
|
+
handlerOptions.executorImplementations = options.executorImplementations;
|
|
116
112
|
}
|
|
117
113
|
if (options.resolveExecutor) {
|
|
118
114
|
handlerOptions.resolveExecutor = options.resolveExecutor;
|
|
@@ -149,7 +145,7 @@ function createRequestContext(c: Context, request = c.req.raw): RequestContext {
|
|
|
149
145
|
}
|
|
150
146
|
|
|
151
147
|
function collectIncomingAuth(ctx: RequestContext, runtime: RouterRuntime): void {
|
|
152
|
-
for (const driver of runtime.
|
|
148
|
+
for (const driver of runtime.upstreamAuthChain) {
|
|
153
149
|
driver.collectIncoming?.(ctx);
|
|
154
150
|
}
|
|
155
151
|
}
|