@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.
@@ -0,0 +1,33 @@
1
+ export { createRouterRuntime } from "./runtime.ts";
2
+ export {
3
+ createFetchProviderInvoker,
4
+ createRouterExecutionRuntime,
5
+ handleChatCompletions,
6
+ handleResponses,
7
+ resolveRequestTarget,
8
+ RouterError,
9
+ ProviderHttpError,
10
+ } from "./execute.ts";
11
+ export { listConfiguredModels, resolveInvocationTarget, resolveInvocationTargets } from "./resolve.ts";
12
+ export type {
13
+ AuthConfig,
14
+ ExecutorNamespaceConfig,
15
+ ExecutorRouteConfig,
16
+ ProviderApiStyle,
17
+ ProviderTargetConfig,
18
+ RouterConfig,
19
+ } from "./config.ts";
20
+ export type {
21
+ CreateRouterOptions,
22
+ ExecutorRouteRuntime,
23
+ ExecutorRuntime,
24
+ ProviderRuntime,
25
+ RouterRuntime,
26
+ } from "./runtime.ts";
27
+ export type {
28
+ HandleChatCompletionsOptions,
29
+ HandleResponsesOptions,
30
+ RouterExecutionOptions,
31
+ UsageReport,
32
+ } from "./execute.ts";
33
+ export type { ResolvedTarget } from "./resolve.ts";
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createRouterRuntime } from "./runtime.ts";
3
+ import { listConfiguredModels, resolveInvocationTarget, resolveInvocationTargets } from "./resolve.ts";
4
+
5
+ describe("router resolution", () => {
6
+ it("resolves executor-backed aliases before provider-backed models", () => {
7
+ const runtime = createRouterRuntime({
8
+ config: {
9
+ providers: {
10
+ openrouter: {
11
+ api_base_url: "https://openrouter.ai/api/v1",
12
+ style: "chat-completions",
13
+ exports: ["gemma-*"],
14
+ catalog: ["gemma-4-31b-it"],
15
+ },
16
+ },
17
+ executors: {
18
+ enei: {
19
+ exports: ["enei-*"],
20
+ models: {
21
+ "enei-1": { executor: "enei-1" },
22
+ "enei-1-pro": { executor: "enei-1-pro", transforms: ["reasoning:high"] },
23
+ },
24
+ },
25
+ },
26
+ },
27
+ });
28
+
29
+ const resolved = resolveInvocationTarget(runtime, "enei-1+slwin:8");
30
+ expect(resolved.source).toBe("executor");
31
+ expect(resolved.namespace).toBe("enei");
32
+ expect(resolved.target).toEqual({
33
+ kind: "executor",
34
+ executor: "enei-1",
35
+ alias: "enei-1",
36
+ transforms: ["slwin:8"],
37
+ });
38
+ });
39
+
40
+ it("resolves qualified provider models and preserves suffix transforms", () => {
41
+ const runtime = createRouterRuntime({
42
+ config: {
43
+ providers: {
44
+ openrouter: {
45
+ api_base_url: "https://openrouter.ai/api/v1",
46
+ style: "chat-completions",
47
+ exports: ["google/*"],
48
+ catalog: ["google/gemma-4-31b-it"],
49
+ },
50
+ },
51
+ executors: {},
52
+ },
53
+ knownSuffixes: ["slwin", "kvtools"],
54
+ });
55
+
56
+ const resolved = resolveInvocationTarget(runtime, "openrouter/google/gemma-4-31b-it+kvtools");
57
+ expect(resolved.source).toBe("provider");
58
+ expect(resolved.target).toEqual({
59
+ kind: "provider",
60
+ provider: "openrouter",
61
+ model: "google/gemma-4-31b-it",
62
+ transforms: ["kvtools"],
63
+ });
64
+ });
65
+
66
+ it("resolves namespace model routes that alias provider targets", () => {
67
+ const runtime = createRouterRuntime({
68
+ config: {
69
+ providers: {
70
+ openrouter: {
71
+ api_base_url: "https://openrouter.ai/api/v1",
72
+ style: "chat-completions",
73
+ exports: ["openai/gpt-4.1-mini"],
74
+ catalog: ["openai/gpt-4.1-mini"],
75
+ },
76
+ },
77
+ executors: {
78
+ semantyka: {
79
+ exports: ["enei-*"],
80
+ models: {
81
+ "enei-1": { provider: "openrouter", model: "openai/gpt-4.1-mini" },
82
+ },
83
+ },
84
+ },
85
+ },
86
+ });
87
+
88
+ const resolved = resolveInvocationTarget(runtime, "semantyka/enei-1");
89
+ expect(resolved.source).toBe("provider");
90
+ expect(resolved.namespace).toBe("semantyka");
91
+ expect(resolved.target).toEqual({
92
+ kind: "provider",
93
+ provider: "openrouter",
94
+ model: "openai/gpt-4.1-mini",
95
+ transforms: [],
96
+ });
97
+ });
98
+
99
+ it("lists configured executor and provider models as qualified IDs", () => {
100
+ const runtime = createRouterRuntime({
101
+ config: {
102
+ providers: {
103
+ openrouter: {
104
+ api_base_url: "https://openrouter.ai/api/v1",
105
+ style: "chat-completions",
106
+ exports: ["google/*", "openai/*"],
107
+ catalog: ["google/gemma-4-31b-it", "openai/gpt-4o", "anthropic/claude-sonnet-4"],
108
+ },
109
+ },
110
+ executors: {
111
+ enei: {
112
+ exports: ["enei-*"],
113
+ models: {
114
+ "enei-1": { executor: "enei-1" },
115
+ "enei-1-pro": { executor: "enei-1-pro" },
116
+ },
117
+ },
118
+ },
119
+ },
120
+ });
121
+
122
+ expect(listConfiguredModels(runtime)).toEqual([
123
+ "enei/enei-1",
124
+ "enei/enei-1-pro",
125
+ "openrouter/google/gemma-4-31b-it",
126
+ "openrouter/openai/gpt-4o",
127
+ ]);
128
+ });
129
+
130
+ it("returns multiple candidates for unqualified provider matches", () => {
131
+ const runtime = createRouterRuntime({
132
+ config: {
133
+ providers: {
134
+ openrouter: {
135
+ api_base_url: "https://openrouter.ai/api/v1",
136
+ style: "chat-completions",
137
+ exports: ["gpt-4o"],
138
+ no_prefix: true,
139
+ },
140
+ backup: {
141
+ api_base_url: "https://backup.example/v1",
142
+ style: "chat-completions",
143
+ exports: ["gpt-4o"],
144
+ no_prefix: true,
145
+ },
146
+ },
147
+ executors: {},
148
+ },
149
+ });
150
+
151
+ const resolved = resolveInvocationTargets(runtime, "gpt-4o");
152
+ expect(resolved).toHaveLength(2);
153
+ expect(resolved.map((item) => item.namespace)).toEqual(["openrouter", "backup"]);
154
+ });
155
+ });
@@ -0,0 +1,171 @@
1
+ import type { ExecutionTarget } from "@neutrome/lil-engine";
2
+ import { parseModelSyntax, type ModelSuffixSpec } from "../util/model-syntax.ts";
3
+ import type { RouterRuntime } from "./runtime.ts";
4
+
5
+ export type ResolvedTarget = {
6
+ requestedModel: string;
7
+ target: ExecutionTarget;
8
+ suffixes: ModelSuffixSpec[];
9
+ namespace: string;
10
+ source: "provider" | "executor";
11
+ };
12
+
13
+ export function resolveInvocationTarget(
14
+ runtime: RouterRuntime,
15
+ requestedModel: string,
16
+ ): ResolvedTarget {
17
+ const candidates = resolveInvocationTargets(runtime, requestedModel);
18
+ if (candidates.length === 0) {
19
+ throw new Error(
20
+ `The model \`${requestedModel}\` does not exist or you do not have access to it.`,
21
+ );
22
+ }
23
+ return candidates[0]!;
24
+ }
25
+
26
+ export function resolveInvocationTargets(
27
+ runtime: RouterRuntime,
28
+ requestedModel: string,
29
+ ): ResolvedTarget[] {
30
+ const parsed = parseModelSyntax(requestedModel, runtime.knownSuffixes);
31
+ const qualified = splitQualified(parsed.baseModel);
32
+ if (qualified) {
33
+ return resolveQualified(runtime, requestedModel, qualified.namespace, qualified.model, parsed.suffixes);
34
+ }
35
+
36
+ const matches: ResolvedTarget[] = [];
37
+ for (const namespace of runtime.executorOrder) {
38
+ const executor = runtime.executors.get(namespace)!;
39
+ const route = executor.routes.get(parsed.baseModel);
40
+ if (route && executor.exportsMatcher(parsed.baseModel)) {
41
+ matches.push({
42
+ requestedModel,
43
+ namespace,
44
+ source: route.target.kind,
45
+ suffixes: parsed.suffixes,
46
+ target: withSuffixTransforms(route.target, parsed.suffixes),
47
+ });
48
+ }
49
+ }
50
+
51
+ for (const namespace of runtime.providerOrder) {
52
+ const provider = runtime.providers.get(namespace)!;
53
+ if (!provider.noPrefix) continue;
54
+ if (provider.exportsMatcher(parsed.baseModel)) {
55
+ matches.push({
56
+ requestedModel,
57
+ namespace,
58
+ source: "provider",
59
+ suffixes: parsed.suffixes,
60
+ target: {
61
+ kind: "provider",
62
+ provider: namespace,
63
+ model: parsed.baseModel,
64
+ transforms: parsed.suffixes.map((suffix) => suffix.raw),
65
+ },
66
+ });
67
+ }
68
+ }
69
+
70
+ return matches;
71
+ }
72
+
73
+ export function listConfiguredModels(runtime: RouterRuntime): string[] {
74
+ const models: string[] = [];
75
+ const seen = new Set<string>();
76
+
77
+ for (const namespace of runtime.executorOrder) {
78
+ const executor = runtime.executors.get(namespace)!;
79
+ for (const alias of executor.routes.keys()) {
80
+ if (executor.exportsMatcher(alias)) {
81
+ models.push(`${namespace}/${alias}`);
82
+ }
83
+ }
84
+ }
85
+
86
+ for (const namespace of runtime.providerOrder) {
87
+ const provider = runtime.providers.get(namespace)!;
88
+ for (const model of provider.catalog) {
89
+ if (provider.exportsMatcher(model)) {
90
+ models.push(`${namespace}/${model}`);
91
+ if (provider.noPrefix && !seen.has(model)) {
92
+ seen.add(model);
93
+ models.push(model);
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ return models;
100
+ }
101
+
102
+ function resolveQualified(
103
+ runtime: RouterRuntime,
104
+ requestedModel: string,
105
+ namespace: string,
106
+ model: string,
107
+ suffixes: ModelSuffixSpec[],
108
+ ): ResolvedTarget[] {
109
+ const provider = runtime.providers.get(namespace);
110
+ const executor = runtime.executors.get(namespace);
111
+
112
+ if (provider && executor) {
113
+ throw new Error(`Ambiguous namespace \`${namespace}\`: both provider and executor are registered`);
114
+ }
115
+
116
+ if (executor) {
117
+ const route = executor.routes.get(model);
118
+ if (!route || !executor.exportsMatcher(model)) {
119
+ return [];
120
+ }
121
+ return [{
122
+ requestedModel,
123
+ namespace,
124
+ source: route.target.kind,
125
+ suffixes,
126
+ target: withSuffixTransforms(route.target, suffixes),
127
+ }];
128
+ }
129
+
130
+ if (provider) {
131
+ if (!provider.exportsMatcher(model)) {
132
+ return [];
133
+ }
134
+ return [{
135
+ requestedModel,
136
+ namespace,
137
+ source: "provider",
138
+ suffixes,
139
+ target: {
140
+ kind: "provider",
141
+ provider: namespace,
142
+ model,
143
+ transforms: suffixes.map((suffix) => suffix.raw),
144
+ },
145
+ }];
146
+ }
147
+
148
+ return [];
149
+ }
150
+
151
+ function withSuffixTransforms(
152
+ target: ExecutionTarget,
153
+ suffixes: ModelSuffixSpec[],
154
+ ): ExecutionTarget {
155
+ return {
156
+ ...target,
157
+ transforms: [...(target.transforms ?? []), ...suffixes.map((suffix) => suffix.raw)],
158
+ };
159
+ }
160
+
161
+ function splitQualified(model: string): { namespace: string; model: string } | null {
162
+ const index = model.indexOf("/");
163
+ if (index <= 0) {
164
+ return null;
165
+ }
166
+
167
+ return {
168
+ namespace: model.slice(0, index).toLowerCase(),
169
+ model: model.slice(index + 1),
170
+ };
171
+ }
@@ -0,0 +1,146 @@
1
+ import type { ExecutionTarget } from "@neutrome/lil-engine";
2
+ import type { AuthDriver } from "../auth/types.ts";
3
+ import { buildAuthChain } from "../auth/registry.ts";
4
+ import { createExportsMatcher } from "../util/glob.ts";
5
+ import type {
6
+ ExecutorNamespaceConfig,
7
+ ExecutorRouteConfig,
8
+ ProviderTargetConfig,
9
+ RouterConfig,
10
+ } from "./config.ts";
11
+
12
+ export type ProviderRuntime = {
13
+ name: string;
14
+ style: ProviderTargetConfig["style"];
15
+ apiBaseUrl: string;
16
+ endpointPath: string;
17
+ allowAnonymous: boolean;
18
+ noPrefix: boolean;
19
+ headers: Readonly<Record<string, string>>;
20
+ exportsMatcher: (model: string) => boolean;
21
+ catalog: readonly string[];
22
+ };
23
+
24
+ export type ExecutorRouteRuntime = {
25
+ requestedAlias: string;
26
+ target: ExecutionTarget;
27
+ transforms: readonly string[];
28
+ };
29
+
30
+ export type ExecutorRuntime = {
31
+ name: string;
32
+ exportsMatcher: (model: string) => boolean;
33
+ routes: ReadonlyMap<string, ExecutorRouteRuntime>;
34
+ };
35
+
36
+ export type RouterRuntime = {
37
+ config: RouterConfig;
38
+ providers: ReadonlyMap<string, ProviderRuntime>;
39
+ providerOrder: readonly string[];
40
+ executors: ReadonlyMap<string, ExecutorRuntime>;
41
+ executorOrder: readonly string[];
42
+ authChain: readonly AuthDriver[];
43
+ knownSuffixes: ReadonlySet<string>;
44
+ fetchImpl: typeof fetch;
45
+ };
46
+
47
+ export type CreateRouterOptions = {
48
+ config: RouterConfig;
49
+ fetchImpl?: typeof fetch;
50
+ knownSuffixes?: string[];
51
+ authDrivers?: readonly AuthDriver[];
52
+ };
53
+
54
+ export function createRouterRuntime(options: CreateRouterOptions): RouterRuntime {
55
+ const providers = new Map<string, ProviderRuntime>();
56
+ const executors = new Map<string, ExecutorRuntime>();
57
+ const providerOrder: string[] = [];
58
+ const executorOrder: string[] = [];
59
+
60
+ for (const [name, config] of Object.entries(options.config.providers)) {
61
+ providerOrder.push(name);
62
+ providers.set(name, buildProviderRuntime(name, config));
63
+ }
64
+
65
+ for (const [name, config] of Object.entries(options.config.executors)) {
66
+ executorOrder.push(name);
67
+ executors.set(name, buildExecutorRuntime(name, config));
68
+ }
69
+
70
+ return {
71
+ config: options.config,
72
+ providers,
73
+ providerOrder,
74
+ executors,
75
+ executorOrder,
76
+ authChain: options.authDrivers ?? buildAuthChain(options.config.auth),
77
+ knownSuffixes: new Set(options.knownSuffixes ?? ["slwin", "kvtools"]),
78
+ fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
79
+ };
80
+ }
81
+
82
+ function buildProviderRuntime(name: string, config: ProviderTargetConfig): ProviderRuntime {
83
+ return {
84
+ name,
85
+ style: config.style,
86
+ apiBaseUrl: config.api_base_url,
87
+ endpointPath: config.endpoint_path ?? defaultEndpointPath(config.style),
88
+ allowAnonymous: config.allow_anonymous ?? false,
89
+ noPrefix: config.no_prefix ?? false,
90
+ headers: config.headers ?? {},
91
+ exportsMatcher: createExportsMatcher(config.exports),
92
+ catalog: config.catalog ?? [],
93
+ };
94
+ }
95
+
96
+ function buildExecutorRuntime(name: string, config: ExecutorNamespaceConfig): ExecutorRuntime {
97
+ const routes = new Map<string, ExecutorRouteRuntime>();
98
+ for (const [alias, route] of Object.entries(config.models)) {
99
+ routes.set(alias, buildExecutorRoute(alias, route));
100
+ }
101
+
102
+ return {
103
+ name,
104
+ exportsMatcher: createExportsMatcher(config.exports),
105
+ routes,
106
+ };
107
+ }
108
+
109
+ function buildExecutorRoute(alias: string, route: ExecutorRouteConfig): ExecutorRouteRuntime {
110
+ if ("provider" in route) {
111
+ return {
112
+ requestedAlias: alias,
113
+ transforms: route.transforms ?? [],
114
+ target: {
115
+ kind: "provider",
116
+ provider: route.provider,
117
+ model: route.model,
118
+ transforms: route.transforms ?? [],
119
+ },
120
+ };
121
+ }
122
+
123
+ return {
124
+ requestedAlias: alias,
125
+ transforms: route.transforms ?? [],
126
+ target: {
127
+ kind: "executor",
128
+ executor: route.executor,
129
+ alias: route.alias ?? alias,
130
+ transforms: route.transforms ?? [],
131
+ },
132
+ };
133
+ }
134
+
135
+ function defaultEndpointPath(style: ProviderTargetConfig["style"]): string {
136
+ switch (style) {
137
+ case "chat-completions":
138
+ return "/chat/completions";
139
+ case "responses":
140
+ return "/responses";
141
+ case "anthropic-messages":
142
+ return "/v1/messages";
143
+ case "google-genai":
144
+ return "";
145
+ }
146
+ }
@@ -0,0 +1,15 @@
1
+ export function matchesGlob(value: string, pattern: string): boolean {
2
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
3
+ const regex = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
4
+ return regex.test(value);
5
+ }
6
+
7
+ export function createExportsMatcher(
8
+ patterns: readonly string[] | null | undefined,
9
+ ): (model: string) => boolean {
10
+ if (patterns === null) return () => true;
11
+ if (patterns === undefined) return () => true;
12
+ if (patterns.length === 0) return () => false;
13
+ return (value: string) =>
14
+ patterns.some((pattern) => matchesGlob(value, pattern));
15
+ }
@@ -0,0 +1,27 @@
1
+ const HOP_BY_HOP = new Set([
2
+ "connection",
3
+ "content-length",
4
+ "host",
5
+ "keep-alive",
6
+ "proxy-authenticate",
7
+ "proxy-authorization",
8
+ "te",
9
+ "trailer",
10
+ "transfer-encoding",
11
+ "upgrade",
12
+ ]);
13
+
14
+ export function cloneForwardHeaders(source: Headers): Headers {
15
+ const headers = new Headers();
16
+ source.forEach((value, key) => {
17
+ if (!HOP_BY_HOP.has(key.toLowerCase())) headers.set(key, value);
18
+ });
19
+ return headers;
20
+ }
21
+
22
+ export function isSse(headers: Headers): boolean {
23
+ return (
24
+ headers.get("content-type")?.toLowerCase().includes("text/event-stream") ??
25
+ false
26
+ );
27
+ }
@@ -0,0 +1,26 @@
1
+ export type ModelSuffixSpec = {
2
+ name: string;
3
+ args: string[];
4
+ raw: string;
5
+ };
6
+
7
+ export type ParsedModelSyntax = {
8
+ baseModel: string;
9
+ suffixes: ModelSuffixSpec[];
10
+ };
11
+
12
+ export function parseModelSyntax(
13
+ model: string,
14
+ knownSuffixes?: ReadonlySet<string>,
15
+ ): ParsedModelSyntax {
16
+ const [baseModel = "", ...suffixParts] = model.split("+");
17
+ const suffixes = suffixParts.filter(Boolean).map((part) => {
18
+ const [namePart = "", ...args] = part.split(":");
19
+ const name = decodeURIComponent(namePart).toLowerCase();
20
+ if (knownSuffixes && !knownSuffixes.has(name)) {
21
+ throw new Error(`Unknown model suffix: ${name}`);
22
+ }
23
+ return { name, args: args.map(decodeURIComponent), raw: part };
24
+ });
25
+ return { baseModel, suffixes };
26
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { eventDataLines, splitSseEvents } from "./sse.ts";
3
+
4
+ describe("SSE parsing", () => {
5
+ it("removes only the optional separator after data:", () => {
6
+ expect(eventDataLines("data: leading")).toEqual([" leading"]);
7
+ expect(eventDataLines("data:leading")).toEqual(["leading"]);
8
+ });
9
+
10
+ it("splits complete events without trimming payload whitespace", () => {
11
+ const split = splitSseEvents("data: one\n\ndata: two\n\n");
12
+
13
+ expect(split.events).toHaveLength(2);
14
+ expect(eventDataLines(split.events[0]!)).toEqual(["one"]);
15
+ expect(eventDataLines(split.events[1]!)).toEqual([" two"]);
16
+ expect(split.remainder).toBe("");
17
+ });
18
+ });
@@ -0,0 +1,23 @@
1
+ export function splitSseEvents(buffer: string): {
2
+ events: string[];
3
+ remainder: string;
4
+ } {
5
+ const normalized = buffer.replace(/\r\n/g, "\n");
6
+ const parts = normalized.split("\n\n");
7
+ const remainder = parts.pop() ?? "";
8
+ return { events: parts, remainder };
9
+ }
10
+
11
+ export function eventDataLines(event: string): string[] {
12
+ return event
13
+ .replace(/\r\n/g, "\n")
14
+ .split("\n")
15
+ .filter((line) => line.startsWith("data:"))
16
+ .map((line) => {
17
+ let data = line.slice(5);
18
+ if (data.startsWith(" ")) {
19
+ data = data.slice(1);
20
+ }
21
+ return data;
22
+ });
23
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { RouterConfig } from "./router/index.ts";
2
+ import { createApp } from "./example.ts";
3
+
4
+ const config: RouterConfig = {
5
+ trace: true,
6
+ auth: {
7
+ order: ["proxy", "env"],
8
+ proxy: {
9
+ headers: ["authorization", "x-openairouter-token"],
10
+ },
11
+ },
12
+ providers: {
13
+ openrouter: {
14
+ api_base_url: "https://openrouter.ai/api/v1",
15
+ style: "chat-completions",
16
+ allow_anonymous: false,
17
+ exports: ["*:free"],
18
+ },
19
+ },
20
+ executors: {},
21
+ };
22
+
23
+ let appPromise: Promise<ReturnType<typeof createApp>> | undefined;
24
+
25
+ async function getApp() {
26
+ return createApp({ config });
27
+ }
28
+
29
+ export default {
30
+ async fetch(
31
+ request: Request,
32
+ env: Record<string, unknown>,
33
+ ctx: ExecutionContext,
34
+ ): Promise<Response> {
35
+ appPromise ??= getApp().catch((error) => {
36
+ appPromise = undefined;
37
+ throw error;
38
+ });
39
+ const app = await appPromise;
40
+ return app.fetch(request, env, ctx);
41
+ },
42
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["node", "./worker-configuration.d.ts", "vitest/globals"],
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*.ts", "src/**/*.d.ts"],
8
+ "exclude": ["test", "dist", "node_modules"],
9
+ }
@@ -0,0 +1,3 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({});