zengate 1.2.0 → 1.3.1

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 CHANGED
@@ -68,6 +68,8 @@ docker exec zengate node index.js setup --show # prints the generated key
68
68
 
69
69
  Images are published for `linux/amd64` and `linux/arm64`, tagged `latest`, `1`, `1.2` and `1.2.3`.
70
70
 
71
+ The same image is on GitHub Container Registry as `ghcr.io/developingchet/zengate`, and the npm package is on GitHub Packages as `@developingchet/zengate`. Installing from GitHub Packages needs a GitHub token, so npmjs.com and Docker Hub remain the simplest sources.
72
+
71
73
  **Docker Compose**
72
74
 
73
75
  ```yaml
@@ -241,13 +243,20 @@ Set values as environment variables or in the config file (same names; see [`con
241
243
  | `TRUST_PROXY` | `0` | Number of reverse-proxy hops to trust for client IPs. |
242
244
  | `LOG_LEVEL` / `LOG_JSON` | `info` / `false` | Log verbosity, and JSON-lines output. |
243
245
  | `OPENCODE_AGENT` | `plan` | The OpenCode agent each session uses. |
244
- | `OPENCODE_PATH` | bundled | Use a different `opencode` binary. |
246
+ | `OPENCODE_PATH` | bundled | Use a different `opencode` binary, OpenCode 1 or 2. |
245
247
  | `OPENCODE_SERVER_URL` | none | Attach to an existing `opencode serve` instead of starting one (see below). |
246
248
  | `OPENCODE_SERVER_USERNAME` / `OPENCODE_SERVER_PASSWORD` | `opencode` / none | Basic auth for that server. Credentials inside `OPENCODE_SERVER_URL` are refused. |
247
249
  | `ALLOW_INSECURE_BACKEND_HTTP` | `false` | Allow a non-loopback `http://` server URL. |
248
250
  | `CONFIG_FILE` | see [First start](#first-start) | Where the config file lives (environment variable only). |
249
251
 
250
- **Attach mode.** `OPENCODE_SERVER_URL` uses a server you run yourself. The gateway can only reject tool calls that server *asks* permission for; that happens in its own sessions and their subagents. It trusts the server's own permission config, plugins and MCP servers. If that config allows tools without asking, they will run. Use it only with a server configured like the managed one: every permission set to `"ask"`, and `experimental.continue_loop_on_deny` set to `true` so a rejected tool does not end the turn with an empty reply. The managed default is safer. Remote servers must use `https://` unless you set `ALLOW_INSECURE_BACKEND_HTTP`.
252
+ **Attach mode.** `OPENCODE_SERVER_URL` uses a server you run yourself. The gateway can only reject tool calls that server *asks* permission for; that happens in its own sessions and their subagents. It trusts the server's own permission config, plugins and MCP servers. If that config allows tools without asking, they will run. On OpenCode 2 this includes "always allow" rules saved for the server's project, which apply after the gateway's session rules and win over them; the `execute` tool and its `fetch` are reachable unless the server denies that action. Use it only with a server configured like the managed one: every permission set to `"ask"`, and `experimental.continue_loop_on_deny` set to `true` so a rejected tool does not end the turn with an empty reply. The managed default is safer. Remote servers must use `https://` unless you set `ALLOW_INSECURE_BACKEND_HTTP`.
253
+
254
+ **OpenCode versions.** The bundled backend is OpenCode 2 (the `@opencode/cli` package). The gateway also works with OpenCode 1 and tells the two apart by how the backend answers, whether it starts a binary (`OPENCODE_PATH`) or attaches to a server (`OPENCODE_SERVER_URL`). With OpenCode 2:
255
+
256
+ - Sessions are created with every action set to `ask`, and the gateway rejects each request, as it does with OpenCode 1.
257
+ - `response_format` is passed to the model as an instruction, because OpenCode 2 has no native structured output. The JSON arrives when the answer is complete rather than streamed.
258
+ - `OPENCODE_AGENT` applies only if the server has an agent of that name; otherwise the session keeps its default agent.
259
+ - OpenCode 2 has an `execute` tool that runs scripts the model writes inside the OpenCode process. Scripts can call `fetch` on any URL without a permission request, so the gateway denies the `execute` action and the tool is not offered to the model. Browser tools are denied too (they also need a desktop app attached to the session). Every other tool, including shell, file, web and subagent tools, asks and is rejected. A later OpenCode 2 release could add a way around this, so after upgrading it, run `npm run test:leak`: it asks a model to use every tool, and to fetch a local listener, in a session with the gateway's permissions, and fails if anything ran.
251
260
 
252
261
  ## Deployment
253
262
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zengate",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "Keyless OpenAI-compatible API (Chat Completions + Responses) for OpenCode's free Zen models, served through the real OpenCode CLI.",
5
5
  "author": {
6
6
  "name": "developingchet",
@@ -32,6 +32,7 @@
32
32
  "test": "node --test \"test/**/*.test.mjs\" \"test/**/*.test.js\"",
33
33
  "test:coverage": "node --test --experimental-test-coverage \"test/**/*.test.mjs\" \"test/**/*.test.js\"",
34
34
  "test:live": "node scripts/live-e2e.mjs",
35
+ "test:leak": "node scripts/live-tool-leak.mjs",
35
36
  "verify": "npm run lint && npm test",
36
37
  "audit": "npm audit --omit=dev --audit-level=high"
37
38
  },
@@ -51,8 +52,8 @@
51
52
  ],
52
53
  "license": "MIT",
53
54
  "dependencies": {
54
- "express": "^5.1.0",
55
- "opencode-ai": "1.18.34"
55
+ "@opencode/cli": "2.0.32",
56
+ "express": "^5.1.0"
56
57
  },
57
58
  "overrides": {
58
59
  "es-define-property": "npm:@socketregistry/es-define-property@^1",
@@ -1,5 +1,6 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { backendOrigin, createOpencodeClient } from './client.js';
3
+ import { probeBackend } from './detect.js';
3
4
  import { resolveOpencodeBinary, spawnCommand } from './binary.js';
4
5
  import {
5
6
  backendEnv, createIsolatedRoot, HEARTBEAT_MS, randomPassword, recordBackendPid, removeIsolatedRoot, sweepStaleRoots, touchIsolatedRoot,
@@ -55,13 +56,14 @@ function killTree(child) {
55
56
  }, 3000).unref();
56
57
  }
57
58
 
58
- async function waitHealthy(client, deadline, isDead) {
59
+ /** Wait until the backend answers, and learn which API generation it speaks. */
60
+ async function waitHealthy(options, deadline, isDead) {
59
61
  let lastError = null;
60
62
  while (Date.now() < deadline) {
61
63
  if (isDead()) throw new Error('OpenCode exited during startup');
62
64
  try {
63
- const health = await client.health();
64
- if (health?.healthy === true) return health;
65
+ const found = await probeBackend(options);
66
+ if (found) return found;
65
67
  } catch (error) {
66
68
  lastError = error;
67
69
  }
@@ -129,14 +131,14 @@ export function createManagedBackend({ opencodePath, logger }) {
129
131
  if (!stopping && served) onCrash(signal || code, root);
130
132
  });
131
133
  const port = await boundPort(proc, START_TIMEOUT_MS);
132
- const candidate = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}`, username: 'opencode', password });
133
- const health = await waitHealthy(candidate, deadline, () => dead);
134
- client = candidate;
135
- version = health.version || null;
134
+ const found = await waitHealthy({ baseUrl: `http://127.0.0.1:${port}`, username: 'opencode', password }, deadline, () => dead);
135
+ client = found.client;
136
+ version = found.health.version || null;
136
137
  ready = true;
137
138
  served = true;
138
139
  restartDelay = 1000;
139
140
  logger.info(`OpenCode ${version || ''} backend ready (managed, isolated, tools disabled)`.replace(' ', ' '));
141
+ logger.debug(`OpenCode speaks the ${client.api} API`);
140
142
  }
141
143
 
142
144
  /** Kill the current backend, if any, and wait (briefly) until it is gone. */
@@ -226,27 +228,35 @@ export function createManagedBackend({ opencodePath, logger }) {
226
228
 
227
229
  /** Uses an OpenCode server someone else runs (OPENCODE_SERVER_URL). */
228
230
  export function createAttachedBackend({ url, username, password, logger }) {
229
- const client = createOpencodeClient({ baseUrl: url, username, password });
231
+ // Until the server has been probed its API generation is unknown; OpenCode 1 is the default.
232
+ let client = createOpencodeClient({ baseUrl: url, username, password });
230
233
  const shownUrl = backendOrigin(url);
231
234
  let ready = false;
232
235
  let version = null;
233
236
  let timer = null;
234
237
 
235
238
  async function probe() {
236
- try {
237
- const health = await client.health();
238
- ready = health?.healthy === true;
239
- version = health?.version || version;
240
- } catch {
241
- ready = false;
239
+ let health = await client.health().catch(() => null);
240
+ if (health?.healthy !== true) {
241
+ // First contact, or the server was replaced by another OpenCode generation.
242
+ health = await probeBackend({ baseUrl: url, username, password }).then(
243
+ (found) => {
244
+ if (found && found.client.api !== client.api) logger.debug(`OpenCode speaks the ${found.client.api} API`);
245
+ if (found) client = found.client;
246
+ return found?.health;
247
+ },
248
+ () => null,
249
+ );
242
250
  }
251
+ ready = health?.healthy === true;
252
+ version = health?.version || version;
243
253
  return ready;
244
254
  }
245
255
 
246
256
  return Object.freeze({
247
257
  mode: 'attached',
248
258
  async start() {
249
- if (!(await probe())) throw new Error(`OpenCode server at ${shownUrl} is not healthy (GET /global/health). Check the URL and credentials.`);
259
+ if (!(await probe())) throw new Error(`OpenCode server at ${shownUrl} is not healthy (GET /global/health or /api/info). Check the URL and credentials.`);
250
260
  logger.info(`Using OpenCode ${version || ''} at ${shownUrl}`.replace(' ', ' '));
251
261
  logger.warn('Attached mode: the gateway rejects tool permissions for its own sessions, but the server\'s own configuration still applies. Prefer the managed backend.');
252
262
  timer = setInterval(probe, 10000);
@@ -12,10 +12,10 @@ function isFile(file) {
12
12
  }
13
13
  }
14
14
 
15
- /** The binary shipped by the official `opencode-ai` npm package (a dependency). */
15
+ /** The binary shipped by the official `@opencode/cli` npm package (a dependency). */
16
16
  function bundledBinary() {
17
17
  try {
18
- const root = path.dirname(require.resolve('opencode-ai/package.json'));
18
+ const root = path.dirname(require.resolve('@opencode/cli/package.json'));
19
19
  const binary = path.join(root, 'bin', 'opencode.exe');
20
20
  return isFile(binary) ? binary : null;
21
21
  } catch {
@@ -35,7 +35,7 @@ function searchPath(names) {
35
35
 
36
36
  /**
37
37
  * Locate the OpenCode CLI: explicit OPENCODE_PATH, then the bundled
38
- * `opencode-ai` package, then PATH. Never guesses other directories.
38
+ * `@opencode/cli` package, then PATH. Never guesses other directories.
39
39
  * @param {string} configured OPENCODE_PATH ('' for automatic)
40
40
  * @returns {{ path: string, source: 'config'|'bundled'|'PATH' }}
41
41
  */
@@ -57,7 +57,7 @@ export function resolveOpencodeBinary(configured) {
57
57
  if (bundled) return { path: bundled, source: 'bundled' };
58
58
  const found = searchPath(process.platform === 'win32' ? windowsNames : ['opencode']);
59
59
  if (found) return { path: found, source: 'PATH' };
60
- throw new Error('OpenCode CLI not found. Run `npm ci` (it installs the bundled opencode-ai package) or set OPENCODE_PATH.');
60
+ throw new Error('OpenCode CLI not found. Run `npm ci` (it installs the bundled @opencode/cli package) or set OPENCODE_PATH.');
61
61
  }
62
62
 
63
63
  /**
@@ -0,0 +1,229 @@
1
+ import { BackendError, createHttp } from './http.js';
2
+ import { adaptEvent, messageFromV2, providersFromModels } from './v2-convert.js';
3
+
4
+ const MESSAGE_PAGE = 200;
5
+ const MAX_PAGES = 5;
6
+ const POLL_MS = 250;
7
+ const AGENTS_CACHE_MS = 60000;
8
+ // OpenCode 2 caps an instruction entry at 256 KiB; stay under it.
9
+ const MAX_INSTRUCTION_BYTES = 200 * 1024;
10
+ const SYSTEM_ENTRY = 'gateway';
11
+ // Every action asks (the gateway rejects each request; an ask nobody answers
12
+ // blocks the tool, it never runs). Two actions are denied outright:
13
+ // - `execute`: the code-mode tool runs scripts in the OpenCode process, and
14
+ // their built-in `fetch` reaches any URL without a permission request.
15
+ // Denying it with resource `*` removes the tool from the model's list.
16
+ // - `browser`: browser tools only work through a desktop app attached to the session.
17
+ // A deny-all ruleset is avoided: it makes Zen's free tier refuse every request.
18
+ const SESSION_PERMISSIONS = Object.freeze([
19
+ { action: '*', resource: '*', effect: 'ask' },
20
+ { action: 'execute', resource: '*', effect: 'deny' },
21
+ { action: 'browser', resource: '*', effect: 'deny' },
22
+ ]);
23
+
24
+ function sleep(ms, signal) {
25
+ return new Promise((resolve, reject) => {
26
+ const onAbort = () => {
27
+ clearTimeout(timer);
28
+ reject(signal.reason ?? Object.assign(new Error('aborted'), { name: 'AbortError' }));
29
+ };
30
+ const timer = setTimeout(() => {
31
+ signal?.removeEventListener('abort', onAbort);
32
+ resolve();
33
+ }, ms);
34
+ signal?.addEventListener('abort', onAbort, { once: true });
35
+ });
36
+ }
37
+
38
+ /** The text of a prompt's parts, and its attachments in OpenCode 2's `files` form. */
39
+ function splitParts(parts = []) {
40
+ const text = parts.filter((part) => part?.type === 'text').map((part) => part.text).join('\n\n');
41
+ const files = parts
42
+ .filter((part) => part?.type === 'file' && part.url)
43
+ .map((part) => {
44
+ // The gateway inlines every attachment. Anything else would be resolved by OpenCode itself.
45
+ if (!String(part.url).startsWith('data:')) throw new Error('Attachments must be inlined as data: URIs');
46
+ return { uri: part.url, ...(part.filename ? { name: part.filename } : {}) };
47
+ });
48
+ return { text, files };
49
+ }
50
+
51
+ /** OpenCode 2 has no per-prompt output format, so the requirement goes into the instructions. */
52
+ function formatInstruction(format) {
53
+ if (!format) return '';
54
+ return format.type === 'json_schema'
55
+ ? `Reply with only JSON matching this schema: ${JSON.stringify(format.schema)}`
56
+ : 'Reply with only a valid JSON object.';
57
+ }
58
+
59
+ /**
60
+ * Client for the `opencode serve` API of OpenCode 2 (everything under /api).
61
+ * It offers the same interface as the OpenCode 1 client in client.js and
62
+ * returns OpenCode 1 shaped data, so the rest of the gateway does not care
63
+ * which generation it talks to.
64
+ *
65
+ * Differences it absorbs:
66
+ * - a prompt is queued and returns at once, so the client waits for the
67
+ * session to go idle and then reads the turn back;
68
+ * - model, agent and system prompt are session state, set before each prompt;
69
+ * - requests are answered per session, not by id alone.
70
+ *
71
+ * @param {{ baseUrl: string, username?: string, password?: string, fetchImpl?: typeof fetch }} options
72
+ */
73
+ export function createOpencodeClientV2(options) {
74
+ const { base, request, openStream } = createHttp(options);
75
+ const id = (value) => encodeURIComponent(value);
76
+ let agentsCache = null;
77
+
78
+ /**
79
+ * OpenCode 2 answers paths outside /api with its web app (HTTP 200, HTML),
80
+ * which would pass for an empty success. Every real reply is JSON.
81
+ */
82
+ async function call(method, path, requestOptions) {
83
+ const body = await request(method, path, requestOptions);
84
+ if (typeof body === 'string') throw new BackendError(`OpenCode ${method} ${path} did not answer with JSON`);
85
+ return body;
86
+ }
87
+
88
+ /** Names of the agents the server knows, cached briefly (the list is static for a server). */
89
+ async function knownAgents(signal) {
90
+ if (agentsCache && Date.now() - agentsCache.at < AGENTS_CACHE_MS) return agentsCache.names;
91
+ let agents;
92
+ try {
93
+ agents = (await call('GET', '/api/agent', { signal }))?.data || [];
94
+ } catch (error) {
95
+ // Selecting an agent is optional; the session keeps its default one. A failure is not cached.
96
+ if (!(error instanceof BackendError) || signal?.aborted) throw error;
97
+ return new Set();
98
+ }
99
+ const names = new Set(agents.map((agent) => agent.id || agent.name));
100
+ agentsCache = { at: Date.now(), names };
101
+ return names;
102
+ }
103
+
104
+ /** Messages of a session, oldest first. */
105
+ async function listMessages(sessionId, { signal, order = 'asc', limit = MESSAGE_PAGE } = {}) {
106
+ const items = [];
107
+ const seen = new Set();
108
+ let cursor = null;
109
+ for (let page = 0; page < MAX_PAGES; page += 1) {
110
+ // The cursor carries the order; the server rejects the two together.
111
+ const query = new URLSearchParams({ limit: String(limit), ...(cursor ? { cursor } : { order }) });
112
+ const body = await call('GET', `/api/session/${id(sessionId)}/message?${query}`, { signal });
113
+ const batch = (body?.data || []).filter((item) => item?.id && !seen.has(item.id));
114
+ for (const item of batch) seen.add(item.id);
115
+ items.push(...batch);
116
+ cursor = body?.cursor?.next;
117
+ if (!cursor || batch.length < limit) break;
118
+ }
119
+ return items;
120
+ }
121
+
122
+ /**
123
+ * Applies the agent, model and system prompt a v1 prompt body carries.
124
+ * @returns the system text when it is too large for an instruction entry, to be sent with the prompt instead
125
+ */
126
+ async function prepare(sessionId, body, signal) {
127
+ // An agent can carry its own model, so it is selected before the model is.
128
+ if (body.agent && (await knownAgents(signal)).has(body.agent)) {
129
+ await call('POST', `/api/session/${id(sessionId)}/agent`, { body: { agent: body.agent }, signal });
130
+ }
131
+ const { providerID, modelID } = body.model || {};
132
+ const system = [body.system, formatInstruction(body.format)].filter(Boolean).join('\n\n');
133
+ const oversized = system && Buffer.byteLength(JSON.stringify(system)) > MAX_INSTRUCTION_BYTES;
134
+ await Promise.all([
135
+ providerID && modelID && call('POST', `/api/session/${id(sessionId)}/model`, {
136
+ body: { model: { providerID, id: modelID, ...(body.variant ? { variant: body.variant } : {}) } },
137
+ signal,
138
+ }),
139
+ system && !oversized && call('PUT', `/api/experimental/session/${id(sessionId)}/instructions/entries/${SYSTEM_ENTRY}`, { body: { value: system }, signal }),
140
+ ]);
141
+ return oversized ? system : '';
142
+ }
143
+
144
+ /** Blocks until the queued prompt has finished; returns the messages after it, oldest first, and the closing idle marker. */
145
+ async function settle(sessionId, userId, signal) {
146
+ try {
147
+ await request('POST', `/api/experimental/session/${id(sessionId)}/wait`, { timeoutMs: 0, signal });
148
+ } catch (error) {
149
+ // The wait route is experimental: without it, polling below is enough.
150
+ if (!(error instanceof BackendError)) throw error;
151
+ }
152
+ for (;;) {
153
+ const messages = await listMessages(sessionId, { signal });
154
+ const sent = messages.findIndex((message) => message.id === userId);
155
+ const turn = sent < 0 ? [] : messages.slice(sent + 1);
156
+ const idle = turn.findLast((message) => message.type === 'idle');
157
+ if (idle) return { turn, idle };
158
+ await sleep(POLL_MS, signal);
159
+ }
160
+ }
161
+
162
+ return Object.freeze({
163
+ api: 'v2',
164
+ baseUrl: base,
165
+ async health(requestOptions) {
166
+ const info = await call('GET', '/api/info', { timeoutMs: 3000, ...requestOptions });
167
+ const healthy = typeof info?.version === 'string' && Array.isArray(info?.urls);
168
+ return { healthy, version: healthy ? info.version : undefined };
169
+ },
170
+ async providers(requestOptions) {
171
+ return providersFromModels((await call('GET', '/api/model', requestOptions))?.data);
172
+ },
173
+ async agents(requestOptions) {
174
+ return (await call('GET', '/api/agent', requestOptions))?.data || [];
175
+ },
176
+ async createSession(requestOptions) {
177
+ // The tools stay offered to the model exactly as in stock OpenCode: denying them
178
+ // all changes the request in a way Zen's free tier refuses.
179
+ const created = await call('POST', '/api/session', {
180
+ body: { title: 'openai-gateway', permissions: SESSION_PERMISSIONS },
181
+ ...requestOptions,
182
+ });
183
+ return { id: created?.data?.id };
184
+ },
185
+ deleteSession: (sessionId, requestOptions) => call('DELETE', `/api/session/${id(sessionId)}`, requestOptions),
186
+ abortSession: (sessionId, requestOptions) => call('POST', `/api/session/${id(sessionId)}/interrupt`, { timeoutMs: 5000, ...requestOptions }),
187
+ async messages(sessionId, requestOptions) {
188
+ return (await listMessages(sessionId, requestOptions)).map((message) => messageFromV2(message, sessionId)).filter(Boolean);
189
+ },
190
+ async message(sessionId, messageId, requestOptions) {
191
+ const body = await call('GET', `/api/session/${id(sessionId)}/message/${id(messageId)}`, requestOptions);
192
+ return messageFromV2(body?.data, sessionId);
193
+ },
194
+ /** Runs one turn to completion; returns the final assistant message as `{ info, parts }`. */
195
+ async prompt(sessionId, body, requestOptions = {}) {
196
+ const { signal } = requestOptions;
197
+ const { text, files } = splitParts(body.parts);
198
+ const overflow = await prepare(sessionId, body, signal);
199
+ const queued = await call('POST', `/api/session/${id(sessionId)}/prompt`, {
200
+ body: { text: overflow ? `${overflow}\n\n${text}` : text, ...(files.length ? { files } : {}) },
201
+ signal,
202
+ });
203
+ if (!queued?.data?.id) throw new BackendError('OpenCode did not return a message id for the prompt');
204
+ const { turn, idle } = await settle(sessionId, queued.data.id, signal);
205
+ const assistant = turn.findLast((message) => message.type === 'assistant');
206
+ const broken = idle.outcome === 'failed' || idle.outcome === 'interrupted';
207
+ if (assistant && (!broken || assistant.error)) return messageFromV2(assistant, sessionId);
208
+ const reason = idle.outcome === 'interrupted' ? 'The OpenCode turn was interrupted.' : 'The OpenCode turn failed before the model answered.';
209
+ return {
210
+ info: { id: assistant?.id ?? idle.id, sessionID: sessionId, role: 'assistant', error: { name: 'UnknownError', data: { message: reason } } },
211
+ parts: [],
212
+ };
213
+ },
214
+ replyPermission: (requestId, reply, message, sessionId) =>
215
+ call('POST', `/api/session/${id(sessionId)}/permission/${id(requestId)}/reply`, {
216
+ body: { decision: reply, ...(message ? { message } : {}) },
217
+ timeoutMs: 5000,
218
+ }),
219
+ rejectQuestion: (requestId, sessionId) => call('DELETE', `/api/session/${id(sessionId)}/form/${id(requestId)}`, { timeoutMs: 5000 }),
220
+ async pendingPermissions() {
221
+ return (await call('GET', '/api/permission/request', { timeoutMs: 5000 }))?.data || [];
222
+ },
223
+ async pendingQuestions() {
224
+ return (await call('GET', '/api/form', { timeoutMs: 5000 }))?.data || [];
225
+ },
226
+ openEvents: (signal) => openStream('/api/event', signal),
227
+ adaptEvent,
228
+ });
229
+ }
@@ -1,99 +1,43 @@
1
- /**
2
- * Thin client for the documented `opencode serve` HTTP API
3
- * (https://opencode.ai/docs/server/). Plain fetch keeps connections pooled
4
- * and avoids coupling the gateway to a specific SDK major version.
5
- */
6
- export class BackendError extends Error {
7
- constructor(message, { status = 0, body = null, cause } = {}) {
8
- super(message, cause ? { cause } : undefined);
9
- this.name = 'BackendError';
10
- this.status = status;
11
- this.body = body;
12
- }
13
- }
14
-
15
- const DEFAULT_TIMEOUT_MS = 30000;
1
+ import { BackendError, backendOrigin, basicAuthHeader, createHttp } from './http.js';
16
2
 
17
- export function basicAuthHeader(username, password) {
18
- if (!password) return null;
19
- return `Basic ${Buffer.from(`${username || 'opencode'}:${password}`).toString('base64')}`;
20
- }
21
-
22
- /**
23
- * A backend URL as it may appear in logs and errors: scheme, host and port.
24
- * Credentials, a path and a query string can all carry secrets.
25
- * @param {string} url
26
- */
27
- export function backendOrigin(url) {
28
- try {
29
- return new URL(url).origin;
30
- } catch {
31
- return '(invalid URL)';
32
- }
33
- }
3
+ export { BackendError, backendOrigin, basicAuthHeader };
34
4
 
35
5
  /**
6
+ * Thin client for the documented OpenCode 1.x `opencode serve` HTTP API
7
+ * (https://opencode.ai/docs/server/). Plain fetch keeps connections pooled
8
+ * and avoids coupling the gateway to a specific SDK major version.
9
+ *
10
+ * OpenCode 2 serves a different API; see client-v2.js, which exposes this
11
+ * same interface.
12
+ *
36
13
  * @param {{ baseUrl: string, username?: string, password?: string, fetchImpl?: typeof fetch }} options
37
14
  */
38
- export function createOpencodeClient({ baseUrl, username, password, fetchImpl = fetch }) {
39
- const base = String(baseUrl).replace(/\/+$/, '');
40
- const shownBase = backendOrigin(base);
41
- const auth = basicAuthHeader(username, password);
42
- const headers = (extra) => ({ accept: 'application/json', ...(auth ? { authorization: auth } : {}), ...extra });
43
-
44
- async function request(method, path, { body, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
45
- const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null;
46
- const combined = [signal, timeout].filter(Boolean);
47
- let response;
48
- try {
49
- response = await fetchImpl(`${base}${path}`, {
50
- method,
51
- redirect: 'error',
52
- headers: headers(body === undefined ? {} : { 'content-type': 'application/json' }),
53
- body: body === undefined ? undefined : JSON.stringify(body),
54
- signal: combined.length > 1 ? AbortSignal.any(combined) : combined[0],
55
- });
56
- } catch (error) {
57
- if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
58
- throw new BackendError(`OpenCode backend unreachable at ${shownBase}: ${error?.cause?.code || error?.message}`, { cause: error });
59
- }
60
- const text = await response.text();
61
- let parsed = null;
62
- if (text) {
63
- try { parsed = JSON.parse(text); } catch { parsed = text; }
64
- }
65
- if (!response.ok) {
66
- throw new BackendError(`OpenCode ${method} ${path} failed with HTTP ${response.status}`, { status: response.status, body: parsed });
67
- }
68
- return parsed;
69
- }
70
-
15
+ export function createOpencodeClient(options) {
16
+ const { base, request, openStream } = createHttp(options);
71
17
  const id = (value) => encodeURIComponent(value);
72
18
 
73
19
  return Object.freeze({
20
+ api: 'v1',
74
21
  baseUrl: base,
75
- health: (options) => request('GET', '/global/health', { timeoutMs: 3000, ...options }),
76
- providers: (options) => request('GET', '/config/providers', options),
77
- agents: (options) => request('GET', '/agent', options),
78
- createSession: (options) => request('POST', '/session', { body: { title: 'openai-gateway' }, ...options }),
79
- deleteSession: (sessionId, options) => request('DELETE', `/session/${id(sessionId)}`, options),
80
- abortSession: (sessionId, options) => request('POST', `/session/${id(sessionId)}/abort`, { timeoutMs: 5000, ...options }),
81
- messages: (sessionId, options) => request('GET', `/session/${id(sessionId)}/message`, options),
82
- message: (sessionId, messageId, options) => request('GET', `/session/${id(sessionId)}/message/${id(messageId)}`, options),
22
+ health: (requestOptions) => request('GET', '/global/health', { timeoutMs: 3000, ...requestOptions }),
23
+ providers: (requestOptions) => request('GET', '/config/providers', requestOptions),
24
+ agents: (requestOptions) => request('GET', '/agent', requestOptions),
25
+ createSession: (requestOptions) => request('POST', '/session', { body: { title: 'openai-gateway' }, ...requestOptions }),
26
+ deleteSession: (sessionId, requestOptions) => request('DELETE', `/session/${id(sessionId)}`, requestOptions),
27
+ abortSession: (sessionId, requestOptions) => request('POST', `/session/${id(sessionId)}/abort`, { timeoutMs: 5000, ...requestOptions }),
28
+ messages: (sessionId, requestOptions) => request('GET', `/session/${id(sessionId)}/message`, requestOptions),
29
+ message: (sessionId, messageId, requestOptions) => request('GET', `/session/${id(sessionId)}/message/${id(messageId)}`, requestOptions),
83
30
  /** Blocks until the assistant turn finishes; returns { info, parts }. */
84
- prompt: (sessionId, body, options) => request('POST', `/session/${id(sessionId)}/message`, { body, timeoutMs: 0, ...options }),
31
+ prompt: (sessionId, body, requestOptions) => request('POST', `/session/${id(sessionId)}/message`, { body, timeoutMs: 0, ...requestOptions }),
32
+ // OpenCode 1 addresses requests by id alone; the session id is only needed by OpenCode 2.
85
33
  replyPermission: (requestId, reply, message) =>
86
34
  request('POST', `/permission/${id(requestId)}/reply`, { body: { reply, ...(message ? { message } : {}) }, timeoutMs: 5000 }),
87
35
  rejectQuestion: (requestId) => request('POST', `/question/${id(requestId)}/reject`, { body: {}, timeoutMs: 5000 }),
88
36
  pendingPermissions: () => request('GET', '/permission', { timeoutMs: 5000 }),
89
37
  pendingQuestions: () => request('GET', '/question', { timeoutMs: 5000 }),
90
38
  /** Opens the server-sent event stream; caller consumes response.body. */
91
- async openEvents(signal) {
92
- const response = await fetchImpl(`${base}/event`, { headers: headers({ accept: 'text/event-stream' }), signal, redirect: 'error' });
93
- if (!response.ok || !response.body) {
94
- throw new BackendError(`OpenCode event stream failed with HTTP ${response.status}`, { status: response.status });
95
- }
96
- return response;
97
- },
39
+ openEvents: (signal) => openStream('/event', signal),
40
+ /** The stream already carries the events the gateway consumes. */
41
+ adaptEvent: (event) => [event],
98
42
  });
99
43
  }
@@ -0,0 +1,28 @@
1
+ import { createOpencodeClient } from './client.js';
2
+ import { createOpencodeClientV2 } from './client-v2.js';
3
+
4
+ // OpenCode 1 first: its health route answers JSON only there (OpenCode 2 serves
5
+ // its web app on every unknown path), so a healthy reply identifies the generation.
6
+ const CLIENTS = [createOpencodeClient, createOpencodeClientV2];
7
+
8
+ /**
9
+ * Find out which API generation the server at `options.baseUrl` speaks.
10
+ * @param {Parameters<typeof createOpencodeClient>[0]} options
11
+ * @returns {Promise<{ client: ReturnType<typeof createOpencodeClient>, health: { healthy: true, version?: string } } | null>}
12
+ * null when the server answers but reports neither generation healthy
13
+ * @throws the first transport error when the server could not be reached
14
+ */
15
+ export async function probeBackend(options) {
16
+ let failure = null;
17
+ for (const create of CLIENTS) {
18
+ const client = create(options);
19
+ try {
20
+ const health = await client.health();
21
+ if (health?.healthy === true) return { client, health };
22
+ } catch (error) {
23
+ failure ??= error;
24
+ }
25
+ }
26
+ if (failure) throw failure;
27
+ return null;
28
+ }
@@ -18,7 +18,7 @@ async function* readChunks(reader) {
18
18
  }
19
19
 
20
20
  /**
21
- * One shared subscription to the backend's /event stream, fanned out to
21
+ * One shared subscription to the backend's event stream, fanned out to
22
22
  * per-session listeners. It also enforces the gateway's security policy:
23
23
  * every OpenCode tool permission request and question from a gateway session
24
24
  * is rejected, so no command, file edit or fetch ever runs on the host.
@@ -54,7 +54,7 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
54
54
 
55
55
  async function rejectPermission(id, sessionId) {
56
56
  try {
57
- await getClient().replyPermission(id, 'reject', TOOL_REJECTION_MESSAGE);
57
+ await getClient().replyPermission(id, 'reject', TOOL_REJECTION_MESSAGE, sessionId);
58
58
  toolRejections += 1;
59
59
  logger.debug('Rejected OpenCode tool permission', { sessionId });
60
60
  } catch (error) {
@@ -65,7 +65,7 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
65
65
 
66
66
  async function rejectQuestion(id, sessionId) {
67
67
  try {
68
- await getClient().rejectQuestion(id);
68
+ await getClient().rejectQuestion(id, sessionId);
69
69
  } catch (error) {
70
70
  if (alreadyAnswered(error)) logger.debug('OpenCode question was already answered', { sessionId });
71
71
  else logger.warn('Failed to reject an OpenCode question', { sessionId, error: error.message });
@@ -112,7 +112,8 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
112
112
  while (!stopped) {
113
113
  controller = new AbortController();
114
114
  try {
115
- const response = await getClient().openEvents(controller.signal);
115
+ const client = getClient();
116
+ const response = await client.openEvents(controller.signal);
116
117
  // fetch() can drop its abort link once its Request is garbage
117
118
  // collected, so stop() also cancels the body directly.
118
119
  const reader = response.body.getReader();
@@ -123,7 +124,7 @@ export function createEventHub({ getClient, logger, ownsAllSessions }) {
123
124
  for await (const frame of readSseFrames(readChunks(reader))) {
124
125
  let event;
125
126
  try { event = JSON.parse(frame.data); } catch { continue; }
126
- if (event && typeof event.type === 'string') dispatch(event);
127
+ if (event && typeof event.type === 'string') client.adaptEvent(event).forEach(dispatch);
127
128
  }
128
129
  if (!stopped) logger.warn('OpenCode event stream ended; reconnecting');
129
130
  } catch (error) {
@@ -0,0 +1,80 @@
1
+ /**
2
+ * HTTP plumbing shared by the OpenCode API clients: Basic auth, timeouts,
3
+ * error mapping and the event-stream request.
4
+ */
5
+ export class BackendError extends Error {
6
+ constructor(message, { status = 0, body = null, cause } = {}) {
7
+ super(message, cause ? { cause } : undefined);
8
+ this.name = 'BackendError';
9
+ this.status = status;
10
+ this.body = body;
11
+ }
12
+ }
13
+
14
+ const DEFAULT_TIMEOUT_MS = 30000;
15
+
16
+ export function basicAuthHeader(username, password) {
17
+ if (!password) return null;
18
+ return `Basic ${Buffer.from(`${username || 'opencode'}:${password}`).toString('base64')}`;
19
+ }
20
+
21
+ /**
22
+ * A backend URL as it may appear in logs and errors: scheme, host and port.
23
+ * Credentials, a path and a query string can all carry secrets.
24
+ * @param {string} url
25
+ */
26
+ export function backendOrigin(url) {
27
+ try {
28
+ return new URL(url).origin;
29
+ } catch {
30
+ return '(invalid URL)';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {{ baseUrl: string, username?: string, password?: string, fetchImpl?: typeof fetch }} options
36
+ */
37
+ export function createHttp({ baseUrl, username, password, fetchImpl = fetch }) {
38
+ const base = String(baseUrl).replace(/\/+$/, '');
39
+ const shownBase = backendOrigin(base);
40
+ const auth = basicAuthHeader(username, password);
41
+ const headers = (extra) => ({ accept: 'application/json', ...(auth ? { authorization: auth } : {}), ...extra });
42
+
43
+ async function request(method, path, { body, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
44
+ const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null;
45
+ const combined = [signal, timeout].filter(Boolean);
46
+ let response;
47
+ try {
48
+ response = await fetchImpl(`${base}${path}`, {
49
+ method,
50
+ redirect: 'error',
51
+ headers: headers(body === undefined ? {} : { 'content-type': 'application/json' }),
52
+ body: body === undefined ? undefined : JSON.stringify(body),
53
+ signal: combined.length > 1 ? AbortSignal.any(combined) : combined[0],
54
+ });
55
+ } catch (error) {
56
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
57
+ throw new BackendError(`OpenCode backend unreachable at ${shownBase}: ${error?.cause?.code || error?.message}`, { cause: error });
58
+ }
59
+ const text = await response.text();
60
+ let parsed = null;
61
+ if (text) {
62
+ try { parsed = JSON.parse(text); } catch { parsed = text; }
63
+ }
64
+ if (!response.ok) {
65
+ throw new BackendError(`OpenCode ${method} ${path} failed with HTTP ${response.status}`, { status: response.status, body: parsed });
66
+ }
67
+ return parsed;
68
+ }
69
+
70
+ /** Opens a server-sent event stream; the caller consumes response.body. */
71
+ async function openStream(path, signal) {
72
+ const response = await fetchImpl(`${base}${path}`, { headers: headers({ accept: 'text/event-stream' }), signal, redirect: 'error' });
73
+ if (!response.ok || !response.body) {
74
+ throw new BackendError(`OpenCode event stream failed with HTTP ${response.status}`, { status: response.status });
75
+ }
76
+ return response;
77
+ }
78
+
79
+ return Object.freeze({ base, request, openStream });
80
+ }
@@ -236,10 +236,11 @@ export function backendEnv(dirs, password) {
236
236
  OPENCODE_SERVER_PASSWORD: password,
237
237
  OPENCODE_CONFIG_CONTENT: JSON.stringify(backendConfig()),
238
238
  OPENCODE_DISABLE_AUTOUPDATE: '1',
239
- OPENCODE_DISABLE_SHARE: '1',
240
- OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
241
239
  OPENCODE_DISABLE_PROJECT_CONFIG: '1',
242
240
  OPENCODE_DISABLE_CLAUDE_CODE: '1',
241
+ // OpenCode 1 switches; OpenCode 2 ignores them.
242
+ OPENCODE_DISABLE_SHARE: '1',
243
+ OPENCODE_DISABLE_LSP_DOWNLOAD: '1',
243
244
  OPENCODE_DISABLE_EXTERNAL_SKILLS: '1',
244
245
  OPENCODE_DISABLE_DEFAULT_PLUGINS: '1',
245
246
  OPENCODE_DISABLE_EMBEDDED_WEB_UI: '1',
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Translation between the OpenCode 2 API and the OpenCode 1 shapes the rest
3
+ * of the gateway consumes (model catalog entries, assistant messages, errors
4
+ * and server-sent events). Keeping the translation in one place lets the
5
+ * runner, event hub and catalog stay independent of the API generation.
6
+ */
7
+
8
+ const MODALITIES = ['text', 'image', 'audio', 'video', 'pdf'];
9
+
10
+ /** Models from GET /api/model as the `{ providers: [...] }` payload of OpenCode 1. */
11
+ export function providersFromModels(models) {
12
+ const providers = new Map();
13
+ for (const model of Array.isArray(models) ? models : []) {
14
+ if (!model?.providerID || !model.modelID || model.enabled === false) continue;
15
+ const input = new Set(model.capabilities?.input || ['text']);
16
+ const released = model.time?.released;
17
+ const raw = {
18
+ id: model.modelID,
19
+ name: model.name || model.modelID,
20
+ status: model.status,
21
+ release_date: Number.isFinite(released) ? new Date(released).toISOString() : undefined,
22
+ capabilities: {
23
+ input: Object.fromEntries(MODALITIES.map((kind) => [kind, input.has(kind)])),
24
+ reasoning: Boolean(model.compatibility?.reasoningField) || (model.variants || []).length > 0,
25
+ },
26
+ variants: Object.fromEntries((model.variants || []).map((variant) => [variant.id, {}])),
27
+ limit: model.limit || {},
28
+ };
29
+ const entry = providers.get(model.providerID) || { id: model.providerID, models: {} };
30
+ entry.models[model.modelID] = raw;
31
+ providers.set(model.providerID, entry);
32
+ }
33
+ return { providers: [...providers.values()] };
34
+ }
35
+
36
+ /**
37
+ * OpenCode 2 reports a failed turn as { type, message, status? }. Map it to
38
+ * the { name, data } error objects of OpenCode 1 that model-errors.js reads.
39
+ */
40
+ export function errorFromV2(error) {
41
+ if (!error) return undefined;
42
+ const type = String(error.type || '');
43
+ const data = { message: String(error.message || ''), ...(Number.isInteger(error.status) ? { statusCode: error.status } : {}) };
44
+ const is = (pattern) => pattern.test(type);
45
+ let name = 'UnknownError';
46
+ if (is(/auth/i)) name = 'ProviderAuthError';
47
+ else if (is(/overflow|context/i)) name = 'ContextOverflowError';
48
+ else if (is(/output.?length|max.?tokens/i)) name = 'MessageOutputLengthError';
49
+ else if (is(/abort|interrupt|cancel/i)) name = 'MessageAbortedError';
50
+ else if (is(/content.?filter/i)) name = 'ContentFilterError';
51
+ else if (is(/structured/i)) name = 'StructuredOutputError';
52
+ else if (data.statusCode) name = 'APIError';
53
+ return { name, data };
54
+ }
55
+
56
+ /** An OpenCode 2 session message as an OpenCode 1 `{ info, parts }`. */
57
+ export function messageFromV2(message, sessionId) {
58
+ if (message?.type === 'assistant') {
59
+ return {
60
+ info: {
61
+ id: message.id,
62
+ sessionID: sessionId,
63
+ role: 'assistant',
64
+ tokens: message.tokens || {},
65
+ finish: message.finish,
66
+ ...(message.error ? { error: errorFromV2(message.error) } : {}),
67
+ },
68
+ parts: (message.content || [])
69
+ .filter((part) => part?.type === 'text' || part?.type === 'reasoning')
70
+ .map((part) => ({ type: part.type, text: part.text || '' })),
71
+ };
72
+ }
73
+ if (message?.type === 'user') {
74
+ return { info: { id: message.id, sessionID: sessionId, role: 'user' }, parts: [{ type: 'text', text: message.text || '' }] };
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** A text or reasoning delta: the part is announced first, since the stream has no reliable start event to rely on. */
80
+ function textDelta(data, kind) {
81
+ if (typeof data.delta !== 'string') return [];
82
+ const partID = `${data.assistantMessageID}:${kind}:${data.ordinal ?? 0}`;
83
+ return [
84
+ { type: 'message.part.updated', properties: { part: { id: partID, type: kind, sessionID: data.sessionID, messageID: data.assistantMessageID } } },
85
+ { type: 'message.part.delta', properties: { sessionID: data.sessionID, messageID: data.assistantMessageID, partID, field: 'text', delta: data.delta } },
86
+ ];
87
+ }
88
+
89
+ /**
90
+ * Translate one OpenCode 2 stream event to zero or more OpenCode 1 events
91
+ * ({ type, properties }). It keeps no state, so a dropped or reordered event
92
+ * cannot leave anything behind. Requests without a session are dropped: they
93
+ * cannot be answered, and the turn they block stops at the request timeout.
94
+ * @returns {object[]}
95
+ */
96
+ export function adaptEvent(event) {
97
+ const data = event?.data || {};
98
+ switch (event?.type) {
99
+ case 'session.created':
100
+ return [{ type: 'session.created', properties: { info: { id: data.sessionID, parentID: data.parentID } } }];
101
+ case 'session.step.started':
102
+ return [{ type: 'message.updated', properties: { info: { id: data.assistantMessageID, sessionID: data.sessionID, role: 'assistant' } } }];
103
+ case 'session.text.delta':
104
+ return textDelta(data, 'text');
105
+ case 'session.reasoning.delta':
106
+ return textDelta(data, 'reasoning');
107
+ case 'session.tool.input.started':
108
+ return [{
109
+ type: 'message.part.updated',
110
+ properties: { part: { id: data.id, type: 'tool', tool: data.name, sessionID: data.sessionID, messageID: data.assistantMessageID } },
111
+ }];
112
+ case 'session.retry.scheduled':
113
+ return [{
114
+ type: 'session.status',
115
+ properties: { sessionID: data.sessionID, status: { type: 'retry', attempt: data.attempt, message: data.error?.message } },
116
+ }];
117
+ case 'permission.asked':
118
+ return data.id && data.sessionID ? [{ type: 'permission.asked', properties: { id: data.id, sessionID: data.sessionID } }] : [];
119
+ case 'form.created':
120
+ return data.form?.id && data.form.sessionID
121
+ ? [{ type: 'question.asked', properties: { id: data.form.id, sessionID: data.form.sessionID } }]
122
+ : [];
123
+ default:
124
+ return [];
125
+ }
126
+ }