@neutrome/open-ai-router 0.4.2 → 0.5.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.
Files changed (62) hide show
  1. package/.dev.vars.example +2 -0
  2. package/README.md +5 -1
  3. package/package.json +5 -4
  4. package/src/admission/access.ts +0 -1
  5. package/src/admission/types.ts +0 -1
  6. package/src/app/factory.test.ts +40 -34
  7. package/src/app/factory.ts +120 -173
  8. package/src/app/google-genai-route.ts +48 -0
  9. package/src/app/http-utils.ts +14 -0
  10. package/src/app/model-listing.ts +29 -0
  11. package/src/app/response-lifecycle.ts +66 -0
  12. package/src/app/types.ts +38 -0
  13. package/src/index.ts +11 -43
  14. package/src/otlp.ts +148 -0
  15. package/src/router/audit.ts +5 -12
  16. package/src/router/config.ts +4 -4
  17. package/src/router/error-codec.ts +170 -0
  18. package/src/router/errors.ts +38 -1
  19. package/src/router/execute.test.ts +177 -107
  20. package/src/router/execute.ts +68 -924
  21. package/src/router/execution-events.ts +39 -0
  22. package/src/router/execution-invocation.ts +144 -0
  23. package/src/router/execution-observation.ts +161 -0
  24. package/src/router/execution-resolution.ts +120 -0
  25. package/src/router/execution-runtime.test.ts +156 -83
  26. package/src/router/execution-runtime.ts +144 -542
  27. package/src/router/execution-transforms.ts +82 -0
  28. package/src/router/execution-types.ts +8 -19
  29. package/src/router/index.ts +6 -5
  30. package/src/router/mcp.ts +12 -6
  31. package/src/router/provider-invoker.ts +134 -0
  32. package/src/router/provider-stream.ts +192 -0
  33. package/src/router/provider-telemetry.ts +59 -0
  34. package/src/router/remote-mcp-execution.ts +61 -0
  35. package/src/router/request-program.ts +16 -0
  36. package/src/router/request-target.ts +56 -0
  37. package/src/router/resolve.test.ts +43 -10
  38. package/src/router/resolve.ts +50 -28
  39. package/src/router/response-delivery.ts +141 -0
  40. package/src/router/runtime.test.ts +56 -0
  41. package/src/router/runtime.ts +79 -9
  42. package/src/router/targets.ts +3 -1
  43. package/src/router/upstream-client.ts +137 -0
  44. package/src/telemetry.test.ts +50 -0
  45. package/src/telemetry.ts +316 -0
  46. package/src/trace-labels.ts +29 -0
  47. package/src/upstream-auth/env.ts +15 -3
  48. package/src/upstream-auth/proxy.ts +6 -1
  49. package/src/upstream-auth/registry.ts +3 -1
  50. package/src/util/glob.ts +1 -1
  51. package/src/util/model-syntax.ts +3 -3
  52. package/src/worker.ts +12 -6
  53. package/worker-configuration.d.ts +195 -164
  54. package/pnpm-workspace.yaml +0 -4
  55. package/src/admission/jwt.test.ts +0 -96
  56. package/src/admission/jwt.ts +0 -221
  57. package/src/app/handlers.ts +0 -198
  58. package/src/example.test.ts +0 -377
  59. package/src/example.ts +0 -73
  60. package/src/observer.test.ts +0 -54
  61. package/src/observer.ts +0 -315
  62. package/src/timing.ts +0 -36
@@ -0,0 +1,56 @@
1
+ import { getModel, type Program } from "@neutrome/lil-engine";
2
+ import { ExecutionError, RouterError } from "./errors.ts";
3
+ import type { Executor } from "./execution-types.ts";
4
+ import { resolveInvocationTarget, type ResolvedTarget } from "./resolve.ts";
5
+ import type { RouterRuntime } from "./runtime.ts";
6
+
7
+ export function resolveModelNamespaceExecutor(
8
+ runtime: RouterRuntime,
9
+ name: string,
10
+ ): Executor | null {
11
+ if (!runtime.modelNamespaces.has(name)) return null;
12
+ return {
13
+ async execute(request, ctx) {
14
+ const model = getModel(request);
15
+ if (!model)
16
+ throw new ExecutionError(
17
+ "resolution",
18
+ "Request is missing model for namespace executor resolution",
19
+ { stage: "target_resolution" },
20
+ );
21
+ return ctx.invoke(request, {
22
+ target: resolveInvocationTarget(runtime, `${name}/${model}`).target,
23
+ });
24
+ },
25
+ async *stream(request, ctx) {
26
+ const model = getModel(request);
27
+ if (!model)
28
+ throw new ExecutionError(
29
+ "resolution",
30
+ "Request is missing model for namespace executor resolution",
31
+ { stage: "target_resolution" },
32
+ );
33
+ yield* ctx.invokeStream(request, {
34
+ target: resolveInvocationTarget(runtime, `${name}/${model}`).target,
35
+ });
36
+ },
37
+ };
38
+ }
39
+
40
+ export function resolveRequestTarget(
41
+ runtime: RouterRuntime,
42
+ request: Program,
43
+ ): ResolvedTarget {
44
+ const model = getModel(request);
45
+ if (!model)
46
+ throw new RouterError("Request is missing `model`", 400, "missing_model");
47
+ try {
48
+ return resolveInvocationTarget(runtime, model);
49
+ } catch (error) {
50
+ throw new RouterError(
51
+ error instanceof Error ? error.message : "Model resolution failed",
52
+ 404,
53
+ "model_not_found",
54
+ );
55
+ }
56
+ }
@@ -1,14 +1,19 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { createRouterRuntime } from "./runtime.ts";
3
- import { listConfiguredModels, resolveInvocationTarget, resolveInvocationTargets } from "./resolve.ts";
3
+ import {
4
+ listConfiguredModels,
5
+ resolveInvocationTarget,
6
+ resolveInvocationTargets,
7
+ } from "./resolve.ts";
4
8
 
5
9
  describe("router resolution", () => {
6
10
  it("resolves executor-backed aliases before provider-backed models", () => {
7
11
  const runtime = createRouterRuntime({
8
12
  config: {
13
+ modelSuffixes: ["slwin"],
9
14
  providers: {
10
15
  openrouter: {
11
- api_base_url: "https://openrouter.ai/api/v1",
16
+ apiBaseUrl: "https://openrouter.ai/api/v1",
12
17
  style: "chat-completions",
13
18
  exports: ["*"],
14
19
  },
@@ -18,7 +23,10 @@ describe("router resolution", () => {
18
23
  exports: ["enei-*"],
19
24
  models: {
20
25
  "enei-1": { executorId: "enei-1" },
21
- "enei-1-pro": { executorId: "enei-1-pro", transforms: ["reasoning:high"] },
26
+ "enei-1-pro": {
27
+ executorId: "enei-1-pro",
28
+ transforms: ["reasoning:high"],
29
+ },
22
30
  },
23
31
  },
24
32
  },
@@ -39,19 +47,22 @@ describe("router resolution", () => {
39
47
  it("resolves qualified provider models and preserves suffix transforms", () => {
40
48
  const runtime = createRouterRuntime({
41
49
  config: {
50
+ modelSuffixes: ["slwin", "kvtools"],
42
51
  providers: {
43
52
  openrouter: {
44
- api_base_url: "https://openrouter.ai/api/v1",
53
+ apiBaseUrl: "https://openrouter.ai/api/v1",
45
54
  style: "chat-completions",
46
55
  exports: ["google/*"],
47
56
  },
48
57
  },
49
58
  modelNamespaces: {},
50
59
  },
51
- knownSuffixes: ["slwin", "kvtools"],
52
60
  });
53
61
 
54
- const resolved = resolveInvocationTarget(runtime, "openrouter/google/gemma-4-31b-it+kvtools");
62
+ const resolved = resolveInvocationTarget(
63
+ runtime,
64
+ "openrouter/google/gemma-4-31b-it+kvtools",
65
+ );
55
66
  expect(resolved.source).toBe("provider");
56
67
  expect(resolved.target).toEqual({
57
68
  kind: "provider",
@@ -61,12 +72,31 @@ describe("router resolution", () => {
61
72
  });
62
73
  });
63
74
 
75
+ it("requires configured suffixes", () => {
76
+ const runtime = createRouterRuntime({
77
+ config: {
78
+ providers: {
79
+ openrouter: {
80
+ apiBaseUrl: "https://openrouter.ai/api/v1",
81
+ style: "chat-completions",
82
+ exports: ["*"],
83
+ },
84
+ },
85
+ modelNamespaces: {},
86
+ },
87
+ });
88
+
89
+ expect(() =>
90
+ resolveInvocationTarget(runtime, "openrouter/openai/gpt-4.1+slwin:8"),
91
+ ).toThrow("Unknown model suffix: slwin");
92
+ });
93
+
64
94
  it("resolves namespace model routes that alias provider targets", () => {
65
95
  const runtime = createRouterRuntime({
66
96
  config: {
67
97
  providers: {
68
98
  openrouter: {
69
- api_base_url: "https://openrouter.ai/api/v1",
99
+ apiBaseUrl: "https://openrouter.ai/api/v1",
70
100
  style: "chat-completions",
71
101
  exports: ["openai/gpt-4.1-mini"],
72
102
  },
@@ -75,7 +105,10 @@ describe("router resolution", () => {
75
105
  semantyka: {
76
106
  exports: ["enei-*"],
77
107
  models: {
78
- "enei-1": { provider: "openrouter", model: "openai/gpt-4.1-mini" },
108
+ "enei-1": {
109
+ provider: "openrouter",
110
+ model: "openai/gpt-4.1-mini",
111
+ },
79
112
  },
80
113
  },
81
114
  },
@@ -98,7 +131,7 @@ describe("router resolution", () => {
98
131
  config: {
99
132
  providers: {
100
133
  openrouter: {
101
- api_base_url: "https://openrouter.ai/api/v1",
134
+ apiBaseUrl: "https://openrouter.ai/api/v1",
102
135
  style: "chat-completions",
103
136
  exports: ["*"],
104
137
  },
@@ -126,7 +159,7 @@ describe("router resolution", () => {
126
159
  config: {
127
160
  providers: {
128
161
  openrouter: {
129
- api_base_url: "https://openrouter.ai/api/v1",
162
+ apiBaseUrl: "https://openrouter.ai/api/v1",
130
163
  style: "chat-completions",
131
164
  exports: ["*"],
132
165
  },
@@ -1,5 +1,8 @@
1
1
  import type { ExecutionTarget } from "@neutrome/lilsdk-ts";
2
- import { parseModelSyntax, type ModelSuffixSpec } from "../util/model-syntax.ts";
2
+ import {
3
+ parseModelSyntax,
4
+ type ModelSuffixSpec,
5
+ } from "../util/model-syntax.ts";
3
6
  import type { RouterRuntime } from "./runtime.ts";
4
7
 
5
8
  export type ResolvedTarget = {
@@ -28,15 +31,22 @@ export function resolveInvocationTargets(
28
31
  runtime: RouterRuntime,
29
32
  requestedModel: string,
30
33
  ): ResolvedTarget[] {
31
- const parsed = parseModelSyntax(requestedModel, runtime.knownSuffixes);
34
+ const parsed = parseModelSyntax(requestedModel, runtime.modelSuffixes);
32
35
  const qualified = splitQualified(parsed.baseModel);
33
36
  if (qualified) {
34
- return resolveQualified(runtime, requestedModel, qualified.namespace, qualified.model, parsed.suffixes);
37
+ return resolveQualified(
38
+ runtime,
39
+ requestedModel,
40
+ qualified.namespace,
41
+ qualified.model,
42
+ parsed.suffixes,
43
+ );
35
44
  }
36
45
 
37
46
  const matches: ResolvedTarget[] = [];
38
47
  for (const namespace of runtime.modelNamespaceOrder) {
39
- const modelNamespace = runtime.modelNamespaces.get(namespace)!;
48
+ const modelNamespace = runtime.modelNamespaces.get(namespace);
49
+ if (!modelNamespace) continue;
40
50
  const route = modelNamespace.routes.get(parsed.baseModel);
41
51
  if (route && modelNamespace.exportsMatcher(parsed.baseModel)) {
42
52
  matches.push({
@@ -57,7 +67,8 @@ export function listConfiguredModels(runtime: RouterRuntime): string[] {
57
67
  const models: string[] = [];
58
68
 
59
69
  for (const namespace of runtime.modelNamespaceOrder) {
60
- const modelNamespace = runtime.modelNamespaces.get(namespace)!;
70
+ const modelNamespace = runtime.modelNamespaces.get(namespace);
71
+ if (!modelNamespace) continue;
61
72
  for (const alias of modelNamespace.routes.keys()) {
62
73
  if (modelNamespace.exportsMatcher(alias)) {
63
74
  models.push(`${namespace}/${alias}`);
@@ -79,7 +90,9 @@ function resolveQualified(
79
90
  const modelNamespace = runtime.modelNamespaces.get(namespace);
80
91
 
81
92
  if (provider && modelNamespace) {
82
- throw new Error(`Ambiguous namespace \`${namespace}\`: both provider and model namespace are registered`);
93
+ throw new Error(
94
+ `Ambiguous namespace \`${namespace}\`: both provider and model namespace are registered`,
95
+ );
83
96
  }
84
97
 
85
98
  if (modelNamespace) {
@@ -87,33 +100,37 @@ function resolveQualified(
87
100
  if (!route || !modelNamespace.exportsMatcher(model)) {
88
101
  return [];
89
102
  }
90
- return [{
91
- requestedModel,
92
- namespace,
93
- source: route.target.kind,
94
- suffixes,
95
- mcps: route.mcps,
96
- target: withSuffixTransforms(route.target, suffixes),
97
- }];
103
+ return [
104
+ {
105
+ requestedModel,
106
+ namespace,
107
+ source: route.target.kind,
108
+ suffixes,
109
+ mcps: route.mcps,
110
+ target: withSuffixTransforms(route.target, suffixes),
111
+ },
112
+ ];
98
113
  }
99
114
 
100
115
  if (provider) {
101
116
  if (!provider.exportsMatcher(model)) {
102
117
  return [];
103
118
  }
104
- return [{
105
- requestedModel,
106
- namespace,
107
- source: "provider",
108
- suffixes,
109
- mcps: [],
110
- target: {
111
- kind: "provider",
112
- provider: namespace,
113
- model,
114
- transforms: suffixes.map((suffix) => suffix.raw),
119
+ return [
120
+ {
121
+ requestedModel,
122
+ namespace,
123
+ source: "provider",
124
+ suffixes,
125
+ mcps: [],
126
+ target: {
127
+ kind: "provider",
128
+ provider: namespace,
129
+ model,
130
+ transforms: suffixes.map((suffix) => suffix.raw),
131
+ },
115
132
  },
116
- }];
133
+ ];
117
134
  }
118
135
 
119
136
  return [];
@@ -125,11 +142,16 @@ function withSuffixTransforms(
125
142
  ): ExecutionTarget {
126
143
  return {
127
144
  ...target,
128
- transforms: [...(target.transforms ?? []), ...suffixes.map((suffix) => suffix.raw)],
145
+ transforms: [
146
+ ...(target.transforms ?? []),
147
+ ...suffixes.map((suffix) => suffix.raw),
148
+ ],
129
149
  };
130
150
  }
131
151
 
132
- function splitQualified(model: string): { namespace: string; model: string } | null {
152
+ function splitQualified(
153
+ model: string,
154
+ ): { namespace: string; model: string } | null {
133
155
  const index = model.indexOf("/");
134
156
  if (index <= 0) {
135
157
  return null;
@@ -0,0 +1,141 @@
1
+ import {
2
+ emitProviderError,
3
+ emitProviderStreamChunk,
4
+ type Program,
5
+ type ProviderStyle,
6
+ } from "@neutrome/lil-engine";
7
+ import { errorProgramFromUnknown, toBody } from "./error-codec.ts";
8
+ import type { ExecutionRuntimeOptions } from "./execution-types.ts";
9
+ import type { ResolvedTarget } from "./resolve.ts";
10
+ import { observeTimedExecution } from "./execution-events.ts";
11
+
12
+ const encoder = new TextEncoder();
13
+ const decoder = new TextDecoder();
14
+
15
+ export async function streamExecutionResponse(
16
+ responseStyle: ProviderStyle,
17
+ chunks: AsyncIterable<Program>,
18
+ resolved: ResolvedTarget,
19
+ observe: ExecutionRuntimeOptions["observe"] | undefined,
20
+ input: { requestId: string; executionId: string; startedAtMs: number },
21
+ cleanup?: () => Promise<void>,
22
+ ): Promise<Response> {
23
+ const iterator = chunks[Symbol.asyncIterator]();
24
+
25
+ const first = await iterator.next();
26
+ observeTimedExecution({
27
+ observe,
28
+ kind: "response.stream_first_chunk",
29
+ requestId: input.requestId,
30
+ executionId: input.executionId,
31
+ startedAtMs: input.startedAtMs,
32
+ finishedAtMs: Date.now(),
33
+ data: {
34
+ streaming: true,
35
+ source: first.done ? "empty" : "chunk",
36
+ },
37
+ });
38
+ if (first.done) {
39
+ await cleanup?.();
40
+ const stream = new ReadableStream<Uint8Array>({
41
+ start(controller) {
42
+ if (responseStyle === "chat-completions") {
43
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
44
+ }
45
+ controller.close();
46
+ },
47
+ });
48
+ return new Response(stream, {
49
+ headers: {
50
+ "content-type": "text/event-stream; charset=utf-8",
51
+ ...routingHeaders(resolved),
52
+ },
53
+ });
54
+ }
55
+
56
+ let done = false;
57
+ const stream = new ReadableStream<Uint8Array>({
58
+ start(controller) {
59
+ const bytes = emitProviderStreamChunk(responseStyle, first.value);
60
+ controller.enqueue(encoder.encode(`data: ${decoder.decode(bytes)}\n\n`));
61
+ },
62
+ async pull(controller) {
63
+ if (done) return;
64
+ try {
65
+ const result = await iterator.next();
66
+ if (result.done) {
67
+ done = true;
68
+ if (responseStyle === "chat-completions") {
69
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
70
+ }
71
+ await cleanup?.();
72
+ controller.close();
73
+ return;
74
+ }
75
+ const bytes = emitProviderStreamChunk(responseStyle, result.value);
76
+ controller.enqueue(
77
+ encoder.encode(`data: ${decoder.decode(bytes)}\n\n`),
78
+ );
79
+ } catch (error) {
80
+ done = true;
81
+ await cleanup?.();
82
+ const program = errorProgramFromUnknown(error);
83
+ const bytes = emitProviderError(responseStyle, program);
84
+ controller.enqueue(
85
+ encoder.encode(`event: error\ndata: ${decoder.decode(bytes)}\n\n`),
86
+ );
87
+ controller.close();
88
+ }
89
+ },
90
+ async cancel() {
91
+ await cleanup?.();
92
+ await iterator.return?.();
93
+ },
94
+ });
95
+
96
+ return new Response(stream, {
97
+ headers: {
98
+ "content-type": "text/event-stream; charset=utf-8",
99
+ ...routingHeaders(resolved),
100
+ },
101
+ });
102
+ }
103
+
104
+ export function jsonExecutionResponse(
105
+ body: Uint8Array,
106
+ resolved: ResolvedTarget,
107
+ userTime: number,
108
+ ): Response {
109
+ return new Response(toBody(body), {
110
+ headers: {
111
+ "content-type": "application/json; charset=utf-8",
112
+ ...routingHeaders(resolved),
113
+ ...timingHeaders(userTime),
114
+ },
115
+ });
116
+ }
117
+
118
+ function routingHeaders(resolved: ResolvedTarget): Record<string, string> {
119
+ if (resolved.target.kind === "provider") {
120
+ return {
121
+ "x-openairouter-source": resolved.source,
122
+ "x-openairouter-namespace": resolved.namespace,
123
+ "x-openairouter-provider-id": resolved.target.provider ?? "",
124
+ "x-openairouter-model-id": resolved.target.model,
125
+ };
126
+ }
127
+
128
+ return {
129
+ "x-openairouter-engine": "open-ai-router",
130
+ "x-openairouter-source": resolved.source,
131
+ "x-openairouter-namespace": resolved.namespace,
132
+ "x-openairouter-executor-id": resolved.target.executorId,
133
+ "x-openairouter-model-id": resolved.target.alias,
134
+ };
135
+ }
136
+
137
+ function timingHeaders(userTimeMs: number): Record<string, string> {
138
+ return {
139
+ "x-openairouter-user-time": String(userTimeMs),
140
+ };
141
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createRouterRuntime } from "./runtime.ts";
3
+
4
+ const provider = {
5
+ apiBaseUrl: "https://api.example.test/v1",
6
+ style: "chat-completions" as const,
7
+ };
8
+
9
+ describe("router runtime configuration", () => {
10
+ it("rejects invalid references and URLs at startup", () => {
11
+ expect(() =>
12
+ createRouterRuntime({
13
+ config: {
14
+ providers: {
15
+ broken: { ...provider, apiBaseUrl: "file:///tmp/provider" },
16
+ },
17
+ modelNamespaces: {},
18
+ },
19
+ }),
20
+ ).toThrow("Provider broken has an invalid HTTP URL");
21
+
22
+ expect(() =>
23
+ createRouterRuntime({
24
+ config: {
25
+ providers: { upstream: provider },
26
+ modelNamespaces: {
27
+ default: {
28
+ models: { chat: { provider: "missing", model: "chat" } },
29
+ },
30
+ },
31
+ },
32
+ }),
33
+ ).toThrow("references unknown provider missing");
34
+ });
35
+
36
+ it("accepts only distinct, usable configured suffix names", () => {
37
+ expect(() =>
38
+ createRouterRuntime({
39
+ config: {
40
+ modelSuffixes: ["trim", "TRIM"],
41
+ providers: { upstream: provider },
42
+ modelNamespaces: {},
43
+ },
44
+ }),
45
+ ).toThrow("Invalid model suffix: TRIM");
46
+
47
+ const runtime = createRouterRuntime({
48
+ config: {
49
+ modelSuffixes: ["trim"],
50
+ providers: { upstream: provider },
51
+ modelNamespaces: {},
52
+ },
53
+ });
54
+ expect(runtime.modelSuffixes).toEqual(new Set(["trim"]));
55
+ });
56
+ });
@@ -40,7 +40,7 @@ export type RouterRuntime = {
40
40
  modelNamespaceOrder: readonly string[];
41
41
  mcps: ReadonlyMap<string, RemoteMcpServerRuntime>;
42
42
  upstreamAuthChain: readonly UpstreamAuthDriver[];
43
- knownSuffixes: ReadonlySet<string>;
43
+ modelSuffixes: ReadonlySet<string>;
44
44
  fetchImpl: typeof fetch;
45
45
  };
46
46
 
@@ -51,11 +51,13 @@ export type RemoteMcpServerRuntime = RemoteMcpServerConfig & {
51
51
  export type CreateRouterOptions = {
52
52
  config: RouterConfig;
53
53
  fetchImpl?: typeof fetch;
54
- knownSuffixes?: string[];
55
54
  upstreamAuthDrivers?: readonly UpstreamAuthDriver[];
56
55
  };
57
56
 
58
- export function createRouterRuntime(options: CreateRouterOptions): RouterRuntime {
57
+ export function createRouterRuntime(
58
+ options: CreateRouterOptions,
59
+ ): RouterRuntime {
60
+ validateRouterConfig(options.config);
59
61
  const providers = new Map<string, ProviderRuntime>();
60
62
  const modelNamespaces = new Map<string, ModelNamespaceRuntime>();
61
63
  const mcps = new Map<string, RemoteMcpServerRuntime>();
@@ -82,24 +84,89 @@ export function createRouterRuntime(options: CreateRouterOptions): RouterRuntime
82
84
  modelNamespaces,
83
85
  modelNamespaceOrder,
84
86
  mcps,
85
- upstreamAuthChain: options.upstreamAuthDrivers ?? buildUpstreamAuthChain(options.config.upstreamAuth),
86
- knownSuffixes: new Set(options.knownSuffixes ?? ["slwin", "kvtools"]),
87
+ upstreamAuthChain:
88
+ options.upstreamAuthDrivers ??
89
+ buildUpstreamAuthChain(options.config.upstreamAuth),
90
+ modelSuffixes: new Set(options.config.modelSuffixes ?? []),
87
91
  fetchImpl: options.fetchImpl ?? globalThis.fetch.bind(globalThis),
88
92
  };
89
93
  }
90
94
 
91
- function buildProviderRuntime(name: string, config: ProviderTargetConfig): ProviderRuntime {
95
+ function validateRouterConfig(config: RouterConfig): void {
96
+ validateModelSuffixes(config.modelSuffixes);
97
+ for (const [name, provider] of Object.entries(config.providers)) {
98
+ validateHttpUrl(provider.apiBaseUrl, `Provider ${name}`);
99
+ }
100
+ for (const [name, mcp] of Object.entries(config.mcps ?? {})) {
101
+ validateHttpUrl(mcp.url, `MCP ${name}`);
102
+ }
103
+ for (const [namespace, namespaceConfig] of Object.entries(
104
+ config.modelNamespaces,
105
+ )) {
106
+ if (Object.keys(namespaceConfig.models).length === 0) {
107
+ throw new Error(`Model namespace ${namespace} has no routes`);
108
+ }
109
+ for (const [alias, route] of Object.entries(namespaceConfig.models)) {
110
+ if ("provider" in route && !config.providers[route.provider]) {
111
+ throw new Error(
112
+ `Model route ${namespace}/${alias} references unknown provider ${route.provider}`,
113
+ );
114
+ }
115
+ for (const mcp of route.mcps ?? []) {
116
+ if (!config.mcps?.[mcp]) {
117
+ throw new Error(
118
+ `Model route ${namespace}/${alias} references unknown MCP ${mcp}`,
119
+ );
120
+ }
121
+ }
122
+ }
123
+ }
124
+ }
125
+
126
+ function validateModelSuffixes(suffixes: readonly string[] | undefined): void {
127
+ if (!suffixes) return;
128
+ const names = new Set<string>();
129
+ for (const suffix of suffixes) {
130
+ if (
131
+ !suffix ||
132
+ suffix !== suffix.trim().toLowerCase() ||
133
+ /[+:]/.test(suffix) ||
134
+ names.has(suffix)
135
+ ) {
136
+ throw new Error(`Invalid model suffix: ${suffix}`);
137
+ }
138
+ names.add(suffix);
139
+ }
140
+ }
141
+
142
+ function validateHttpUrl(value: string, label: string): void {
143
+ try {
144
+ const url = new URL(value);
145
+ if (url.protocol !== "http:" && url.protocol !== "https:")
146
+ throw new Error("unsupported protocol");
147
+ } catch {
148
+ throw new Error(`${label} has an invalid HTTP URL`);
149
+ }
150
+ }
151
+
152
+ function buildProviderRuntime(
153
+ name: string,
154
+ config: ProviderTargetConfig,
155
+ ): ProviderRuntime {
92
156
  return {
93
157
  name,
94
158
  style: config.style,
95
- apiBaseUrl: config.api_base_url,
159
+ apiBaseUrl: config.apiBaseUrl,
96
160
  endpointPath: defaultEndpointPath(config.style),
97
161
  headers: config.headers ?? {},
98
162
  exportsMatcher: createExportsMatcher(config.exports),
99
163
  };
100
164
  }
101
165
 
102
- function buildModelNamespaceRuntime(name: string, config: ModelNamespaceConfig): ModelNamespaceRuntime {
166
+ function buildModelNamespaceRuntime(
167
+ name: string,
168
+ config: ModelNamespaceConfig,
169
+ ): ModelNamespaceRuntime {
103
170
  const routes = new Map<string, ModelRouteRuntime>();
104
171
  for (const [alias, route] of Object.entries(config.models)) {
105
172
  routes.set(alias, buildModelRoute(alias, route));
@@ -112,7 +179,10 @@ function buildModelNamespaceRuntime(name: string, config: ModelNamespaceConfig):
112
179
  };
113
180
  }
114
181
 
115
- function buildModelRoute(alias: string, route: ModelRouteConfig): ModelRouteRuntime {
182
+ function buildModelRoute(
183
+ alias: string,
184
+ route: ModelRouteConfig,
185
+ ): ModelRouteRuntime {
116
186
  if ("provider" in route) {
117
187
  return {
118
188
  requestedAlias: alias,
@@ -2,7 +2,9 @@ import type { ExecutionTarget } from "./execution-types.ts";
2
2
 
3
3
  export function executionTargetId(target: ExecutionTarget): string {
4
4
  return target.kind === "provider"
5
- ? target.provider ? `${target.provider}/${target.model}` : target.model
5
+ ? target.provider
6
+ ? `${target.provider}/${target.model}`
7
+ : target.model
6
8
  : `${target.executorId}/${target.alias}`;
7
9
  }
8
10