@velum-labs/routekit-gateway 0.18.1 → 0.18.2
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/dist/backend.d.ts
CHANGED
|
@@ -44,8 +44,8 @@ export type Backend = {
|
|
|
44
44
|
resolveModelRoute?(requested: string | undefined, nativeProvider?: string): BackendModelRoute | undefined;
|
|
45
45
|
/**
|
|
46
46
|
* Whether the backend serves this exact model id itself. Unlike
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* The defaulting resolver folds unknown ids into the default; this
|
|
48
|
+
* distinguishes "mine" from "unknown", so the
|
|
49
49
|
* gateway can hand unknown ids to a relay (e.g. the Codex backend relay)
|
|
50
50
|
* instead of silently routing them to the default.
|
|
51
51
|
*/
|
|
@@ -5,6 +5,7 @@ export type SwitchingGatewayProxy = {
|
|
|
5
5
|
target(): string;
|
|
6
6
|
swapTarget(target: string): string;
|
|
7
7
|
waitForTargetIdle(target: string, graceMs: number): Promise<boolean>;
|
|
8
|
+
retire(graceMs?: number): Promise<void>;
|
|
8
9
|
drain(graceMs?: number): Promise<void>;
|
|
9
10
|
close(): Promise<void>;
|
|
10
11
|
};
|
package/dist/switching-proxy.js
CHANGED
|
@@ -68,13 +68,17 @@ function writeJson(res, status, value) {
|
|
|
68
68
|
res.setHeader("content-length", String(payload.length));
|
|
69
69
|
res.end(payload);
|
|
70
70
|
}
|
|
71
|
-
async function pipe(res, upstream) {
|
|
71
|
+
async function pipe(res, upstream, closeConnection) {
|
|
72
72
|
res.statusCode = upstream.status;
|
|
73
73
|
for (const [name, value] of upstream.headers) {
|
|
74
74
|
if (!HOP_BY_HOP.has(name.toLowerCase()) && name.toLowerCase() !== "content-length") {
|
|
75
75
|
res.setHeader(name, value);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
if (closeConnection()) {
|
|
79
|
+
res.shouldKeepAlive = false;
|
|
80
|
+
res.setHeader("connection", "close");
|
|
81
|
+
}
|
|
78
82
|
if (upstream.body === null) {
|
|
79
83
|
res.end();
|
|
80
84
|
return;
|
|
@@ -108,8 +112,13 @@ export async function startSwitchingGatewayProxy(input) {
|
|
|
108
112
|
};
|
|
109
113
|
const generations = new Map([[active.url, active]]);
|
|
110
114
|
let draining = false;
|
|
115
|
+
let retiring = false;
|
|
111
116
|
let inflight = 0;
|
|
112
117
|
const server = createServer((req, res) => {
|
|
118
|
+
if (retiring) {
|
|
119
|
+
res.shouldKeepAlive = false;
|
|
120
|
+
res.setHeader("connection", "close");
|
|
121
|
+
}
|
|
113
122
|
inflight += 1;
|
|
114
123
|
res.once("close", () => {
|
|
115
124
|
inflight -= 1;
|
|
@@ -170,7 +179,7 @@ export async function startSwitchingGatewayProxy(input) {
|
|
|
170
179
|
AbortSignal.timeout(10 * 60 * 1000)
|
|
171
180
|
])
|
|
172
181
|
});
|
|
173
|
-
await pipe(res, upstream);
|
|
182
|
+
await pipe(res, upstream, () => retiring);
|
|
174
183
|
}
|
|
175
184
|
catch {
|
|
176
185
|
if (!res.destroyed && !res.headersSent) {
|
|
@@ -205,14 +214,33 @@ export async function startSwitchingGatewayProxy(input) {
|
|
|
205
214
|
const address = server.address();
|
|
206
215
|
const port = typeof address === "object" && address !== null ? address.port : input.port ?? 0;
|
|
207
216
|
let drainRun;
|
|
217
|
+
let retireRun;
|
|
218
|
+
const waitForInflight = async (graceMs) => {
|
|
219
|
+
const deadline = Date.now() + graceMs;
|
|
220
|
+
while (inflight > 0 && Date.now() < deadline) {
|
|
221
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const closeRetiringListener = async (graceMs) => {
|
|
225
|
+
server.closeIdleConnections();
|
|
226
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
227
|
+
await waitForInflight(graceMs);
|
|
228
|
+
if (inflight > 0)
|
|
229
|
+
server.closeAllConnections();
|
|
230
|
+
await closed;
|
|
231
|
+
};
|
|
232
|
+
const retire = (graceMs = 0) => {
|
|
233
|
+
retireRun ??= (async () => {
|
|
234
|
+
retiring = true;
|
|
235
|
+
await closeRetiringListener(graceMs);
|
|
236
|
+
})();
|
|
237
|
+
return retireRun;
|
|
238
|
+
};
|
|
208
239
|
const drain = (graceMs = 0) => {
|
|
209
240
|
drainRun ??= (async () => {
|
|
210
241
|
draining = true;
|
|
211
242
|
server.closeIdleConnections();
|
|
212
|
-
|
|
213
|
-
while (inflight > 0 && Date.now() < deadline) {
|
|
214
|
-
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
215
|
-
}
|
|
243
|
+
await waitForInflight(graceMs);
|
|
216
244
|
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
217
245
|
server.closeAllConnections();
|
|
218
246
|
await closed;
|
|
@@ -252,6 +280,7 @@ export async function startSwitchingGatewayProxy(input) {
|
|
|
252
280
|
generations.delete(generation.url);
|
|
253
281
|
return result;
|
|
254
282
|
},
|
|
283
|
+
retire,
|
|
255
284
|
drain,
|
|
256
285
|
close: async () => await drain(0)
|
|
257
286
|
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { startSwitchingGatewayProxy } from "../switching-proxy.js";
|
|
5
|
+
async function heldTarget() {
|
|
6
|
+
let markStarted;
|
|
7
|
+
const started = new Promise((resolve) => {
|
|
8
|
+
markStarted = resolve;
|
|
9
|
+
});
|
|
10
|
+
let release;
|
|
11
|
+
const released = new Promise((resolve) => {
|
|
12
|
+
release = resolve;
|
|
13
|
+
});
|
|
14
|
+
const server = createServer((request, response) => {
|
|
15
|
+
if (request.url === "/stream") {
|
|
16
|
+
response.setHeader("content-type", "text/plain");
|
|
17
|
+
response.write("first\n");
|
|
18
|
+
markStarted();
|
|
19
|
+
void released.then(() => response.end("last\n"));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
response.end("ok");
|
|
23
|
+
});
|
|
24
|
+
await new Promise((resolve, reject) => {
|
|
25
|
+
server.once("error", reject);
|
|
26
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
27
|
+
});
|
|
28
|
+
const port = server.address().port;
|
|
29
|
+
return {
|
|
30
|
+
url: `http://127.0.0.1:${port}`,
|
|
31
|
+
started,
|
|
32
|
+
release,
|
|
33
|
+
close: async () => await new Promise((resolve, reject) => server.close((error) => (error === undefined ? resolve() : reject(error))))
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
test("switching proxy retirement preserves an admitted streaming response", async () => {
|
|
37
|
+
const target = await heldTarget();
|
|
38
|
+
const proxy = await startSwitchingGatewayProxy({ target: target.url });
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(`${proxy.url()}/stream`);
|
|
41
|
+
const reader = response.body?.getReader();
|
|
42
|
+
assert.ok(reader !== undefined);
|
|
43
|
+
const first = await reader.read();
|
|
44
|
+
assert.match(Buffer.from(first.value ?? []).toString("utf8"), /first/);
|
|
45
|
+
await target.started;
|
|
46
|
+
const retiring = proxy.retire(2_000);
|
|
47
|
+
target.release();
|
|
48
|
+
let remainder = "";
|
|
49
|
+
for (;;) {
|
|
50
|
+
const { done, value } = await reader.read();
|
|
51
|
+
if (done)
|
|
52
|
+
break;
|
|
53
|
+
remainder += Buffer.from(value ?? []).toString("utf8");
|
|
54
|
+
}
|
|
55
|
+
assert.match(remainder, /last/);
|
|
56
|
+
await retiring;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await proxy.close();
|
|
60
|
+
await target.close();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
test("switching proxy retirement deadline terminates a stream that never finishes", async () => {
|
|
64
|
+
const target = await heldTarget();
|
|
65
|
+
const proxy = await startSwitchingGatewayProxy({ target: target.url });
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${proxy.url()}/stream`);
|
|
68
|
+
const reader = response.body?.getReader();
|
|
69
|
+
assert.ok(reader !== undefined);
|
|
70
|
+
await reader.read();
|
|
71
|
+
await target.started;
|
|
72
|
+
const startedAt = Date.now();
|
|
73
|
+
await proxy.retire(250);
|
|
74
|
+
assert.ok(Date.now() - startedAt >= 200);
|
|
75
|
+
await assert.rejects(async () => {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const { done } = await reader.read();
|
|
78
|
+
if (done)
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
target.release();
|
|
85
|
+
await proxy.close();
|
|
86
|
+
await target.close();
|
|
87
|
+
}
|
|
88
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-gateway",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.2",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"@aws-sdk/client-bedrock": "3.1095.0",
|
|
30
30
|
"@aws-sdk/client-bedrock-runtime": "3.1095.0",
|
|
31
31
|
"zod": "4.4.3",
|
|
32
|
-
"@velum-labs/routekit-contracts": "0.18.
|
|
33
|
-
"@velum-labs/routekit-registry": "0.18.
|
|
34
|
-
"@velum-labs/routekit-runtime": "0.18.
|
|
35
|
-
"@velum-labs/routekit-tracing": "0.18.
|
|
32
|
+
"@velum-labs/routekit-contracts": "0.18.2",
|
|
33
|
+
"@velum-labs/routekit-registry": "0.18.2",
|
|
34
|
+
"@velum-labs/routekit-runtime": "0.18.2",
|
|
35
|
+
"@velum-labs/routekit-tracing": "0.18.2"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"routekit",
|