@neutrome/open-ai-router 0.1.6 → 0.1.8

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,26 +1,26 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
7
7
  },
8
- "scripts": {
9
- "cf-typegen": "wrangler types",
10
- "test": "vitest run",
11
- "typecheck": "tsc -p tsconfig.json --noEmit",
12
- "dev": "wrangler dev",
13
- "preview": "wrangler preview",
14
- "deploy": "wrangler deploy"
15
- },
16
8
  "dependencies": {
17
- "@neutrome/lil-engine": "0.1.2",
18
- "hono": "^4.12.18"
9
+ "hono": "^4.12.18",
10
+ "@neutrome/lil-engine": "0.1.3"
19
11
  },
20
12
  "devDependencies": {
21
13
  "@types/node": "^25.9.3",
22
14
  "typescript": "^6.0.3",
23
15
  "vitest": "^4.1.6",
24
16
  "wrangler": "^4.91.0"
17
+ },
18
+ "scripts": {
19
+ "cf-typegen": "wrangler types",
20
+ "test": "vitest run",
21
+ "typecheck": "tsc -p tsconfig.json --noEmit",
22
+ "dev": "wrangler dev",
23
+ "preview": "wrangler preview",
24
+ "deploy": "wrangler deploy"
25
25
  }
26
- }
26
+ }
@@ -0,0 +1,186 @@
1
+ import { Hono, type Context } from "hono";
2
+ import type { Executor, ProgramTransform } from "@neutrome/lil-engine";
3
+ import type { ExecutionRuntimeOptions } from "@neutrome/lil-engine";
4
+ import { cors } from "./cors.ts";
5
+ import { healthHandler } from "./health.ts";
6
+ import {
7
+ createRouterRuntime,
8
+ handleAnthropicMessages,
9
+ handleChatCompletions,
10
+ handleGoogleGenAI,
11
+ handleResponses,
12
+ listConfiguredModels,
13
+ type RouterRuntime,
14
+ } from "../router/index.ts";
15
+ import type { RequestContext } from "../auth/types.ts";
16
+ import type { RouterConfig } from "../router/config.ts";
17
+ import type { UsageReport } from "../router/execute.ts";
18
+
19
+ export type RouterAppAuth = {
20
+ authenticate(request: Request, env: Record<string, unknown>): Promise<object | null>;
21
+ canAccess(principal: object, modelId: string): boolean;
22
+ };
23
+
24
+ export type RouterAppHooks = {
25
+ beforeRequest?(ctx: { request: Request; model: string | null; env: Record<string, unknown> }): Promise<Response | void>;
26
+ onUsage?(report: UsageReport, env: Record<string, unknown>): void;
27
+ onToolCall?(report: { toolName: string }, env: Record<string, unknown>): void;
28
+ };
29
+
30
+ export type RouterAppOptions = {
31
+ config: RouterConfig;
32
+ executors: Readonly<Record<string, Executor>>;
33
+ transforms?: Readonly<Record<string, ProgramTransform>>;
34
+ auth: RouterAppAuth;
35
+ hooks?: RouterAppHooks;
36
+ fetchImpl?: typeof fetch;
37
+ knownSuffixes?: string[];
38
+ observe?: ExecutionRuntimeOptions["observe"];
39
+ requestIdFactory?: ExecutionRuntimeOptions["requestIdFactory"];
40
+ executionIdFactory?: ExecutionRuntimeOptions["executionIdFactory"];
41
+ upstreamTimeoutMs?: number;
42
+ };
43
+
44
+ export function createRouterApp(options: RouterAppOptions) {
45
+ const runtime = createRouterRuntime({
46
+ config: options.config,
47
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
48
+ ...(options.knownSuffixes ? { knownSuffixes: options.knownSuffixes } : {}),
49
+ });
50
+
51
+ const app = new Hono();
52
+
53
+ app.use("/v1/*", cors());
54
+ app.get("/health", healthHandler());
55
+
56
+ app.get("/v1/models", async (c) => {
57
+ const env = honoEnv(c);
58
+ const principal = await options.auth.authenticate(c.req.raw, env);
59
+ if (!principal) return unauthorized();
60
+
61
+ const models = listConfiguredModels(runtime)
62
+ .filter((id) => options.auth.canAccess(principal, id))
63
+ .map((id) => ({
64
+ id,
65
+ object: "model" as const,
66
+ created: 0,
67
+ owned_by: id.split("/")[0] || "unknown",
68
+ }));
69
+ return c.json({ object: "list", data: models });
70
+ });
71
+
72
+ app.post("/v1/chat/completions", (c) => runAuthorized(c, c.req.raw, handleChatCompletions));
73
+ app.post("/v1/responses", (c) => runAuthorized(c, c.req.raw, handleResponses));
74
+ app.post("/v1/messages", (c) => runAuthorized(c, c.req.raw, handleAnthropicMessages));
75
+
76
+ app.post("/v1/models/*", async (c) => {
77
+ const route = parseGoogleGenAIRoute(c.req.raw);
78
+ if (!route) return c.text("Not Found", 404);
79
+ const request = await rewriteGoogleGenAIRequest(c.req.raw, route);
80
+ return runAuthorized(c, request, handleGoogleGenAI, route.stream);
81
+ });
82
+
83
+ app.all("*", (c) => c.text("Not Found", 404));
84
+
85
+ async function runAuthorized(
86
+ c: Context,
87
+ request: Request,
88
+ handler: (opts: any) => Promise<Response>,
89
+ forceStreaming?: boolean,
90
+ ): Promise<Response> {
91
+ const env = honoEnv(c);
92
+ const principal = await options.auth.authenticate(request, env);
93
+ if (!principal) return unauthorized();
94
+
95
+ const model = await readModel(request.clone());
96
+ if (model && !options.auth.canAccess(principal, model)) {
97
+ return errorResponse(`Model \`${model}\` does not exist or you do not have access.`, 404, "model_not_found");
98
+ }
99
+
100
+ if (options.hooks?.beforeRequest) {
101
+ const hookResponse = await options.hooks.beforeRequest({ request, model, env });
102
+ if (hookResponse) return hookResponse;
103
+ }
104
+
105
+ const ctx: RequestContext = {
106
+ request,
107
+ incomingAuth: new Map(),
108
+ env,
109
+ state: new Map(),
110
+ };
111
+ for (const driver of runtime.authChain) {
112
+ driver.collectIncoming?.(ctx);
113
+ }
114
+
115
+ const onUsage = options.hooks?.onUsage
116
+ ? (report: UsageReport) => options.hooks!.onUsage!(report, env)
117
+ : undefined;
118
+
119
+ return handler({
120
+ runtime,
121
+ ctx,
122
+ executors: options.executors,
123
+ ...(options.transforms ? { transforms: options.transforms } : {}),
124
+ ...(options.observe ? { observe: options.observe } : {}),
125
+ ...(options.upstreamTimeoutMs !== undefined ? { upstreamTimeoutMs: options.upstreamTimeoutMs } : {}),
126
+ ...(onUsage ? { onUsage } : {}),
127
+ ...(forceStreaming ? { forceStreaming: true } : {}),
128
+ });
129
+ }
130
+
131
+ return app;
132
+ }
133
+
134
+ function honoEnv(c: Context): Record<string, unknown> {
135
+ return (c.env ?? {}) as Record<string, unknown>;
136
+ }
137
+
138
+ function parseGoogleGenAIRoute(request: Request): { model: string; stream: boolean } | null {
139
+ const pathname = new URL(request.url).pathname;
140
+ const match = pathname.match(/^\/v1\/models\/(.+):(generateContent|streamGenerateContent)$/);
141
+ if (!match?.[1] || !match[2]) return null;
142
+ try {
143
+ return { model: decodeURIComponent(match[1]), stream: match[2] === "streamGenerateContent" };
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ async function rewriteGoogleGenAIRequest(
150
+ request: Request,
151
+ route: { model: string; stream: boolean },
152
+ ): Promise<Request> {
153
+ const bodyText = await request.text();
154
+ const headers = new Headers(request.headers);
155
+ try {
156
+ const raw = JSON.parse(bodyText) as Record<string, unknown>;
157
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
158
+ return new Request(request.url, {
159
+ method: request.method,
160
+ headers,
161
+ body: JSON.stringify({ ...raw, model: route.model, ...(route.stream ? { stream: true } : {}) }),
162
+ });
163
+ }
164
+ } catch {}
165
+ return new Request(request.url, { method: request.method, headers, body: bodyText });
166
+ }
167
+
168
+ async function readModel(request: Request): Promise<string | null> {
169
+ try {
170
+ const body = (await request.json()) as { model?: unknown };
171
+ return typeof body.model === "string" ? body.model : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ function unauthorized(): Response {
178
+ return errorResponse("Authentication required", 401, "auth_required");
179
+ }
180
+
181
+ function errorResponse(message: string, status: number, code: string): Response {
182
+ return new Response(
183
+ JSON.stringify({ error: { message, type: "invalid_request_error", param: null, code } }),
184
+ { status, headers: { "content-type": "application/json; charset=utf-8" } },
185
+ );
186
+ }
@@ -0,0 +1,51 @@
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
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
+ export { createRouterApp } from "./app/factory.ts";
2
+ export type { RouterAppAuth, RouterAppHooks, RouterAppOptions } from "./app/factory.ts";
3
+
1
4
  export { createApp } from "./example.ts";
2
5
  export type { CreateAppOptions } from "./example.ts";
3
6
 
@@ -47,6 +50,8 @@ export type {
47
50
  export { buildAuthChain } from "./auth/registry.ts";
48
51
  export { envAuthDriver } from "./auth/env.ts";
49
52
  export { proxyAuthDriver } from "./auth/proxy.ts";
53
+ export { createApiKeyAuth } from "./auth/apikey.ts";
54
+ export type { ApiKeyAuth, ApiKeyEntry } from "./auth/apikey.ts";
50
55
 
51
56
  export { cors } from "./app/cors.ts";
52
57
  export { healthHandler } from "./app/health.ts";
package/src/worker.ts CHANGED
@@ -1,3 +1,5 @@
1
+ type ExecutionContext = { waitUntil(p: Promise<unknown>): void; passThroughOnException(): void };
2
+
1
3
  import type { RouterConfig } from "./router/index.ts";
2
4
  import { createApp } from "./example.ts";
3
5
 
package/tsconfig.json CHANGED
@@ -1,9 +1,21 @@
1
1
  {
2
- "extends": "../../tsconfig.base.json",
3
2
  "compilerOptions": {
4
- "types": ["node", "./worker-configuration.d.ts", "vitest/globals"],
5
- "outDir": "dist"
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
7
+ "strict": true,
8
+ "allowImportingTsExtensions": true,
9
+ "allowArbitraryExtensions": true,
10
+ "rewriteRelativeImportExtensions": true,
11
+ "noUncheckedIndexedAccess": true,
12
+ "exactOptionalPropertyTypes": true,
13
+ "skipLibCheck": true,
14
+ "declaration": true,
15
+ "sourceMap": true,
16
+ "noEmit": true,
17
+ "types": ["node", "vitest/globals"]
6
18
  },
7
19
  "include": ["src/**/*.ts", "src/**/*.d.ts"],
8
- "exclude": ["test", "dist", "node_modules"],
20
+ "exclude": ["test", "dist", "node_modules"]
9
21
  }