zengate 1.1.1 → 1.3.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 +20 -10
- package/package.json +4 -3
- package/scripts/setup.mjs +2 -1
- package/src/bootstrap.js +4 -6
- package/src/cli.js +14 -3
- package/src/config.js +3 -0
- package/src/openai/chat.js +0 -2
- package/src/openai/remote-media.js +15 -11
- package/src/openai/responses.js +0 -2
- package/src/openai/url-guard.js +83 -22
- package/src/opencode/backend.js +28 -17
- package/src/opencode/binary.js +4 -4
- package/src/opencode/client-v2.js +229 -0
- package/src/opencode/client.js +25 -67
- package/src/opencode/detect.js +28 -0
- package/src/opencode/events.js +6 -5
- package/src/opencode/http.js +80 -0
- package/src/opencode/isolation.js +3 -2
- package/src/opencode/v2-convert.js +126 -0
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ zengate
|
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
65
|
docker run -d --name zengate -p 127.0.0.1:8083:8083 -v zengate:/data developingchet/zengate
|
|
66
|
-
docker
|
|
66
|
+
docker exec zengate node index.js setup --show # prints the generated key
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
Images are published for `linux/amd64` and `linux/arm64`, tagged `latest`, `1`, `1.2` and `1.2.3`.
|
|
@@ -101,17 +101,19 @@ npm start
|
|
|
101
101
|
|
|
102
102
|
### First start
|
|
103
103
|
|
|
104
|
-
The gateway creates a config file with a random API key and
|
|
104
|
+
The gateway creates a config file with a random API key and says where it is. The key itself is not printed, so it does not end up in service logs:
|
|
105
105
|
|
|
106
106
|
```
|
|
107
|
-
Created a gateway API key
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
Created a gateway API key and saved it to /home/you/.config/zengate/config.json.
|
|
108
|
+
Use it as the OpenAI API key in your client. To see it, run `zengate setup --show`
|
|
109
|
+
or read API_KEY from that file; `zengate setup --rotate` replaces it.
|
|
110
110
|
|
|
111
111
|
OpenAI-compatible API on http://127.0.0.1:8083/v1 (auth: API key)
|
|
112
112
|
7 models: big-pickle, ...
|
|
113
113
|
```
|
|
114
114
|
|
|
115
|
+
In Docker, run `docker exec zengate node index.js setup --show`; under systemd, read `/var/lib/zengate/config.json` with `sudo`.
|
|
116
|
+
|
|
115
117
|
Where the config file lives:
|
|
116
118
|
|
|
117
119
|
| How you run it | Config file |
|
|
@@ -129,6 +131,7 @@ Key commands (`npm run setup -- <flag>` from a source checkout):
|
|
|
129
131
|
```bash
|
|
130
132
|
zengate setup # create a key if none exists
|
|
131
133
|
zengate setup --rotate # replace the key
|
|
134
|
+
zengate setup --show # print the saved key
|
|
132
135
|
zengate setup --print # print a fresh key without saving it (for env vars and secret stores)
|
|
133
136
|
zengate --help
|
|
134
137
|
```
|
|
@@ -238,13 +241,20 @@ Set values as environment variables or in the config file (same names; see [`con
|
|
|
238
241
|
| `TRUST_PROXY` | `0` | Number of reverse-proxy hops to trust for client IPs. |
|
|
239
242
|
| `LOG_LEVEL` / `LOG_JSON` | `info` / `false` | Log verbosity, and JSON-lines output. |
|
|
240
243
|
| `OPENCODE_AGENT` | `plan` | The OpenCode agent each session uses. |
|
|
241
|
-
| `OPENCODE_PATH` | bundled | Use a different `opencode` binary. |
|
|
244
|
+
| `OPENCODE_PATH` | bundled | Use a different `opencode` binary, OpenCode 1 or 2. |
|
|
242
245
|
| `OPENCODE_SERVER_URL` | none | Attach to an existing `opencode serve` instead of starting one (see below). |
|
|
243
|
-
| `OPENCODE_SERVER_USERNAME` / `OPENCODE_SERVER_PASSWORD` | `opencode` / none | Basic auth for that server. |
|
|
246
|
+
| `OPENCODE_SERVER_USERNAME` / `OPENCODE_SERVER_PASSWORD` | `opencode` / none | Basic auth for that server. Credentials inside `OPENCODE_SERVER_URL` are refused. |
|
|
244
247
|
| `ALLOW_INSECURE_BACKEND_HTTP` | `false` | Allow a non-loopback `http://` server URL. |
|
|
245
248
|
| `CONFIG_FILE` | see [First start](#first-start) | Where the config file lives (environment variable only). |
|
|
246
249
|
|
|
247
|
-
**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`.
|
|
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. 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`.
|
|
251
|
+
|
|
252
|
+
**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:
|
|
253
|
+
|
|
254
|
+
- Sessions are created with every action set to `ask`, and the gateway rejects each request, as it does with OpenCode 1.
|
|
255
|
+
- `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.
|
|
256
|
+
- `OPENCODE_AGENT` applies only if the server has an agent of that name; otherwise the session keeps its default agent.
|
|
257
|
+
- 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.
|
|
248
258
|
|
|
249
259
|
## Deployment
|
|
250
260
|
|
|
@@ -275,12 +285,12 @@ sha256sum -c checksums.txt
|
|
|
275
285
|
|
|
276
286
|
## Security
|
|
277
287
|
|
|
278
|
-
- The API key is required unless you set `ALLOW_NO_AUTH`. Keys are compared in constant time, the config file is written with mode `0600`, and keys are never logged.
|
|
288
|
+
- The API key is required unless you set `ALLOW_NO_AUTH`. Keys are compared in constant time, the config file is written with mode `0600`, and keys are never logged: the first start reports where the generated key was saved, not the key.
|
|
279
289
|
- The gateway binds to loopback by default and warns when it listens elsewhere, because traffic is plain HTTP, so put TLS in front.
|
|
280
290
|
- OpenCode tools are never executed, and OpenCode runs isolated from your home directory and configuration.
|
|
281
291
|
- Attachment URLs must be `https` and resolve to public addresses. Loopback, private, link-local and similar ranges (including IPv6 forms that embed them) are refused, which blocks SSRF into your network. The gateway fetches them itself and connects only to the address it checked, including on every redirect, so DNS rebinding cannot reach an internal host. OpenCode never sees the URL.
|
|
282
292
|
- Conversation history is sent to the model as a tagged transcript. Message text, file names and tool names are escaped so they cannot fake another turn, a tool result or a function call.
|
|
283
|
-
- Rate limiting, a bounded queue with a wait limit, body and attachment size limits, and per-request timeouts all apply. Requests that are uploading, queued or running are capped at `MAX_CONCURRENT + MAX_QUEUE`, which bounds the memory held by request bodies, and one client address may be uploading at most a quarter of that at once. Attachment URLs appear in errors and logs
|
|
293
|
+
- Rate limiting, a bounded queue with a wait limit, body and attachment size limits, and per-request timeouts all apply. Requests that are uploading, queued or running are capped at `MAX_CONCURRENT + MAX_QUEUE`, which bounds the memory held by request bodies, and one client address may be uploading at most a quarter of that at once. Attachment hosts are resolved inside the request's slot and timeout, with at most two DNS lookups in flight across the gateway. Attachment URLs appear in errors and logs as their origin only, without path, query string or credentials. A request with `n` choices uses `n` concurrency slots, and slots are always released on disconnect or timeout.
|
|
284
294
|
- Stored responses (`previous_response_id`, `GET /v1/responses/{id}`) are visible only to the API key that created them.
|
|
285
295
|
- The gateway sends no telemetry and never logs request bodies. OpenCode's auto-update and session sharing are disabled.
|
|
286
296
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zengate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
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
|
-
"
|
|
55
|
-
"
|
|
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",
|
package/scripts/setup.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Gateway key helper (same as `zengate setup`).
|
|
3
3
|
// npm run setup create config.json with a key (keeps an existing key)
|
|
4
4
|
// npm run setup -- --rotate replace the key in config.json
|
|
5
|
+
// npm run setup -- --show print the key saved in config.json
|
|
5
6
|
// npm run setup -- --print only print a fresh key (for env vars / secret stores)
|
|
6
7
|
import path from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -12,7 +13,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
12
13
|
const { command, flags } = parseCommand(['setup', ...process.argv.slice(2)]);
|
|
13
14
|
|
|
14
15
|
if (command !== 'setup') {
|
|
15
|
-
process.stderr.write(`Unknown option: ${process.argv.slice(2).join(' ')}. Use --rotate or --print.\n`);
|
|
16
|
+
process.stderr.write(`Unknown option: ${process.argv.slice(2).join(' ')}. Use --rotate, --show or --print.\n`);
|
|
16
17
|
process.exitCode = 2;
|
|
17
18
|
} else {
|
|
18
19
|
try {
|
package/src/bootstrap.js
CHANGED
|
@@ -41,13 +41,11 @@ export function loadOrProvisionConfig({ configPath, env = process.env, logger, p
|
|
|
41
41
|
'or set ALLOW_NO_AUTH=true to deliberately serve without a key.',
|
|
42
42
|
]);
|
|
43
43
|
}
|
|
44
|
+
// Only where the key is, never the key: service managers keep stdout as logs.
|
|
44
45
|
print('');
|
|
45
|
-
print(` Created a gateway API key
|
|
46
|
-
print('');
|
|
47
|
-
print(`
|
|
48
|
-
print('');
|
|
49
|
-
print(' Use it as the OpenAI API key in your client. It is not shown again;');
|
|
50
|
-
print(' read it from that file, or run `zengate setup --rotate` to replace it.');
|
|
46
|
+
print(` Created a gateway API key and saved it to ${configPath}.`);
|
|
47
|
+
print(' Use it as the OpenAI API key in your client. To see it, run `zengate setup --show`');
|
|
48
|
+
print(' or read API_KEY from that file; `zengate setup --rotate` replaces it.');
|
|
51
49
|
print('');
|
|
52
50
|
logger.debug('Provisioned a new API key', { configPath });
|
|
53
51
|
return loadConfig({ ...fileConfig, API_KEY: key }, env);
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { generateApiKey, writeConfigFile } from './bootstrap.js';
|
|
2
2
|
import { readConfigFile } from './config.js';
|
|
3
3
|
|
|
4
|
-
const SETUP_FLAGS = new Set(['--rotate', '--print']);
|
|
4
|
+
const SETUP_FLAGS = new Set(['--rotate', '--print', '--show']);
|
|
5
5
|
|
|
6
6
|
export const HELP_TEXT = `zengate: an OpenAI-compatible API for OpenCode's free Zen models
|
|
7
7
|
|
|
@@ -9,6 +9,7 @@ Usage:
|
|
|
9
9
|
zengate start the gateway (creates an API key on first start)
|
|
10
10
|
zengate setup create config.json with an API key (keeps an existing key)
|
|
11
11
|
zengate setup --rotate replace the API key
|
|
12
|
+
zengate setup --show print the saved API key
|
|
12
13
|
zengate setup --print print a fresh key without saving it
|
|
13
14
|
zengate --version print the version
|
|
14
15
|
zengate --help show this help
|
|
@@ -29,19 +30,29 @@ export function parseCommand(args) {
|
|
|
29
30
|
return { command: 'unknown', flags: args };
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** Print the API key saved in config.json. */
|
|
34
|
+
function showKey({ configPath, flags, out }) {
|
|
35
|
+
if (flags.length > 1) throw new Error('--show cannot be combined with other setup options.');
|
|
36
|
+
const { API_KEY: key } = readConfigFile(configPath);
|
|
37
|
+
if (!key) throw new Error(`${configPath} has no API_KEY. Run \`zengate setup\` to create one.`);
|
|
38
|
+
out(key);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
/**
|
|
33
|
-
* Create, keep or rotate the gateway API key in config.json.
|
|
43
|
+
* Create, keep, show or rotate the gateway API key in config.json.
|
|
34
44
|
* @param {{ configPath: string, flags: string[], out: (line?: string) => void }} options
|
|
35
45
|
* @returns {number} process exit code
|
|
36
46
|
*/
|
|
37
47
|
export function runSetup({ configPath, flags, out }) {
|
|
48
|
+
if (flags.includes('--show')) return showKey({ configPath, flags, out });
|
|
38
49
|
if (flags.includes('--print')) {
|
|
39
50
|
out(generateApiKey());
|
|
40
51
|
return 0;
|
|
41
52
|
}
|
|
42
53
|
const current = readConfigFile(configPath);
|
|
43
54
|
if (current.API_KEY && !flags.includes('--rotate')) {
|
|
44
|
-
out(`${configPath} already has an API_KEY. Use --rotate to replace it.`);
|
|
55
|
+
out(`${configPath} already has an API_KEY. Use --show to print it or --rotate to replace it.`);
|
|
45
56
|
return 0;
|
|
46
57
|
}
|
|
47
58
|
const key = generateApiKey();
|
package/src/config.js
CHANGED
|
@@ -127,6 +127,9 @@ function validateServerUrl(config, problems) {
|
|
|
127
127
|
problems.push('OPENCODE_SERVER_URL must be an absolute http(s) URL');
|
|
128
128
|
return;
|
|
129
129
|
}
|
|
130
|
+
if (url.username || url.password) {
|
|
131
|
+
problems.push('OPENCODE_SERVER_URL must not contain a username or password; set OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD instead');
|
|
132
|
+
}
|
|
130
133
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
131
134
|
problems.push('OPENCODE_SERVER_URL must use http or https');
|
|
132
135
|
} else if (url.protocol === 'http:' && !isLoopbackHost(url.hostname) && !config.ALLOW_INSECURE_BACKEND_HTTP) {
|
package/src/openai/chat.js
CHANGED
|
@@ -7,7 +7,6 @@ import { mapLimited } from './map-limited.js';
|
|
|
7
7
|
import { buildPrompt } from './prompt.js';
|
|
8
8
|
import { inlineRemoteAttachments } from './remote-media.js';
|
|
9
9
|
import { openSse } from './sse-writer.js';
|
|
10
|
-
import { assertPublicUrls } from './url-guard.js';
|
|
11
10
|
|
|
12
11
|
const completionId = () => `chatcmpl-${crypto.randomBytes(12).toString('hex')}`;
|
|
13
12
|
const now = () => Math.floor(Date.now() / 1000);
|
|
@@ -40,7 +39,6 @@ export function chatCompletionsHandler({ runner, catalog, limits }) {
|
|
|
40
39
|
const request = parseChatRequest(req.body, limits);
|
|
41
40
|
const model = await catalog.resolve(request.model);
|
|
42
41
|
const prompt = buildPrompt(request, model);
|
|
43
|
-
await assertPublicUrls(prompt.parts);
|
|
44
42
|
setIgnoredParams(res, request.ignored);
|
|
45
43
|
await req.withSlot(async (signal, parallel) => {
|
|
46
44
|
const ready = await inlineRemoteAttachments(prompt, { ...limits, signal });
|
|
@@ -4,7 +4,7 @@ import { ApiError, invalidRequest, unsupported } from '../server/errors.js';
|
|
|
4
4
|
import { fileBlock } from './markup.js';
|
|
5
5
|
import { mapLimited } from './map-limited.js';
|
|
6
6
|
import { kindForMime } from './media.js';
|
|
7
|
-
import {
|
|
7
|
+
import { assertPublicUrls, isBlockedAddress, publicLookup, resolvePublic } from './url-guard.js';
|
|
8
8
|
|
|
9
9
|
const FETCH_TIMEOUT_MS = 30000;
|
|
10
10
|
const MAX_REDIRECTS = 3;
|
|
@@ -18,14 +18,15 @@ const USER_AGENT = 'zengate (+https://github.com/developingchet/zengate)';
|
|
|
18
18
|
const attachmentError = (message) => invalidRequest(message, null, 'invalid_attachment_url');
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* An attachment URL as it may appear in errors and logs:
|
|
22
|
-
* their token in the query string
|
|
23
|
-
* are left out.
|
|
21
|
+
* An attachment URL as it may appear in errors and logs: only its origin.
|
|
22
|
+
* Signed URLs carry their token in the query string or in a path segment,
|
|
23
|
+
* and some URLs carry credentials, so all of those are left out.
|
|
24
24
|
*/
|
|
25
25
|
export function shown(url) {
|
|
26
26
|
try {
|
|
27
27
|
const parsed = new URL(url);
|
|
28
|
-
|
|
28
|
+
const bare = parsed.pathname === '/' && !parsed.search && !parsed.hash;
|
|
29
|
+
return `${parsed.origin}/${bare ? '' : '…'}`;
|
|
29
30
|
} catch {
|
|
30
31
|
return '(invalid URL)';
|
|
31
32
|
}
|
|
@@ -157,7 +158,7 @@ function requestOnce(url, { transport, lookup, signal }) {
|
|
|
157
158
|
async function download(url, { transport, lookup, isBlocked, signal, limit }) {
|
|
158
159
|
let current = new URL(url);
|
|
159
160
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
160
|
-
if (net.isIP(current.hostname.replace(/^\[|\]$/g, ''))) await resolvePublic(current.hostname, isBlocked);
|
|
161
|
+
if (net.isIP(current.hostname.replace(/^\[|\]$/g, ''))) await resolvePublic(current.hostname, { isBlocked });
|
|
161
162
|
const response = await requestOnce(current, { transport, lookup, signal });
|
|
162
163
|
const status = response.statusCode || 0;
|
|
163
164
|
if (status >= 300 && status < 400 && response.headers.location) {
|
|
@@ -198,6 +199,10 @@ async function inlinePart(part, options) {
|
|
|
198
199
|
* inlined text) fetched by the gateway, so OpenCode never fetches a
|
|
199
200
|
* client-chosen URL itself: no redirects to internal hosts, no DNS
|
|
200
201
|
* rebinding, and remote files obey the same size limits as inline ones.
|
|
202
|
+
* Every host is checked before the first download starts. Callers run this
|
|
203
|
+
* inside the request's slot, so the DNS lookups and downloads count against
|
|
204
|
+
* MAX_CONCURRENT and stop when `signal` aborts (client disconnect or
|
|
205
|
+
* REQUEST_TIMEOUT_MS).
|
|
201
206
|
* @param {{ parts: object[] }} prompt from buildPrompt
|
|
202
207
|
* @param {{ maxBytes: number, maxTotalBytes: number, signal: AbortSignal,
|
|
203
208
|
* transport?: { request: typeof https.request }, isBlocked?: (address: string) => boolean }} options
|
|
@@ -206,14 +211,13 @@ async function inlinePart(part, options) {
|
|
|
206
211
|
export async function inlineRemoteAttachments(prompt, { maxBytes, maxTotalBytes, signal, transport = https, isBlocked = isBlockedAddress }) {
|
|
207
212
|
const remote = prompt.parts.filter((part) => typeof part.url === 'string' && part.url.startsWith('https:'));
|
|
208
213
|
if (remote.length === 0) return prompt;
|
|
209
|
-
|
|
210
|
-
throw invalidRequest(`At most ${MAX_REMOTE_PARTS} attachments may be URLs; send the rest as data URIs.`, null, 'too_many_attachment_urls');
|
|
211
|
-
}
|
|
214
|
+
await assertPublicUrls(remote, { isBlocked, signal });
|
|
212
215
|
// One failed attachment fails the request, so the other downloads are cancelled too.
|
|
213
216
|
const siblings = new AbortController();
|
|
217
|
+
const downloads = AbortSignal.any([signal, siblings.signal]);
|
|
214
218
|
const options = {
|
|
215
|
-
transport, lookup: publicLookup(isBlocked), isBlocked,
|
|
216
|
-
signal:
|
|
219
|
+
transport, lookup: publicLookup({ isBlocked, signal: downloads }), isBlocked,
|
|
220
|
+
signal: downloads,
|
|
217
221
|
budget: createBudget({ maxBytes, maxTotalBytes }),
|
|
218
222
|
};
|
|
219
223
|
const fetched = await mapLimited(remote, PARALLEL_FETCHES, (part) => inlinePart(part, options), () => siblings.abort());
|
package/src/openai/responses.js
CHANGED
|
@@ -6,7 +6,6 @@ import { inlineRemoteAttachments } from './remote-media.js';
|
|
|
6
6
|
import { createResponseBuilder } from './response-builder.js';
|
|
7
7
|
import { parseResponsesRequest } from './responses-request.js';
|
|
8
8
|
import { openSse } from './sse-writer.js';
|
|
9
|
-
import { assertPublicUrls } from './url-guard.js';
|
|
10
9
|
|
|
11
10
|
function assistantHistory(result) {
|
|
12
11
|
return { role: 'assistant', content: result.content, media: [], toolCalls: result.toolCalls.map(({ id, name, arguments: args }) => ({ id, name, arguments: args })) };
|
|
@@ -22,7 +21,6 @@ export function responsesHandlers({ runner, catalog, store, limits }) {
|
|
|
22
21
|
const request = parseResponsesRequest(req.body, limits, scoped);
|
|
23
22
|
const model = await catalog.resolve(request.model);
|
|
24
23
|
const prompt = buildPrompt(request, model);
|
|
25
|
-
await assertPublicUrls(prompt.parts);
|
|
26
24
|
setIgnoredParams(res, request.ignored);
|
|
27
25
|
|
|
28
26
|
await req.withSlot(async (signal) => {
|
package/src/openai/url-guard.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import dns from 'node:dns/promises';
|
|
2
2
|
import net from 'node:net';
|
|
3
3
|
import { invalidRequest } from '../server/errors.js';
|
|
4
|
+
import { mapLimited } from './map-limited.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Attachment URLs are fetched by the gateway, so a client could otherwise
|
|
@@ -23,9 +24,64 @@ const BLOCKED_V6 = new net.BlockList();
|
|
|
23
24
|
].forEach(([address, prefix]) => BLOCKED_V6.addSubnet(address, prefix, 'ipv6'));
|
|
24
25
|
|
|
25
26
|
const LOOKUP_TIMEOUT_MS = 5000;
|
|
27
|
+
/**
|
|
28
|
+
* dns.lookup runs getaddrinfo on libuv's thread pool (four threads by
|
|
29
|
+
* default, shared with file system and crypto work) and cannot be cancelled:
|
|
30
|
+
* a lookup the caller stopped waiting for keeps its thread until the resolver
|
|
31
|
+
* answers or gives up. Each lookup holds a permit until it really settles, so
|
|
32
|
+
* names that never resolve occupy at most this many threads process-wide.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_LOOKUPS_IN_FLIGHT = 2;
|
|
26
35
|
/** Most attachments one request may send as URLs. */
|
|
27
36
|
export const MAX_REMOTE_PARTS = 16;
|
|
28
37
|
|
|
38
|
+
let lookupsInFlight = 0;
|
|
39
|
+
const lookupWaiters = new Set();
|
|
40
|
+
|
|
41
|
+
/** Waits for a lookup permit; rejects with the signal's reason if it aborts first. */
|
|
42
|
+
function acquireLookup(signal) {
|
|
43
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
44
|
+
if (lookupsInFlight < MAX_LOOKUPS_IN_FLIGHT) {
|
|
45
|
+
lookupsInFlight += 1;
|
|
46
|
+
return Promise.resolve();
|
|
47
|
+
}
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const onAbort = () => {
|
|
50
|
+
lookupWaiters.delete(grant);
|
|
51
|
+
reject(signal.reason);
|
|
52
|
+
};
|
|
53
|
+
const grant = () => {
|
|
54
|
+
signal.removeEventListener('abort', onAbort);
|
|
55
|
+
lookupsInFlight += 1;
|
|
56
|
+
resolve();
|
|
57
|
+
};
|
|
58
|
+
lookupWaiters.add(grant);
|
|
59
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function releaseLookup() {
|
|
64
|
+
lookupsInFlight -= 1;
|
|
65
|
+
const [next] = lookupWaiters;
|
|
66
|
+
if (next) {
|
|
67
|
+
lookupWaiters.delete(next);
|
|
68
|
+
next();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Settles like `promise`, or rejects with the signal's reason once it aborts. */
|
|
73
|
+
function untilAborted(promise, signal) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const onAbort = () => reject(signal.reason);
|
|
76
|
+
if (signal.aborted) {
|
|
77
|
+
onAbort();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
81
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', onAbort));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
29
85
|
/** The eight 16-bit groups of a valid IPv6 address (which may end in dotted IPv4). */
|
|
30
86
|
function ipv6Groups(address) {
|
|
31
87
|
let text = address.toLowerCase();
|
|
@@ -62,30 +118,30 @@ export function isBlockedAddress(address) {
|
|
|
62
118
|
return v4 ? BLOCKED_V4.check(v4, 'ipv4') : BLOCKED_V6.check(plain, 'ipv6');
|
|
63
119
|
}
|
|
64
120
|
|
|
65
|
-
|
|
121
|
+
/** The addresses of `hostname`, within LOOKUP_TIMEOUT_MS (waiting for a permit included). */
|
|
122
|
+
async function resolve(hostname, signal) {
|
|
66
123
|
const literal = hostname.replace(/^\[|\]$/g, '');
|
|
67
124
|
if (net.isIP(literal)) return [{ address: literal, family: net.isIP(literal) }];
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
return await Promise.race([dns.lookup(literal, { all: true, verbatim: true }), timeout]);
|
|
75
|
-
} finally {
|
|
76
|
-
clearTimeout(timer);
|
|
77
|
-
}
|
|
125
|
+
const deadline = AbortSignal.any([AbortSignal.timeout(LOOKUP_TIMEOUT_MS), ...(signal ? [signal] : [])]);
|
|
126
|
+
await acquireLookup(deadline);
|
|
127
|
+
const lookup = (async () => dns.lookup(literal, { all: true, verbatim: true }))();
|
|
128
|
+
lookup.then(releaseLookup, releaseLookup);
|
|
129
|
+
return untilAborted(lookup, deadline);
|
|
78
130
|
}
|
|
79
131
|
|
|
80
132
|
/**
|
|
81
133
|
* Every address `hostname` resolves to, provided all of them are public.
|
|
82
134
|
* @param {string} hostname
|
|
83
|
-
* @param {(address: string) => boolean} [
|
|
135
|
+
* @param {{ isBlocked?: (address: string) => boolean, signal?: AbortSignal }} [options]
|
|
136
|
+
* `signal` stops the wait (rejecting with its reason) when the request ends
|
|
84
137
|
* @returns {Promise<{ address: string, family: number }[]>}
|
|
85
138
|
*/
|
|
86
|
-
export async function resolvePublic(hostname, isBlocked = isBlockedAddress) {
|
|
139
|
+
export async function resolvePublic(hostname, { isBlocked = isBlockedAddress, signal } = {}) {
|
|
87
140
|
// One message for both failures, so clients cannot probe which internal names exist.
|
|
88
|
-
const records = await resolve(hostname).catch(() =>
|
|
141
|
+
const records = await resolve(hostname, signal).catch(() => {
|
|
142
|
+
if (signal?.aborted) throw signal.reason;
|
|
143
|
+
return [];
|
|
144
|
+
});
|
|
89
145
|
if (!records.length || records.some((record) => isBlocked(record.address))) {
|
|
90
146
|
throw invalidRequest(`Attachment host '${hostname}' is not a public address.`, null, 'invalid_attachment_url');
|
|
91
147
|
}
|
|
@@ -96,11 +152,11 @@ export async function resolvePublic(hostname, isBlocked = isBlockedAddress) {
|
|
|
96
152
|
* A `lookup` for http(s).request that only ever connects to the addresses it
|
|
97
153
|
* has just checked, so DNS rebinding between check and connect cannot reach
|
|
98
154
|
* an internal address.
|
|
99
|
-
* @param {(address: string) => boolean} [
|
|
155
|
+
* @param {{ isBlocked?: (address: string) => boolean, signal?: AbortSignal }} [options]
|
|
100
156
|
*/
|
|
101
|
-
export function publicLookup(isBlocked = isBlockedAddress) {
|
|
157
|
+
export function publicLookup({ isBlocked = isBlockedAddress, signal } = {}) {
|
|
102
158
|
return (hostname, options, callback) => {
|
|
103
|
-
resolvePublic(hostname, isBlocked).then((records) => {
|
|
159
|
+
resolvePublic(hostname, { isBlocked, signal }).then((records) => {
|
|
104
160
|
const wanted = options?.family ? records.filter((record) => record.family === options.family) : records;
|
|
105
161
|
if (!wanted.length) {
|
|
106
162
|
callback(Object.assign(new Error(`No IPv${options.family} address for ${hostname}`), { code: 'ENOTFOUND' }));
|
|
@@ -114,16 +170,21 @@ export function publicLookup(isBlocked = isBlockedAddress) {
|
|
|
114
170
|
}
|
|
115
171
|
|
|
116
172
|
/**
|
|
117
|
-
* Reject prompt parts whose https URL resolves to a non-public address
|
|
118
|
-
*
|
|
173
|
+
* Reject prompt parts whose https URL resolves to a non-public address, so a
|
|
174
|
+
* request fails before any download starts. The fetch itself re-checks
|
|
175
|
+
* through publicLookup.
|
|
119
176
|
* @param {{ url?: string }[]} parts OpenCode prompt parts
|
|
177
|
+
* @param {{ isBlocked?: (address: string) => boolean, signal?: AbortSignal }} [options]
|
|
120
178
|
*/
|
|
121
|
-
export async function assertPublicUrls(parts) {
|
|
179
|
+
export async function assertPublicUrls(parts, { isBlocked = isBlockedAddress, signal } = {}) {
|
|
122
180
|
const remote = parts.filter((part) => typeof part.url === 'string' && part.url.startsWith('https:'));
|
|
123
181
|
// Checked before resolving anything, so one request cannot queue thousands of DNS lookups.
|
|
124
182
|
if (remote.length > MAX_REMOTE_PARTS) {
|
|
125
183
|
throw invalidRequest(`At most ${MAX_REMOTE_PARTS} attachments may be URLs; send the rest as data URIs.`, null, 'too_many_attachment_urls');
|
|
126
184
|
}
|
|
127
|
-
const hosts = new Set(remote.map((part) => new URL(part.url).hostname));
|
|
128
|
-
|
|
185
|
+
const hosts = [...new Set(remote.map((part) => new URL(part.url).hostname))];
|
|
186
|
+
// Once one host is refused, the others stop waiting for a lookup permit.
|
|
187
|
+
const siblings = new AbortController();
|
|
188
|
+
const checks = AbortSignal.any([siblings.signal, ...(signal ? [signal] : [])]);
|
|
189
|
+
await mapLimited(hosts, hosts.length, (hostname) => resolvePublic(hostname, { isBlocked, signal: checks }), () => siblings.abort());
|
|
129
190
|
}
|
package/src/opencode/backend.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile, spawn } from 'node:child_process';
|
|
2
|
-
import { createOpencodeClient } from './client.js';
|
|
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
|
-
|
|
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
|
|
64
|
-
if (
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
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,36 @@ 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
|
-
|
|
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 });
|
|
233
|
+
const shownUrl = backendOrigin(url);
|
|
230
234
|
let ready = false;
|
|
231
235
|
let version = null;
|
|
232
236
|
let timer = null;
|
|
233
237
|
|
|
234
238
|
async function probe() {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
);
|
|
241
250
|
}
|
|
251
|
+
ready = health?.healthy === true;
|
|
252
|
+
version = health?.version || version;
|
|
242
253
|
return ready;
|
|
243
254
|
}
|
|
244
255
|
|
|
245
256
|
return Object.freeze({
|
|
246
257
|
mode: 'attached',
|
|
247
258
|
async start() {
|
|
248
|
-
if (!(await probe())) throw new Error(`OpenCode server at ${
|
|
249
|
-
logger.info(`Using OpenCode ${version || ''} at ${
|
|
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.`);
|
|
260
|
+
logger.info(`Using OpenCode ${version || ''} at ${shownUrl}`.replace(' ', ' '));
|
|
250
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.');
|
|
251
262
|
timer = setInterval(probe, 10000);
|
|
252
263
|
timer.unref();
|
package/src/opencode/binary.js
CHANGED
|
@@ -12,10 +12,10 @@ function isFile(file) {
|
|
|
12
12
|
}
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** The binary shipped by the official
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/opencode/client.js
CHANGED
|
@@ -1,85 +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
|
-
}
|
|
1
|
+
import { BackendError, backendOrigin, basicAuthHeader, createHttp } from './http.js';
|
|
14
2
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export function basicAuthHeader(username, password) {
|
|
18
|
-
if (!password) return null;
|
|
19
|
-
return `Basic ${Buffer.from(`${username || 'opencode'}:${password}`).toString('base64')}`;
|
|
20
|
-
}
|
|
3
|
+
export { BackendError, backendOrigin, basicAuthHeader };
|
|
21
4
|
|
|
22
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
|
+
*
|
|
23
13
|
* @param {{ baseUrl: string, username?: string, password?: string, fetchImpl?: typeof fetch }} options
|
|
24
14
|
*/
|
|
25
|
-
export function createOpencodeClient(
|
|
26
|
-
const base =
|
|
27
|
-
const auth = basicAuthHeader(username, password);
|
|
28
|
-
const headers = (extra) => ({ accept: 'application/json', ...(auth ? { authorization: auth } : {}), ...extra });
|
|
29
|
-
|
|
30
|
-
async function request(method, path, { body, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
31
|
-
const timeout = timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : null;
|
|
32
|
-
const combined = [signal, timeout].filter(Boolean);
|
|
33
|
-
let response;
|
|
34
|
-
try {
|
|
35
|
-
response = await fetchImpl(`${base}${path}`, {
|
|
36
|
-
method,
|
|
37
|
-
redirect: 'error',
|
|
38
|
-
headers: headers(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
39
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
40
|
-
signal: combined.length > 1 ? AbortSignal.any(combined) : combined[0],
|
|
41
|
-
});
|
|
42
|
-
} catch (error) {
|
|
43
|
-
if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
|
|
44
|
-
throw new BackendError(`OpenCode backend unreachable at ${base}: ${error?.cause?.code || error?.message}`, { cause: error });
|
|
45
|
-
}
|
|
46
|
-
const text = await response.text();
|
|
47
|
-
let parsed = null;
|
|
48
|
-
if (text) {
|
|
49
|
-
try { parsed = JSON.parse(text); } catch { parsed = text; }
|
|
50
|
-
}
|
|
51
|
-
if (!response.ok) {
|
|
52
|
-
throw new BackendError(`OpenCode ${method} ${path} failed with HTTP ${response.status}`, { status: response.status, body: parsed });
|
|
53
|
-
}
|
|
54
|
-
return parsed;
|
|
55
|
-
}
|
|
56
|
-
|
|
15
|
+
export function createOpencodeClient(options) {
|
|
16
|
+
const { base, request, openStream } = createHttp(options);
|
|
57
17
|
const id = (value) => encodeURIComponent(value);
|
|
58
18
|
|
|
59
19
|
return Object.freeze({
|
|
20
|
+
api: 'v1',
|
|
60
21
|
baseUrl: base,
|
|
61
|
-
health: (
|
|
62
|
-
providers: (
|
|
63
|
-
agents: (
|
|
64
|
-
createSession: (
|
|
65
|
-
deleteSession: (sessionId,
|
|
66
|
-
abortSession: (sessionId,
|
|
67
|
-
messages: (sessionId,
|
|
68
|
-
message: (sessionId, messageId,
|
|
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),
|
|
69
30
|
/** Blocks until the assistant turn finishes; returns { info, parts }. */
|
|
70
|
-
prompt: (sessionId, body,
|
|
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.
|
|
71
33
|
replyPermission: (requestId, reply, message) =>
|
|
72
34
|
request('POST', `/permission/${id(requestId)}/reply`, { body: { reply, ...(message ? { message } : {}) }, timeoutMs: 5000 }),
|
|
73
35
|
rejectQuestion: (requestId) => request('POST', `/question/${id(requestId)}/reject`, { body: {}, timeoutMs: 5000 }),
|
|
74
36
|
pendingPermissions: () => request('GET', '/permission', { timeoutMs: 5000 }),
|
|
75
37
|
pendingQuestions: () => request('GET', '/question', { timeoutMs: 5000 }),
|
|
76
38
|
/** Opens the server-sent event stream; caller consumes response.body. */
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
throw new BackendError(`OpenCode event stream failed with HTTP ${response.status}`, { status: response.status });
|
|
81
|
-
}
|
|
82
|
-
return response;
|
|
83
|
-
},
|
|
39
|
+
openEvents: (signal) => openStream('/event', signal),
|
|
40
|
+
/** The stream already carries the events the gateway consumes. */
|
|
41
|
+
adaptEvent: (event) => [event],
|
|
84
42
|
});
|
|
85
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
|
+
}
|
package/src/opencode/events.js
CHANGED
|
@@ -18,7 +18,7 @@ async function* readChunks(reader) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* One shared subscription to the backend's
|
|
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
|
|
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')
|
|
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
|
+
}
|