@velum-labs/routekit-gateway 0.9.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/LICENSE +201 -0
- package/README.md +28 -0
- package/dist/acp-agent.d.ts +38 -0
- package/dist/acp-agent.js +142 -0
- package/dist/acp-registry.d.ts +36 -0
- package/dist/acp-registry.js +85 -0
- package/dist/adapters/anthropic.d.ts +131 -0
- package/dist/adapters/anthropic.js +1195 -0
- package/dist/adapters/chat.d.ts +14 -0
- package/dist/adapters/chat.js +34 -0
- package/dist/adapters/cursor.d.ts +34 -0
- package/dist/adapters/cursor.js +305 -0
- package/dist/adapters/dropped.d.ts +10 -0
- package/dist/adapters/dropped.js +24 -0
- package/dist/adapters/openai-chat-wire.d.ts +93 -0
- package/dist/adapters/openai-chat-wire.js +143 -0
- package/dist/adapters/responses-stream.d.ts +7 -0
- package/dist/adapters/responses-stream.js +597 -0
- package/dist/adapters/responses.d.ts +174 -0
- package/dist/adapters/responses.js +778 -0
- package/dist/adapters/server-tool-loop.d.ts +94 -0
- package/dist/adapters/server-tool-loop.js +477 -0
- package/dist/adapters/upstream-error.d.ts +14 -0
- package/dist/adapters/upstream-error.js +25 -0
- package/dist/adapters/validate.d.ts +27 -0
- package/dist/adapters/validate.js +180 -0
- package/dist/adapters/web-search.d.ts +46 -0
- package/dist/adapters/web-search.js +151 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +28 -0
- package/dist/backend.d.ts +151 -0
- package/dist/backend.js +143 -0
- package/dist/capacity-pool.d.ts +31 -0
- package/dist/capacity-pool.js +99 -0
- package/dist/cost.d.ts +49 -0
- package/dist/cost.js +112 -0
- package/dist/endpoint-health.d.ts +54 -0
- package/dist/endpoint-health.js +123 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +23 -0
- package/dist/provenance.d.ts +31 -0
- package/dist/provenance.js +191 -0
- package/dist/provider-backends.d.ts +40 -0
- package/dist/provider-backends.js +1050 -0
- package/dist/provider-source.d.ts +40 -0
- package/dist/provider-source.js +293 -0
- package/dist/router.d.ts +168 -0
- package/dist/router.js +474 -0
- package/dist/server.d.ts +67 -0
- package/dist/server.js +930 -0
- package/dist/sse/chat-assembler.d.ts +45 -0
- package/dist/sse/chat-assembler.js +190 -0
- package/dist/sse/parse.d.ts +50 -0
- package/dist/sse/parse.js +149 -0
- package/dist/sse-wire.d.ts +10 -0
- package/dist/sse-wire.js +31 -0
- package/dist/switching-proxy.d.ts +15 -0
- package/dist/switching-proxy.js +232 -0
- package/dist/test/acp-agent.test.d.ts +1 -0
- package/dist/test/acp-agent.test.js +66 -0
- package/dist/test/acp-registry.test.d.ts +1 -0
- package/dist/test/acp-registry.test.js +70 -0
- package/dist/test/anthropic.test.d.ts +1 -0
- package/dist/test/anthropic.test.js +793 -0
- package/dist/test/auth.test.d.ts +1 -0
- package/dist/test/auth.test.js +25 -0
- package/dist/test/boundary.test.d.ts +1 -0
- package/dist/test/boundary.test.js +32 -0
- package/dist/test/chat.test.d.ts +1 -0
- package/dist/test/chat.test.js +418 -0
- package/dist/test/cost.test.d.ts +1 -0
- package/dist/test/cost.test.js +60 -0
- package/dist/test/cursor.test.d.ts +1 -0
- package/dist/test/cursor.test.js +100 -0
- package/dist/test/drain.test.d.ts +1 -0
- package/dist/test/drain.test.js +116 -0
- package/dist/test/dropped.test.d.ts +1 -0
- package/dist/test/dropped.test.js +80 -0
- package/dist/test/endpoint-health.test.d.ts +1 -0
- package/dist/test/endpoint-health.test.js +73 -0
- package/dist/test/provenance.test.d.ts +1 -0
- package/dist/test/provenance.test.js +176 -0
- package/dist/test/provider-backends.test.d.ts +1 -0
- package/dist/test/provider-backends.test.js +699 -0
- package/dist/test/responses.test.d.ts +1 -0
- package/dist/test/responses.test.js +813 -0
- package/dist/test/routed-backend.test.d.ts +1 -0
- package/dist/test/routed-backend.test.js +39 -0
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +297 -0
- package/dist/test/server-resilience.test.d.ts +1 -0
- package/dist/test/server-resilience.test.js +169 -0
- package/dist/test/sse-codec.test.d.ts +1 -0
- package/dist/test/sse-codec.test.js +186 -0
- package/dist/test/web-search-loop.test.d.ts +1 -0
- package/dist/test/web-search-loop.test.js +469 -0
- package/dist/test/wire-validation.test.d.ts +1 -0
- package/dist/test/wire-validation.test.js +140 -0
- package/package.json +48 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { ModelRoutedBackend } from "../backend.js";
|
|
4
|
+
function stubBackend(id, defaultModel) {
|
|
5
|
+
const chats = [];
|
|
6
|
+
return {
|
|
7
|
+
defaultModel,
|
|
8
|
+
chats,
|
|
9
|
+
chat(body) {
|
|
10
|
+
chats.push(body);
|
|
11
|
+
return Promise.resolve(new Response(JSON.stringify({ served_by: id }), {
|
|
12
|
+
status: 200,
|
|
13
|
+
headers: { "content-type": "application/json" }
|
|
14
|
+
}));
|
|
15
|
+
},
|
|
16
|
+
models: () => Promise.resolve(new Response("{}", { status: 200 })),
|
|
17
|
+
embeddings: () => Promise.resolve(new Response("{}", { status: 200 }))
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
test("ModelRoutedBackend dispatches by requested model id", async () => {
|
|
21
|
+
const primary = stubBackend("primary", "qwen3");
|
|
22
|
+
const routed = stubBackend("front-door");
|
|
23
|
+
const backend = new ModelRoutedBackend({
|
|
24
|
+
routedModelIds: ["route-primary", "route-secondary"],
|
|
25
|
+
routed,
|
|
26
|
+
primary
|
|
27
|
+
});
|
|
28
|
+
const member = (await (await backend.chat({ model: "qwen3", messages: [] })).json());
|
|
29
|
+
assert.equal(member.served_by, "primary");
|
|
30
|
+
const routedResponse = (await (await backend.chat({ model: "route-secondary", messages: [] })).json());
|
|
31
|
+
assert.equal(routedResponse.served_by, "front-door");
|
|
32
|
+
// No model at all falls back to the primary (its defaultModel applies).
|
|
33
|
+
const bare = (await (await backend.chat({ messages: [] })).json());
|
|
34
|
+
assert.equal(bare.served_by, "primary");
|
|
35
|
+
assert.equal(backend.defaultModel, "qwen3");
|
|
36
|
+
assert.deepEqual([...backend.listModelIds()], ["qwen3", "route-primary", "route-secondary"]);
|
|
37
|
+
assert.equal(backend.resolveModel("route-primary"), "route-primary");
|
|
38
|
+
assert.equal(backend.resolveModel("anything-else"), "qwen3");
|
|
39
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { CatalogBackend, NoModelAvailableError, parseDiscoveredModels, parseRouterConfig, UnknownModelError } from "../index.js";
|
|
4
|
+
function fakeSource(sourceId, models, calls = []) {
|
|
5
|
+
return {
|
|
6
|
+
sourceId,
|
|
7
|
+
async discoverModels() {
|
|
8
|
+
return models;
|
|
9
|
+
},
|
|
10
|
+
async chat(body, _signal, _options) {
|
|
11
|
+
const model = typeof body === "object" &&
|
|
12
|
+
body !== null &&
|
|
13
|
+
"model" in body &&
|
|
14
|
+
typeof body.model === "string"
|
|
15
|
+
? body.model
|
|
16
|
+
: undefined;
|
|
17
|
+
calls.push({ source: sourceId, ...(model !== undefined ? { model } : {}) });
|
|
18
|
+
return Response.json({ source: sourceId, model });
|
|
19
|
+
},
|
|
20
|
+
async embeddings() {
|
|
21
|
+
return Response.json({});
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
test("RouterConfig accepts explicit provider maps and namespaced defaults", () => {
|
|
26
|
+
assert.deepEqual(parseRouterConfig({ providers: {} }).providers, {});
|
|
27
|
+
const config = parseRouterConfig({
|
|
28
|
+
providers: {
|
|
29
|
+
openai: {},
|
|
30
|
+
codex: { strategy: "round_robin", switchThreshold: 0.8 }
|
|
31
|
+
},
|
|
32
|
+
defaultModel: "codex/gpt-5.5"
|
|
33
|
+
});
|
|
34
|
+
assert.equal(config.providers.openai?.strategy, "capacity_weighted");
|
|
35
|
+
assert.equal(config.providers.codex?.strategy, "round_robin");
|
|
36
|
+
assert.throws(() => parseRouterConfig({
|
|
37
|
+
providers: { openai: {} },
|
|
38
|
+
defaultModel: "gpt-5.5"
|
|
39
|
+
}), /provider\/model namespace/);
|
|
40
|
+
assert.throws(() => parseRouterConfig({
|
|
41
|
+
providers: { openai: {} },
|
|
42
|
+
defaultModel: "codex/gpt-5.5"
|
|
43
|
+
}), /provider "codex" is not configured/);
|
|
44
|
+
assert.throws(() => parseRouterConfig({ endpoints: [] }), /invalid input|unrecognized key/i);
|
|
45
|
+
assert.throws(() => parseRouterConfig({
|
|
46
|
+
providers: { openai: {} },
|
|
47
|
+
reasoningCapabilities: {
|
|
48
|
+
"openai/opaque": {
|
|
49
|
+
efforts: [{ id: "quick" }],
|
|
50
|
+
defaultEffort: "missing"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}), /default reasoning effort/);
|
|
54
|
+
});
|
|
55
|
+
test("empty provider configuration creates a credential-independent empty catalog", async () => {
|
|
56
|
+
const backend = await CatalogBackend.create({
|
|
57
|
+
config: { providers: {} },
|
|
58
|
+
env: {}
|
|
59
|
+
});
|
|
60
|
+
assert.equal(backend.defaultModel, undefined);
|
|
61
|
+
assert.deepEqual(backend.listModelIds(), []);
|
|
62
|
+
assert.deepEqual(await backend.providerStatuses(), []);
|
|
63
|
+
assert.deepEqual(await (await backend.models()).json(), {
|
|
64
|
+
object: "list",
|
|
65
|
+
data: []
|
|
66
|
+
});
|
|
67
|
+
assert.throws(() => backend.chat({ messages: [] }), (error) => error instanceof NoModelAvailableError);
|
|
68
|
+
assert.throws(() => backend.chat({ model: "openai/not-configured", messages: [] }), (error) => error instanceof UnknownModelError &&
|
|
69
|
+
error.model === "openai/not-configured");
|
|
70
|
+
assert.throws(() => backend.embeddings({ input: "hello" }), (error) => error instanceof NoModelAvailableError);
|
|
71
|
+
});
|
|
72
|
+
test("discovery normalizes native response shapes", () => {
|
|
73
|
+
assert.deepEqual(parseDiscoveredModels("openai", {
|
|
74
|
+
data: [{ id: "gpt-5.5" }, { id: "gpt-5.5" }, { nope: true }]
|
|
75
|
+
}).map((model) => model.id), ["gpt-5.5"]);
|
|
76
|
+
assert.deepEqual(parseDiscoveredModels("anthropic", {
|
|
77
|
+
data: [{ id: "claude-opus-4-1" }]
|
|
78
|
+
}).map((model) => model.id), ["claude-opus-4-1"]);
|
|
79
|
+
assert.deepEqual(parseDiscoveredModels("google", {
|
|
80
|
+
models: [{ name: "models/gemini-2.5-pro" }]
|
|
81
|
+
}).map((model) => model.id), ["gemini-2.5-pro"]);
|
|
82
|
+
assert.deepEqual(parseDiscoveredModels("codex", {
|
|
83
|
+
models: [
|
|
84
|
+
{
|
|
85
|
+
slug: "gpt-5.5",
|
|
86
|
+
default_reasoning_level: "balanced",
|
|
87
|
+
supported_reasoning_levels: [
|
|
88
|
+
{ effort: "quick", description: "Quick" },
|
|
89
|
+
{ effort: "balanced", description: "Balanced" }
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
}, "codex").map((model) => model.id), ["gpt-5.5"]);
|
|
94
|
+
const reasoning = parseDiscoveredModels("codex", {
|
|
95
|
+
models: [
|
|
96
|
+
{
|
|
97
|
+
slug: "opaque",
|
|
98
|
+
default_reasoning_level: "balanced",
|
|
99
|
+
supported_reasoning_levels: ["quick", "balanced"]
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}, "codex")[0]?.reasoning;
|
|
103
|
+
assert.deepEqual(reasoning?.efforts, [{ id: "quick" }, { id: "balanced" }]);
|
|
104
|
+
assert.equal(reasoning?.defaultEffort, "balanced");
|
|
105
|
+
assert.equal(reasoning?.wireShape, "openai-responses");
|
|
106
|
+
assert.equal(reasoning?.provenance, "provider");
|
|
107
|
+
assert.throws(() => parseDiscoveredModels("openai", { data: [] }), /no usable openai models/);
|
|
108
|
+
});
|
|
109
|
+
test("catalog namespaces live models and strips the source before dispatch", async () => {
|
|
110
|
+
const calls = [];
|
|
111
|
+
const backend = await CatalogBackend.create({
|
|
112
|
+
config: {
|
|
113
|
+
providers: { openai: {}, openrouter: {} },
|
|
114
|
+
defaultModel: "openrouter/moonshotai/kimi-k2-thinking"
|
|
115
|
+
},
|
|
116
|
+
sources: {
|
|
117
|
+
openai: fakeSource("openai", [{ id: "gpt-5.5" }], calls),
|
|
118
|
+
openrouter: fakeSource("openrouter", [{ id: "moonshotai/kimi-k2-thinking" }], calls)
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
assert.deepEqual(backend.listModelIds(), [
|
|
122
|
+
"openai/gpt-5.5",
|
|
123
|
+
"openrouter/moonshotai/kimi-k2-thinking"
|
|
124
|
+
]);
|
|
125
|
+
assert.deepEqual(backend.resolveModelRoute("gpt-5.5", "openai"), {
|
|
126
|
+
publicId: "openai/gpt-5.5",
|
|
127
|
+
nativeId: "gpt-5.5",
|
|
128
|
+
provider: "openai"
|
|
129
|
+
});
|
|
130
|
+
assert.equal(backend.resolveModelRoute("gpt-5.5"), undefined, "bare ids remain invalid without a native-provider scope");
|
|
131
|
+
assert.deepEqual(backend.resolveModelRoute("openrouter/moonshotai/kimi-k2-thinking", "openai"), {
|
|
132
|
+
publicId: "openrouter/moonshotai/kimi-k2-thinking",
|
|
133
|
+
nativeId: "moonshotai/kimi-k2-thinking",
|
|
134
|
+
provider: "openrouter"
|
|
135
|
+
}, "an exact canonical id wins even on a native client door");
|
|
136
|
+
await backend.chat({ messages: [] });
|
|
137
|
+
await backend.chat({ model: "openai/gpt-5.5", messages: [] });
|
|
138
|
+
assert.deepEqual(calls, [
|
|
139
|
+
{ source: "openrouter", model: "moonshotai/kimi-k2-thinking" },
|
|
140
|
+
{ source: "openai", model: "gpt-5.5" }
|
|
141
|
+
]);
|
|
142
|
+
const models = (await (await backend.models()).json());
|
|
143
|
+
assert.deepEqual(models.data.map((model) => [model.id, model.owned_by]), [
|
|
144
|
+
["openai/gpt-5.5", "openai"],
|
|
145
|
+
["openrouter/moonshotai/kimi-k2-thinking", "openrouter"]
|
|
146
|
+
]);
|
|
147
|
+
assert.deepEqual(backend.modelInfo("openai/gpt-5.5"), {
|
|
148
|
+
id: "openai/gpt-5.5",
|
|
149
|
+
provider: "openai",
|
|
150
|
+
nativeModel: "gpt-5.5",
|
|
151
|
+
accountClass: "api-key",
|
|
152
|
+
billingMode: "metered-api",
|
|
153
|
+
default: false,
|
|
154
|
+
capabilities: {},
|
|
155
|
+
reasoning: null
|
|
156
|
+
});
|
|
157
|
+
assert.equal(backend.modelInfo("openai/not-real"), undefined);
|
|
158
|
+
});
|
|
159
|
+
test("model info exhaustively classifies subscription and proxy billing", async () => {
|
|
160
|
+
const backend = await CatalogBackend.create({
|
|
161
|
+
config: {
|
|
162
|
+
providers: { codex: {}, "claude-code": {}, cliproxy: {} },
|
|
163
|
+
defaultModel: "codex/gpt-5.5"
|
|
164
|
+
},
|
|
165
|
+
sources: {
|
|
166
|
+
codex: fakeSource("codex", [
|
|
167
|
+
{
|
|
168
|
+
id: "gpt-5.5",
|
|
169
|
+
capabilities: { tools: "supported" },
|
|
170
|
+
reasoning: {
|
|
171
|
+
status: "supported",
|
|
172
|
+
efforts: [{ id: "high" }],
|
|
173
|
+
provenance: "provider",
|
|
174
|
+
refreshedAt: "2026-07-22T00:00:00.000Z"
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
]),
|
|
178
|
+
"claude-code": fakeSource("claude-code", [{ id: "claude-opus-4-1" }]),
|
|
179
|
+
cliproxy: fakeSource("cliproxy", [{ id: "local-route" }])
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
assert.deepEqual(backend.modelInfo("codex/gpt-5.5"), {
|
|
183
|
+
id: "codex/gpt-5.5",
|
|
184
|
+
provider: "codex",
|
|
185
|
+
nativeModel: "gpt-5.5",
|
|
186
|
+
accountClass: "subscription",
|
|
187
|
+
billingMode: "subscription",
|
|
188
|
+
default: true,
|
|
189
|
+
capabilities: { tools: "supported" },
|
|
190
|
+
reasoning: {
|
|
191
|
+
status: "supported",
|
|
192
|
+
efforts: [{ id: "high" }],
|
|
193
|
+
provenance: "provider",
|
|
194
|
+
refreshedAt: "2026-07-22T00:00:00.000Z"
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
assert.deepEqual({
|
|
198
|
+
accountClass: backend.modelInfo("claude-code/claude-opus-4-1")?.accountClass,
|
|
199
|
+
billingMode: backend.modelInfo("claude-code/claude-opus-4-1")?.billingMode
|
|
200
|
+
}, { accountClass: "subscription", billingMode: "subscription" });
|
|
201
|
+
assert.deepEqual({
|
|
202
|
+
accountClass: backend.modelInfo("cliproxy/local-route")?.accountClass,
|
|
203
|
+
billingMode: backend.modelInfo("cliproxy/local-route")?.billingMode
|
|
204
|
+
}, { accountClass: "proxy", billingMode: "upstream-managed" });
|
|
205
|
+
await backend.close();
|
|
206
|
+
});
|
|
207
|
+
test("cliproxy routes are attributed as subscription billing", async () => {
|
|
208
|
+
const backend = await CatalogBackend.create({
|
|
209
|
+
config: {
|
|
210
|
+
providers: { cliproxy: {} },
|
|
211
|
+
defaultModel: "cliproxy/gpt-test"
|
|
212
|
+
},
|
|
213
|
+
sources: {
|
|
214
|
+
cliproxy: fakeSource("cliproxy", [{ id: "gpt-test" }])
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
const updates = [];
|
|
218
|
+
const response = await backend.chat({ model: "cliproxy/gpt-test", messages: [] }, undefined, { onAttribution: (update) => updates.push(update) });
|
|
219
|
+
assert.equal(response.status, 200);
|
|
220
|
+
assert.deepEqual(updates, [
|
|
221
|
+
{
|
|
222
|
+
effective_model: "cliproxy/gpt-test",
|
|
223
|
+
native_model: "gpt-test",
|
|
224
|
+
provider: "cliproxy",
|
|
225
|
+
billing_mode: "subscription"
|
|
226
|
+
}
|
|
227
|
+
]);
|
|
228
|
+
});
|
|
229
|
+
test("catalog applies configured opaque efforts and rejects unavailable values before egress", async () => {
|
|
230
|
+
const calls = [];
|
|
231
|
+
const backend = await CatalogBackend.create({
|
|
232
|
+
config: {
|
|
233
|
+
providers: { openai: {} },
|
|
234
|
+
defaultModel: "openai/opaque",
|
|
235
|
+
reasoningCapabilities: {
|
|
236
|
+
"openai/opaque": {
|
|
237
|
+
efforts: [
|
|
238
|
+
{ id: "balanced", aliases: ["cursor-balanced"] },
|
|
239
|
+
{ id: "deep" }
|
|
240
|
+
],
|
|
241
|
+
defaultEffort: "balanced",
|
|
242
|
+
wireShape: "openai-chat"
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
sources: {
|
|
247
|
+
openai: fakeSource("openai", [{ id: "opaque" }], calls)
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
const accepted = await backend.chat({
|
|
251
|
+
model: "openai/opaque",
|
|
252
|
+
reasoning_effort: "cursor-balanced",
|
|
253
|
+
messages: []
|
|
254
|
+
});
|
|
255
|
+
assert.equal(accepted.status, 200);
|
|
256
|
+
const rejected = await backend.chat({
|
|
257
|
+
model: "openai/opaque",
|
|
258
|
+
reasoning_effort: "maximum",
|
|
259
|
+
messages: []
|
|
260
|
+
});
|
|
261
|
+
assert.equal(rejected.status, 400);
|
|
262
|
+
const malformed = await backend.chat({
|
|
263
|
+
model: "openai/opaque",
|
|
264
|
+
reasoning_effort: 7,
|
|
265
|
+
messages: []
|
|
266
|
+
});
|
|
267
|
+
assert.equal(malformed.status, 400);
|
|
268
|
+
assert.equal(calls.length, 1);
|
|
269
|
+
assert.equal(backend.reasoningCapabilities("openai/opaque")?.provenance, "config");
|
|
270
|
+
});
|
|
271
|
+
test("unknown models never fall through to the default source", async () => {
|
|
272
|
+
const backend = await CatalogBackend.create({
|
|
273
|
+
config: { providers: { openai: {} } },
|
|
274
|
+
sources: { openai: fakeSource("openai", [{ id: "gpt-5.5" }]) }
|
|
275
|
+
});
|
|
276
|
+
assert.throws(() => backend.chat({ model: "openai/not-real", messages: [] }), (error) => error instanceof UnknownModelError && error.model === "openai/not-real");
|
|
277
|
+
});
|
|
278
|
+
test("startup reports provider-specific discovery and credential failures", async () => {
|
|
279
|
+
await assert.rejects(CatalogBackend.create({
|
|
280
|
+
config: { providers: { openai: {} } },
|
|
281
|
+
sources: {
|
|
282
|
+
openai: {
|
|
283
|
+
...fakeSource("openai", []),
|
|
284
|
+
async discoverModels() {
|
|
285
|
+
throw new Error("bad token");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}), /provider "openai" discovery failed: bad token/);
|
|
290
|
+
await assert.rejects(CatalogBackend.create({
|
|
291
|
+
config: { providers: { openai: {} } },
|
|
292
|
+
env: {}
|
|
293
|
+
}), /provider "openai" is missing credential environment variable OPENAI_API_KEY/);
|
|
294
|
+
await assert.rejects(CatalogBackend.create({
|
|
295
|
+
config: { providers: { codex: {} } }
|
|
296
|
+
}), /provider "codex" requires enrolled subscription accounts/);
|
|
297
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { startGateway } from "../server.js";
|
|
4
|
+
/**
|
|
5
|
+
* Crash resilience: an upstream stream that dies mid-response (the shape of a
|
|
6
|
+
* local model server being OOM-killed during a turn) must fail only that one
|
|
7
|
+
* request. Historically the error path wrote JSON onto a response whose
|
|
8
|
+
* headers were already sent, which threw inside the catch handler and killed
|
|
9
|
+
* the whole process hosting the gateway, leaving the client with a bare
|
|
10
|
+
* "stream disconnected" error.
|
|
11
|
+
*/
|
|
12
|
+
/** A backend whose chat stream emits one SSE chunk, then errors mid-stream. */
|
|
13
|
+
function midStreamFailureBackend() {
|
|
14
|
+
return {
|
|
15
|
+
defaultModel: "mock-model",
|
|
16
|
+
chat: async () => {
|
|
17
|
+
const body = new ReadableStream({
|
|
18
|
+
start(controller) {
|
|
19
|
+
controller.enqueue(Buffer.from('data: {"choices":[{"delta":{"content":"hi"}}]}\n\n', "utf8"));
|
|
20
|
+
controller.error(new Error("upstream server crashed (simulated OOM kill)"));
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
return new Response(body, {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { "content-type": "text/event-stream" }
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
models: async () => new Response(JSON.stringify({ object: "list", data: [{ id: "mock-model" }] }), {
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: { "content-type": "application/json" }
|
|
31
|
+
}),
|
|
32
|
+
embeddings: async () => new Response(JSON.stringify({}), { status: 200 })
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
test("a mid-stream upstream failure does not kill the gateway process", async () => {
|
|
36
|
+
const unhandled = [];
|
|
37
|
+
const onUnhandled = (reason) => {
|
|
38
|
+
unhandled.push(reason);
|
|
39
|
+
};
|
|
40
|
+
process.on("unhandledRejection", onUnhandled);
|
|
41
|
+
const gateway = await startGateway({ backend: midStreamFailureBackend() });
|
|
42
|
+
try {
|
|
43
|
+
// The streaming request fails abnormally (destroyed socket), not silently.
|
|
44
|
+
await assert.rejects(async () => {
|
|
45
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
46
|
+
method: "POST",
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
body: JSON.stringify({ model: "mock-model", stream: true, messages: [{ role: "user", content: "hi" }] })
|
|
49
|
+
});
|
|
50
|
+
await response.text();
|
|
51
|
+
});
|
|
52
|
+
// Give any would-be unhandled rejection a macrotask to surface.
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
54
|
+
assert.deepEqual(unhandled, [], "no unhandled rejection escaped the error path");
|
|
55
|
+
// The gateway (and its hosting process) is still alive and serving.
|
|
56
|
+
const health = await fetch(`${gateway.url()}/health`);
|
|
57
|
+
assert.equal(health.status, 200);
|
|
58
|
+
const models = await fetch(`${gateway.url()}/v1/models`);
|
|
59
|
+
assert.equal(models.status, 200);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
process.off("unhandledRejection", onUnhandled);
|
|
63
|
+
await gateway.close();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
test("an error before headers are sent still yields a 502 JSON body", async () => {
|
|
67
|
+
const backend = {
|
|
68
|
+
...midStreamFailureBackend(),
|
|
69
|
+
chat: async () => {
|
|
70
|
+
throw new Error("backend exploded before responding");
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const gateway = await startGateway({ backend });
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "content-type": "application/json" },
|
|
78
|
+
body: JSON.stringify({ model: "mock-model", messages: [{ role: "user", content: "hi" }] })
|
|
79
|
+
});
|
|
80
|
+
assert.equal(response.status, 502);
|
|
81
|
+
const body = (await response.json());
|
|
82
|
+
assert.equal(body.error?.type, "upstream_error");
|
|
83
|
+
assert.equal(body.error?.message, "upstream request failed");
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await gateway.close();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
test("client disconnect cancels the upstream response body", async () => {
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
let upstreamController;
|
|
92
|
+
const backend = {
|
|
93
|
+
defaultModel: "mock-model",
|
|
94
|
+
chat: async () => new Response(new ReadableStream({
|
|
95
|
+
start(controller) {
|
|
96
|
+
upstreamController = controller;
|
|
97
|
+
controller.enqueue(Buffer.from('data: {"choices":[{"delta":{"content":"first"}}]}\n\n'));
|
|
98
|
+
},
|
|
99
|
+
cancel() {
|
|
100
|
+
cancelled = true;
|
|
101
|
+
}
|
|
102
|
+
}), { status: 200, headers: { "content-type": "text/event-stream" } }),
|
|
103
|
+
models: async () => new Response(JSON.stringify({ data: [] }), { status: 200 }),
|
|
104
|
+
embeddings: async () => new Response(JSON.stringify({}), { status: 200 })
|
|
105
|
+
};
|
|
106
|
+
const gateway = await startGateway({ backend });
|
|
107
|
+
const aborter = new AbortController();
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "content-type": "application/json" },
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
model: "mock-model",
|
|
114
|
+
stream: true,
|
|
115
|
+
messages: [{ role: "user", content: "hi" }]
|
|
116
|
+
}),
|
|
117
|
+
signal: aborter.signal
|
|
118
|
+
});
|
|
119
|
+
const reader = response.body?.getReader();
|
|
120
|
+
assert.ok(reader !== undefined);
|
|
121
|
+
await reader.read();
|
|
122
|
+
aborter.abort();
|
|
123
|
+
const deadline = Date.now() + 1_000;
|
|
124
|
+
while (!cancelled && Date.now() < deadline) {
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
126
|
+
}
|
|
127
|
+
if (!cancelled)
|
|
128
|
+
upstreamController?.close();
|
|
129
|
+
assert.equal(cancelled, true, "the gateway must cancel a body it can no longer deliver");
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
try {
|
|
133
|
+
upstreamController?.close();
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// already cancelled
|
|
137
|
+
}
|
|
138
|
+
await gateway.close();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
test("oversized request bodies are rejected before the backend is called", async () => {
|
|
142
|
+
let chatCalls = 0;
|
|
143
|
+
const backend = {
|
|
144
|
+
defaultModel: "mock-model",
|
|
145
|
+
chat: async () => {
|
|
146
|
+
chatCalls += 1;
|
|
147
|
+
return new Response(JSON.stringify({ choices: [] }), { status: 200 });
|
|
148
|
+
},
|
|
149
|
+
models: async () => new Response(JSON.stringify({ data: [] }), { status: 200 }),
|
|
150
|
+
embeddings: async () => new Response(JSON.stringify({}), { status: 200 })
|
|
151
|
+
};
|
|
152
|
+
const gateway = await startGateway({ backend });
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "content-type": "application/json" },
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
model: "mock-model",
|
|
159
|
+
messages: [{ role: "user", content: "hi" }],
|
|
160
|
+
padding: "x".repeat(17 * 1024 * 1024)
|
|
161
|
+
})
|
|
162
|
+
});
|
|
163
|
+
assert.equal(response.status, 413);
|
|
164
|
+
assert.equal(chatCalls, 0);
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await gateway.close();
|
|
168
|
+
}
|
|
169
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|