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,119 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+
6
+ const ROOT_PREFIX = 'zengate-';
7
+
8
+ /** Environment variables the backend legitimately needs from the host. */
9
+ const PASSTHROUGH_ENV = [
10
+ 'PATH', 'Path', 'PATHEXT', 'SystemRoot', 'SYSTEMROOT', 'windir', 'WINDIR', 'ComSpec', 'SystemDrive',
11
+ 'TEMP', 'TMP', 'TMPDIR', 'LANG', 'LC_ALL', 'TZ',
12
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
13
+ 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
14
+ ];
15
+
16
+ /**
17
+ * OpenCode configuration for the managed backend. Every tool permission is
18
+ * "ask" (the tool stays advertised exactly as in stock OpenCode) and the
19
+ * gateway answers every ask with "reject", so tools never run.
20
+ */
21
+ export function backendConfig() {
22
+ return {
23
+ $schema: 'https://opencode.ai/config.json',
24
+ autoupdate: false,
25
+ share: 'disabled',
26
+ snapshot: false,
27
+ permission: {
28
+ '*': 'ask', read: 'ask', edit: 'ask', bash: 'ask', glob: 'ask', grep: 'ask', list: 'ask',
29
+ task: 'ask', webfetch: 'ask', websearch: 'ask', external_directory: 'ask', skill: 'ask', lsp: 'ask',
30
+ },
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Create a private (0700) scratch tree: an empty workspace plus a fake
36
+ * home/XDG layout, so the backend never reads the host user's OpenCode or
37
+ * Claude config, skills, agents, credentials or projects.
38
+ */
39
+ export function createIsolatedRoot() {
40
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), ROOT_PREFIX));
41
+ if (process.platform !== 'win32') fs.chmodSync(root, 0o700);
42
+ const dirs = {};
43
+ for (const name of ['home', 'workspace', 'data', 'config', 'cache', 'state']) {
44
+ dirs[name] = path.join(root, name);
45
+ fs.mkdirSync(dirs[name], { recursive: true, mode: 0o700 });
46
+ }
47
+ fs.writeFileSync(path.join(root, 'owner.pid'), String(process.pid), { mode: 0o600 });
48
+ return { root, ...dirs };
49
+ }
50
+
51
+ export function removeIsolatedRoot(root) {
52
+ if (!root || !path.basename(root).startsWith(ROOT_PREFIX)) return;
53
+ try {
54
+ fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
55
+ } catch {
56
+ // A backend that is still exiting may hold files open; the next start sweeps it.
57
+ }
58
+ }
59
+
60
+ function isAlive(pid) {
61
+ try {
62
+ process.kill(pid, 0);
63
+ return true;
64
+ } catch (error) {
65
+ return error.code === 'EPERM';
66
+ }
67
+ }
68
+
69
+ /** Remove scratch trees left behind by gateway processes that no longer exist. */
70
+ export function sweepStaleRoots() {
71
+ let entries = [];
72
+ try {
73
+ entries = fs.readdirSync(os.tmpdir(), { withFileTypes: true });
74
+ } catch {
75
+ return 0;
76
+ }
77
+ let removed = 0;
78
+ for (const entry of entries) {
79
+ if (!entry.isDirectory() || !entry.name.startsWith(ROOT_PREFIX)) continue;
80
+ const root = path.join(os.tmpdir(), entry.name);
81
+ let pid = 0;
82
+ try { pid = Number(fs.readFileSync(path.join(root, 'owner.pid'), 'utf8')); } catch { /* unreadable: leave it */ }
83
+ if (pid > 0 && pid !== process.pid && !isAlive(pid)) {
84
+ removeIsolatedRoot(root);
85
+ removed += 1;
86
+ }
87
+ }
88
+ return removed;
89
+ }
90
+
91
+ /** Minimal child environment: no gateway secrets, isolated homes, stock OpenCode behavior. */
92
+ export function backendEnv(dirs, password) {
93
+ const env = {};
94
+ for (const key of PASSTHROUGH_ENV) if (process.env[key] !== undefined) env[key] = process.env[key];
95
+ return {
96
+ ...env,
97
+ HOME: dirs.home,
98
+ USERPROFILE: dirs.home,
99
+ APPDATA: path.join(dirs.home, 'AppData', 'Roaming'),
100
+ LOCALAPPDATA: path.join(dirs.home, 'AppData', 'Local'),
101
+ XDG_DATA_HOME: dirs.data,
102
+ XDG_CONFIG_HOME: dirs.config,
103
+ XDG_CACHE_HOME: dirs.cache,
104
+ XDG_STATE_HOME: dirs.state,
105
+ OPENCODE_SERVER_USERNAME: 'opencode',
106
+ OPENCODE_SERVER_PASSWORD: password,
107
+ OPENCODE_CONFIG_CONTENT: JSON.stringify(backendConfig()),
108
+ OPENCODE_DISABLE_AUTOUPDATE: '1',
109
+ OPENCODE_DISABLE_SHARE: '1',
110
+ OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
111
+ OPENCODE_DISABLE_PROJECT_CONFIG: '1',
112
+ OPENCODE_DISABLE_CLAUDE_CODE: '1',
113
+ OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
114
+ OPENCODE_DISABLE_DEFAULT_PLUGINS: '1',
115
+ OPENCODE_DISABLE_EMBEDDED_WEB_UI: '1',
116
+ };
117
+ }
118
+
119
+ export const randomPassword = () => crypto.randomBytes(32).toString('base64url');
@@ -0,0 +1,66 @@
1
+ import { ApiError } from '../server/errors.js';
2
+ import { BackendError } from './client.js';
3
+
4
+ const MAX_DETAIL = 400;
5
+
6
+ function detail(error) {
7
+ const message = error?.data?.message || error?.message || '';
8
+ return String(message).replace(/\s+/g, ' ').trim().slice(0, MAX_DETAIL);
9
+ }
10
+
11
+ function retryAfter(error) {
12
+ const headers = error?.data?.responseHeaders || {};
13
+ const value = Number(headers['retry-after'] ?? headers['Retry-After']);
14
+ return Number.isFinite(value) && value > 0 && value < 3600 ? Math.ceil(value) : 30;
15
+ }
16
+
17
+ /**
18
+ * Translate an OpenCode assistant-message error into either a finish reason
19
+ * (the turn produced a usable, if cut short, answer) or an ApiError to throw.
20
+ * @returns {{ finish?: string, throw?: ApiError }}
21
+ */
22
+ export function mapModelError(error) {
23
+ const name = error?.name || 'UnknownError';
24
+ const message = detail(error);
25
+ switch (name) {
26
+ case 'MessageOutputLengthError':
27
+ return { finish: 'length' };
28
+ case 'ContentFilterError':
29
+ return { finish: 'content_filter' };
30
+ case 'MessageAbortedError':
31
+ return { throw: new ApiError(499, 'Request cancelled.', { code: 'cancelled' }) };
32
+ case 'ContextOverflowError':
33
+ return { throw: new ApiError(400, `The conversation is too long for this model's context window. ${message}`.trim(), { param: 'messages', code: 'context_length_exceeded' }) };
34
+ case 'StructuredOutputError':
35
+ return { throw: new ApiError(502, `The model did not produce output matching the requested JSON schema. ${message}`.trim(), { code: 'structured_output_failed' }) };
36
+ case 'ProviderAuthError':
37
+ return { throw: new ApiError(502, `OpenCode Zen refused the request: ${message}`, { code: 'upstream_auth_failed' }) };
38
+ case 'APIError': {
39
+ const status = Number(error?.data?.statusCode) || 502;
40
+ if (status === 429) {
41
+ return { throw: new ApiError(429, `OpenCode Zen rate limit reached for this model; retry later. ${message}`.trim(), { code: 'upstream_rate_limited', retryAfter: retryAfter(error) }) };
42
+ }
43
+ if (status === 400 || status === 413 || status === 422) {
44
+ return { throw: new ApiError(400, `The model rejected the request: ${message}`, { code: 'upstream_rejected' }) };
45
+ }
46
+ if (status === 401 || status === 403) {
47
+ return { throw: new ApiError(502, `OpenCode Zen refused the request: ${message}`, { code: 'upstream_refused' }) };
48
+ }
49
+ return { throw: new ApiError(502, `The model provider failed: ${message}`, { code: 'upstream_error' }) };
50
+ }
51
+ default:
52
+ return { throw: new ApiError(502, `The model provider failed: ${message || name}`, { code: 'upstream_error' }) };
53
+ }
54
+ }
55
+
56
+ /** Translate an HTTP-level backend failure into an ApiError. */
57
+ export function mapBackendError(error) {
58
+ if (error instanceof ApiError) return error;
59
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') return error;
60
+ if (error instanceof BackendError && error.status === 400) {
61
+ const raw = error.body && typeof error.body === 'object' ? JSON.stringify(error.body) : String(error.body || 'no details');
62
+ const message = raw.slice(0, MAX_DETAIL);
63
+ return new ApiError(400, `OpenCode rejected the request: ${message}`, { code: 'backend_rejected', cause: error });
64
+ }
65
+ return new ApiError(503, 'The OpenCode backend is unavailable; retry shortly.', { code: 'backend_unavailable', retryAfter: 2, cause: error });
66
+ }
@@ -0,0 +1,224 @@
1
+ import { ApiError } from '../server/errors.js';
2
+ import { mapBackendError, mapModelError } from './model-errors.js';
3
+
4
+ const MESSAGE_SEPARATOR = '\n\n';
5
+ const MAX_UPSTREAM_RETRIES = 3;
6
+ const NATIVE_ATTEMPT = Symbol('native-client-tool-attempt');
7
+ const STREAMED_KINDS = new Set(['text', 'reasoning']);
8
+
9
+ /**
10
+ * Runs one prompt in a throwaway OpenCode session and streams its output.
11
+ *
12
+ * Deltas arrive over the shared event hub while the blocking prompt call is
13
+ * pending; when it returns, the final message is reconciled against what was
14
+ * streamed so no text is lost or duplicated even if events were missed.
15
+ */
16
+ export function createRunner({ getClient, hub, logger, agent }) {
17
+ /**
18
+ * @param {{ model: { providerID: string, modelID: string }, system?: string, parts: object[], variant?: string, format?: object }} request
19
+ * @param {{ signal: AbortSignal, onDelta?: (kind: 'text'|'reasoning', text: string) => void }} options
20
+ */
21
+ async function run(request, { signal: outer, onDelta = () => {} }) {
22
+ let signal = outer;
23
+ const client = getClient();
24
+ let sessionId;
25
+ try {
26
+ sessionId = (await client.createSession({ signal }))?.id;
27
+ } catch (error) {
28
+ throw mapBackendError(error);
29
+ }
30
+ if (!sessionId) throw mapBackendError(new Error('OpenCode did not return a session id'));
31
+
32
+ // Local controller: aborts on the caller's signal, or when OpenCode
33
+ // keeps retrying a failing upstream (it would otherwise retry until
34
+ // REQUEST_TIMEOUT_MS).
35
+ const local = new AbortController();
36
+ const forward = () => local.abort(outer.reason);
37
+ if (signal.aborted) forward();
38
+ else signal.addEventListener('abort', forward, { once: true });
39
+ const tracker = createStreamTracker(onDelta);
40
+ const clientTools = new Set(request.clientTools || []);
41
+ let nativeAttempt = null;
42
+ const onEvent = (event) => {
43
+ tracker.handle(event);
44
+ const part = event.type === 'message.part.updated' ? event.properties?.part : null;
45
+ const attempted = part?.type === 'tool' ? invalidToolName(part, clientTools) : null;
46
+ if (attempted !== null && !local.signal.aborted) {
47
+ // The model called a client function (or a nonexistent tool)
48
+ // natively. Letting OpenCode continue only produces a detour
49
+ // (and trips some providers), so stop; generate() retries.
50
+ logger.debug('Model attempted a native call to a non-OpenCode tool', { sessionId, tool: attempted });
51
+ nativeAttempt = attempted || 'unnamed tool';
52
+ local.abort(NATIVE_ATTEMPT);
53
+ return;
54
+ }
55
+ const status = event.type === 'session.status' ? event.properties?.status : null;
56
+ if (status?.type !== 'retry') return;
57
+ logger.debug('OpenCode is retrying the upstream request', { sessionId, attempt: status.attempt, message: status.message });
58
+ if (status.attempt >= MAX_UPSTREAM_RETRIES && !local.signal.aborted) {
59
+ local.abort(new ApiError(502, `The model provider keeps failing: ${String(status.message || 'unknown error').slice(0, 300)}`, { code: 'upstream_error' }));
60
+ }
61
+ };
62
+ const unsubscribe = hub.subscribe(sessionId, onEvent);
63
+ const abortSession = () => { client.abortSession(sessionId).catch(() => {}); };
64
+ local.signal.addEventListener('abort', abortSession, { once: true });
65
+ signal = local.signal;
66
+ try {
67
+ const body = {
68
+ model: request.model,
69
+ agent,
70
+ parts: request.parts,
71
+ ...(request.system ? { system: request.system } : {}),
72
+ ...(request.variant ? { variant: request.variant } : {}),
73
+ ...(request.format ? { format: request.format } : {}),
74
+ };
75
+ let last;
76
+ try {
77
+ last = await client.prompt(sessionId, body, { signal });
78
+ } catch (error) {
79
+ if (nativeAttempt && signal.reason === NATIVE_ATTEMPT) {
80
+ return { text: '', reasoning: '', usage: { input: 0, output: 0, reasoning: 0, cacheRead: 0 }, finish: 'stop', nativeToolAttempt: nativeAttempt };
81
+ }
82
+ if (signal.aborted) throw signal.reason ?? error;
83
+ throw mapBackendError(error);
84
+ }
85
+ // Several assistant messages mean OpenCode continued after a
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];
90
+ const result = summarize(assistantMessages);
91
+ tracker.flushRemainder(result);
92
+ return result;
93
+ } finally {
94
+ local.signal.removeEventListener('abort', abortSession);
95
+ outer.removeEventListener('abort', forward);
96
+ unsubscribe();
97
+ void cleanup(client, sessionId);
98
+ }
99
+ }
100
+
101
+ async function cleanup(client, sessionId) {
102
+ for (let attempt = 0; attempt < 2; attempt += 1) {
103
+ try {
104
+ await client.deleteSession(sessionId);
105
+ break;
106
+ } catch (error) {
107
+ if (attempt === 1) logger.debug('Could not delete OpenCode session', { sessionId, error: error.message });
108
+ }
109
+ }
110
+ hub.release(sessionId);
111
+ }
112
+
113
+ return Object.freeze({ run });
114
+ }
115
+
116
+ /** Tracks part kinds and message roles so only assistant text/reasoning streams. */
117
+ function createStreamTracker(onDelta) {
118
+ const partKinds = new Map();
119
+ const pendingDeltas = new Map();
120
+ const roles = new Map();
121
+ const streamed = { text: '', reasoning: '' };
122
+ const lastMessage = { text: null, reasoning: null };
123
+
124
+ const emit = (kind, messageId, delta) => {
125
+ if (!delta) return;
126
+ const separator = streamed[kind] && lastMessage[kind] !== messageId ? MESSAGE_SEPARATOR : '';
127
+ lastMessage[kind] = messageId;
128
+ streamed[kind] += separator + delta;
129
+ onDelta(kind, separator + delta);
130
+ };
131
+
132
+ const flushPart = (partId) => {
133
+ const queued = pendingDeltas.get(partId);
134
+ const kind = partKinds.get(partId);
135
+ if (!queued || !kind) return;
136
+ pendingDeltas.delete(partId);
137
+ if (STREAMED_KINDS.has(kind)) for (const { messageId, delta } of queued) emit(kind, messageId, delta);
138
+ };
139
+
140
+ function handle(event) {
141
+ const props = event.properties || {};
142
+ if (event.type === 'message.updated' && props.info?.id) {
143
+ roles.set(props.info.id, props.info.role);
144
+ } else if (event.type === 'message.part.updated' && props.part?.id) {
145
+ partKinds.set(props.part.id, props.part.type);
146
+ flushPart(props.part.id);
147
+ } else if (event.type === 'message.part.delta' && props.field === 'text' && typeof props.delta === 'string') {
148
+ if (roles.get(props.messageID) === 'user') return;
149
+ const queued = pendingDeltas.get(props.partID) || [];
150
+ queued.push({ messageId: props.messageID, delta: props.delta });
151
+ pendingDeltas.set(props.partID, queued);
152
+ flushPart(props.partID);
153
+ }
154
+ }
155
+
156
+ function flushRemainder(result) {
157
+ for (const kind of STREAMED_KINDS) {
158
+ const finalText = result[kind];
159
+ if (finalText.startsWith(streamed[kind])) {
160
+ const rest = finalText.slice(streamed[kind].length);
161
+ if (rest) {
162
+ streamed[kind] = finalText;
163
+ onDelta(kind, rest);
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ return {
170
+ handle,
171
+ flushRemainder,
172
+ assistantCount: () => [...roles.values()].filter((role) => role === 'assistant').length,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Name of the tool the model tried to call when it is not an OpenCode tool:
178
+ * a pending part already named after a client function, or OpenCode's
179
+ * `invalid` stand-in. Returns null for ordinary OpenCode tool parts.
180
+ */
181
+ function invalidToolName(part, clientTools) {
182
+ if (clientTools.has(part.tool)) return part.tool;
183
+ if (part.tool === 'invalid') return String(part.state?.input?.tool ?? '');
184
+ return null;
185
+ }
186
+
187
+ function joinParts(messages, type) {
188
+ return messages
189
+ .map((message) => (message.parts || []).filter((p) => p?.type === type).map((p) => p.text || '').join(''))
190
+ .filter(Boolean)
191
+ .join(MESSAGE_SEPARATOR);
192
+ }
193
+
194
+ /** Collapse the turn's assistant messages into text, reasoning, usage and finish. */
195
+ function summarize(messages) {
196
+ const usage = { input: 0, output: 0, reasoning: 0, cacheRead: 0 };
197
+ let error = null;
198
+ let structured;
199
+ for (const message of messages) {
200
+ const tokens = message?.info?.tokens || {};
201
+ usage.input += tokens.input || 0;
202
+ usage.output += tokens.output || 0;
203
+ usage.reasoning += tokens.reasoning || 0;
204
+ usage.cacheRead += tokens.cache?.read || 0;
205
+ if (message?.info?.error) error = message.info.error;
206
+ if (message?.info?.structured !== undefined) structured = message.info.structured;
207
+ }
208
+ const last = messages[messages.length - 1]?.info || {};
209
+ const mapped = error ? mapModelError(error) : null;
210
+ if (mapped?.throw) throw mapped.throw;
211
+ return {
212
+ text: joinParts(messages, 'text'),
213
+ reasoning: joinParts(messages, 'reasoning'),
214
+ structured,
215
+ usage,
216
+ finish: mapped?.finish || normalizeFinish(last.finish),
217
+ };
218
+ }
219
+
220
+ function normalizeFinish(finish) {
221
+ if (finish === 'length') return 'length';
222
+ if (finish === 'content-filter') return 'content_filter';
223
+ return 'stop';
224
+ }
@@ -0,0 +1,40 @@
1
+ const MAX_FRAME_CHARS = 16 * 1024 * 1024;
2
+
3
+ /**
4
+ * Parse a server-sent-events byte stream into { event, data } frames.
5
+ * Handles CRLF/LF, multi-line data and chunk boundaries anywhere.
6
+ * @param {AsyncIterable<Uint8Array>} body
7
+ */
8
+ export async function* readSseFrames(body) {
9
+ const decoder = new TextDecoder();
10
+ let buffer = '';
11
+ for await (const chunk of body) {
12
+ buffer += decoder.decode(chunk, { stream: true });
13
+ if (buffer.length > MAX_FRAME_CHARS) throw new Error('SSE frame exceeds size limit');
14
+ let boundary;
15
+ while ((boundary = findBoundary(buffer)) !== null) {
16
+ const frame = buffer.slice(0, boundary.index);
17
+ buffer = buffer.slice(boundary.index + boundary.length);
18
+ const parsed = parseFrame(frame);
19
+ if (parsed) yield parsed;
20
+ }
21
+ }
22
+ buffer += decoder.decode();
23
+ const tail = parseFrame(buffer);
24
+ if (tail) yield tail;
25
+ }
26
+
27
+ function findBoundary(buffer) {
28
+ const match = /\r?\n\r?\n/.exec(buffer);
29
+ return match ? { index: match.index, length: match[0].length } : null;
30
+ }
31
+
32
+ function parseFrame(frame) {
33
+ let event = '';
34
+ const data = [];
35
+ for (const line of frame.split(/\r?\n/)) {
36
+ if (line.startsWith('data:')) data.push(line.slice(line[5] === ' ' ? 6 : 5));
37
+ else if (line.startsWith('event:')) event = line.slice(6).trim();
38
+ }
39
+ return data.length ? { event, data: data.join('\n') } : null;
40
+ }
package/src/paths.js ADDED
@@ -0,0 +1,40 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+
4
+ const APP_DIR = 'zengate';
5
+ const CONFIG_NAME = 'config.json';
6
+
7
+ /**
8
+ * True when the code runs from an installed npm package (npm -g, npx or a
9
+ * dependency) rather than a source checkout.
10
+ * @param {string} root package root directory
11
+ * @returns {boolean}
12
+ */
13
+ export function isInstalledPackage(root) {
14
+ return root.split(/[\\/]+/).includes('node_modules');
15
+ }
16
+
17
+ /**
18
+ * The per-user config directory for this platform.
19
+ * @param {{ env: object, platform: string, homedir: string }} options
20
+ * @returns {string}
21
+ */
22
+ function userConfigDir({ env, platform, homedir }) {
23
+ if (platform === 'win32') return path.join(env.APPDATA || path.join(homedir, 'AppData', 'Roaming'), APP_DIR);
24
+ if (platform === 'darwin') return path.join(homedir, 'Library', 'Application Support', APP_DIR);
25
+ const xdg = env.XDG_CONFIG_HOME && path.isAbsolute(env.XDG_CONFIG_HOME) ? env.XDG_CONFIG_HOME : path.join(homedir, '.config');
26
+ return path.join(xdg, APP_DIR);
27
+ }
28
+
29
+ /**
30
+ * Where config.json lives: CONFIG_FILE if set, next to the code in a source
31
+ * checkout, otherwise the per-user config directory (an installed package
32
+ * directory may be read-only and is replaced on every upgrade).
33
+ * @param {{ root: string, env?: object, platform?: string, homedir?: string }} options
34
+ * @returns {string}
35
+ */
36
+ export function defaultConfigPath({ root, env = process.env, platform = process.platform, homedir = os.homedir() }) {
37
+ if (env.CONFIG_FILE) return env.CONFIG_FILE;
38
+ if (!isInstalledPackage(root)) return path.join(root, CONFIG_NAME);
39
+ return path.join(userConfigDir({ env, platform, homedir }), CONFIG_NAME);
40
+ }
@@ -0,0 +1,96 @@
1
+ import cors from 'cors';
2
+ import express from 'express';
3
+ import { chatCompletionsHandler } from '../openai/chat.js';
4
+ import { modelsHandlers } from '../openai/models.js';
5
+ import { responsesHandlers } from '../openai/responses.js';
6
+ import { ApiError, sendError, toApiError } from './errors.js';
7
+ import { createLimiter } from './limiter.js';
8
+ import { createMetrics } from './metrics.js';
9
+ import { authMiddleware, rateLimitMiddleware, requestId, securityHeaders } from './middleware.js';
10
+ import { slotMiddleware } from './slot.js';
11
+
12
+ const MB = 1024 * 1024;
13
+ const EXPOSED_HEADERS = ['x-request-id', 'x-gateway-ignored-params', 'Retry-After'];
14
+
15
+ /** Wrap an async handler so rejections reach the error handler. */
16
+ const route = (handler) => (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
17
+
18
+ function openAiRoutes({ runner, catalog, store, limits }) {
19
+ const router = express.Router();
20
+ const models = modelsHandlers({ catalog });
21
+ const responses = responsesHandlers({ runner, catalog, store, limits });
22
+ router.get('/models', route(models.list));
23
+ router.get('/models/*id', route(models.retrieve));
24
+ router.post('/chat/completions', route(chatCompletionsHandler({ runner, catalog, limits })));
25
+ router.post('/responses', route(responses.create));
26
+ router.get('/responses/:id', route(responses.retrieve));
27
+ router.delete('/responses/:id', route(responses.remove));
28
+ return router;
29
+ }
30
+
31
+ function requestLogger(logger) {
32
+ return (req, res, next) => {
33
+ const scoped = (level) => (msg, fields) => logger[level](msg, { requestId: req.id, ...fields });
34
+ req.log = { debug: scoped('debug'), info: scoped('info'), warn: scoped('warn'), error: scoped('error') };
35
+ next();
36
+ };
37
+ }
38
+
39
+ function errorHandler(error, req, res, next) {
40
+ const apiError = toApiError(error);
41
+ if (apiError.status >= 500 && apiError.status !== 504) {
42
+ req.log.error('Request failed', { status: apiError.status, error: error?.message, cause: error?.cause?.message });
43
+ } else {
44
+ req.log.debug('Request rejected', { status: apiError.status, code: apiError.code, error: apiError.message });
45
+ }
46
+ if (res.headersSent) {
47
+ if (!res.writableEnded) res.end();
48
+ return undefined;
49
+ }
50
+ if (req.socket.destroyed) return undefined;
51
+ void next;
52
+ return sendError(res, apiError);
53
+ }
54
+
55
+ /**
56
+ * The HTTP surface. OpenAI routes are served under /v1 and, for clients
57
+ * configured with a bare base URL, at the root as well.
58
+ * @param {{ config: object, logger: object, backend: object, hub: object, catalog: object, runner: object, store: object }} deps
59
+ */
60
+ export function createApp({ config, logger, backend, hub, catalog, runner, store }) {
61
+ const app = express();
62
+ const metrics = createMetrics();
63
+ const limiter = createLimiter({ maxConcurrent: config.MAX_CONCURRENT, maxQueue: config.MAX_QUEUE });
64
+ const rateLimit = rateLimitMiddleware({ perMinute: config.RATE_LIMIT_PER_MINUTE, onLimited: metrics.rateLimited });
65
+ const limits = { maxBytes: config.MAX_MEDIA_MB * MB };
66
+
67
+ app.disable('x-powered-by');
68
+ app.set('trust proxy', config.TRUST_PROXY || false);
69
+ app.use(requestId, securityHeaders, metrics.middleware, requestLogger(logger));
70
+ if (config.CORS_ORIGINS.length) {
71
+ app.use(cors({ origin: config.CORS_ORIGINS, exposedHeaders: EXPOSED_HEADERS, maxAge: 600 }));
72
+ }
73
+
74
+ app.get('/health', (req, res) => res.json({ status: 'ok' }));
75
+ app.get('/ready', (req, res) => {
76
+ const ready = backend.isReady() && hub.isConnected();
77
+ res.status(ready ? 200 : 503).json({ status: ready ? 'ready' : 'starting', backend: backend.mode, opencode: backend.version() });
78
+ });
79
+
80
+ app.use(rateLimit);
81
+ app.use(authMiddleware({ apiKeys: config.API_KEYS, allowNoAuth: config.ALLOW_NO_AUTH, onFailure: metrics.authFailure }));
82
+ app.get('/metrics', (req, res) => res.json(metrics.snapshot({
83
+ slots: limiter.stats(), stored_responses: store.size(), backend_ready: backend.isReady(), events_connected: hub.isConnected(),
84
+ })));
85
+ app.use(express.json({ limit: config.MAX_BODY_MB * MB }));
86
+ app.use(slotMiddleware({ limiter, timeoutMs: config.REQUEST_TIMEOUT_MS }));
87
+
88
+ const routes = openAiRoutes({ runner, catalog, store, limits });
89
+ app.use('/v1', routes);
90
+ app.use(routes);
91
+
92
+ app.use((req, res) => sendError(res, new ApiError(404, `Unknown endpoint: ${req.method} ${req.path}`, { code: 'unknown_url' })));
93
+ app.use(errorHandler);
94
+
95
+ return { app, close: () => rateLimit.close() };
96
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Errors surfaced to API clients use the OpenAI error envelope:
3
+ * { "error": { "message", "type", "param", "code" } }.
4
+ */
5
+ export class ApiError extends Error {
6
+ /**
7
+ * @param {number} status HTTP status
8
+ * @param {string} message client-safe message
9
+ * @param {{ type?: string, param?: string|null, code?: string|null, retryAfter?: number, cause?: unknown }} [options]
10
+ */
11
+ constructor(status, message, { type, param = null, code = null, retryAfter, cause } = {}) {
12
+ super(message, cause ? { cause } : undefined);
13
+ this.name = 'ApiError';
14
+ this.status = status;
15
+ this.type = type ?? defaultType(status);
16
+ this.param = param;
17
+ this.code = code;
18
+ this.retryAfter = retryAfter;
19
+ }
20
+
21
+ toJSON() {
22
+ return { error: { message: this.message, type: this.type, param: this.param, code: this.code } };
23
+ }
24
+ }
25
+
26
+ function defaultType(status) {
27
+ if (status === 401) return 'authentication_error';
28
+ if (status === 403) return 'permission_error';
29
+ if (status === 404) return 'not_found_error';
30
+ if (status === 429) return 'rate_limit_error';
31
+ if (status >= 500) return 'server_error';
32
+ return 'invalid_request_error';
33
+ }
34
+
35
+ export const invalidRequest = (message, param = null, code = null) => new ApiError(400, message, { param, code });
36
+
37
+ export const unsupported = (message, param = null) => new ApiError(400, message, { param, code: 'unsupported_parameter' });
38
+
39
+ /** Normalize anything thrown into an ApiError without leaking internals. */
40
+ export function toApiError(error) {
41
+ if (error instanceof ApiError) return error;
42
+ if (error?.type === 'entity.too.large') {
43
+ return new ApiError(413, 'Request body too large. Raise MAX_BODY_MB if this is expected.', { code: 'request_too_large' });
44
+ }
45
+ if (error?.type === 'entity.parse.failed') return invalidRequest('Request body is not valid JSON.');
46
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') {
47
+ return new ApiError(504, 'The model did not finish before REQUEST_TIMEOUT_MS.', { code: 'timeout' });
48
+ }
49
+ return new ApiError(500, 'Internal server error.');
50
+ }
51
+
52
+ export function sendError(res, error) {
53
+ const apiError = toApiError(error);
54
+ if (apiError.retryAfter) res.set('Retry-After', String(apiError.retryAfter));
55
+ res.status(apiError.status).json(apiError.toJSON());
56
+ }