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/server/metrics.js
CHANGED
|
@@ -1,31 +1,139 @@
|
|
|
1
|
-
|
|
1
|
+
const BUCKETS_S = Object.freeze([0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300]);
|
|
2
|
+
const MAX_LABELLED_PATH = 512;
|
|
3
|
+
const KNOWN_ROUTES = new Set(['/chat/completions', '/completions', '/responses', '/models', '/embeddings', '/metrics', '/health', '/ready']);
|
|
4
|
+
|
|
5
|
+
/** A bounded route label: known paths, ids replaced, everything else "other". */
|
|
6
|
+
export function routeLabel(originalUrl) {
|
|
7
|
+
const raw = String(originalUrl || '').split('?')[0];
|
|
8
|
+
if (raw.length > MAX_LABELLED_PATH) return 'other';
|
|
9
|
+
let end = raw.length;
|
|
10
|
+
while (end > 1 && raw[end - 1] === '/') end -= 1;
|
|
11
|
+
const path = raw.slice(0, end).replace(/^\/v1(?=\/)/, '');
|
|
12
|
+
if (KNOWN_ROUTES.has(path)) return path;
|
|
13
|
+
if (/^\/responses\/[^/]+$/.test(path)) return '/responses/{id}';
|
|
14
|
+
if (path.startsWith('/models/')) return '/models/{id}';
|
|
15
|
+
return 'other';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function createHistogram() {
|
|
19
|
+
const series = new Map();
|
|
20
|
+
return {
|
|
21
|
+
observe(label, seconds) {
|
|
22
|
+
let entry = series.get(label);
|
|
23
|
+
if (!entry) {
|
|
24
|
+
entry = { counts: new Array(BUCKETS_S.length).fill(0), count: 0, sum: 0 };
|
|
25
|
+
series.set(label, entry);
|
|
26
|
+
}
|
|
27
|
+
const index = BUCKETS_S.findIndex((bound) => seconds <= bound);
|
|
28
|
+
if (index >= 0) entry.counts[index] += 1;
|
|
29
|
+
entry.count += 1;
|
|
30
|
+
entry.sum += seconds;
|
|
31
|
+
},
|
|
32
|
+
series,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const escapeLabel = (value) => String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
37
|
+
|
|
38
|
+
const metricHeader = (name, type, help) => [`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`];
|
|
39
|
+
|
|
40
|
+
function histogramLines(name, help, histogram) {
|
|
41
|
+
const lines = metricHeader(name, 'histogram', help);
|
|
42
|
+
for (const [route, entry] of histogram.series) {
|
|
43
|
+
const label = `route="${escapeLabel(route)}"`;
|
|
44
|
+
let cumulative = 0;
|
|
45
|
+
BUCKETS_S.forEach((bound, index) => {
|
|
46
|
+
cumulative += entry.counts[index];
|
|
47
|
+
lines.push(`${name}_bucket{${label},le="${bound}"} ${cumulative}`);
|
|
48
|
+
});
|
|
49
|
+
lines.push(`${name}_bucket{${label},le="+Inf"} ${entry.count}`, `${name}_sum{${label}} ${entry.sum}`, `${name}_count{${label}} ${entry.count}`);
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const summary = (histogram) => Object.fromEntries([...histogram.series].map(([route, { count, sum }]) => [
|
|
55
|
+
route, { count, mean_ms: count ? Math.round((sum / count) * 1000) : 0 },
|
|
56
|
+
]));
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* In-process counters and latency histograms, served on GET /metrics as JSON
|
|
60
|
+
* or in the Prometheus text format.
|
|
61
|
+
*/
|
|
2
62
|
export function createMetrics() {
|
|
3
63
|
const startedAt = Date.now();
|
|
4
64
|
const byStatus = new Map();
|
|
65
|
+
const duration = createHistogram();
|
|
66
|
+
const firstToken = createHistogram();
|
|
5
67
|
let requests = 0;
|
|
6
68
|
let authFailures = 0;
|
|
7
69
|
let rateLimited = 0;
|
|
8
70
|
|
|
9
71
|
const middleware = (req, res, next) => {
|
|
10
72
|
requests += 1;
|
|
11
|
-
|
|
12
|
-
|
|
73
|
+
const started = performance.now();
|
|
74
|
+
const route = routeLabel(req.originalUrl ?? req.url);
|
|
75
|
+
let tokenSeen = false;
|
|
76
|
+
/** Record the time to the first generated token; only the first call counts. */
|
|
77
|
+
req.markFirstToken = () => {
|
|
78
|
+
if (tokenSeen) return;
|
|
79
|
+
tokenSeen = true;
|
|
80
|
+
firstToken.observe(route, (performance.now() - started) / 1000);
|
|
81
|
+
};
|
|
82
|
+
// 'close' also fires when the client goes away first; count those as 499, as nginx does.
|
|
83
|
+
res.on('close', () => {
|
|
84
|
+
const key = res.writableFinished ? String(res.statusCode) : '499';
|
|
13
85
|
byStatus.set(key, (byStatus.get(key) || 0) + 1);
|
|
86
|
+
duration.observe(route, (performance.now() - started) / 1000);
|
|
14
87
|
});
|
|
15
88
|
next();
|
|
16
89
|
};
|
|
17
90
|
|
|
91
|
+
const snapshot = (extra = {}) => ({
|
|
92
|
+
uptime_s: Math.round((Date.now() - startedAt) / 1000),
|
|
93
|
+
requests,
|
|
94
|
+
auth_failures: authFailures,
|
|
95
|
+
rate_limited: rateLimited,
|
|
96
|
+
responses_by_status: Object.fromEntries(byStatus),
|
|
97
|
+
latency: summary(duration),
|
|
98
|
+
time_to_first_token: summary(firstToken),
|
|
99
|
+
...extra,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {{ slots: object, storedResponses: number, backendReady: boolean, eventsConnected: boolean,
|
|
104
|
+
* toolRejections: number, opencode: string|null }} gauges
|
|
105
|
+
*/
|
|
106
|
+
function prometheus(gauges) {
|
|
107
|
+
const lines = [
|
|
108
|
+
...metricHeader('zengate_up_seconds', 'gauge', 'Seconds since the gateway started.'),
|
|
109
|
+
`zengate_up_seconds ${Math.round((Date.now() - startedAt) / 1000)}`,
|
|
110
|
+
...metricHeader('zengate_http_responses_total', 'counter', 'Responses by HTTP status.'),
|
|
111
|
+
...[...byStatus].map(([status, count]) => `zengate_http_responses_total{status="${status}"} ${count}`),
|
|
112
|
+
...metricHeader('zengate_auth_failures_total', 'counter', 'Requests rejected for a missing or wrong API key.'),
|
|
113
|
+
`zengate_auth_failures_total ${authFailures}`,
|
|
114
|
+
...metricHeader('zengate_rate_limited_total', 'counter', 'Requests rejected by the client rate limit.'),
|
|
115
|
+
`zengate_rate_limited_total ${rateLimited}`,
|
|
116
|
+
...metricHeader('zengate_tool_rejections_total', 'counter', 'OpenCode tool calls rejected; each costs the model an extra round trip.'),
|
|
117
|
+
`zengate_tool_rejections_total ${gauges.toolRejections}`,
|
|
118
|
+
...metricHeader('zengate_slots', 'gauge', 'Concurrency slots and queue.'),
|
|
119
|
+
...['active', 'queued', 'admitted', 'maxConcurrent', 'maxQueue'].map((key) => `zengate_slots{state="${key}"} ${gauges.slots[key]}`),
|
|
120
|
+
...metricHeader('zengate_stored_responses', 'gauge', 'Responses kept for previous_response_id.'),
|
|
121
|
+
`zengate_stored_responses ${gauges.storedResponses}`,
|
|
122
|
+
...metricHeader('zengate_backend_ready', 'gauge', 'Whether OpenCode is healthy (1) or not (0).'),
|
|
123
|
+
`zengate_backend_ready{opencode="${escapeLabel(gauges.opencode ?? 'unknown')}"} ${gauges.backendReady ? 1 : 0}`,
|
|
124
|
+
...metricHeader('zengate_backend_events_connected', 'gauge', 'Whether the OpenCode event stream is connected.'),
|
|
125
|
+
`zengate_backend_events_connected ${gauges.eventsConnected ? 1 : 0}`,
|
|
126
|
+
...histogramLines('zengate_request_duration_seconds', 'Time from request start to the end of the response.', duration),
|
|
127
|
+
...histogramLines('zengate_time_to_first_token_seconds', 'Time from request start to the first generated text.', firstToken),
|
|
128
|
+
];
|
|
129
|
+
return `${lines.join('\n')}\n`;
|
|
130
|
+
}
|
|
131
|
+
|
|
18
132
|
return Object.freeze({
|
|
19
133
|
middleware,
|
|
20
134
|
authFailure: () => { authFailures += 1; },
|
|
21
135
|
rateLimited: () => { rateLimited += 1; },
|
|
22
|
-
snapshot
|
|
23
|
-
|
|
24
|
-
requests,
|
|
25
|
-
auth_failures: authFailures,
|
|
26
|
-
rate_limited: rateLimited,
|
|
27
|
-
responses_by_status: Object.fromEntries(byStatus),
|
|
28
|
-
...extra,
|
|
29
|
-
}),
|
|
136
|
+
snapshot,
|
|
137
|
+
prometheus,
|
|
30
138
|
});
|
|
31
139
|
}
|
package/src/server/middleware.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { clientKey } from './client-key.js';
|
|
2
4
|
import { ApiError, sendError } from './errors.js';
|
|
3
5
|
|
|
4
6
|
const PUBLIC_PATHS = new Set(['/health', '/ready']);
|
|
@@ -63,7 +65,7 @@ export function rateLimitMiddleware({ perMinute, onLimited, maxClients = 50000 }
|
|
|
63
65
|
|
|
64
66
|
const middleware = (req, res, next) => {
|
|
65
67
|
if (perMinute <= 0 || PUBLIC_PATHS.has(req.path)) return next();
|
|
66
|
-
const ip = req
|
|
68
|
+
const ip = clientKey(req);
|
|
67
69
|
const now = Date.now();
|
|
68
70
|
let bucket = buckets.get(ip);
|
|
69
71
|
if (!bucket) {
|
|
@@ -85,6 +87,33 @@ export function rateLimitMiddleware({ perMinute, onLimited, maxClients = 50000 }
|
|
|
85
87
|
return middleware;
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/** The name in a Host header, lower-cased, without port, brackets or a trailing dot. */
|
|
91
|
+
function hostName(header) {
|
|
92
|
+
const value = String(header || '').trim().toLowerCase();
|
|
93
|
+
if (value.startsWith('[')) return value.slice(1, value.indexOf(']'));
|
|
94
|
+
return value.replace(/:\d*$/, '').replace(/\.$/, '');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Without API keys, a web page could use the gateway through DNS rebinding:
|
|
99
|
+
* its own domain is made to resolve to this host, so the browser treats the
|
|
100
|
+
* gateway as same-origin. Such requests carry that domain as Host, so only
|
|
101
|
+
* names that cannot be rebound are accepted: IP addresses, localhost and its
|
|
102
|
+
* subdomains, and ALLOWED_HOSTS.
|
|
103
|
+
* @param {{ allowedHosts: string[] }} options
|
|
104
|
+
*/
|
|
105
|
+
export function hostGuard({ allowedHosts }) {
|
|
106
|
+
const allowed = new Set(['localhost', ...allowedHosts.map((host) => hostName(host))]);
|
|
107
|
+
return (req, res, next) => {
|
|
108
|
+
if (PUBLIC_PATHS.has(req.path)) return next();
|
|
109
|
+
const host = hostName(req.headers.host);
|
|
110
|
+
if (host && (allowed.has(host) || host.endsWith('.localhost') || net.isIP(host))) return next();
|
|
111
|
+
return sendError(res, new ApiError(403,
|
|
112
|
+
'This Host name is not accepted while ALLOW_NO_AUTH is on. Use an IP address or localhost, or add the name to ALLOWED_HOSTS.',
|
|
113
|
+
{ code: 'host_not_allowed' }));
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
88
117
|
export function securityHeaders(req, res, next) {
|
|
89
118
|
res.set({
|
|
90
119
|
'X-Content-Type-Options': 'nosniff',
|
package/src/server/slot.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ApiError } from './errors.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Adds `req.withSlot(fn, weight)`: runs `fn(signal)` holding `weight` concurrency
|
|
4
|
+
* Adds `req.withSlot(fn, weight)`: runs `fn(signal, granted)` holding `weight` concurrency
|
|
5
|
+
* slots; `granted` is the weight actually held, which MAX_CONCURRENT may cap.
|
|
5
6
|
* The signal aborts when the client disconnects (499) or REQUEST_TIMEOUT_MS
|
|
6
|
-
* elapses (504); either way the slot is released
|
|
7
|
-
* work ignores the signal.
|
|
7
|
+
* elapses after the slot is granted (504); either way the slot is released
|
|
8
|
+
* immediately, even if the work ignores the signal. Time spent queued is
|
|
9
|
+
* bounded separately by the limiter.
|
|
8
10
|
*/
|
|
9
11
|
export function slotMiddleware({ limiter, timeoutMs }) {
|
|
10
12
|
return (req, res, next) => {
|
|
@@ -14,17 +16,21 @@ export function slotMiddleware({ limiter, timeoutMs }) {
|
|
|
14
16
|
if (!res.writableFinished) controller.abort(new ApiError(499, 'Client closed the request.', { code: 'cancelled' }));
|
|
15
17
|
};
|
|
16
18
|
res.on('close', onClose);
|
|
17
|
-
|
|
18
|
-
`The model did not finish within ${Math.round(timeoutMs / 1000)}s (REQUEST_TIMEOUT_MS).`, { code: 'timeout' })), timeoutMs);
|
|
19
|
-
timer.unref();
|
|
19
|
+
let timer = null;
|
|
20
20
|
let release;
|
|
21
21
|
try {
|
|
22
22
|
release = await limiter.acquire(controller.signal, weight);
|
|
23
|
-
|
|
23
|
+
timer = setTimeout(() => controller.abort(new ApiError(504,
|
|
24
|
+
`The model did not finish within ${Math.round(timeoutMs / 1000)}s (REQUEST_TIMEOUT_MS).`, { code: 'timeout' })), timeoutMs);
|
|
25
|
+
timer.unref();
|
|
26
|
+
return await fn(controller.signal, release.weight ?? weight);
|
|
24
27
|
} finally {
|
|
25
28
|
clearTimeout(timer);
|
|
26
29
|
res.off('close', onClose);
|
|
27
30
|
release?.();
|
|
31
|
+
// Work started under this slot (such as sibling choices after one
|
|
32
|
+
// failed) must not outlive it.
|
|
33
|
+
if (!controller.signal.aborted) controller.abort(new ApiError(499, 'The request has finished.', { code: 'cancelled' }));
|
|
28
34
|
}
|
|
29
35
|
};
|
|
30
36
|
next();
|