@neutrome/open-ai-router 0.1.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 ADDED
@@ -0,0 +1,25 @@
1
+ # `@neutrome/open-ai-router`
2
+
3
+ Deployable router app and reusable router/runtime library surface built on `lil-core` + `lil-exec`.
4
+
5
+ ```ts
6
+ import { createApp, createRouterRuntime, type RouterConfig } from "@neutrome/open-ai-router";
7
+ ```
8
+
9
+ Primary runtime model:
10
+
11
+ - client JSON/SSE -> `lil-core` IR
12
+ - target resolution -> `lil-exec`
13
+ - provider transport -> router host
14
+
15
+ Development commands:
16
+
17
+ ```bash
18
+ pnpm --filter @neutrome/open-ai-router test
19
+ pnpm --filter @neutrome/open-ai-router typecheck
20
+ ```
21
+
22
+ Notes:
23
+
24
+ - `RouterConfig` is the canonical router config surface.
25
+ - `/v1/models` lists configured executor aliases plus provider `catalog` entries.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@neutrome/open-ai-router",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
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
+ "dependencies": {
17
+ "@neutrome/lil-engine": "0.1.0",
18
+ "hono": "^4.12.18"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^25.9.3",
22
+ "typescript": "^6.0.3",
23
+ "vitest": "^4.1.6",
24
+ "wrangler": "^4.91.0"
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ import { cors as honoCors } from "hono/cors";
2
+
3
+ export const cors = () =>
4
+ honoCors({
5
+ origin: "*",
6
+ allowMethods: ["GET", "POST", "OPTIONS"],
7
+ allowHeaders: ["Content-Type", "Authorization", "X-OpenAIRouter-Token"],
8
+ });
@@ -0,0 +1,192 @@
1
+ import type { Context } from "hono";
2
+ import type { Executor, ProgramTransform } from "@neutrome/lil-engine";
3
+ import type { RequestContext } from "../auth/types.ts";
4
+ import {
5
+ handleChatCompletions,
6
+ handleResponses,
7
+ listConfiguredModels,
8
+ type RouterExecutionOptions,
9
+ type RouterRuntime,
10
+ } from "../router/index.ts";
11
+
12
+ type OpenAiModel = {
13
+ id: string;
14
+ object: "model";
15
+ created: number;
16
+ owned_by: string;
17
+ };
18
+
19
+ export type ChatCompletionsHandlerOptions = RouterExecutionOptions & {
20
+ executors?: Readonly<Record<string, Executor>>;
21
+ transforms?: Readonly<Record<string, ProgramTransform>>;
22
+ };
23
+
24
+ export type ResponsesHandlerOptions = ChatCompletionsHandlerOptions;
25
+
26
+ export function listModelsHandler(runtime: RouterRuntime) {
27
+ return async (c: Context) => {
28
+ const modelIds = await listAvailableModels(runtime);
29
+ const models: OpenAiModel[] = modelIds.map((id) => ({
30
+ id,
31
+ object: "model",
32
+ created: 0,
33
+ owned_by: ownerFromModelId(id),
34
+ }));
35
+ return c.json({ object: "list", data: models });
36
+ };
37
+ }
38
+
39
+ async function listAvailableModels(runtime: RouterRuntime): Promise<string[]> {
40
+ const seen = new Set<string>();
41
+ const models: string[] = [];
42
+
43
+ for (const id of listConfiguredModels(runtime)) {
44
+ addModel(id);
45
+ }
46
+
47
+ await Promise.all(runtime.providerOrder.map(async (namespace) => {
48
+ const provider = runtime.providers.get(namespace);
49
+ if (!provider || provider.catalog.length > 0) {
50
+ return;
51
+ }
52
+
53
+ for (const model of await fetchProviderCatalog(runtime, provider.apiBaseUrl)) {
54
+ if (provider.exportsMatcher(model)) {
55
+ addModel(`${namespace}/${model}`);
56
+ }
57
+ }
58
+ }));
59
+
60
+ return models;
61
+
62
+ function addModel(id: string): void {
63
+ if (seen.has(id)) {
64
+ return;
65
+ }
66
+ seen.add(id);
67
+ models.push(id);
68
+ }
69
+ }
70
+
71
+ async function fetchProviderCatalog(runtime: RouterRuntime, apiBaseUrl: string): Promise<string[]> {
72
+ try {
73
+ const response = await runtime.fetchImpl(`${apiBaseUrl}/models`, {
74
+ method: "GET",
75
+ headers: { accept: "application/json" },
76
+ });
77
+ if (!response.ok) {
78
+ return [];
79
+ }
80
+
81
+ const raw = await response.json() as { data?: unknown };
82
+ const data = raw.data;
83
+ if (!Array.isArray(data)) {
84
+ return [];
85
+ }
86
+
87
+ return data
88
+ .map((model) => (model as { id?: unknown } | null | undefined)?.id)
89
+ .filter((id): id is string => typeof id === "string");
90
+ } catch {
91
+ return [];
92
+ }
93
+ }
94
+
95
+ export function chatCompletionsHandler(
96
+ runtime: RouterRuntime,
97
+ options: ChatCompletionsHandlerOptions = {},
98
+ ) {
99
+ return async (c: Context) => {
100
+ const ctx = createRequestContext(c);
101
+ collectIncomingAuth(ctx, runtime);
102
+ const handlerOptions = {
103
+ runtime,
104
+ ctx,
105
+ } as Parameters<typeof handleChatCompletions>[0];
106
+
107
+ if (options.executors) {
108
+ handlerOptions.executors = options.executors;
109
+ }
110
+ if (options.resolveExecutor) {
111
+ handlerOptions.resolveExecutor = options.resolveExecutor;
112
+ }
113
+ if (options.transforms) {
114
+ handlerOptions.transforms = options.transforms;
115
+ }
116
+ if (options.observe) {
117
+ handlerOptions.observe = options.observe;
118
+ }
119
+ if (options.requestIdFactory) {
120
+ handlerOptions.requestIdFactory = options.requestIdFactory;
121
+ }
122
+ if (options.executionIdFactory) {
123
+ handlerOptions.executionIdFactory = options.executionIdFactory;
124
+ }
125
+ if (options.upstreamTimeoutMs !== undefined) {
126
+ handlerOptions.upstreamTimeoutMs = options.upstreamTimeoutMs;
127
+ }
128
+
129
+ return handleChatCompletions(handlerOptions);
130
+ };
131
+ }
132
+
133
+ export function responsesHandler(
134
+ runtime: RouterRuntime,
135
+ options: ResponsesHandlerOptions = {},
136
+ ) {
137
+ return async (c: Context) => {
138
+ const ctx = createRequestContext(c);
139
+ collectIncomingAuth(ctx, runtime);
140
+ const handlerOptions = {
141
+ runtime,
142
+ ctx,
143
+ } as Parameters<typeof handleResponses>[0];
144
+
145
+ if (options.executors) {
146
+ handlerOptions.executors = options.executors;
147
+ }
148
+ if (options.resolveExecutor) {
149
+ handlerOptions.resolveExecutor = options.resolveExecutor;
150
+ }
151
+ if (options.transforms) {
152
+ handlerOptions.transforms = options.transforms;
153
+ }
154
+ if (options.observe) {
155
+ handlerOptions.observe = options.observe;
156
+ }
157
+ if (options.requestIdFactory) {
158
+ handlerOptions.requestIdFactory = options.requestIdFactory;
159
+ }
160
+ if (options.executionIdFactory) {
161
+ handlerOptions.executionIdFactory = options.executionIdFactory;
162
+ }
163
+ if (options.upstreamTimeoutMs !== undefined) {
164
+ handlerOptions.upstreamTimeoutMs = options.upstreamTimeoutMs;
165
+ }
166
+
167
+ return handleResponses(handlerOptions);
168
+ };
169
+ }
170
+
171
+ function createRequestContext(c: Context): RequestContext {
172
+ return {
173
+ request: c.req.raw,
174
+ incomingAuth: new Map(),
175
+ env: (c.env ?? {}) as Record<string, unknown>,
176
+ state: new Map(),
177
+ };
178
+ }
179
+
180
+ function collectIncomingAuth(ctx: RequestContext, runtime: RouterRuntime): void {
181
+ for (const driver of runtime.authChain) {
182
+ driver.collectIncoming?.(ctx);
183
+ }
184
+ }
185
+
186
+ function ownerFromModelId(id: string): string {
187
+ const slash = id.indexOf("/");
188
+ if (slash <= 0) {
189
+ return "unknown";
190
+ }
191
+ return id.slice(0, slash);
192
+ }
@@ -0,0 +1,5 @@
1
+ import type { Context } from "hono";
2
+
3
+ export function healthHandler() {
4
+ return (c: Context) => c.text("OK");
5
+ }
@@ -0,0 +1,10 @@
1
+ export type AuthConfigShape = {
2
+ order?: string[];
3
+ proxy?: {
4
+ headers?: string[];
5
+ };
6
+ env?: {
7
+ suffix?: string;
8
+ prefix?: string;
9
+ };
10
+ };
@@ -0,0 +1,16 @@
1
+ import type { AuthDriver, TargetAuthContext, AuthResult } from "./types.ts";
2
+
3
+ export function envAuthDriver(suffix = "_API_KEY", prefix = ""): AuthDriver {
4
+ return {
5
+ name: "env",
6
+ collectTarget(ctx: TargetAuthContext): AuthResult | null {
7
+ const key =
8
+ ctx.env[`${prefix}${ctx.providerName.toUpperCase()}${suffix}`];
9
+ if (typeof key !== "string" || !key) return null;
10
+ return {
11
+ headers: { authorization: `Bearer ${key}` },
12
+ source: "env",
13
+ };
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,21 @@
1
+ import type { AuthDriver, RequestContext, TargetAuthContext, AuthResult } from "./types.ts";
2
+
3
+ export function proxyAuthDriver(headers: string[]): AuthDriver {
4
+ return {
5
+ name: "proxy",
6
+ collectIncoming(ctx: RequestContext) {
7
+ for (const name of headers) {
8
+ const value = ctx.request.headers.get(name);
9
+ if (value) ctx.incomingAuth.set(name.toLowerCase(), value);
10
+ }
11
+ },
12
+ collectTarget(ctx: TargetAuthContext): AuthResult | null {
13
+ const authHeader = ctx.incomingAuth.get("authorization");
14
+ if (!authHeader) return null;
15
+ return {
16
+ headers: { authorization: authHeader },
17
+ source: "proxy",
18
+ };
19
+ },
20
+ };
21
+ }
@@ -0,0 +1,25 @@
1
+ import type { AuthConfigShape } from "./config.ts";
2
+ import type { AuthDriver } from "./types.ts";
3
+ import { proxyAuthDriver } from "./proxy.ts";
4
+ import { envAuthDriver } from "./env.ts";
5
+
6
+ export function buildAuthChain(config?: AuthConfigShape): AuthDriver[] {
7
+ if (!config) return [proxyAuthDriver(["authorization"]), envAuthDriver()];
8
+ const order = config.order ?? ["proxy", "env"];
9
+ const drivers: AuthDriver[] = [];
10
+ for (const name of order) {
11
+ switch (name) {
12
+ case "proxy":
13
+ drivers.push(
14
+ proxyAuthDriver(config.proxy?.headers ?? ["authorization"]),
15
+ );
16
+ break;
17
+ case "env":
18
+ drivers.push(
19
+ envAuthDriver(config.env?.suffix ?? "_API_KEY", config.env?.prefix),
20
+ );
21
+ break;
22
+ }
23
+ }
24
+ return drivers;
25
+ }
@@ -0,0 +1,23 @@
1
+ export type AuthResult = {
2
+ headers: HeadersInit;
3
+ source: string;
4
+ };
5
+
6
+ export type RequestContext = {
7
+ request: Request;
8
+ incomingAuth: Map<string, string>;
9
+ env: Record<string, unknown>;
10
+ state?: Map<string, unknown>;
11
+ };
12
+
13
+ export type TargetAuthContext = RequestContext & {
14
+ providerName: string;
15
+ };
16
+
17
+ export interface AuthDriver {
18
+ name: string;
19
+ collectIncoming?(ctx: RequestContext): void;
20
+ collectTarget(
21
+ ctx: TargetAuthContext,
22
+ ): Promise<AuthResult | null> | AuthResult | null;
23
+ }
@@ -0,0 +1,229 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createApp } from "./example.ts";
3
+
4
+ const decoder = new TextDecoder();
5
+
6
+ describe("open-ai-router createApp", () => {
7
+ it("lists configured provider catalog entries and executor aliases", async () => {
8
+ const app = createApp({
9
+ config: {
10
+ providers: {
11
+ openrouter: {
12
+ api_base_url: "https://openrouter.ai/api/v1",
13
+ style: "chat-completions",
14
+ exports: ["gpt-4o"],
15
+ catalog: ["gpt-4o"],
16
+ },
17
+ },
18
+ executors: {
19
+ semantyka: {
20
+ exports: ["enei-*"],
21
+ models: {
22
+ "enei-1": { executor: "enei-1" },
23
+ },
24
+ },
25
+ },
26
+ },
27
+ });
28
+
29
+ const response = await app.request("/v1/models");
30
+
31
+ expect(response.status).toBe(200);
32
+ expect(await response.json()).toEqual({
33
+ object: "list",
34
+ data: [
35
+ { id: "semantyka/enei-1", object: "model", created: 0, owned_by: "semantyka" },
36
+ { id: "openrouter/gpt-4o", object: "model", created: 0, owned_by: "openrouter" },
37
+ ],
38
+ });
39
+ });
40
+
41
+ it("lists provider /models entries when provider catalog is not configured", async () => {
42
+ const app = createApp({
43
+ config: {
44
+ providers: {
45
+ openrouter: {
46
+ api_base_url: "https://openrouter.ai/api/v1",
47
+ style: "chat-completions",
48
+ exports: ["*:free"],
49
+ },
50
+ },
51
+ executors: {},
52
+ },
53
+ fetchImpl: vi.fn(async () =>
54
+ new Response(
55
+ JSON.stringify({
56
+ data: [
57
+ { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" },
58
+ { id: "openai/gpt-4.1-mini" },
59
+ ],
60
+ }),
61
+ {
62
+ headers: { "content-type": "application/json; charset=utf-8" },
63
+ },
64
+ )
65
+ ),
66
+ });
67
+
68
+ const response = await app.request("/v1/models");
69
+
70
+ expect(response.status).toBe(200);
71
+ expect(await response.json()).toEqual({
72
+ object: "list",
73
+ data: [
74
+ {
75
+ id: "openrouter/nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
76
+ object: "model",
77
+ created: 0,
78
+ owned_by: "openrouter",
79
+ },
80
+ ],
81
+ });
82
+ });
83
+
84
+ it("serves provider-backed chat completions through the app surface", async () => {
85
+ let capturedBody: Record<string, unknown> | null = null;
86
+
87
+ const app = createApp({
88
+ config: {
89
+ auth: {
90
+ order: ["proxy"],
91
+ proxy: {
92
+ headers: ["authorization"],
93
+ },
94
+ },
95
+ providers: {
96
+ openrouter: {
97
+ api_base_url: "https://openrouter.ai/api/v1",
98
+ style: "chat-completions",
99
+ exports: ["gpt-4o"],
100
+ allow_anonymous: false,
101
+ no_prefix: true,
102
+ },
103
+ },
104
+ executors: {},
105
+ },
106
+ fetchImpl: vi.fn(async (_input, init) => {
107
+ capturedBody = JSON.parse(decoder.decode(new Uint8Array(init?.body as ArrayBuffer)));
108
+ return new Response(
109
+ JSON.stringify({
110
+ id: "resp_1",
111
+ model: "gpt-4o",
112
+ choices: [
113
+ {
114
+ index: 0,
115
+ message: { role: "assistant", content: "hello" },
116
+ finish_reason: "stop",
117
+ },
118
+ ],
119
+ }),
120
+ {
121
+ headers: {
122
+ "content-type": "application/json; charset=utf-8",
123
+ },
124
+ },
125
+ );
126
+ }),
127
+ });
128
+
129
+ const response = await app.request("/v1/chat/completions", {
130
+ method: "POST",
131
+ headers: {
132
+ authorization: "Bearer test-key",
133
+ "content-type": "application/json",
134
+ },
135
+ body: JSON.stringify({
136
+ model: "gpt-4o",
137
+ messages: [{ role: "user", content: "hi" }],
138
+ }),
139
+ });
140
+
141
+ expect(response.status).toBe(200);
142
+ expect(capturedBody).toEqual({
143
+ model: "gpt-4o",
144
+ messages: [{ role: "user", content: "hi" }],
145
+ });
146
+ expect(response.headers.get("x-openairouter-provider-id")).toBe("openrouter");
147
+ expect(await response.json()).toEqual({
148
+ object: "chat.completion",
149
+ id: "resp_1",
150
+ model: "gpt-4o",
151
+ choices: [
152
+ {
153
+ index: 0,
154
+ message: { role: "assistant", content: "hello" },
155
+ finish_reason: "stop",
156
+ },
157
+ ],
158
+ });
159
+ });
160
+
161
+ it("serves responses through chat-completions provider transport", async () => {
162
+ let capturedBody: Record<string, unknown> | null = null;
163
+
164
+ const app = createApp({
165
+ config: {
166
+ providers: {
167
+ openrouter: {
168
+ api_base_url: "https://openrouter.ai/api/v1",
169
+ style: "chat-completions",
170
+ allow_anonymous: true,
171
+ exports: ["gpt-4o"],
172
+ no_prefix: true,
173
+ },
174
+ },
175
+ executors: {},
176
+ },
177
+ fetchImpl: vi.fn(async (_input, init) => {
178
+ capturedBody = JSON.parse(decoder.decode(new Uint8Array(init?.body as ArrayBuffer)));
179
+ return new Response(
180
+ JSON.stringify({
181
+ id: "resp_2",
182
+ model: "gpt-4o",
183
+ choices: [
184
+ {
185
+ index: 0,
186
+ message: { role: "assistant", content: "hello" },
187
+ finish_reason: "stop",
188
+ },
189
+ ],
190
+ }),
191
+ {
192
+ headers: {
193
+ "content-type": "application/json; charset=utf-8",
194
+ },
195
+ },
196
+ );
197
+ }),
198
+ });
199
+
200
+ const response = await app.request("/v1/responses", {
201
+ method: "POST",
202
+ headers: {
203
+ "content-type": "application/json",
204
+ },
205
+ body: JSON.stringify({
206
+ model: "gpt-4o",
207
+ input: [{ role: "user", content: "hi" }],
208
+ }),
209
+ });
210
+
211
+ expect(response.status).toBe(200);
212
+ expect(capturedBody).toEqual({
213
+ model: "gpt-4o",
214
+ messages: [{ role: "user", content: "hi" }],
215
+ });
216
+ expect(await response.json()).toEqual({
217
+ id: "resp_2",
218
+ model: "gpt-4o",
219
+ output: [
220
+ {
221
+ type: "message",
222
+ role: "assistant",
223
+ status: "completed",
224
+ content: [{ type: "output_text", text: "hello" }],
225
+ },
226
+ ],
227
+ });
228
+ });
229
+ });
package/src/example.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { Hono } from "hono";
2
+ import { cors } from "./app/cors.ts";
3
+ import {
4
+ chatCompletionsHandler,
5
+ listModelsHandler,
6
+ responsesHandler,
7
+ type ChatCompletionsHandlerOptions,
8
+ } from "./app/handlers.ts";
9
+ import { healthHandler } from "./app/health.ts";
10
+ import { createRouterRuntime, type CreateRouterOptions } from "./router/index.ts";
11
+
12
+ export type CreateAppOptions = CreateRouterOptions & ChatCompletionsHandlerOptions;
13
+
14
+ export function createApp(options: CreateAppOptions) {
15
+ const routerOptions = {
16
+ config: options.config,
17
+ } as CreateRouterOptions;
18
+
19
+ if (options.fetchImpl) {
20
+ routerOptions.fetchImpl = options.fetchImpl;
21
+ }
22
+ if (options.knownSuffixes) {
23
+ routerOptions.knownSuffixes = options.knownSuffixes;
24
+ }
25
+
26
+ const runtime = createRouterRuntime(routerOptions);
27
+ const app = new Hono();
28
+
29
+ const handlerOptions = {} as ChatCompletionsHandlerOptions;
30
+ if (options.executors) {
31
+ handlerOptions.executors = options.executors;
32
+ }
33
+ if (options.transforms) {
34
+ handlerOptions.transforms = options.transforms;
35
+ }
36
+ if (options.observe) {
37
+ handlerOptions.observe = options.observe;
38
+ }
39
+ if (options.requestIdFactory) {
40
+ handlerOptions.requestIdFactory = options.requestIdFactory;
41
+ }
42
+ if (options.executionIdFactory) {
43
+ handlerOptions.executionIdFactory = options.executionIdFactory;
44
+ }
45
+ if (options.upstreamTimeoutMs !== undefined) {
46
+ handlerOptions.upstreamTimeoutMs = options.upstreamTimeoutMs;
47
+ }
48
+
49
+ app.use("/v1/models", cors());
50
+ app.get("/v1/models", listModelsHandler(runtime));
51
+
52
+ app.use("/v1/chat/completions", cors());
53
+ app.post("/v1/chat/completions", chatCompletionsHandler(runtime, handlerOptions));
54
+
55
+ app.use("/v1/responses", cors());
56
+ app.post("/v1/responses", responsesHandler(runtime, handlerOptions));
57
+
58
+ app.get("/health", healthHandler());
59
+ app.all("*", (c) => c.text("Not Found", 404));
60
+
61
+ return app;
62
+ }