zengate 1.0.4 → 1.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.
- package/README.md +31 -14
- package/config.json.example +3 -0
- package/index.js +1 -0
- package/package.json +2 -2
- package/src/bootstrap.js +4 -1
- package/src/config.js +4 -0
- package/src/gateway.js +32 -10
- package/src/node-version.js +11 -0
- package/src/openai/chat-request.js +17 -7
- package/src/openai/chat.js +17 -10
- package/src/openai/completions.js +140 -0
- package/src/openai/generate.js +55 -26
- package/src/openai/ignored-params.js +15 -0
- package/src/openai/length-limit.js +49 -0
- package/src/openai/map-limited.js +30 -0
- package/src/openai/markup.js +31 -0
- package/src/openai/prompt.js +9 -5
- package/src/openai/remote-media.js +222 -0
- package/src/openai/responses-request.js +12 -7
- package/src/openai/responses-store.js +27 -7
- package/src/openai/responses.js +5 -2
- package/src/openai/stop.js +38 -14
- package/src/openai/tool-calls.js +8 -2
- package/src/openai/url-guard.js +104 -36
- package/src/openai/usage-estimate.js +42 -0
- package/src/opencode/backend.js +100 -39
- package/src/opencode/client.js +1 -0
- package/src/opencode/events.js +22 -3
- package/src/opencode/isolation.js +139 -9
- package/src/opencode/runner.js +25 -6
- package/src/server/app.js +59 -10
- package/src/server/client-key.js +29 -0
- package/src/server/limiter.js +63 -9
- package/src/server/metrics.js +119 -11
- package/src/server/middleware.js +30 -1
- package/src/server/slot.js +13 -7
package/src/opencode/events.js
CHANGED
|
@@ -34,6 +34,7 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
|
|
|
34
34
|
let controller = null;
|
|
35
35
|
let connectedWaiters = [];
|
|
36
36
|
let loop = null;
|
|
37
|
+
let toolRejections = 0;
|
|
37
38
|
|
|
38
39
|
const isOurs = (sessionId) => ownsAllSessions || owned.has(sessionId);
|
|
39
40
|
|
|
@@ -45,12 +46,20 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
|
|
|
45
46
|
}
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* OpenCode answers 404 once a request is gone: rejecting one request of
|
|
51
|
+
* a session also rejects the rest, and the sweep can race the event.
|
|
52
|
+
*/
|
|
53
|
+
const alreadyAnswered = (error) => error?.status === 404;
|
|
54
|
+
|
|
48
55
|
async function rejectPermission(id, sessionId) {
|
|
49
56
|
try {
|
|
50
57
|
await getClient().replyPermission(id, 'reject', TOOL_REJECTION_MESSAGE);
|
|
58
|
+
toolRejections += 1;
|
|
51
59
|
logger.debug('Rejected OpenCode tool permission', { sessionId });
|
|
52
60
|
} catch (error) {
|
|
53
|
-
logger.
|
|
61
|
+
if (alreadyAnswered(error)) logger.debug('OpenCode tool permission was already answered', { sessionId });
|
|
62
|
+
else logger.warn('Failed to reject an OpenCode tool permission', { sessionId, error: error.message });
|
|
54
63
|
}
|
|
55
64
|
}
|
|
56
65
|
|
|
@@ -58,7 +67,8 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
|
|
|
58
67
|
try {
|
|
59
68
|
await getClient().rejectQuestion(id);
|
|
60
69
|
} catch (error) {
|
|
61
|
-
logger.
|
|
70
|
+
if (alreadyAnswered(error)) logger.debug('OpenCode question was already answered', { sessionId });
|
|
71
|
+
else logger.warn('Failed to reject an OpenCode question', { sessionId, error: error.message });
|
|
62
72
|
}
|
|
63
73
|
}
|
|
64
74
|
|
|
@@ -142,12 +152,21 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
|
|
|
142
152
|
};
|
|
143
153
|
},
|
|
144
154
|
release(sessionId) {
|
|
145
|
-
|
|
155
|
+
// Subagents can start subagents of their own, so release the whole tree.
|
|
156
|
+
const descendants = [...(children.get(sessionId) || [])];
|
|
146
157
|
children.delete(sessionId);
|
|
158
|
+
while (descendants.length) {
|
|
159
|
+
const child = descendants.pop();
|
|
160
|
+
owned.delete(child);
|
|
161
|
+
descendants.push(...(children.get(child) || []));
|
|
162
|
+
children.delete(child);
|
|
163
|
+
}
|
|
147
164
|
owned.delete(sessionId);
|
|
148
165
|
listeners.delete(sessionId);
|
|
149
166
|
},
|
|
150
167
|
isConnected: () => connected,
|
|
168
|
+
/** OpenCode tool permissions rejected so far. */
|
|
169
|
+
toolRejections: () => toolRejections,
|
|
151
170
|
waitConnected(timeoutMs) {
|
|
152
171
|
if (connected) return Promise.resolve(true);
|
|
153
172
|
return new Promise((resolve) => {
|
|
@@ -1,9 +1,36 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import net from 'node:net';
|
|
3
4
|
import os from 'node:os';
|
|
4
5
|
import path from 'node:path';
|
|
5
6
|
|
|
6
7
|
const ROOT_PREFIX = 'zengate-';
|
|
8
|
+
const OWNER_FILE = 'owner.pid';
|
|
9
|
+
const BACKEND_FILE = 'backend.pid';
|
|
10
|
+
const SOCKET_FILE = 'owner.sock';
|
|
11
|
+
const PROBE_TIMEOUT_MS = 2000;
|
|
12
|
+
/** Tells this gateway process apart from an earlier one that had the same pid. */
|
|
13
|
+
const INSTANCE_ID = crypto.randomBytes(8).toString('hex');
|
|
14
|
+
/**
|
|
15
|
+
* A tree made in another pid namespace (another container sharing this
|
|
16
|
+
* /tmp) cannot be judged by its pid. Its owner listens on a Unix socket in
|
|
17
|
+
* the tree, which refuses connections once the owner is gone but still
|
|
18
|
+
* accepts them while it is merely frozen, and touches the tree every
|
|
19
|
+
* HEARTBEAT_MS for filesystems where that socket cannot be reached.
|
|
20
|
+
*/
|
|
21
|
+
export const HEARTBEAT_MS = 30000;
|
|
22
|
+
const FOREIGN_STALE_MS = 4 * HEARTBEAT_MS;
|
|
23
|
+
|
|
24
|
+
function pidNamespace() {
|
|
25
|
+
try {
|
|
26
|
+
return fs.readlinkSync('/proc/self/ns/pid');
|
|
27
|
+
} catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const PID_NAMESPACE = pidNamespace();
|
|
32
|
+
/** Liveness sockets of the trees this process owns, by root. */
|
|
33
|
+
const ownerSockets = new Map();
|
|
7
34
|
|
|
8
35
|
/** Environment variables the backend legitimately needs from the host. */
|
|
9
36
|
const PASSTHROUGH_ENV = [
|
|
@@ -17,6 +44,11 @@ const PASSTHROUGH_ENV = [
|
|
|
17
44
|
* OpenCode configuration for the managed backend. Every tool permission is
|
|
18
45
|
* "ask" (the tool stays advertised exactly as in stock OpenCode) and the
|
|
19
46
|
* gateway answers every ask with "reject", so tools never run.
|
|
47
|
+
*
|
|
48
|
+
* `continue_loop_on_deny` hands each rejection back to the model instead of
|
|
49
|
+
* ending the turn. OpenCode rejects every other pending request of a session
|
|
50
|
+
* along with the one answered, and without it those plain rejections stop
|
|
51
|
+
* the turn before the model has written an answer.
|
|
20
52
|
*/
|
|
21
53
|
export function backendConfig() {
|
|
22
54
|
return {
|
|
@@ -28,6 +60,7 @@ export function backendConfig() {
|
|
|
28
60
|
'*': 'ask', read: 'ask', edit: 'ask', bash: 'ask', glob: 'ask', grep: 'ask', list: 'ask',
|
|
29
61
|
task: 'ask', webfetch: 'ask', websearch: 'ask', external_directory: 'ask', skill: 'ask', lsp: 'ask',
|
|
30
62
|
},
|
|
63
|
+
experimental: { continue_loop_on_deny: true },
|
|
31
64
|
};
|
|
32
65
|
}
|
|
33
66
|
|
|
@@ -44,12 +77,44 @@ export function createIsolatedRoot() {
|
|
|
44
77
|
dirs[name] = path.join(root, name);
|
|
45
78
|
fs.mkdirSync(dirs[name], { recursive: true, mode: 0o700 });
|
|
46
79
|
}
|
|
47
|
-
fs.writeFileSync(path.join(root,
|
|
80
|
+
fs.writeFileSync(path.join(root, OWNER_FILE), `${process.pid}\n${INSTANCE_ID}\n${PID_NAMESPACE}\n`, { mode: 0o600 });
|
|
81
|
+
holdOwnerSocket(root);
|
|
48
82
|
return { root, ...dirs };
|
|
49
83
|
}
|
|
50
84
|
|
|
85
|
+
function holdOwnerSocket(root) {
|
|
86
|
+
if (process.platform === 'win32') return;
|
|
87
|
+
const server = net.createServer((socket) => socket.destroy());
|
|
88
|
+
// Without the socket (a path too long for one, say) the heartbeat still vouches for the tree.
|
|
89
|
+
server.on('error', () => ownerSockets.delete(root));
|
|
90
|
+
server.listen(path.join(root, SOCKET_FILE));
|
|
91
|
+
server.unref();
|
|
92
|
+
ownerSockets.set(root, server);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Mark a tree as still in use (see HEARTBEAT_MS). */
|
|
96
|
+
export function touchIsolatedRoot(root) {
|
|
97
|
+
try {
|
|
98
|
+
const now = new Date();
|
|
99
|
+
fs.utimesSync(path.join(root, OWNER_FILE), now, now);
|
|
100
|
+
} catch {
|
|
101
|
+
// The tree is gone or being removed.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Remember the backend's pid so a later start can stop it if this gateway dies first. */
|
|
106
|
+
export function recordBackendPid(root, pid) {
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(path.join(root, BACKEND_FILE), String(pid), { mode: 0o600 });
|
|
109
|
+
} catch {
|
|
110
|
+
// Only orphan cleanup depends on it.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
51
114
|
export function removeIsolatedRoot(root) {
|
|
52
115
|
if (!root || !path.basename(root).startsWith(ROOT_PREFIX)) return;
|
|
116
|
+
ownerSockets.get(root)?.close();
|
|
117
|
+
ownerSockets.delete(root);
|
|
53
118
|
try {
|
|
54
119
|
fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
|
55
120
|
} catch {
|
|
@@ -66,8 +131,75 @@ function isAlive(pid) {
|
|
|
66
131
|
}
|
|
67
132
|
}
|
|
68
133
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
134
|
+
/** Only trees this user created are swept; another user's look-alike in a shared /tmp is left alone. */
|
|
135
|
+
function ownedByUs(root) {
|
|
136
|
+
if (typeof process.getuid !== 'function') return true;
|
|
137
|
+
try {
|
|
138
|
+
const stat = fs.lstatSync(root);
|
|
139
|
+
return stat.isDirectory() && stat.uid === process.getuid();
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readOwner(root) {
|
|
146
|
+
try {
|
|
147
|
+
const file = path.join(root, OWNER_FILE);
|
|
148
|
+
const [pid, instance = '', namespace = ''] = fs.readFileSync(file, 'utf8').split('\n');
|
|
149
|
+
return { pid: Number(pid), instance: instance.trim(), namespace: namespace.trim(), touchedAt: fs.statSync(file).mtimeMs };
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether the gateway that owns `root` still accepts connections on its liveness socket. */
|
|
156
|
+
function ownerAnswers(root) {
|
|
157
|
+
if (process.platform === 'win32') return Promise.resolve(false);
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const socket = net.connect(path.join(root, SOCKET_FILE));
|
|
160
|
+
const settle = (alive) => {
|
|
161
|
+
socket.destroy();
|
|
162
|
+
resolve(alive);
|
|
163
|
+
};
|
|
164
|
+
socket.setTimeout(PROBE_TIMEOUT_MS, () => settle(true));
|
|
165
|
+
socket.once('connect', () => settle(true));
|
|
166
|
+
socket.once('error', () => settle(false));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* A tree is stale when its gateway is gone. A container restart reuses the
|
|
172
|
+
* same pid (often 1), so a tree with our pid but another instance id is
|
|
173
|
+
* stale too. A tree from another pid namespace is stale once its owner has
|
|
174
|
+
* stopped touching it and no longer answers on its socket.
|
|
175
|
+
*/
|
|
176
|
+
async function isStale(root, owner) {
|
|
177
|
+
if (!owner || !(owner.pid > 0)) return false;
|
|
178
|
+
if (owner.namespace && owner.namespace !== PID_NAMESPACE) {
|
|
179
|
+
return Date.now() - owner.touchedAt > FOREIGN_STALE_MS && !(await ownerAnswers(root));
|
|
180
|
+
}
|
|
181
|
+
if (owner.pid === process.pid) return owner.instance !== INSTANCE_ID;
|
|
182
|
+
return !isAlive(owner.pid);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Stop the OpenCode backend a dead gateway left running (Linux only: the
|
|
187
|
+
* process must still be working in that tree, so an unrelated process that
|
|
188
|
+
* reused the pid is never touched).
|
|
189
|
+
*/
|
|
190
|
+
function stopOrphanBackend(root) {
|
|
191
|
+
if (process.platform !== 'linux') return;
|
|
192
|
+
try {
|
|
193
|
+
const pid = Number(fs.readFileSync(path.join(root, BACKEND_FILE), 'utf8'));
|
|
194
|
+
if (!(pid > 1) || fs.readlinkSync(`/proc/${pid}/cwd`) !== path.join(root, 'workspace')) return;
|
|
195
|
+
try { process.kill(-pid, 'SIGKILL'); } catch { process.kill(pid, 'SIGKILL'); }
|
|
196
|
+
} catch {
|
|
197
|
+
// No pid file, or the backend is already gone.
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Remove scratch trees, and stop backends, left behind by gateway processes that no longer exist. */
|
|
202
|
+
export async function sweepStaleRoots() {
|
|
71
203
|
let entries = [];
|
|
72
204
|
try {
|
|
73
205
|
entries = fs.readdirSync(os.tmpdir(), { withFileTypes: true });
|
|
@@ -78,12 +210,10 @@ export function sweepStaleRoots() {
|
|
|
78
210
|
for (const entry of entries) {
|
|
79
211
|
if (!entry.isDirectory() || !entry.name.startsWith(ROOT_PREFIX)) continue;
|
|
80
212
|
const root = path.join(os.tmpdir(), entry.name);
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
removed += 1;
|
|
86
|
-
}
|
|
213
|
+
if (!ownedByUs(root) || !(await isStale(root, readOwner(root)))) continue;
|
|
214
|
+
stopOrphanBackend(root);
|
|
215
|
+
removeIsolatedRoot(root);
|
|
216
|
+
removed += 1;
|
|
87
217
|
}
|
|
88
218
|
return removed;
|
|
89
219
|
}
|
package/src/opencode/runner.js
CHANGED
|
@@ -82,11 +82,7 @@ export function createRunner({ getClient, hub, logger, agent }) {
|
|
|
82
82
|
if (signal.aborted) throw signal.reason ?? error;
|
|
83
83
|
throw mapBackendError(error);
|
|
84
84
|
}
|
|
85
|
-
|
|
86
|
-
// rejected tool; without live events we cannot know, so re-read.
|
|
87
|
-
const assistantMessages = tracker.assistantCount() > 1 || !hub.isConnected()
|
|
88
|
-
? (await client.messages(sessionId, { signal })).filter((m) => m?.info?.role === 'assistant')
|
|
89
|
-
: [last];
|
|
85
|
+
const assistantMessages = await readTurn({ client, sessionId, last, ids: tracker.assistantIds(), connected: hub.isConnected(), signal });
|
|
90
86
|
const result = summarize(assistantMessages);
|
|
91
87
|
tracker.flushRemainder(result);
|
|
92
88
|
return result;
|
|
@@ -98,6 +94,28 @@ export function createRunner({ getClient, hub, logger, agent }) {
|
|
|
98
94
|
}
|
|
99
95
|
}
|
|
100
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The turn's assistant messages. Several of them mean OpenCode continued
|
|
99
|
+
* after a rejected tool, so the earlier ones are fetched one by one: the
|
|
100
|
+
* session listing also returns the user message, which OpenCode fails to
|
|
101
|
+
* encode (HTTP 400) when the prompt carried an output format. Without live
|
|
102
|
+
* events the message ids are unknown and only the listing can find them.
|
|
103
|
+
* If the re-read fails, the final message alone is still a valid answer.
|
|
104
|
+
*/
|
|
105
|
+
async function readTurn({ client, sessionId, last, ids, connected, signal }) {
|
|
106
|
+
const earlier = ids.filter((messageId) => messageId !== last?.info?.id);
|
|
107
|
+
if (connected && earlier.length === 0) return [last];
|
|
108
|
+
try {
|
|
109
|
+
if (!connected) return (await client.messages(sessionId, { signal })).filter((m) => m?.info?.role === 'assistant');
|
|
110
|
+
const fetched = await Promise.all(earlier.map((messageId) => client.message(sessionId, messageId, { signal })));
|
|
111
|
+
return [...fetched, last];
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (signal.aborted) throw signal.reason ?? error;
|
|
114
|
+
logger.warn('Could not re-read the OpenCode turn; answering from its final message', { sessionId, error: error.message });
|
|
115
|
+
return [last];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
101
119
|
async function cleanup(client, sessionId) {
|
|
102
120
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
103
121
|
try {
|
|
@@ -169,7 +187,8 @@ function createStreamTracker(onDelta) {
|
|
|
169
187
|
return {
|
|
170
188
|
handle,
|
|
171
189
|
flushRemainder,
|
|
172
|
-
|
|
190
|
+
/** Assistant message ids in the order OpenCode created them. */
|
|
191
|
+
assistantIds: () => [...roles].filter(([, role]) => role === 'assistant').map(([messageId]) => messageId),
|
|
173
192
|
};
|
|
174
193
|
}
|
|
175
194
|
|
package/src/server/app.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { chatCompletionsHandler } from '../openai/chat.js';
|
|
3
|
+
import { completionsHandler } from '../openai/completions.js';
|
|
3
4
|
import { modelsHandlers } from '../openai/models.js';
|
|
4
5
|
import { responsesHandlers } from '../openai/responses.js';
|
|
5
6
|
import { corsMiddleware } from './cors.js';
|
|
6
7
|
import { ApiError, sendError, toApiError } from './errors.js';
|
|
7
|
-
import { createLimiter } from './limiter.js';
|
|
8
|
+
import { admissionMiddleware, createLimiter } from './limiter.js';
|
|
8
9
|
import { createMetrics } from './metrics.js';
|
|
9
|
-
import { authMiddleware, rateLimitMiddleware, requestId, securityHeaders } from './middleware.js';
|
|
10
|
+
import { authMiddleware, hostGuard, rateLimitMiddleware, requestId, securityHeaders } from './middleware.js';
|
|
10
11
|
import { slotMiddleware } from './slot.js';
|
|
11
12
|
|
|
12
13
|
const MB = 1024 * 1024;
|
|
13
14
|
const EXPOSED_HEADERS = ['x-request-id', 'x-gateway-ignored-params', 'Retry-After'];
|
|
15
|
+
const PROBE_PATHS = new Set(['/health', '/ready']);
|
|
14
16
|
|
|
15
17
|
/** Wrap an async handler so rejections reach the error handler. */
|
|
16
18
|
const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
|
@@ -22,16 +24,33 @@ function openAiRoutes({ runner, catalog, store, limits }) {
|
|
|
22
24
|
router.get('/models', route(models.list));
|
|
23
25
|
router.get('/models/*id', route(models.retrieve));
|
|
24
26
|
router.post('/chat/completions', route(chatCompletionsHandler({ runner, catalog, limits })));
|
|
27
|
+
router.post('/completions', route(completionsHandler({ runner, catalog })));
|
|
28
|
+
router.post('/embeddings', () => {
|
|
29
|
+
throw new ApiError(404, 'Embeddings are not available: OpenCode Zen serves chat models only.', { code: 'unsupported_endpoint' });
|
|
30
|
+
});
|
|
25
31
|
router.post('/responses', route(responses.create));
|
|
26
32
|
router.get('/responses/:id', route(responses.retrieve));
|
|
27
33
|
router.delete('/responses/:id', route(responses.remove));
|
|
28
34
|
return router;
|
|
29
35
|
}
|
|
30
36
|
|
|
37
|
+
/** Adds `req.log` and writes an access-log line for every request except probes. */
|
|
31
38
|
function requestLogger(logger) {
|
|
32
39
|
return (req, res, next) => {
|
|
33
40
|
const scoped = (level) => (msg, fields) => logger[level](msg, { requestId: req.id, ...fields });
|
|
34
41
|
req.log = { debug: scoped('debug'), info: scoped('info'), warn: scoped('warn'), error: scoped('error') };
|
|
42
|
+
if (!PROBE_PATHS.has(req.path)) {
|
|
43
|
+
const started = performance.now();
|
|
44
|
+
res.once('close', () => {
|
|
45
|
+
// Control and line-separator characters are dropped so a path cannot forge or break up log lines.
|
|
46
|
+
const path = req.originalUrl.split('?')[0].replace(/[\n\r\u2028\u2029\x00-\x1f\x7f-\x9f]/g, '');
|
|
47
|
+
req.log.info(`${req.method} ${path} ${res.statusCode}`, {
|
|
48
|
+
ms: Math.round(performance.now() - started),
|
|
49
|
+
client: req.clientId,
|
|
50
|
+
...(res.writableFinished ? {} : { aborted: true }),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
35
54
|
next();
|
|
36
55
|
};
|
|
37
56
|
}
|
|
@@ -60,9 +79,11 @@ function errorHandler(error, req, res, next) {
|
|
|
60
79
|
export function createApp({ config, logger, backend, hub, catalog, runner, store }) {
|
|
61
80
|
const app = express();
|
|
62
81
|
const metrics = createMetrics();
|
|
63
|
-
const limiter = createLimiter({ maxConcurrent: config.MAX_CONCURRENT, maxQueue: config.MAX_QUEUE });
|
|
82
|
+
const limiter = createLimiter({ maxConcurrent: config.MAX_CONCURRENT, maxQueue: config.MAX_QUEUE, queueTimeoutMs: config.QUEUE_TIMEOUT_MS });
|
|
83
|
+
const admission = admissionMiddleware({ limit: config.MAX_CONCURRENT + config.MAX_QUEUE });
|
|
64
84
|
const rateLimit = rateLimitMiddleware({ perMinute: config.RATE_LIMIT_PER_MINUTE, onLimited: metrics.rateLimited });
|
|
65
|
-
const limits = { maxBytes: config.MAX_MEDIA_MB * MB };
|
|
85
|
+
const limits = { maxBytes: config.MAX_MEDIA_MB * MB, maxTotalBytes: config.MAX_BODY_MB * MB };
|
|
86
|
+
let draining = false;
|
|
66
87
|
|
|
67
88
|
app.disable('x-powered-by');
|
|
68
89
|
app.set('trust proxy', config.TRUST_PROXY || false);
|
|
@@ -73,15 +94,38 @@ export function createApp({ config, logger, backend, hub, catalog, runner, store
|
|
|
73
94
|
|
|
74
95
|
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
|
75
96
|
app.get('/ready', (req, res) => {
|
|
76
|
-
const ready = backend.isReady() && hub.isConnected();
|
|
77
|
-
|
|
97
|
+
const ready = !draining && backend.isReady() && hub.isConnected();
|
|
98
|
+
const status = draining ? 'stopping' : (ready ? 'ready' : 'starting');
|
|
99
|
+
res.status(ready ? 200 : 503).json({ status, backend: backend.mode });
|
|
78
100
|
});
|
|
79
101
|
|
|
102
|
+
if (config.ALLOW_NO_AUTH) app.use(hostGuard({ allowedHosts: config.ALLOWED_HOSTS }));
|
|
80
103
|
app.use(rateLimit);
|
|
81
104
|
app.use(authMiddleware({ apiKeys: config.API_KEYS, allowNoAuth: config.ALLOW_NO_AUTH, onFailure: metrics.authFailure }));
|
|
82
|
-
app.get('/metrics', (req, res) =>
|
|
83
|
-
slots
|
|
84
|
-
|
|
105
|
+
app.get('/metrics', (req, res) => {
|
|
106
|
+
const slots = { ...limiter.stats(), admitted: admission.count() };
|
|
107
|
+
const toolRejections = hub.toolRejections?.() ?? 0;
|
|
108
|
+
// Prometheus asks for OpenMetrics or text/plain with a version parameter, which
|
|
109
|
+
// req.accepts() does not match; browsers and curl get JSON.
|
|
110
|
+
const accept = String(req.headers.accept || '');
|
|
111
|
+
const scraper = /openmetrics-text|text\/plain/.test(accept) && !accept.includes('application/json');
|
|
112
|
+
if (req.query.format === 'prometheus' || (req.query.format === undefined && scraper)) {
|
|
113
|
+
res.type('text/plain; version=0.0.4').send(metrics.prometheus({
|
|
114
|
+
slots, storedResponses: store.size(), backendReady: backend.isReady(), eventsConnected: hub.isConnected(),
|
|
115
|
+
toolRejections, opencode: backend.version(),
|
|
116
|
+
}));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
res.json(metrics.snapshot({
|
|
120
|
+
slots,
|
|
121
|
+
stored_responses: store.size(),
|
|
122
|
+
backend_ready: backend.isReady(),
|
|
123
|
+
events_connected: hub.isConnected(),
|
|
124
|
+
tool_rejections: toolRejections,
|
|
125
|
+
opencode: backend.version(),
|
|
126
|
+
}));
|
|
127
|
+
});
|
|
128
|
+
app.use(admission);
|
|
85
129
|
app.use(express.json({ limit: config.MAX_BODY_MB * MB }));
|
|
86
130
|
app.use(slotMiddleware({ limiter, timeoutMs: config.REQUEST_TIMEOUT_MS }));
|
|
87
131
|
|
|
@@ -92,5 +136,10 @@ export function createApp({ config, logger, backend, hub, catalog, runner, store
|
|
|
92
136
|
app.use((req, res) => sendError(res, new ApiError(404, `Unknown endpoint: ${req.method} ${req.path}`, { code: 'unknown_url' })));
|
|
93
137
|
app.use(errorHandler);
|
|
94
138
|
|
|
95
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
app,
|
|
141
|
+
/** Report "not ready" so load balancers stop sending new requests. */
|
|
142
|
+
startDraining: () => { draining = true; },
|
|
143
|
+
close: () => rateLimit.close(),
|
|
144
|
+
};
|
|
96
145
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
|
|
3
|
+
const MAPPED_IPV4 = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i;
|
|
4
|
+
|
|
5
|
+
/** The first four groups (the /64 network) of an IPv6 address, normalized. */
|
|
6
|
+
function network64(address) {
|
|
7
|
+
const plain = address.split('%')[0].toLowerCase();
|
|
8
|
+
const [head, tail = ''] = plain.split('::');
|
|
9
|
+
const left = head ? head.split(':') : [];
|
|
10
|
+
if (!plain.includes('::')) return left.slice(0, 4).map((group) => parseInt(group, 16).toString(16)).join(':');
|
|
11
|
+
const right = tail ? tail.split(':') : [];
|
|
12
|
+
// An embedded IPv4 address fills the last two groups.
|
|
13
|
+
const rightGroups = right.reduce((count, group) => count + (group.includes('.') ? 2 : 1), 0);
|
|
14
|
+
const groups = [...left, ...Array(Math.max(0, 8 - left.length - rightGroups)).fill('0'), ...right];
|
|
15
|
+
return groups.slice(0, 4).map((group) => parseInt(group, 16).toString(16)).join(':');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The key a client is rate limited by. An IPv6 client normally has a whole
|
|
20
|
+
* /64 to pick addresses from, so IPv6 addresses count per /64 network;
|
|
21
|
+
* IPv4-mapped IPv6 counts as the IPv4 address.
|
|
22
|
+
* @param {{ ip?: string, socket?: { remoteAddress?: string } }} req
|
|
23
|
+
*/
|
|
24
|
+
export function clientKey(req) {
|
|
25
|
+
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
|
26
|
+
if (!net.isIPv6(ip)) return ip;
|
|
27
|
+
const mapped = MAPPED_IPV4.exec(ip);
|
|
28
|
+
return mapped ? mapped[1] : `${network64(ip)}::/64`;
|
|
29
|
+
}
|
package/src/server/limiter.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { clientKey } from './client-key.js';
|
|
1
2
|
import { ApiError } from './errors.js';
|
|
2
3
|
|
|
4
|
+
const busy = (message) => new ApiError(429, message, { code: 'server_busy', retryAfter: 2 });
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* Concurrency gate with a bounded FIFO queue.
|
|
5
8
|
*
|
|
@@ -7,10 +10,12 @@ import { ApiError } from './errors.js';
|
|
|
7
10
|
* slots are free (a request with n choices runs n generations, so it costs
|
|
8
11
|
* n slots). The slots are released exactly once: when the caller calls
|
|
9
12
|
* release, or when `signal` aborts (timeout or client disconnect), so work
|
|
10
|
-
* that ignores cancellation can never hold a slot forever.
|
|
11
|
-
*
|
|
13
|
+
* that ignores cancellation can never hold a slot forever. A request that
|
|
14
|
+
* waits longer than `queueTimeoutMs` gets a 429, well before a proxy in
|
|
15
|
+
* front would give up on a response that has not started.
|
|
16
|
+
* @param {{ maxConcurrent: number, maxQueue: number, queueTimeoutMs?: number }} options
|
|
12
17
|
*/
|
|
13
|
-
export function createLimiter({ maxConcurrent, maxQueue }) {
|
|
18
|
+
export function createLimiter({ maxConcurrent, maxQueue, queueTimeoutMs = 0 }) {
|
|
14
19
|
let active = 0;
|
|
15
20
|
const waiting = [];
|
|
16
21
|
|
|
@@ -30,7 +35,7 @@ export function createLimiter({ maxConcurrent, maxQueue }) {
|
|
|
30
35
|
pump();
|
|
31
36
|
};
|
|
32
37
|
signal?.addEventListener('abort', release, { once: true });
|
|
33
|
-
return release;
|
|
38
|
+
return Object.assign(release, { weight });
|
|
34
39
|
};
|
|
35
40
|
|
|
36
41
|
const abortedError = () => new ApiError(499, 'Request cancelled.', { code: 'cancelled' });
|
|
@@ -42,24 +47,32 @@ export function createLimiter({ maxConcurrent, maxQueue }) {
|
|
|
42
47
|
active += weight;
|
|
43
48
|
return Promise.resolve(makeRelease(signal, weight));
|
|
44
49
|
}
|
|
45
|
-
if (waiting.length >= maxQueue)
|
|
46
|
-
return Promise.reject(new ApiError(429, 'Gateway is busy; retry shortly.', { code: 'server_busy', retryAfter: 2 }));
|
|
47
|
-
}
|
|
50
|
+
if (waiting.length >= maxQueue) return Promise.reject(busy('Gateway is busy; retry shortly.'));
|
|
48
51
|
return new Promise((resolve, reject) => {
|
|
52
|
+
let timer = null;
|
|
49
53
|
const entry = {
|
|
50
54
|
weight,
|
|
51
55
|
grant() {
|
|
56
|
+
clearTimeout(timer);
|
|
52
57
|
signal?.removeEventListener('abort', onAbort);
|
|
53
58
|
active += weight;
|
|
54
59
|
resolve(makeRelease(signal, weight));
|
|
55
60
|
},
|
|
56
61
|
};
|
|
57
|
-
const
|
|
62
|
+
const leave = (error) => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
signal?.removeEventListener('abort', onAbort);
|
|
58
65
|
const index = waiting.indexOf(entry);
|
|
59
66
|
if (index >= 0) waiting.splice(index, 1);
|
|
60
|
-
reject(
|
|
67
|
+
reject(error);
|
|
61
68
|
pump();
|
|
62
69
|
};
|
|
70
|
+
const onAbort = () => leave(abortedError());
|
|
71
|
+
if (queueTimeoutMs > 0) {
|
|
72
|
+
const seconds = Math.round(queueTimeoutMs / 1000);
|
|
73
|
+
timer = setTimeout(() => leave(busy(`Gateway is busy; no slot freed up within ${seconds}s (QUEUE_TIMEOUT_MS).`)), queueTimeoutMs);
|
|
74
|
+
timer.unref();
|
|
75
|
+
}
|
|
63
76
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
64
77
|
waiting.push(entry);
|
|
65
78
|
});
|
|
@@ -70,3 +83,44 @@ export function createLimiter({ maxConcurrent, maxQueue }) {
|
|
|
70
83
|
stats: () => ({ active, queued: waiting.length, maxConcurrent, maxQueue }),
|
|
71
84
|
});
|
|
72
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Caps the requests that are reading a body, queued or running. Bodies are
|
|
89
|
+
* parsed before a request reaches the queue, so without this cap many large
|
|
90
|
+
* uploads at once could hold far more memory than MAX_QUEUE implies.
|
|
91
|
+
*
|
|
92
|
+
* One client address may have at most `perClientUploads` bodies still
|
|
93
|
+
* arriving, so a client that trickles its uploads cannot take every place.
|
|
94
|
+
* Ordinary JSON bodies arrive at once and barely count against it.
|
|
95
|
+
* @param {{ limit: number, perClientUploads?: number }} options
|
|
96
|
+
*/
|
|
97
|
+
export function admissionMiddleware({ limit, perClientUploads = Math.max(2, Math.ceil(limit / 4)) }) {
|
|
98
|
+
let admitted = 0;
|
|
99
|
+
const uploading = new Map();
|
|
100
|
+
const middleware = (req, res, next) => {
|
|
101
|
+
if (req.method !== 'POST') return next();
|
|
102
|
+
if (admitted >= limit) return next(busy('Gateway is busy; retry shortly.'));
|
|
103
|
+
const client = clientKey(req);
|
|
104
|
+
const inProgress = uploading.get(client) || 0;
|
|
105
|
+
if (inProgress >= perClientUploads) return next(busy('Too many uploads in progress from this client; retry shortly.'));
|
|
106
|
+
admitted += 1;
|
|
107
|
+
uploading.set(client, inProgress + 1);
|
|
108
|
+
let arrived = false;
|
|
109
|
+
const bodyArrived = () => {
|
|
110
|
+
if (arrived) return;
|
|
111
|
+
arrived = true;
|
|
112
|
+
const left = uploading.get(client) - 1;
|
|
113
|
+
if (left > 0) uploading.set(client, left);
|
|
114
|
+
else uploading.delete(client);
|
|
115
|
+
};
|
|
116
|
+
if (req.readableEnded) bodyArrived();
|
|
117
|
+
else req.once('end', bodyArrived);
|
|
118
|
+
res.once('close', () => {
|
|
119
|
+
admitted -= 1;
|
|
120
|
+
bodyArrived();
|
|
121
|
+
});
|
|
122
|
+
return next();
|
|
123
|
+
};
|
|
124
|
+
middleware.count = () => admitted;
|
|
125
|
+
return middleware;
|
|
126
|
+
}
|