@neutrome/open-ai-router 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/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 +11 -8
- package/src/app/factory.ts +37 -27
- package/src/app/handlers.ts +2 -2
- package/src/example.test.ts +1 -1
- package/src/example.ts +3 -0
- package/src/index.ts +19 -8
- package/src/model.example.ts +2 -2
- package/src/router/config.ts +3 -3
- package/src/router/execute.test.ts +1 -1
- package/src/router/execute.ts +20 -8
- package/src/router/index.ts +2 -1
- package/src/router/runtime.ts +5 -5
- 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 +1 -1
- package/src/auth/apikey.ts +0 -51
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/open-ai-router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.ts"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
10
10
|
"hono": "^4.12.18",
|
|
11
11
|
"@neutrome/lil-engine": "0.2.0",
|
|
12
|
-
"@neutrome/lilsdk-ts": "0.2.
|
|
12
|
+
"@neutrome/lilsdk-ts": "0.2.1"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
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
|
@@ -31,10 +31,7 @@ describe("createRouterApp trace finalization", () => {
|
|
|
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 },
|
|
@@ -82,10 +79,7 @@ describe("createRouterApp trace finalization", () => {
|
|
|
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,29 +1,34 @@
|
|
|
1
1
|
import { Hono, type Context } from "hono";
|
|
2
2
|
import type { Executor, ProgramTransform } from "@neutrome/lilsdk-ts";
|
|
3
|
+
import { getModel, setStreaming, type Program, type ProviderStyle } from "@neutrome/lil-engine";
|
|
3
4
|
import { cors } from "./cors.ts";
|
|
4
5
|
import { healthHandler } from "./health.ts";
|
|
5
6
|
import { createTraceCollector, type ExecutionTrace } from "../observer.ts";
|
|
7
|
+
import { canAccessPrincipalModel } from "../admission/access.ts";
|
|
6
8
|
import {
|
|
7
9
|
createRouterRuntime,
|
|
8
10
|
handleAnthropicMessages,
|
|
9
11
|
handleChatCompletions,
|
|
10
12
|
handleGoogleGenAI,
|
|
13
|
+
parseRequestProgram,
|
|
11
14
|
handleResponses,
|
|
12
15
|
listConfiguredModels,
|
|
13
|
-
type RouterRuntime,
|
|
14
16
|
} from "../router/index.ts";
|
|
15
|
-
import type {
|
|
17
|
+
import type { AuthPrincipal, RuntimeAuthenticator } from "../admission/types.ts";
|
|
18
|
+
import type { RequestContext } from "../upstream-auth/types.ts";
|
|
16
19
|
import type { RouterConfig } from "../router/config.ts";
|
|
17
20
|
import type { RemoteMcpClientFactory } from "../router/mcp.ts";
|
|
18
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
|
|
|
@@ -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,
|
|
152
|
+
program,
|
|
138
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,5 +1,5 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
|
-
import type { RequestContext } from "../auth/types.ts";
|
|
2
|
+
import type { RequestContext } from "../upstream-auth/types.ts";
|
|
3
3
|
import {
|
|
4
4
|
handleAnthropicMessages,
|
|
5
5
|
handleChatCompletions,
|
|
@@ -145,7 +145,7 @@ function createRequestContext(c: Context, request = c.req.raw): RequestContext {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
function collectIncomingAuth(ctx: RequestContext, runtime: RouterRuntime): void {
|
|
148
|
-
for (const driver of runtime.
|
|
148
|
+
for (const driver of runtime.upstreamAuthChain) {
|
|
149
149
|
driver.collectIncoming?.(ctx);
|
|
150
150
|
}
|
|
151
151
|
}
|
package/src/example.test.ts
CHANGED
package/src/example.ts
CHANGED
|
@@ -24,6 +24,9 @@ export function createApp(options: CreateAppOptions) {
|
|
|
24
24
|
if (options.knownSuffixes) {
|
|
25
25
|
routerOptions.knownSuffixes = options.knownSuffixes;
|
|
26
26
|
}
|
|
27
|
+
if (options.upstreamAuthDrivers) {
|
|
28
|
+
routerOptions.upstreamAuthDrivers = options.upstreamAuthDrivers;
|
|
29
|
+
}
|
|
27
30
|
|
|
28
31
|
const runtime = createRouterRuntime(routerOptions);
|
|
29
32
|
const app = new Hono();
|
package/src/index.ts
CHANGED
|
@@ -38,7 +38,6 @@ export {
|
|
|
38
38
|
export type {
|
|
39
39
|
AuditEvent,
|
|
40
40
|
AuditEventInput,
|
|
41
|
-
AuthConfig,
|
|
42
41
|
CreateRouterOptions,
|
|
43
42
|
ExecutionErrorDetails,
|
|
44
43
|
ExecutionErrorKind,
|
|
@@ -71,6 +70,7 @@ export type {
|
|
|
71
70
|
RouterConfig,
|
|
72
71
|
RouterExecutionOptions,
|
|
73
72
|
RouterRuntime,
|
|
73
|
+
UpstreamAuthConfig,
|
|
74
74
|
} from "./router/index.ts";
|
|
75
75
|
export type {
|
|
76
76
|
RemoteMcpClientFactory,
|
|
@@ -85,16 +85,27 @@ export {
|
|
|
85
85
|
} from "./router/index.ts";
|
|
86
86
|
|
|
87
87
|
export type {
|
|
88
|
-
AuthDriver,
|
|
89
88
|
AuthResult,
|
|
90
89
|
RequestContext,
|
|
91
90
|
TargetAuthContext,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
export {
|
|
95
|
-
export {
|
|
96
|
-
export {
|
|
97
|
-
export
|
|
91
|
+
UpstreamAuthDriver,
|
|
92
|
+
} from "./upstream-auth/types.ts";
|
|
93
|
+
export { buildUpstreamAuthChain } from "./upstream-auth/registry.ts";
|
|
94
|
+
export { envAuthDriver } from "./upstream-auth/env.ts";
|
|
95
|
+
export { proxyAuthDriver } from "./upstream-auth/proxy.ts";
|
|
96
|
+
export {
|
|
97
|
+
createRuntimeJwtAuthenticator,
|
|
98
|
+
DEFAULT_RUNTIME_AUTH_AUDIENCE,
|
|
99
|
+
DEFAULT_RUNTIME_AUTH_ISSUER,
|
|
100
|
+
readRuntimeToken,
|
|
101
|
+
verifyRuntimeJwt,
|
|
102
|
+
} from "./admission/jwt.ts";
|
|
103
|
+
export { canAccessPrincipalModel } from "./admission/access.ts";
|
|
104
|
+
export type {
|
|
105
|
+
AuthPrincipal,
|
|
106
|
+
RuntimeAuthenticator,
|
|
107
|
+
} from "./admission/types.ts";
|
|
108
|
+
export type { RuntimeJwtAuthConfig } from "./admission/jwt.ts";
|
|
98
109
|
|
|
99
110
|
export { cors } from "./app/cors.ts";
|
|
100
111
|
export { healthHandler } from "./app/health.ts";
|
package/src/model.example.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createTwoPassExecutor,
|
|
3
3
|
INTERNAL_DRAFT_TOOL_NAME,
|
|
4
|
-
|
|
5
|
-
} from "@neutrome/lilsdk-ts";
|
|
4
|
+
} from "@neutrome/lilsdk-ts/managed";
|
|
5
|
+
import type { Executor } from "@neutrome/lilsdk-ts";
|
|
6
6
|
|
|
7
7
|
const LOCALIZED_MODEL = "default/openai/gpt-oss-20b";
|
|
8
8
|
const REASONING_MODEL = "default/openai/gpt-oss-120b";
|
package/src/router/config.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ProviderStyle } from "@neutrome/lil-engine";
|
|
2
|
-
import type {
|
|
2
|
+
import type { UpstreamAuthConfigShape } from "../upstream-auth/config.ts";
|
|
3
3
|
|
|
4
4
|
export type ProviderApiStyle = ProviderStyle;
|
|
5
5
|
|
|
6
|
-
export type
|
|
6
|
+
export type UpstreamAuthConfig = UpstreamAuthConfigShape;
|
|
7
7
|
|
|
8
8
|
export type ProviderTargetConfig = {
|
|
9
9
|
api_base_url: string;
|
|
@@ -46,7 +46,7 @@ export type RemoteMcpServerConfig = {
|
|
|
46
46
|
|
|
47
47
|
export type RouterConfig = {
|
|
48
48
|
trace?: boolean;
|
|
49
|
-
|
|
49
|
+
upstreamAuth?: UpstreamAuthConfig;
|
|
50
50
|
providers: Record<string, ProviderTargetConfig>;
|
|
51
51
|
modelNamespaces: Record<string, ModelNamespaceConfig>;
|
|
52
52
|
mcps?: Record<string, RemoteMcpServerConfig>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { appendAssistantMessage } from "@neutrome/lilsdk-ts/synthetic";
|
|
2
2
|
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import type { RequestContext } from "../auth/types.ts";
|
|
3
|
+
import type { RequestContext } from "../upstream-auth/types.ts";
|
|
4
4
|
import { handleChatCompletions } from "./execute.ts";
|
|
5
5
|
import type { RemoteMcpClientFactory } from "./mcp.ts";
|
|
6
6
|
import { createRouterRuntime } from "./runtime.ts";
|
package/src/router/execute.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type {
|
|
|
30
30
|
ProviderInvoker,
|
|
31
31
|
} from "./execution-types.ts";
|
|
32
32
|
import { createAuditEvent } from "./audit.ts";
|
|
33
|
-
import type { RequestContext, TargetAuthContext } from "../auth/types.ts";
|
|
33
|
+
import type { RequestContext, TargetAuthContext } from "../upstream-auth/types.ts";
|
|
34
34
|
import { cloneForwardHeaders, isSse } from "../util/headers.ts";
|
|
35
35
|
import { eventDataLines, splitSseEvents } from "../util/sse.ts";
|
|
36
36
|
import { renderProgramAsLil } from "../observer.ts";
|
|
@@ -58,21 +58,25 @@ export type RouterExecutionOptions = Pick<
|
|
|
58
58
|
export type HandleChatCompletionsOptions = RouterExecutionOptions & {
|
|
59
59
|
runtime: RouterRuntime;
|
|
60
60
|
ctx: RequestContext;
|
|
61
|
+
program?: Program;
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
export type HandleResponsesOptions = RouterExecutionOptions & {
|
|
64
65
|
runtime: RouterRuntime;
|
|
65
66
|
ctx: RequestContext;
|
|
67
|
+
program?: Program;
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
export type HandleAnthropicMessagesOptions = RouterExecutionOptions & {
|
|
69
71
|
runtime: RouterRuntime;
|
|
70
72
|
ctx: RequestContext;
|
|
73
|
+
program?: Program;
|
|
71
74
|
};
|
|
72
75
|
|
|
73
76
|
export type HandleGoogleGenAIOptions = RouterExecutionOptions & {
|
|
74
77
|
runtime: RouterRuntime;
|
|
75
78
|
ctx: RequestContext;
|
|
79
|
+
program?: Program;
|
|
76
80
|
forceStreaming?: boolean;
|
|
77
81
|
};
|
|
78
82
|
|
|
@@ -170,8 +174,7 @@ export async function handleChatCompletions(
|
|
|
170
174
|
options: HandleChatCompletionsOptions,
|
|
171
175
|
): Promise<Response> {
|
|
172
176
|
try {
|
|
173
|
-
const
|
|
174
|
-
const request = parseChatCompletionsRequest(requestBody);
|
|
177
|
+
const request = options.program ?? await parseRequestProgram("chat-completions", options.ctx.request);
|
|
175
178
|
const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
|
|
176
179
|
emitObservedProgram(options.observe, "request.received", request, {
|
|
177
180
|
requestId: incomingExecutionId,
|
|
@@ -243,14 +246,13 @@ async function handleProviderStyle(
|
|
|
243
246
|
options: RouterExecutionOptions & {
|
|
244
247
|
runtime: RouterRuntime;
|
|
245
248
|
ctx: RequestContext;
|
|
249
|
+
program?: Program;
|
|
246
250
|
},
|
|
247
251
|
mutateRequest?: (request: Program) => Program,
|
|
248
252
|
): Promise<Response> {
|
|
249
253
|
try {
|
|
250
|
-
const
|
|
251
|
-
const request = mutateRequest
|
|
252
|
-
? mutateRequest(parseProviderRequest(style, requestBody))
|
|
253
|
-
: parseProviderRequest(style, requestBody);
|
|
254
|
+
const baseRequest = options.program ?? await parseRequestProgram(style, options.ctx.request);
|
|
255
|
+
const request = mutateRequest ? mutateRequest(baseRequest) : baseRequest;
|
|
254
256
|
const incomingExecutionId = `incoming_${crypto.randomUUID()}`;
|
|
255
257
|
emitObservedProgram(options.observe, "request.received", request, {
|
|
256
258
|
requestId: incomingExecutionId,
|
|
@@ -315,6 +317,16 @@ export function resolveRequestTarget(
|
|
|
315
317
|
}
|
|
316
318
|
}
|
|
317
319
|
|
|
320
|
+
export async function parseRequestProgram(
|
|
321
|
+
style: ProviderStyle,
|
|
322
|
+
request: Request,
|
|
323
|
+
): Promise<Program> {
|
|
324
|
+
const body = new Uint8Array(await request.arrayBuffer());
|
|
325
|
+
return style === "chat-completions"
|
|
326
|
+
? parseChatCompletionsRequest(body)
|
|
327
|
+
: parseProviderRequest(style, body);
|
|
328
|
+
}
|
|
329
|
+
|
|
318
330
|
export function createFetchProviderInvoker(
|
|
319
331
|
runtime: RouterRuntime,
|
|
320
332
|
ctx: RequestContext,
|
|
@@ -625,7 +637,7 @@ async function fetchProvider(
|
|
|
625
637
|
headers.set(key, value);
|
|
626
638
|
}
|
|
627
639
|
|
|
628
|
-
for (const driver of runtime.
|
|
640
|
+
for (const driver of runtime.upstreamAuthChain) {
|
|
629
641
|
const targetCtx: TargetAuthContext = { ...ctx, providerName: provider.name };
|
|
630
642
|
const auth = await driver.collectTarget(targetCtx);
|
|
631
643
|
if (!auth) {
|
package/src/router/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
handleAnthropicMessages,
|
|
9
9
|
handleChatCompletions,
|
|
10
10
|
handleGoogleGenAI,
|
|
11
|
+
parseRequestProgram,
|
|
11
12
|
handleResponses,
|
|
12
13
|
ProviderHttpError,
|
|
13
14
|
resolveRequestTarget,
|
|
@@ -22,7 +23,6 @@ export {
|
|
|
22
23
|
wireToolName,
|
|
23
24
|
} from "./mcp.ts";
|
|
24
25
|
export type {
|
|
25
|
-
AuthConfig,
|
|
26
26
|
ModelNamespaceConfig,
|
|
27
27
|
ModelRouteConfig,
|
|
28
28
|
ProviderApiStyle,
|
|
@@ -30,6 +30,7 @@ export type {
|
|
|
30
30
|
RemoteMcpServerConfig,
|
|
31
31
|
RemoteMcpToolConfig,
|
|
32
32
|
RouterConfig,
|
|
33
|
+
UpstreamAuthConfig,
|
|
33
34
|
} from "./config.ts";
|
|
34
35
|
export type {
|
|
35
36
|
CreateRouterOptions,
|
package/src/router/runtime.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExecutionTarget } from "@neutrome/lilsdk-ts";
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
2
|
+
import type { UpstreamAuthDriver } from "../upstream-auth/types.ts";
|
|
3
|
+
import { buildUpstreamAuthChain } from "../upstream-auth/registry.ts";
|
|
4
4
|
import { createExportsMatcher } from "../util/glob.ts";
|
|
5
5
|
import type {
|
|
6
6
|
ModelNamespaceConfig,
|
|
@@ -39,7 +39,7 @@ export type RouterRuntime = {
|
|
|
39
39
|
modelNamespaces: ReadonlyMap<string, ModelNamespaceRuntime>;
|
|
40
40
|
modelNamespaceOrder: readonly string[];
|
|
41
41
|
mcps: ReadonlyMap<string, RemoteMcpServerRuntime>;
|
|
42
|
-
|
|
42
|
+
upstreamAuthChain: readonly UpstreamAuthDriver[];
|
|
43
43
|
knownSuffixes: ReadonlySet<string>;
|
|
44
44
|
fetchImpl: typeof fetch;
|
|
45
45
|
};
|
|
@@ -52,7 +52,7 @@ export type CreateRouterOptions = {
|
|
|
52
52
|
config: RouterConfig;
|
|
53
53
|
fetchImpl?: typeof fetch;
|
|
54
54
|
knownSuffixes?: string[];
|
|
55
|
-
|
|
55
|
+
upstreamAuthDrivers?: readonly UpstreamAuthDriver[];
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
export function createRouterRuntime(options: CreateRouterOptions): RouterRuntime {
|
|
@@ -82,7 +82,7 @@ export function createRouterRuntime(options: CreateRouterOptions): RouterRuntime
|
|
|
82
82
|
modelNamespaces,
|
|
83
83
|
modelNamespaceOrder,
|
|
84
84
|
mcps,
|
|
85
|
-
|
|
85
|
+
upstreamAuthChain: options.upstreamAuthDrivers ?? buildUpstreamAuthChain(options.config.upstreamAuth),
|
|
86
86
|
knownSuffixes: new Set(options.knownSuffixes ?? ["slwin", "kvtools"]),
|
|
87
87
|
fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
|
|
88
88
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AuthResult, TargetAuthContext, UpstreamAuthDriver } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
export function envAuthDriver(suffix = "_API_KEY", prefix = ""):
|
|
3
|
+
export function envAuthDriver(suffix = "_API_KEY", prefix = ""): UpstreamAuthDriver {
|
|
4
4
|
const counters = new Map<string, number>();
|
|
5
5
|
|
|
6
6
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AuthResult, RequestContext, TargetAuthContext, UpstreamAuthDriver } from "./types.ts";
|
|
2
2
|
|
|
3
|
-
export function proxyAuthDriver(headers: string[]):
|
|
3
|
+
export function proxyAuthDriver(headers: string[]): UpstreamAuthDriver {
|
|
4
4
|
return {
|
|
5
5
|
name: "proxy",
|
|
6
6
|
collectIncoming(ctx: RequestContext) {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { UpstreamAuthConfigShape } from "./config.ts";
|
|
2
|
+
import type { UpstreamAuthDriver } from "./types.ts";
|
|
3
3
|
import { proxyAuthDriver } from "./proxy.ts";
|
|
4
4
|
import { envAuthDriver } from "./env.ts";
|
|
5
5
|
|
|
6
|
-
export function
|
|
6
|
+
export function buildUpstreamAuthChain(config?: UpstreamAuthConfigShape): UpstreamAuthDriver[] {
|
|
7
7
|
if (!config) return [proxyAuthDriver(["authorization"]), envAuthDriver()];
|
|
8
8
|
const order = config.order ?? ["proxy", "env"];
|
|
9
|
-
const drivers:
|
|
9
|
+
const drivers: UpstreamAuthDriver[] = [];
|
|
10
10
|
for (const name of order) {
|
|
11
11
|
switch (name) {
|
|
12
12
|
case "proxy":
|
package/src/worker.ts
CHANGED
package/src/auth/apikey.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
export type ApiKeyEntry = {
|
|
2
|
-
tokenHash: string;
|
|
3
|
-
allowedModels: string[] | null;
|
|
4
|
-
allowedNamespaces: string[] | null;
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export type ApiKeyAuth = {
|
|
8
|
-
authenticate(request: Request): Promise<ApiKeyEntry | null>;
|
|
9
|
-
canAccess(key: ApiKeyEntry, modelId: string): boolean;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export function createApiKeyAuth(keys: readonly ApiKeyEntry[]): ApiKeyAuth {
|
|
13
|
-
return {
|
|
14
|
-
async authenticate(request) {
|
|
15
|
-
const token = readApiToken(request);
|
|
16
|
-
if (!token) return null;
|
|
17
|
-
const hash = await sha256Hex(token);
|
|
18
|
-
return keys.find((k) => k.tokenHash === hash) ?? null;
|
|
19
|
-
},
|
|
20
|
-
|
|
21
|
-
canAccess(key, modelId) {
|
|
22
|
-
const hasModelRestriction = key.allowedModels && key.allowedModels.length > 0;
|
|
23
|
-
const hasNamespaceRestriction = key.allowedNamespaces && key.allowedNamespaces.length > 0;
|
|
24
|
-
if (!hasModelRestriction && !hasNamespaceRestriction) return true;
|
|
25
|
-
if (hasModelRestriction && key.allowedModels!.includes(modelId)) return true;
|
|
26
|
-
const ns = modelId.split("/")[0] ?? "";
|
|
27
|
-
return !!(hasNamespaceRestriction && key.allowedNamespaces!.includes(ns));
|
|
28
|
-
},
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function readApiToken(request: Request): string | null {
|
|
33
|
-
const authHeader = request.headers.get("authorization");
|
|
34
|
-
const bearerMatch = authHeader?.match(/^Bearer\s+(.+)$/i);
|
|
35
|
-
if (bearerMatch?.[1]) return bearerMatch[1];
|
|
36
|
-
|
|
37
|
-
const headerToken = request.headers.get("x-api-key")
|
|
38
|
-
?? request.headers.get("x-goog-api-key");
|
|
39
|
-
if (headerToken) return headerToken;
|
|
40
|
-
|
|
41
|
-
const url = new URL(request.url);
|
|
42
|
-
return url.searchParams.get("key") ?? url.searchParams.get("api_key");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async function sha256Hex(value: string): Promise<string> {
|
|
46
|
-
const bytes = new TextEncoder().encode(value);
|
|
47
|
-
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
48
|
-
return Array.from(new Uint8Array(digest))
|
|
49
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
50
|
-
.join("");
|
|
51
|
-
}
|