zengate 1.0.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/config.json.example +23 -0
  4. package/index.js +84 -0
  5. package/package.json +57 -0
  6. package/scripts/setup.mjs +24 -0
  7. package/src/bootstrap.js +66 -0
  8. package/src/cli.js +55 -0
  9. package/src/config.js +193 -0
  10. package/src/gateway.js +90 -0
  11. package/src/logger.js +35 -0
  12. package/src/openai/chat-request.js +183 -0
  13. package/src/openai/chat.js +100 -0
  14. package/src/openai/generate.js +143 -0
  15. package/src/openai/media.js +104 -0
  16. package/src/openai/models.js +17 -0
  17. package/src/openai/prompt.js +101 -0
  18. package/src/openai/response-builder.js +135 -0
  19. package/src/openai/responses-request.js +176 -0
  20. package/src/openai/responses-store.js +78 -0
  21. package/src/openai/responses.js +65 -0
  22. package/src/openai/sse-writer.js +37 -0
  23. package/src/openai/stop.js +41 -0
  24. package/src/openai/tool-calls.js +187 -0
  25. package/src/openai/url-guard.js +61 -0
  26. package/src/opencode/backend.js +201 -0
  27. package/src/opencode/binary.js +75 -0
  28. package/src/opencode/catalog.js +83 -0
  29. package/src/opencode/client.js +84 -0
  30. package/src/opencode/events.js +168 -0
  31. package/src/opencode/isolation.js +119 -0
  32. package/src/opencode/model-errors.js +66 -0
  33. package/src/opencode/runner.js +224 -0
  34. package/src/opencode/sse-reader.js +40 -0
  35. package/src/paths.js +40 -0
  36. package/src/server/app.js +96 -0
  37. package/src/server/errors.js +56 -0
  38. package/src/server/limiter.js +72 -0
  39. package/src/server/metrics.js +31 -0
  40. package/src/server/middleware.js +94 -0
  41. package/src/server/slot.js +32 -0
@@ -0,0 +1,72 @@
1
+ import { ApiError } from './errors.js';
2
+
3
+ /**
4
+ * Concurrency gate with a bounded FIFO queue.
5
+ *
6
+ * `acquire(signal, weight)` resolves to a release function once `weight`
7
+ * slots are free (a request with n choices runs n generations, so it costs
8
+ * n slots). The slots are released exactly once: when the caller calls
9
+ * release, or when `signal` aborts (timeout or client disconnect), so work
10
+ * that ignores cancellation can never hold a slot forever.
11
+ * @param {{ maxConcurrent: number, maxQueue: number }} options
12
+ */
13
+ export function createLimiter({ maxConcurrent, maxQueue }) {
14
+ let active = 0;
15
+ const waiting = [];
16
+
17
+ const fits = (weight) => active + weight <= maxConcurrent;
18
+
19
+ const pump = () => {
20
+ while (waiting.length > 0 && fits(waiting[0].weight)) waiting.shift().grant();
21
+ };
22
+
23
+ const makeRelease = (signal, weight) => {
24
+ let released = false;
25
+ const release = () => {
26
+ if (released) return;
27
+ released = true;
28
+ signal?.removeEventListener('abort', release);
29
+ active -= weight;
30
+ pump();
31
+ };
32
+ signal?.addEventListener('abort', release, { once: true });
33
+ return release;
34
+ };
35
+
36
+ const abortedError = () => new ApiError(499, 'Request cancelled.', { code: 'cancelled' });
37
+
38
+ function acquire(signal, requested = 1) {
39
+ const weight = Math.min(Math.max(1, Math.floor(requested) || 1), maxConcurrent);
40
+ if (signal?.aborted) return Promise.reject(abortedError());
41
+ if (waiting.length === 0 && fits(weight)) {
42
+ active += weight;
43
+ return Promise.resolve(makeRelease(signal, weight));
44
+ }
45
+ if (waiting.length >= maxQueue) {
46
+ return Promise.reject(new ApiError(429, 'Gateway is busy; retry shortly.', { code: 'server_busy', retryAfter: 2 }));
47
+ }
48
+ return new Promise((resolve, reject) => {
49
+ const entry = {
50
+ weight,
51
+ grant() {
52
+ signal?.removeEventListener('abort', onAbort);
53
+ active += weight;
54
+ resolve(makeRelease(signal, weight));
55
+ },
56
+ };
57
+ const onAbort = () => {
58
+ const index = waiting.indexOf(entry);
59
+ if (index >= 0) waiting.splice(index, 1);
60
+ reject(abortedError());
61
+ pump();
62
+ };
63
+ signal?.addEventListener('abort', onAbort, { once: true });
64
+ waiting.push(entry);
65
+ });
66
+ }
67
+
68
+ return Object.freeze({
69
+ acquire,
70
+ stats: () => ({ active, queued: waiting.length, maxConcurrent, maxQueue }),
71
+ });
72
+ }
@@ -0,0 +1,31 @@
1
+ /** Minimal in-process counters exposed as JSON on GET /metrics. */
2
+ export function createMetrics() {
3
+ const startedAt = Date.now();
4
+ const byStatus = new Map();
5
+ let requests = 0;
6
+ let authFailures = 0;
7
+ let rateLimited = 0;
8
+
9
+ const middleware = (req, res, next) => {
10
+ requests += 1;
11
+ res.on('finish', () => {
12
+ const key = String(res.statusCode);
13
+ byStatus.set(key, (byStatus.get(key) || 0) + 1);
14
+ });
15
+ next();
16
+ };
17
+
18
+ return Object.freeze({
19
+ middleware,
20
+ authFailure: () => { authFailures += 1; },
21
+ rateLimited: () => { rateLimited += 1; },
22
+ snapshot: (extra = {}) => ({
23
+ uptime_s: Math.round((Date.now() - startedAt) / 1000),
24
+ requests,
25
+ auth_failures: authFailures,
26
+ rate_limited: rateLimited,
27
+ responses_by_status: Object.fromEntries(byStatus),
28
+ ...extra,
29
+ }),
30
+ });
31
+ }
@@ -0,0 +1,94 @@
1
+ import crypto from 'node:crypto';
2
+ import { ApiError, sendError } from './errors.js';
3
+
4
+ const PUBLIC_PATHS = new Set(['/health', '/ready']);
5
+
6
+ /** Constant-time comparison; hashing first hides the key length. */
7
+ function safeEqual(a, b) {
8
+ const ha = crypto.createHash('sha256').update(a).digest();
9
+ const hb = crypto.createHash('sha256').update(b).digest();
10
+ return crypto.timingSafeEqual(ha, hb);
11
+ }
12
+
13
+ function presentedKey(req) {
14
+ const header = req.headers.authorization || '';
15
+ const match = /^Bearer\s+(.+)$/i.exec(header);
16
+ return match ? match[1].trim() : '';
17
+ }
18
+
19
+ /**
20
+ * Gateway authentication. Clients send `Authorization: Bearer <API_KEY>`,
21
+ * which is what every OpenAI SDK does with its api key setting.
22
+ */
23
+ export function authMiddleware({ apiKeys, allowNoAuth, onFailure }) {
24
+ return (req, res, next) => {
25
+ req.clientId = 'anonymous';
26
+ if (allowNoAuth || PUBLIC_PATHS.has(req.path) || req.method === 'OPTIONS') return next();
27
+ const key = presentedKey(req);
28
+ if (key && apiKeys.some((candidate) => safeEqual(key, candidate))) {
29
+ // Opaque per-key id: scopes stored responses to the key that made them.
30
+ req.clientId = crypto.createHash('sha256').update(key).digest('hex').slice(0, 32);
31
+ return next();
32
+ }
33
+ onFailure?.();
34
+ res.set('WWW-Authenticate', 'Bearer');
35
+ return sendError(res, new ApiError(401, key
36
+ ? 'Incorrect API key provided.'
37
+ : 'Missing API key. Send it as "Authorization: Bearer <API_KEY>".', { code: 'invalid_api_key' }));
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Per-client token bucket (burst = limit, refill = limit per minute).
43
+ * O(1) memory per client; idle entries are swept every minute.
44
+ */
45
+ export function rateLimitMiddleware({ perMinute, onLimited, maxClients = 50000 }) {
46
+ const buckets = new Map();
47
+ const refillPerMs = perMinute / 60000;
48
+ const sweep = setInterval(() => {
49
+ const cutoff = Date.now() - 120000;
50
+ for (const [ip, bucket] of buckets) if (bucket.at < cutoff) buckets.delete(ip);
51
+ }, 60000);
52
+ sweep.unref();
53
+
54
+ const middleware = (req, res, next) => {
55
+ if (perMinute <= 0 || PUBLIC_PATHS.has(req.path)) return next();
56
+ const ip = req.ip || req.socket?.remoteAddress || 'unknown';
57
+ const now = Date.now();
58
+ let bucket = buckets.get(ip);
59
+ if (!bucket) {
60
+ if (buckets.size >= maxClients) buckets.delete(buckets.keys().next().value);
61
+ bucket = { tokens: perMinute, at: now };
62
+ buckets.set(ip, bucket);
63
+ }
64
+ bucket.tokens = Math.min(perMinute, bucket.tokens + (now - bucket.at) * refillPerMs);
65
+ bucket.at = now;
66
+ if (bucket.tokens < 1) {
67
+ onLimited?.();
68
+ const retryAfter = Math.max(1, Math.ceil((1 - bucket.tokens) / refillPerMs / 1000));
69
+ return sendError(res, new ApiError(429, 'Rate limit reached for this client; slow down.', { code: 'rate_limit_exceeded', retryAfter }));
70
+ }
71
+ bucket.tokens -= 1;
72
+ return next();
73
+ };
74
+ middleware.close = () => clearInterval(sweep);
75
+ return middleware;
76
+ }
77
+
78
+ export function securityHeaders(req, res, next) {
79
+ res.set({
80
+ 'X-Content-Type-Options': 'nosniff',
81
+ 'X-Frame-Options': 'DENY',
82
+ 'Referrer-Policy': 'no-referrer',
83
+ 'Cache-Control': 'no-store',
84
+ });
85
+ next();
86
+ }
87
+
88
+ /** Per-request id (echoes a client-supplied one when it looks sane). */
89
+ export function requestId(req, res, next) {
90
+ const incoming = req.headers['x-request-id'];
91
+ req.id = typeof incoming === 'string' && /^[\w.-]{1,64}$/.test(incoming) ? incoming : `req_${crypto.randomBytes(12).toString('hex')}`;
92
+ res.set('x-request-id', req.id);
93
+ next();
94
+ }
@@ -0,0 +1,32 @@
1
+ import { ApiError } from './errors.js';
2
+
3
+ /**
4
+ * Adds `req.withSlot(fn, weight)`: runs `fn(signal)` holding `weight` concurrency slots.
5
+ * The signal aborts when the client disconnects (499) or REQUEST_TIMEOUT_MS
6
+ * elapses (504); either way the slot is released immediately, even if the
7
+ * work ignores the signal.
8
+ */
9
+ export function slotMiddleware({ limiter, timeoutMs }) {
10
+ return (req, res, next) => {
11
+ req.withSlot = async (fn, weight = 1) => {
12
+ const controller = new AbortController();
13
+ const onClose = () => {
14
+ if (!res.writableFinished) controller.abort(new ApiError(499, 'Client closed the request.', { code: 'cancelled' }));
15
+ };
16
+ res.on('close', onClose);
17
+ const timer = setTimeout(() => controller.abort(new ApiError(504,
18
+ `The model did not finish within ${Math.round(timeoutMs / 1000)}s (REQUEST_TIMEOUT_MS).`, { code: 'timeout' })), timeoutMs);
19
+ timer.unref();
20
+ let release;
21
+ try {
22
+ release = await limiter.acquire(controller.signal, weight);
23
+ return await fn(controller.signal);
24
+ } finally {
25
+ clearTimeout(timer);
26
+ res.off('close', onClose);
27
+ release?.();
28
+ }
29
+ };
30
+ next();
31
+ };
32
+ }