@neutrome/open-ai-router 0.9.3 → 0.9.6
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 +2 -2
- package/src/app/factory.test.ts +2 -0
- package/src/app/factory.ts +0 -3
- package/src/app/types.ts +0 -1
- package/src/router/config.ts +1 -0
- package/src/router/execute.test.ts +76 -2
- package/src/router/execute.ts +1 -14
- package/src/router/provider-invoker.ts +5 -11
- package/src/router/provider-stream.ts +1 -2
- package/src/router/remote-mcp-execution.ts +0 -1
- package/src/router/resolve.test.ts +7 -0
- package/src/router/runtime.test.ts +14 -0
- package/src/router/runtime.ts +11 -0
- package/src/router/upstream-client.test.ts +1 -0
- package/src/worker.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neutrome/open-ai-router",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"posthog-node": "^5.41.0",
|
|
18
18
|
"zod": "^4.2.1",
|
|
19
19
|
"@neutrome/lil-engine": "0.6.1",
|
|
20
|
-
"@neutrome/lilsdk": "0.6.
|
|
20
|
+
"@neutrome/lilsdk": "0.6.5"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^25.9.3",
|
package/src/app/factory.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ const config: RouterConfig = {
|
|
|
10
10
|
openrouter: {
|
|
11
11
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
12
12
|
style: "chat-completions",
|
|
13
|
+
timeoutMs: 120_000,
|
|
13
14
|
exports: ["*"],
|
|
14
15
|
},
|
|
15
16
|
},
|
|
@@ -31,6 +32,7 @@ describe("createRouterApp trace finalization", () => {
|
|
|
31
32
|
google: {
|
|
32
33
|
apiBaseUrl: "https://google.example.test/v1beta/openai",
|
|
33
34
|
style: "chat-completions",
|
|
35
|
+
timeoutMs: 120_000,
|
|
34
36
|
exports: ["*"],
|
|
35
37
|
},
|
|
36
38
|
},
|
package/src/app/factory.ts
CHANGED
|
@@ -300,9 +300,6 @@ export function createRouterApp(options: RouterAppOptions) {
|
|
|
300
300
|
onNamedInvocation(result) {
|
|
301
301
|
usage.recordInvocation(result);
|
|
302
302
|
},
|
|
303
|
-
...(options.upstreamTimeoutMs !== undefined
|
|
304
|
-
? { upstreamTimeoutMs: options.upstreamTimeoutMs }
|
|
305
|
-
: {}),
|
|
306
303
|
...(options.remoteMcpClientFactory
|
|
307
304
|
? { remoteMcpClientFactory: options.remoteMcpClientFactory }
|
|
308
305
|
: {}),
|
package/src/app/types.ts
CHANGED
|
@@ -39,7 +39,6 @@ export type RouterAppOptions = {
|
|
|
39
39
|
observe?: ExecutionRuntimeOptions["observe"];
|
|
40
40
|
requestIdFactory?: ExecutionRuntimeOptions["requestIdFactory"];
|
|
41
41
|
executionIdFactory?: ExecutionRuntimeOptions["executionIdFactory"];
|
|
42
|
-
upstreamTimeoutMs?: number;
|
|
43
42
|
telemetryDistinctId?: string;
|
|
44
43
|
remoteMcpClientFactory?: RemoteMcpClientFactory;
|
|
45
44
|
};
|
package/src/router/config.ts
CHANGED
|
@@ -20,6 +20,7 @@ describe("router execution", () => {
|
|
|
20
20
|
openrouter: {
|
|
21
21
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
22
22
|
style: "chat-completions",
|
|
23
|
+
timeoutMs: 120_000,
|
|
23
24
|
exports: ["*"],
|
|
24
25
|
},
|
|
25
26
|
},
|
|
@@ -121,6 +122,7 @@ describe("router execution", () => {
|
|
|
121
122
|
openrouter: {
|
|
122
123
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
123
124
|
style: "chat-completions",
|
|
125
|
+
timeoutMs: 120_000,
|
|
124
126
|
exports: ["*"],
|
|
125
127
|
},
|
|
126
128
|
},
|
|
@@ -276,6 +278,7 @@ describe("router execution", () => {
|
|
|
276
278
|
openai: {
|
|
277
279
|
apiBaseUrl: "https://api.openai.com/v1",
|
|
278
280
|
style: "responses",
|
|
281
|
+
timeoutMs: 120_000,
|
|
279
282
|
exports: ["*"],
|
|
280
283
|
},
|
|
281
284
|
},
|
|
@@ -356,6 +359,7 @@ describe("router execution", () => {
|
|
|
356
359
|
anthropic: {
|
|
357
360
|
apiBaseUrl: "https://api.anthropic.com",
|
|
358
361
|
style: "anthropic-messages",
|
|
362
|
+
timeoutMs: 120_000,
|
|
359
363
|
exports: ["*"],
|
|
360
364
|
},
|
|
361
365
|
},
|
|
@@ -437,6 +441,7 @@ describe("router execution", () => {
|
|
|
437
441
|
google: {
|
|
438
442
|
apiBaseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
|
439
443
|
style: "google-genai",
|
|
444
|
+
timeoutMs: 120_000,
|
|
440
445
|
exports: ["*"],
|
|
441
446
|
},
|
|
442
447
|
},
|
|
@@ -570,6 +575,73 @@ describe("router execution", () => {
|
|
|
570
575
|
});
|
|
571
576
|
});
|
|
572
577
|
|
|
578
|
+
it("applies configured suffix transforms to nested executor invocations", async () => {
|
|
579
|
+
let transformApplied = false;
|
|
580
|
+
const runtime = createRouterRuntime({
|
|
581
|
+
config: {
|
|
582
|
+
modelSuffixes: ["trim-input"],
|
|
583
|
+
providers: {},
|
|
584
|
+
modelNamespaces: {
|
|
585
|
+
namespace: {
|
|
586
|
+
exports: ["*"],
|
|
587
|
+
models: {
|
|
588
|
+
root: { target: { executor: "root" } },
|
|
589
|
+
model: { target: { executor: "model" } },
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
const ctx: RequestContext = {
|
|
596
|
+
request: new Request("http://local/v1/chat/completions", {
|
|
597
|
+
method: "POST",
|
|
598
|
+
headers: { "content-type": "application/json" },
|
|
599
|
+
body: JSON.stringify({
|
|
600
|
+
model: "namespace/root",
|
|
601
|
+
messages: [{ role: "user", content: "hi" }],
|
|
602
|
+
}),
|
|
603
|
+
}),
|
|
604
|
+
incomingAuth: new Map(),
|
|
605
|
+
env: {},
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const response = await handleChatCompletions({
|
|
609
|
+
runtime,
|
|
610
|
+
ctx,
|
|
611
|
+
transforms: {
|
|
612
|
+
"trim-input": {
|
|
613
|
+
name: "trim-input",
|
|
614
|
+
capabilities: [],
|
|
615
|
+
apply(request) {
|
|
616
|
+
transformApplied = true;
|
|
617
|
+
return request;
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
executorImplementations: {
|
|
622
|
+
root: {
|
|
623
|
+
async execute(request, executorContext) {
|
|
624
|
+
return executorContext.invoke(
|
|
625
|
+
"namespace/model+trim-input",
|
|
626
|
+
request,
|
|
627
|
+
);
|
|
628
|
+
},
|
|
629
|
+
async *stream() {},
|
|
630
|
+
},
|
|
631
|
+
model: {
|
|
632
|
+
async execute() {
|
|
633
|
+
expect(transformApplied).toBe(true);
|
|
634
|
+
return appendAssistantMessage(createProgram(), "done");
|
|
635
|
+
},
|
|
636
|
+
async *stream() {},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
expect(response.status).toBe(200);
|
|
642
|
+
expect(transformApplied).toBe(true);
|
|
643
|
+
});
|
|
644
|
+
|
|
573
645
|
it("streams provider chunks as openai sse output", async () => {
|
|
574
646
|
const runtime = createRouterRuntime({
|
|
575
647
|
config: {
|
|
@@ -577,6 +649,7 @@ describe("router execution", () => {
|
|
|
577
649
|
openrouter: {
|
|
578
650
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
579
651
|
style: "chat-completions",
|
|
652
|
+
timeoutMs: 120_000,
|
|
580
653
|
exports: ["*"],
|
|
581
654
|
},
|
|
582
655
|
},
|
|
@@ -655,6 +728,7 @@ describe("router execution", () => {
|
|
|
655
728
|
openrouter: {
|
|
656
729
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
657
730
|
style: "chat-completions",
|
|
731
|
+
timeoutMs: 1_000,
|
|
658
732
|
exports: ["*"],
|
|
659
733
|
},
|
|
660
734
|
},
|
|
@@ -694,13 +768,12 @@ describe("router execution", () => {
|
|
|
694
768
|
const response = await handleChatCompletions({
|
|
695
769
|
runtime,
|
|
696
770
|
ctx,
|
|
697
|
-
upstreamTimeoutMs: 5,
|
|
698
771
|
});
|
|
699
772
|
|
|
700
773
|
expect(response.status).toBe(504);
|
|
701
774
|
expect(await response.json()).toEqual({
|
|
702
775
|
error: {
|
|
703
|
-
message: "Upstream provider timed out after
|
|
776
|
+
message: "Upstream provider timed out after 1000ms",
|
|
704
777
|
type: "invalid_request_error",
|
|
705
778
|
param: null,
|
|
706
779
|
code: "upstream_timeout",
|
|
@@ -716,6 +789,7 @@ describe("router execution", () => {
|
|
|
716
789
|
anthropic: {
|
|
717
790
|
apiBaseUrl: "https://api.anthropic.com",
|
|
718
791
|
style: "anthropic-messages",
|
|
792
|
+
timeoutMs: 120_000,
|
|
719
793
|
exports: ["*"],
|
|
720
794
|
},
|
|
721
795
|
},
|
package/src/router/execute.ts
CHANGED
|
@@ -24,12 +24,6 @@ import {
|
|
|
24
24
|
toBody,
|
|
25
25
|
toProviderError,
|
|
26
26
|
} from "./error-codec.ts";
|
|
27
|
-
import {
|
|
28
|
-
createUpstreamSignal,
|
|
29
|
-
fetchProvider,
|
|
30
|
-
getProviderOrThrow,
|
|
31
|
-
normalizeUpstreamAbort,
|
|
32
|
-
} from "./upstream-client.ts";
|
|
33
27
|
import { emitProviderUsage, injectStreamUsage } from "./provider-telemetry.ts";
|
|
34
28
|
import {
|
|
35
29
|
applyRoutingHeaders,
|
|
@@ -66,8 +60,6 @@ import { countUserProgramTokens, protocolUsage } from "../usage/user-usage.ts";
|
|
|
66
60
|
|
|
67
61
|
const encoder = new TextEncoder();
|
|
68
62
|
const decoder = new TextDecoder();
|
|
69
|
-
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120_000;
|
|
70
|
-
|
|
71
63
|
export type RouterExecutionOptions = Pick<
|
|
72
64
|
ExecutionRuntimeOptions,
|
|
73
65
|
| "cache"
|
|
@@ -78,7 +70,6 @@ export type RouterExecutionOptions = Pick<
|
|
|
78
70
|
> & {
|
|
79
71
|
executorImplementations?: Readonly<Record<string, Executor>>;
|
|
80
72
|
transforms?: Readonly<Record<string, ProgramTransform>>;
|
|
81
|
-
upstreamTimeoutMs?: number;
|
|
82
73
|
remoteMcpClientFactory?: RemoteMcpClientFactory;
|
|
83
74
|
incomingExecutionId?: string;
|
|
84
75
|
userUsage?: { inputTokens: number };
|
|
@@ -132,11 +123,7 @@ export function createRouterExecutionRuntime(
|
|
|
132
123
|
}
|
|
133
124
|
return resolveRequestTarget(runtime, request).target;
|
|
134
125
|
},
|
|
135
|
-
providerInvoker: createFetchProviderInvoker(
|
|
136
|
-
runtime,
|
|
137
|
-
ctx,
|
|
138
|
-
options.upstreamTimeoutMs,
|
|
139
|
-
),
|
|
126
|
+
providerInvoker: createFetchProviderInvoker(runtime, ctx),
|
|
140
127
|
};
|
|
141
128
|
|
|
142
129
|
if (options.executorImplementations) {
|
|
@@ -19,17 +19,17 @@ import type { RouterRuntime } from "./runtime.ts";
|
|
|
19
19
|
import { observeTimedExecution } from "./execution-events.ts";
|
|
20
20
|
import { normalizeChatRequest } from "../telemetry/chat-normalization.ts";
|
|
21
21
|
|
|
22
|
-
const DEFAULT_UPSTREAM_TIMEOUT_MS = 120_000;
|
|
23
|
-
|
|
24
22
|
export function createFetchProviderInvoker(
|
|
25
23
|
runtime: RouterRuntime,
|
|
26
24
|
ctx: RequestContext,
|
|
27
|
-
upstreamTimeoutMs = DEFAULT_UPSTREAM_TIMEOUT_MS,
|
|
28
25
|
): ProviderInvoker {
|
|
29
26
|
return {
|
|
30
27
|
async execute(request, providerCtx) {
|
|
31
28
|
const provider = getProviderOrThrow(runtime, providerCtx.target.provider);
|
|
32
|
-
const timed = createUpstreamSignal(
|
|
29
|
+
const timed = createUpstreamSignal(
|
|
30
|
+
providerCtx.signal,
|
|
31
|
+
provider.timeoutMs,
|
|
32
|
+
);
|
|
33
33
|
|
|
34
34
|
try {
|
|
35
35
|
const resolvedRequest = setModel(request, providerCtx.target.model);
|
|
@@ -126,13 +126,7 @@ export function createFetchProviderInvoker(
|
|
|
126
126
|
},
|
|
127
127
|
|
|
128
128
|
stream(request, providerCtx) {
|
|
129
|
-
return streamProvider(
|
|
130
|
-
runtime,
|
|
131
|
-
ctx,
|
|
132
|
-
upstreamTimeoutMs,
|
|
133
|
-
request,
|
|
134
|
-
providerCtx,
|
|
135
|
-
);
|
|
129
|
+
return streamProvider(runtime, ctx, request, providerCtx);
|
|
136
130
|
},
|
|
137
131
|
};
|
|
138
132
|
}
|
|
@@ -29,12 +29,11 @@ const decoder = new TextDecoder();
|
|
|
29
29
|
export async function* streamProvider(
|
|
30
30
|
runtime: RouterRuntime,
|
|
31
31
|
ctx: RequestContext,
|
|
32
|
-
upstreamTimeoutMs: number,
|
|
33
32
|
request: Program,
|
|
34
33
|
providerCtx: ProviderInvocationContext,
|
|
35
34
|
): AsyncGenerator<Program> {
|
|
36
35
|
const provider = getProviderOrThrow(runtime, providerCtx.target.provider);
|
|
37
|
-
const timed = createUpstreamSignal(providerCtx.signal,
|
|
36
|
+
const timed = createUpstreamSignal(providerCtx.signal, provider.timeoutMs);
|
|
38
37
|
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
39
38
|
let buffer = "";
|
|
40
39
|
|
|
@@ -11,7 +11,6 @@ import type { RouterRuntime } from "./runtime.ts";
|
|
|
11
11
|
type RemoteMcpExecutionOptions = {
|
|
12
12
|
executorImplementations?: Readonly<Record<string, Executor>>;
|
|
13
13
|
transforms?: Readonly<Record<string, ProgramTransform>>;
|
|
14
|
-
upstreamTimeoutMs?: number;
|
|
15
14
|
remoteMcpClientFactory?: RemoteMcpClientFactory;
|
|
16
15
|
incomingExecutionId?: string;
|
|
17
16
|
};
|
|
@@ -16,6 +16,7 @@ describe("router resolution", () => {
|
|
|
16
16
|
openrouter: {
|
|
17
17
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
18
18
|
style: "chat-completions",
|
|
19
|
+
timeoutMs: 120_000,
|
|
19
20
|
exports: ["*"],
|
|
20
21
|
},
|
|
21
22
|
},
|
|
@@ -53,6 +54,7 @@ describe("router resolution", () => {
|
|
|
53
54
|
openrouter: {
|
|
54
55
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
55
56
|
style: "chat-completions",
|
|
57
|
+
timeoutMs: 120_000,
|
|
56
58
|
exports: ["google/*"],
|
|
57
59
|
},
|
|
58
60
|
},
|
|
@@ -80,6 +82,7 @@ describe("router resolution", () => {
|
|
|
80
82
|
emma: {
|
|
81
83
|
apiBaseUrl: "https://api.example.test/v1",
|
|
82
84
|
style: "chat-completions",
|
|
85
|
+
timeoutMs: 120_000,
|
|
83
86
|
exports: [],
|
|
84
87
|
},
|
|
85
88
|
},
|
|
@@ -107,6 +110,7 @@ describe("router resolution", () => {
|
|
|
107
110
|
openrouter: {
|
|
108
111
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
109
112
|
style: "chat-completions",
|
|
113
|
+
timeoutMs: 120_000,
|
|
110
114
|
exports: ["*"],
|
|
111
115
|
},
|
|
112
116
|
},
|
|
@@ -126,6 +130,7 @@ describe("router resolution", () => {
|
|
|
126
130
|
openrouter: {
|
|
127
131
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
128
132
|
style: "chat-completions",
|
|
133
|
+
timeoutMs: 120_000,
|
|
129
134
|
exports: ["openai/gpt-4.1-mini"],
|
|
130
135
|
},
|
|
131
136
|
},
|
|
@@ -163,6 +168,7 @@ describe("router resolution", () => {
|
|
|
163
168
|
openrouter: {
|
|
164
169
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
165
170
|
style: "chat-completions",
|
|
171
|
+
timeoutMs: 120_000,
|
|
166
172
|
exports: ["*"],
|
|
167
173
|
},
|
|
168
174
|
},
|
|
@@ -191,6 +197,7 @@ describe("router resolution", () => {
|
|
|
191
197
|
openrouter: {
|
|
192
198
|
apiBaseUrl: "https://openrouter.ai/api/v1",
|
|
193
199
|
style: "chat-completions",
|
|
200
|
+
timeoutMs: 120_000,
|
|
194
201
|
exports: ["*"],
|
|
195
202
|
},
|
|
196
203
|
},
|
|
@@ -4,6 +4,7 @@ import { createRouterRuntime } from "./runtime.ts";
|
|
|
4
4
|
const provider = {
|
|
5
5
|
apiBaseUrl: "https://api.example.test/v1",
|
|
6
6
|
style: "chat-completions" as const,
|
|
7
|
+
timeoutMs: 120_000,
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
describe("router runtime configuration", () => {
|
|
@@ -55,4 +56,17 @@ describe("router runtime configuration", () => {
|
|
|
55
56
|
});
|
|
56
57
|
expect(runtime.modelSuffixes).toEqual(new Set(["trim"]));
|
|
57
58
|
});
|
|
59
|
+
|
|
60
|
+
it("requires provider timeouts from one second through fifteen minutes", () => {
|
|
61
|
+
for (const timeoutMs of [999, 900_001, 1.5]) {
|
|
62
|
+
expect(() =>
|
|
63
|
+
createRouterRuntime({
|
|
64
|
+
config: {
|
|
65
|
+
providers: { upstream: { ...provider, timeoutMs } },
|
|
66
|
+
modelNamespaces: {},
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
).toThrow("timeout must be an integer between 1000 and 900000ms");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
58
72
|
});
|
package/src/router/runtime.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type ProviderRuntime = {
|
|
|
12
12
|
name: string;
|
|
13
13
|
style: ProviderTargetConfig["style"];
|
|
14
14
|
apiBaseUrl: string;
|
|
15
|
+
timeoutMs: number;
|
|
15
16
|
endpointPath: string;
|
|
16
17
|
headers: Readonly<Record<string, string>>;
|
|
17
18
|
auth?: ProviderTargetConfig["auth"];
|
|
@@ -90,6 +91,7 @@ function validateRouterConfig(config: RouterConfig): void {
|
|
|
90
91
|
validateModelSuffixes(config.modelSuffixes);
|
|
91
92
|
for (const [name, provider] of Object.entries(config.providers)) {
|
|
92
93
|
validateHttpUrl(provider.apiBaseUrl, `Provider ${name}`);
|
|
94
|
+
validateProviderTimeout(provider.timeoutMs, `Provider ${name}`);
|
|
93
95
|
}
|
|
94
96
|
for (const [name, mcp] of Object.entries(config.mcps ?? {})) {
|
|
95
97
|
validateHttpUrl(mcp.url, `MCP ${name}`);
|
|
@@ -120,6 +122,14 @@ function validateRouterConfig(config: RouterConfig): void {
|
|
|
120
122
|
}
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
function validateProviderTimeout(value: number, label: string): void {
|
|
126
|
+
if (!Number.isInteger(value) || value < 1_000 || value > 900_000) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${label} timeout must be an integer between 1000 and 900000ms`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
123
133
|
function validateModelSuffixes(suffixes: readonly string[] | undefined): void {
|
|
124
134
|
if (!suffixes) return;
|
|
125
135
|
const names = new Set<string>();
|
|
@@ -154,6 +164,7 @@ function buildProviderRuntime(
|
|
|
154
164
|
name,
|
|
155
165
|
style: config.style,
|
|
156
166
|
apiBaseUrl: config.apiBaseUrl,
|
|
167
|
+
timeoutMs: config.timeoutMs,
|
|
157
168
|
endpointPath: defaultEndpointPath(config.style),
|
|
158
169
|
headers: config.headers ?? {},
|
|
159
170
|
auth: config.auth,
|