@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.2.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.0"
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
+
@@ -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
+ }
@@ -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 { RequestContext } from "../auth/types.ts";
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: { request: Request; model: string | null; env: Record<string, unknown> }): Promise<Response | void>;
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) => options.auth.canAccess(principal, 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
- const model = await readModel(request.clone());
97
- if (model && !options.auth.canAccess(principal, model)) {
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.authChain) {
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
- const executionCtx = c.executionCtx as { waitUntil?(promise: Promise<unknown>): void } | undefined;
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;
@@ -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.authChain) {
148
+ for (const driver of runtime.upstreamAuthChain) {
149
149
  driver.collectIncoming?.(ctx);
150
150
  }
151
151
  }
@@ -46,7 +46,7 @@ describe("open-ai-router createApp", () => {
46
46
 
47
47
  const app = createApp({
48
48
  config: {
49
- auth: {
49
+ upstreamAuth: {
50
50
  order: ["proxy"],
51
51
  proxy: {
52
52
  headers: ["authorization"],
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
- } from "./auth/types.ts";
93
- export { buildAuthChain } from "./auth/registry.ts";
94
- export { envAuthDriver } from "./auth/env.ts";
95
- export { proxyAuthDriver } from "./auth/proxy.ts";
96
- export { createApiKeyAuth } from "./auth/apikey.ts";
97
- export type { ApiKeyAuth, ApiKeyEntry } from "./auth/apikey.ts";
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";
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  createTwoPassExecutor,
3
3
  INTERNAL_DRAFT_TOOL_NAME,
4
- type Executor,
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";
@@ -1,9 +1,9 @@
1
1
  import type { ProviderStyle } from "@neutrome/lil-engine";
2
- import type { AuthConfigShape } from "../auth/config.ts";
2
+ import type { UpstreamAuthConfigShape } from "../upstream-auth/config.ts";
3
3
 
4
4
  export type ProviderApiStyle = ProviderStyle;
5
5
 
6
- export type AuthConfig = AuthConfigShape;
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
- auth?: AuthConfig;
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";
@@ -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 requestBody = new Uint8Array(await options.ctx.request.arrayBuffer());
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 requestBody = new Uint8Array(await options.ctx.request.arrayBuffer());
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.authChain) {
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) {
@@ -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,
@@ -1,6 +1,6 @@
1
1
  import type { ExecutionTarget } from "@neutrome/lilsdk-ts";
2
- import type { AuthDriver } from "../auth/types.ts";
3
- import { buildAuthChain } from "../auth/registry.ts";
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
- authChain: readonly AuthDriver[];
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
- authDrivers?: readonly AuthDriver[];
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
- authChain: options.authDrivers ?? buildAuthChain(options.config.auth),
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,4 +1,4 @@
1
- export type AuthConfigShape = {
1
+ export type UpstreamAuthConfigShape = {
2
2
  order?: string[];
3
3
  proxy?: {
4
4
  headers?: string[];
@@ -1,6 +1,6 @@
1
- import type { AuthDriver, TargetAuthContext, AuthResult } from "./types.ts";
1
+ import type { AuthResult, TargetAuthContext, UpstreamAuthDriver } from "./types.ts";
2
2
 
3
- export function envAuthDriver(suffix = "_API_KEY", prefix = ""): AuthDriver {
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 { AuthDriver, RequestContext, TargetAuthContext, AuthResult } from "./types.ts";
1
+ import type { AuthResult, RequestContext, TargetAuthContext, UpstreamAuthDriver } from "./types.ts";
2
2
 
3
- export function proxyAuthDriver(headers: string[]): AuthDriver {
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 { AuthConfigShape } from "./config.ts";
2
- import type { AuthDriver } from "./types.ts";
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 buildAuthChain(config?: AuthConfigShape): AuthDriver[] {
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: AuthDriver[] = [];
9
+ const drivers: UpstreamAuthDriver[] = [];
10
10
  for (const name of order) {
11
11
  switch (name) {
12
12
  case "proxy":
@@ -14,7 +14,7 @@ export type TargetAuthContext = RequestContext & {
14
14
  providerName: string;
15
15
  };
16
16
 
17
- export interface AuthDriver {
17
+ export interface UpstreamAuthDriver {
18
18
  name: string;
19
19
  collectIncoming?(ctx: RequestContext): void;
20
20
  collectTarget(
package/src/worker.ts CHANGED
@@ -5,7 +5,7 @@ import { createApp } from "./example.ts";
5
5
 
6
6
  const config: RouterConfig = {
7
7
  trace: true,
8
- auth: {
8
+ upstreamAuth: {
9
9
  order: ["proxy", "env"],
10
10
  proxy: {
11
11
  headers: ["authorization", "x-openairouter-token"],
@@ -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
- }