zengate 1.0.4 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -14
- package/config.json.example +3 -0
- package/index.js +1 -0
- package/package.json +2 -2
- package/src/bootstrap.js +4 -1
- package/src/config.js +4 -0
- package/src/gateway.js +32 -10
- package/src/node-version.js +11 -0
- package/src/openai/chat-request.js +17 -7
- package/src/openai/chat.js +17 -10
- package/src/openai/completions.js +140 -0
- package/src/openai/generate.js +55 -26
- package/src/openai/ignored-params.js +15 -0
- package/src/openai/length-limit.js +49 -0
- package/src/openai/map-limited.js +30 -0
- package/src/openai/markup.js +31 -0
- package/src/openai/prompt.js +9 -5
- package/src/openai/remote-media.js +222 -0
- package/src/openai/responses-request.js +12 -7
- package/src/openai/responses-store.js +27 -7
- package/src/openai/responses.js +5 -2
- package/src/openai/stop.js +38 -14
- package/src/openai/tool-calls.js +8 -2
- package/src/openai/url-guard.js +104 -36
- package/src/openai/usage-estimate.js +42 -0
- package/src/opencode/backend.js +100 -39
- package/src/opencode/client.js +1 -0
- package/src/opencode/events.js +22 -3
- package/src/opencode/isolation.js +139 -9
- package/src/opencode/runner.js +25 -6
- package/src/server/app.js +59 -10
- package/src/server/client-key.js +29 -0
- package/src/server/limiter.js +63 -9
- package/src/server/metrics.js +119 -11
- package/src/server/middleware.js +30 -1
- package/src/server/slot.js +13 -7
package/README.md
CHANGED
|
@@ -36,7 +36,8 @@ OpenCode's free Zen models only work from inside OpenCode. So this gateway does
|
|
|
36
36
|
Trade-offs to know about:
|
|
37
37
|
|
|
38
38
|
- **Prompt overhead.** Each request carries OpenCode's system prompt and tool list (roughly 6–9k input tokens, largely cache hits), and adds about 1–3 s of latency.
|
|
39
|
-
- **Sampling parameters are ignored.** Settings like `temperature` and `
|
|
39
|
+
- **Sampling parameters are ignored.** Settings like `temperature` and `top_p` cannot be forwarded through OpenCode. The gateway accepts them and lists them in an `x-gateway-ignored-params` response header.
|
|
40
|
+
- **`max_tokens` is approximate.** OpenCode has no output limit either, so the gateway counts about four characters as a token, cuts the answer there, stops the model and reports `finish_reason: "length"`. As in OpenAI's API, `max_completion_tokens` and `max_output_tokens` include reasoning text and `max_tokens` covers only the answer. Usage for a cut answer is estimated, because OpenCode reports none for a stopped turn.
|
|
40
41
|
- **Upstream terms apply.** Availability, rate limits and the model list are set by OpenCode Zen and can change at any time. Free models may have their own data policies; see the [Zen docs](https://opencode.ai/docs/zen/).
|
|
41
42
|
|
|
42
43
|
> **Fair use.** zengate talks to Zen only through the official OpenCode CLI and never bypasses its limits or free-tier checks. You are responsible for following OpenCode's terms and fair-use expectations. Run it for yourself or your team, not as a public or resold service.
|
|
@@ -78,6 +79,13 @@ services:
|
|
|
78
79
|
- "127.0.0.1:8083:8083"
|
|
79
80
|
volumes:
|
|
80
81
|
- zengate:/data
|
|
82
|
+
# Longer than SHUTDOWN_TIMEOUT_MS plus 5s, so in-flight requests can finish.
|
|
83
|
+
stop_grace_period: 20s
|
|
84
|
+
cap_drop: [ALL]
|
|
85
|
+
security_opt: ["no-new-privileges:true"]
|
|
86
|
+
# OpenCode writes only to /tmp and the gateway only to /data.
|
|
87
|
+
read_only: true
|
|
88
|
+
tmpfs: [/tmp]
|
|
81
89
|
volumes:
|
|
82
90
|
zengate:
|
|
83
91
|
```
|
|
@@ -182,10 +190,12 @@ curl http://127.0.0.1:8083/v1/chat/completions \
|
|
|
182
190
|
|---|---|
|
|
183
191
|
| `POST /v1/chat/completions` | Streaming (SSE, `stream_options.include_usage`), `n` 1–4, `stop`, `tools` / `tool_choice` / `parallel_tool_calls`, legacy `functions`, `response_format` (`json_object`, `json_schema`), `reasoning_effort`, `developer` / `system` / `tool` roles, `reasoning_content` in responses |
|
|
184
192
|
| `POST /v1/responses` | Streaming with the standard event sequence, `instructions`, `previous_response_id`, `item_reference`, `function` and `custom` tools, `text.format`, `reasoning.effort` / `reasoning` summary items, `store` |
|
|
185
|
-
| `GET` / `DELETE /v1/responses/{id}` | Stored in memory for 1 hour,
|
|
193
|
+
| `GET` / `DELETE /v1/responses/{id}` | Stored in memory for 1 hour, within `RESPONSES_STORE_MAX` entries and `RESPONSES_STORE_MB` |
|
|
186
194
|
| `GET /v1/models`, `GET /v1/models/{id}` | Live list from OpenCode |
|
|
187
|
-
| `GET /health`, `GET /ready` | Public liveness and readiness probes |
|
|
188
|
-
| `
|
|
195
|
+
| `GET /health`, `GET /ready` | Public liveness and readiness probes (`/ready` answers `503` while starting or shutting down) |
|
|
196
|
+
| `POST /v1/completions` | Legacy text completions: `prompt` as a string or list of strings, `n`, `stop`, `max_tokens`, `echo`, streaming. The model is asked to continue the text, so it behaves like a chat model, not a base model. Without `max_tokens` the length is not limited (OpenAI defaults to 16) |
|
|
197
|
+
| `POST /v1/embeddings` | `404 unsupported_endpoint`: Zen serves chat models only |
|
|
198
|
+
| `GET /metrics` | Request counters, latency and time-to-first-token, slot usage and the OpenCode version (needs the key). JSON by default; Prometheus text format for `?format=prometheus` or an `Accept: text/plain` scrape |
|
|
189
199
|
|
|
190
200
|
Routes also work without the `/v1` prefix. Errors use the standard OpenAI envelope, `{"error": {"message", "type", "param", "code"}}`, with matching HTTP status codes (400, 401, 404, 413, 429 with `Retry-After`, 502, 503, 504).
|
|
191
201
|
|
|
@@ -201,9 +211,11 @@ Routes also work without the `/v1` prefix. Errors use the standard OpenAI envelo
|
|
|
201
211
|
|
|
202
212
|
Text files (plain text, markdown, JSON, CSV and so on) are inlined as text, so every model can read them.
|
|
203
213
|
|
|
214
|
+
The gateway downloads `https` attachment URLs itself (at most 16 in one request, 30 seconds each, up to 3 redirects) and passes the content on, so remote files obey `MAX_MEDIA_MB` and `MAX_BODY_MB` like inline ones. The server's `Content-Type` must match the part: an `image_url` must return an image. This direct connection does not use `HTTPS_PROXY`.
|
|
215
|
+
|
|
204
216
|
**Function calling** is emulated. OpenCode sessions cannot register your functions natively, so their schemas go into the prompt and the model's calls come back as standard `tool_calls` or `function_call` items. It works reliably with `big-pickle`. If a model tries to call a function the wrong way, or ignores `tool_choice: "required"`, the gateway retries once with a correction.
|
|
205
217
|
|
|
206
|
-
**Not supported** (clear 400 error): `logprobs`, audio output, `file_id` references (there is no Files API), `background` responses, the Conversations API and stored prompts. Hosted tools such as `web_search` in Responses are ignored and listed in `x-gateway-ignored-params`.
|
|
218
|
+
**Not supported** (clear 400 error): `logprobs`, `suffix`, audio output, `file_id` references (there is no Files API), `background` responses, the Conversations API and stored prompts. Hosted tools such as `web_search` in Responses are ignored and listed in `x-gateway-ignored-params`.
|
|
207
219
|
|
|
208
220
|
## Configuration
|
|
209
221
|
|
|
@@ -212,13 +224,16 @@ Set values as environment variables or in the config file (same names; see [`con
|
|
|
212
224
|
| Setting | Default | Meaning |
|
|
213
225
|
|---|---|---|
|
|
214
226
|
| `API_KEY` / `API_KEYS` | generated | Gateway key(s), at least 16 characters. `API_KEYS` takes a list for rotation. |
|
|
215
|
-
| `ALLOW_NO_AUTH` | `false` | Serve without any key (explicit opt-out). |
|
|
227
|
+
| `ALLOW_NO_AUTH` | `false` | Serve without any key (explicit opt-out). Requests must then use an IP address, `localhost` or a name in `ALLOWED_HOSTS` as their Host, which stops web pages from reaching the gateway through DNS rebinding. |
|
|
228
|
+
| `ALLOWED_HOSTS` | none | Extra Host names accepted while `ALLOW_NO_AUTH` is on, such as the name a reverse proxy forwards. |
|
|
216
229
|
| `HOST` / `PORT` | `127.0.0.1` / `8083` | Listen address. |
|
|
217
230
|
| `MAX_CONCURRENT` / `MAX_QUEUE` | `8` / `32` | Parallel generations, and how many requests may wait (then `429`). |
|
|
231
|
+
| `QUEUE_TIMEOUT_MS` | `30000` | How long a request may wait for a slot (then `429`). Keep it below your proxy's response timeout. |
|
|
218
232
|
| `RATE_LIMIT_PER_MINUTE` | `120` | Per client IP (`0` turns it off). |
|
|
219
|
-
| `REQUEST_TIMEOUT_MS` | `300000` | Per generation (then `504`). |
|
|
233
|
+
| `REQUEST_TIMEOUT_MS` | `300000` | Per generation, counted from when it gets a slot (then `504`). |
|
|
234
|
+
| `SHUTDOWN_TIMEOUT_MS` | `10000` | How long a shutdown waits for in-flight requests. Give Docker or systemd at least 5 seconds more. |
|
|
220
235
|
| `MAX_BODY_MB` / `MAX_MEDIA_MB` | `25` / `20` | Request body limit, and the limit per attachment. |
|
|
221
|
-
| `RESPONSES_STORE_MAX` | `500` | Stored responses for `previous_response_id
|
|
236
|
+
| `RESPONSES_STORE_MAX` / `RESPONSES_STORE_MB` | `500` / `256` | Stored responses for `previous_response_id`, by count and approximate memory (`RESPONSES_STORE_MAX=0` turns storage off). |
|
|
222
237
|
| `CORS_ORIGINS` | none | Browser origins allowed to call the API (explicit list; `*` is refused). |
|
|
223
238
|
| `TRUST_PROXY` | `0` | Number of reverse-proxy hops to trust for client IPs. |
|
|
224
239
|
| `LOG_LEVEL` / `LOG_JSON` | `info` / `false` | Log verbosity, and JSON-lines output. |
|
|
@@ -229,13 +244,13 @@ Set values as environment variables or in the config file (same names; see [`con
|
|
|
229
244
|
| `ALLOW_INSECURE_BACKEND_HTTP` | `false` | Allow a non-loopback `http://` server URL. |
|
|
230
245
|
| `CONFIG_FILE` | see [First start](#first-start) | Where the config file lives (environment variable only). |
|
|
231
246
|
|
|
232
|
-
**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
|
|
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`.
|
|
233
248
|
|
|
234
249
|
## Deployment
|
|
235
250
|
|
|
236
|
-
**Docker.** The key persists in the `/data` volume. You can pass `-e API_KEY=...` instead. Publish the port on `127.0.0.1`, or put a TLS reverse proxy in front before exposing it to a network.
|
|
251
|
+
**Docker.** The key persists in the `/data` volume. You can pass `-e API_KEY=...` instead. Publish the port on `127.0.0.1`, or put a TLS reverse proxy in front before exposing it to a network. `docker stop` waits only 10 seconds before killing the container, so pass `--stop-timeout 20` (or `stop_grace_period` in Compose) to let in-flight requests finish. The container also runs with a read-only root (`--read-only --tmpfs /tmp`), `--cap-drop ALL` and `--security-opt no-new-privileges:true`. On shutdown, `/ready` answers `503` for up to 2 seconds before the listener closes, so a load balancer polling it stops sending traffic first.
|
|
237
252
|
|
|
238
|
-
**systemd.** See [`deploy/zengate.service`](deploy/zengate.service). It runs as an unprivileged user with a hardened sandbox, and the key goes in `/var/lib/zengate/config.json`.
|
|
253
|
+
**systemd.** See [`deploy/zengate.service`](deploy/zengate.service). It runs as an unprivileged user with a hardened sandbox and a system-call filter (`@system-service`), and the key goes in `/var/lib/zengate/config.json`.
|
|
239
254
|
|
|
240
255
|
**Behind a reverse proxy.** Set `TRUST_PROXY=1` (or however many hops you have) so rate limits apply per real client. Disable response buffering for streaming. The gateway already sends `X-Accel-Buffering: no` for nginx.
|
|
241
256
|
|
|
@@ -263,8 +278,9 @@ sha256sum -c checksums.txt
|
|
|
263
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.
|
|
264
279
|
- The gateway binds to loopback by default and warns when it listens elsewhere, because traffic is plain HTTP, so put TLS in front.
|
|
265
280
|
- OpenCode tools are never executed, and OpenCode runs isolated from your home directory and configuration.
|
|
266
|
-
- Attachment URLs must be `https` and resolve to public addresses. Loopback, private, link-local and similar ranges are refused, which blocks SSRF into your network.
|
|
267
|
-
-
|
|
281
|
+
- 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
|
+
- 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 without their query string or credentials. A request with `n` choices uses `n` concurrency slots, and slots are always released on disconnect or timeout.
|
|
268
284
|
- Stored responses (`previous_response_id`, `GET /v1/responses/{id}`) are visible only to the API key that created them.
|
|
269
285
|
- The gateway sends no telemetry and never logs request bodies. OpenCode's auto-update and session sharing are disabled.
|
|
270
286
|
|
|
@@ -277,7 +293,8 @@ To report a vulnerability, see [SECURITY.md](SECURITY.md).
|
|
|
277
293
|
| `401 invalid_api_key` | Send `Authorization: Bearer <key from the config file>`. |
|
|
278
294
|
| `404 model_not_found` | Use an id from `GET /v1/models`. The free model list changes over time. |
|
|
279
295
|
| `400 unsupported_modality` | That model can't take this input kind. Pick one that does. |
|
|
280
|
-
| `429 server_busy` / `rate_limit_exceeded` | Raise `MAX_CONCURRENT` / `MAX_QUEUE` / `RATE_LIMIT_PER_MINUTE`, or slow down. |
|
|
296
|
+
| `429 server_busy` / `rate_limit_exceeded` | Raise `MAX_CONCURRENT` / `MAX_QUEUE` / `QUEUE_TIMEOUT_MS` / `RATE_LIMIT_PER_MINUTE`, or slow down. |
|
|
297
|
+
| `400 invalid_attachment_url` | The attachment URL is not public, failed to download, or returned the wrong type. Send it as a base64 data URI instead. |
|
|
281
298
|
| `429 upstream_rate_limited`, `502 upstream_*` | OpenCode Zen is limiting or failing. Retry later or try another model. |
|
|
282
299
|
| `503 backend_unavailable` | OpenCode is still starting or restarting. Check `GET /ready`. Run with `LOG_LEVEL=debug` to see OpenCode's own logs. |
|
|
283
300
|
| Startup: `Port ... in use` | Another process has the port. Set `PORT`. |
|
package/config.json.example
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
"MAX_QUEUE": 32,
|
|
8
8
|
"RATE_LIMIT_PER_MINUTE": 120,
|
|
9
9
|
"REQUEST_TIMEOUT_MS": 300000,
|
|
10
|
+
"QUEUE_TIMEOUT_MS": 30000,
|
|
11
|
+
"SHUTDOWN_TIMEOUT_MS": 10000,
|
|
10
12
|
"MAX_BODY_MB": 25,
|
|
11
13
|
"MAX_MEDIA_MB": 20,
|
|
12
14
|
"RESPONSES_STORE_MAX": 500,
|
|
15
|
+
"RESPONSES_STORE_MB": 256,
|
|
13
16
|
"CORS_ORIGINS": [],
|
|
14
17
|
"TRUST_PROXY": 0,
|
|
15
18
|
"LOG_LEVEL": "info",
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zengate",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"license": "MIT",
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"express": "^5.1.0",
|
|
55
|
-
"opencode-ai": "
|
|
55
|
+
"opencode-ai": "1.18.32"
|
|
56
56
|
},
|
|
57
57
|
"overrides": {
|
|
58
58
|
"es-define-property": "npm:@socketregistry/es-define-property@^1",
|
package/src/bootstrap.js
CHANGED
|
@@ -13,7 +13,10 @@ export function generateApiKey() {
|
|
|
13
13
|
export function writeConfigFile(filePath, data) {
|
|
14
14
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
15
15
|
const temp = `${filePath}.${process.pid}.tmp`;
|
|
16
|
-
|
|
16
|
+
// Never write through a file or symlink already at the temp path: unlink
|
|
17
|
+
// removes a link itself, and 'wx' creates the file fresh with mode 0600.
|
|
18
|
+
fs.rmSync(temp, { force: true });
|
|
19
|
+
fs.writeFileSync(temp, `${JSON.stringify(data, null, 4)}\n`, { mode: 0o600, flag: 'wx' });
|
|
17
20
|
fs.renameSync(temp, filePath);
|
|
18
21
|
}
|
|
19
22
|
|
package/src/config.js
CHANGED
|
@@ -21,10 +21,14 @@ const SPEC = Object.freeze({
|
|
|
21
21
|
MAX_QUEUE: { type: 'int', default: 32, min: 0, max: 1000 },
|
|
22
22
|
RATE_LIMIT_PER_MINUTE: { type: 'int', default: 120, min: 0, max: 100000 },
|
|
23
23
|
REQUEST_TIMEOUT_MS: { type: 'int', default: 300000, min: 10000, max: 3600000 },
|
|
24
|
+
QUEUE_TIMEOUT_MS: { type: 'int', default: 30000, min: 1000, max: 3600000 },
|
|
25
|
+
SHUTDOWN_TIMEOUT_MS: { type: 'int', default: 10000, min: 1000, max: 3600000 },
|
|
24
26
|
MAX_BODY_MB: { type: 'int', default: 25, min: 1, max: 200 },
|
|
25
27
|
MAX_MEDIA_MB: { type: 'int', default: 20, min: 1, max: 100 },
|
|
26
28
|
RESPONSES_STORE_MAX: { type: 'int', default: 500, min: 0, max: 100000 },
|
|
29
|
+
RESPONSES_STORE_MB: { type: 'int', default: 256, min: 1, max: 65536 },
|
|
27
30
|
CORS_ORIGINS: { type: 'list', default: [] },
|
|
31
|
+
ALLOWED_HOSTS: { type: 'list', default: [] },
|
|
28
32
|
TRUST_PROXY: { type: 'hops', default: 0 },
|
|
29
33
|
LOG_LEVEL: { type: 'enum', default: 'info', values: ['debug', 'info', 'warn', 'error'] },
|
|
30
34
|
LOG_JSON: { type: 'bool', default: false },
|
package/src/gateway.js
CHANGED
|
@@ -6,7 +6,11 @@ import { createResponsesStore } from './openai/responses-store.js';
|
|
|
6
6
|
import { createApp } from './server/app.js';
|
|
7
7
|
|
|
8
8
|
const HUB_CONNECT_TIMEOUT_MS = 10000;
|
|
9
|
-
const
|
|
9
|
+
const MB = 1024 * 1024;
|
|
10
|
+
// Time allowed to receive a whole request (headers and body). Generation
|
|
11
|
+
// time is not included; REQUEST_TIMEOUT_MS bounds that.
|
|
12
|
+
const BODY_TIMEOUT_MS = 120000;
|
|
13
|
+
const SHUTDOWN_NOTICE_MS = 2000;
|
|
10
14
|
|
|
11
15
|
export function createBackend(config, logger) {
|
|
12
16
|
return config.OPENCODE_SERVER_URL
|
|
@@ -29,9 +33,25 @@ function listen(app, { PORT, HOST }) {
|
|
|
29
33
|
});
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
|
|
36
|
+
const openConnections = (server) => new Promise((resolve) => {
|
|
37
|
+
server.getConnections((error, count) => resolve(error ? 0 : count));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Keep listening for `noticeMs` while /ready reports "stopping", so a load
|
|
42
|
+
* balancer that polls it stops routing here before the listener closes.
|
|
43
|
+
* Skipped when nobody is connected, so a local Ctrl+C stays instant.
|
|
44
|
+
* @returns {Promise<number>} how long it waited
|
|
45
|
+
*/
|
|
46
|
+
async function announceShutdown(server, noticeMs) {
|
|
47
|
+
if (noticeMs <= 0 || (await openConnections(server)) === 0) return 0;
|
|
48
|
+
await new Promise((resolve) => setTimeout(resolve, noticeMs));
|
|
49
|
+
return noticeMs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function drain(server, timeoutMs) {
|
|
33
53
|
return new Promise((resolve) => {
|
|
34
|
-
const force = setTimeout(() => server.closeAllConnections(),
|
|
54
|
+
const force = setTimeout(() => server.closeAllConnections(), timeoutMs);
|
|
35
55
|
force.unref();
|
|
36
56
|
server.close(() => { clearTimeout(force); resolve(); });
|
|
37
57
|
server.closeIdleConnections();
|
|
@@ -42,15 +62,15 @@ function drain(server) {
|
|
|
42
62
|
* Wires the OpenCode backend, event hub, catalog and HTTP app together and
|
|
43
63
|
* starts listening.
|
|
44
64
|
* @param {object} config validated config from loadConfig
|
|
45
|
-
* @param {{ logger: object, backend?: object }} deps backend
|
|
65
|
+
* @param {{ logger: object, backend?: object, shutdownNoticeMs?: number }} deps backend and notice are injectable for tests
|
|
46
66
|
*/
|
|
47
|
-
export async function startGateway(config, { logger, backend = createBackend(config, logger) }) {
|
|
67
|
+
export async function startGateway(config, { logger, backend = createBackend(config, logger), shutdownNoticeMs = SHUTDOWN_NOTICE_MS }) {
|
|
48
68
|
const getClient = () => backend.getClient();
|
|
49
69
|
const hub = createEventHub({ getClient, logger, ownsAllSessions: backend.ownsAllSessions });
|
|
50
70
|
const catalog = createCatalog({ getClient, logger });
|
|
51
71
|
const runner = createRunner({ getClient, hub, logger, agent: config.OPENCODE_AGENT });
|
|
52
|
-
const store = createResponsesStore({ maxEntries: config.RESPONSES_STORE_MAX });
|
|
53
|
-
const { app, close: closeApp } = createApp({ config, logger, backend, hub, catalog, runner, store });
|
|
72
|
+
const store = createResponsesStore({ maxEntries: config.RESPONSES_STORE_MAX, maxChars: config.RESPONSES_STORE_MB * MB });
|
|
73
|
+
const { app, close: closeApp, startDraining } = createApp({ config, logger, backend, hub, catalog, runner, store });
|
|
54
74
|
|
|
55
75
|
await backend.start();
|
|
56
76
|
let server;
|
|
@@ -70,15 +90,17 @@ export async function startGateway(config, { logger, backend = createBackend(con
|
|
|
70
90
|
await backend.stop();
|
|
71
91
|
throw error;
|
|
72
92
|
}
|
|
73
|
-
|
|
74
|
-
server.requestTimeout = 0;
|
|
93
|
+
server.requestTimeout = BODY_TIMEOUT_MS;
|
|
75
94
|
server.headersTimeout = 30000;
|
|
76
95
|
server.keepAliveTimeout = 65000;
|
|
77
96
|
|
|
78
97
|
let stopping = null;
|
|
79
98
|
const stop = () => {
|
|
80
99
|
stopping ??= (async () => {
|
|
81
|
-
|
|
100
|
+
startDraining();
|
|
101
|
+
// In-flight requests keep running during the notice, so it counts towards SHUTDOWN_TIMEOUT_MS.
|
|
102
|
+
const noticed = await announceShutdown(server, Math.min(shutdownNoticeMs, config.SHUTDOWN_TIMEOUT_MS / 2));
|
|
103
|
+
await drain(server, config.SHUTDOWN_TIMEOUT_MS - noticed);
|
|
82
104
|
closeApp();
|
|
83
105
|
await hub.stop();
|
|
84
106
|
await backend.stop();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imported first by index.js, before any module that needs Node 24, so an
|
|
3
|
+
* older runtime gets one clear message instead of an obscure failure later.
|
|
4
|
+
*/
|
|
5
|
+
const REQUIRED_MAJOR = 24;
|
|
6
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
7
|
+
|
|
8
|
+
if (major < REQUIRED_MAJOR) {
|
|
9
|
+
process.stderr.write(`zengate needs Node.js ${REQUIRED_MAJOR} or newer; this is ${process.version}.\n`);
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { invalidRequest, unsupported } from '../server/errors.js';
|
|
2
2
|
import { audioFromBase64, fileAttachment, imageFromUrl, videoFromUrl } from './media.js';
|
|
3
|
+
import { parseMaxTokens } from './length-limit.js';
|
|
4
|
+
import { escapeFileTags, fileBlock } from './markup.js';
|
|
3
5
|
import { newCallId } from './tool-calls.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -8,24 +10,29 @@ import { newCallId } from './tool-calls.js';
|
|
|
8
10
|
* They are reported back in the `x-gateway-ignored-params` header.
|
|
9
11
|
*/
|
|
10
12
|
const IGNORED = new Set([
|
|
11
|
-
'temperature', 'top_p', '
|
|
13
|
+
'temperature', 'top_p', 'presence_penalty', 'frequency_penalty',
|
|
12
14
|
'seed', 'logit_bias', 'user', 'metadata', 'store', 'service_tier', 'prompt_cache_key', 'prompt_cache_retention',
|
|
13
15
|
'safety_identifier', 'verbosity', 'prediction', 'web_search_options', 'top_k', 'min_p', 'repetition_penalty',
|
|
14
16
|
]);
|
|
15
17
|
const HANDLED = new Set([
|
|
16
18
|
'model', 'messages', 'stream', 'stream_options', 'n', 'stop', 'response_format', 'reasoning_effort',
|
|
17
19
|
'tools', 'tool_choice', 'parallel_tool_calls', 'functions', 'function_call', 'logprobs', 'top_logprobs',
|
|
18
|
-
'modalities', 'audio',
|
|
20
|
+
'modalities', 'audio', 'max_tokens', 'max_completion_tokens',
|
|
19
21
|
]);
|
|
20
22
|
const ROLES = new Set(['system', 'developer', 'user', 'assistant', 'tool', 'function']);
|
|
21
23
|
const MAX_MESSAGES = 2000;
|
|
22
24
|
export const MAX_CHOICES = 4;
|
|
25
|
+
/** OpenAI's own limit on functions in one request. */
|
|
26
|
+
export const MAX_TOOLS = 128;
|
|
27
|
+
/** Long enough for any real stop sequence; the stop filter rescans up to this much text. */
|
|
28
|
+
const MAX_STOP_CHARS = 1000;
|
|
23
29
|
|
|
24
30
|
const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
25
31
|
|
|
26
32
|
export function parseTools(tools, param = 'tools') {
|
|
27
33
|
if (tools === undefined || tools === null) return [];
|
|
28
34
|
if (!Array.isArray(tools)) throw invalidRequest('tools must be an array.', param);
|
|
35
|
+
if (tools.length > MAX_TOOLS) throw invalidRequest(`${param} may contain at most ${MAX_TOOLS} entries.`, param);
|
|
29
36
|
const seen = new Set();
|
|
30
37
|
return tools.map((tool, index) => {
|
|
31
38
|
const fn = tool?.type === 'function' ? tool.function : tool?.type === undefined ? tool : null;
|
|
@@ -60,7 +67,7 @@ function textOf(parts, param) {
|
|
|
60
67
|
}
|
|
61
68
|
|
|
62
69
|
function parseUserContent(content, param, media) {
|
|
63
|
-
if (typeof content === 'string') return { text: content, media: [] };
|
|
70
|
+
if (typeof content === 'string') return { text: escapeFileTags(content), media: [] };
|
|
64
71
|
if (content === null || content === undefined) return { text: '', media: [] };
|
|
65
72
|
if (!Array.isArray(content)) throw invalidRequest('content must be a string or an array of content parts.', param);
|
|
66
73
|
const texts = [];
|
|
@@ -69,18 +76,18 @@ function parseUserContent(content, param, media) {
|
|
|
69
76
|
const where = `${param}[${index}]`;
|
|
70
77
|
const options = { ...media, param: where };
|
|
71
78
|
switch (part?.type) {
|
|
72
|
-
case 'text': texts.push(
|
|
79
|
+
case 'text': texts.push(escapeFileTags(part.text)); break;
|
|
73
80
|
case 'image_url': attachments.push(imageFromUrl(typeof part.image_url === 'string' ? part.image_url : part.image_url?.url, options)); break;
|
|
74
81
|
case 'input_audio': attachments.push(audioFromBase64(part.input_audio?.data, part.input_audio?.format, options)); break;
|
|
75
82
|
case 'file': attachments.push(fileAttachment(part.file || {}, options)); break;
|
|
76
83
|
case 'video_url': attachments.push(videoFromUrl(typeof part.video_url === 'string' ? part.video_url : part.video_url?.url, options)); break;
|
|
77
|
-
case 'refusal': texts.push(
|
|
84
|
+
case 'refusal': texts.push(escapeFileTags(part.refusal)); break;
|
|
78
85
|
default:
|
|
79
86
|
throw unsupported(`Unsupported content part type '${part?.type}'. Use text, image_url, input_audio, file or video_url.`, where);
|
|
80
87
|
}
|
|
81
88
|
});
|
|
82
89
|
const inlined = attachments.filter((a) => a.kind === 'text');
|
|
83
|
-
for (const file of inlined) texts.push(
|
|
90
|
+
for (const file of inlined) texts.push(fileBlock(file.filename, file.text));
|
|
84
91
|
return { text: texts.join(''), media: attachments.filter((a) => a.kind !== 'text') };
|
|
85
92
|
}
|
|
86
93
|
|
|
@@ -139,10 +146,11 @@ export function parseResponseFormat(format, param = 'response_format') {
|
|
|
139
146
|
throw invalidRequest('response_format.type must be text, json_object or json_schema.', param);
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
function parseStop(stop) {
|
|
149
|
+
export function parseStop(stop) {
|
|
143
150
|
if (stop === undefined || stop === null) return [];
|
|
144
151
|
const list = Array.isArray(stop) ? stop : [stop];
|
|
145
152
|
if (list.length > 4 || list.some((s) => typeof s !== 'string')) throw invalidRequest('stop must be a string or up to 4 strings.', 'stop');
|
|
153
|
+
if (list.some((s) => s.length > MAX_STOP_CHARS)) throw invalidRequest(`Each stop sequence may be at most ${MAX_STOP_CHARS} characters.`, 'stop');
|
|
146
154
|
return list.filter(Boolean);
|
|
147
155
|
}
|
|
148
156
|
|
|
@@ -176,6 +184,8 @@ export function parseChatRequest(body, media) {
|
|
|
176
184
|
reasoningEffort: typeof body.reasoning_effort === 'string' ? body.reasoning_effort : null,
|
|
177
185
|
stop: parseStop(body.stop),
|
|
178
186
|
n,
|
|
187
|
+
maxTokens: parseMaxTokens(body, ['max_completion_tokens', 'max_tokens']),
|
|
188
|
+
limitsReasoning: body.max_completion_tokens !== undefined && body.max_completion_tokens !== null,
|
|
179
189
|
stream: body.stream === true,
|
|
180
190
|
includeUsage: body.stream_options?.include_usage === true,
|
|
181
191
|
ignored,
|
package/src/openai/chat.js
CHANGED
|
@@ -2,7 +2,10 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import { toApiError } from '../server/errors.js';
|
|
3
3
|
import { parseChatRequest } from './chat-request.js';
|
|
4
4
|
import { chatUsage, generate } from './generate.js';
|
|
5
|
+
import { setIgnoredParams } from './ignored-params.js';
|
|
6
|
+
import { mapLimited } from './map-limited.js';
|
|
5
7
|
import { buildPrompt } from './prompt.js';
|
|
8
|
+
import { inlineRemoteAttachments } from './remote-media.js';
|
|
6
9
|
import { openSse } from './sse-writer.js';
|
|
7
10
|
import { assertPublicUrls } from './url-guard.js';
|
|
8
11
|
|
|
@@ -38,15 +41,19 @@ export function chatCompletionsHandler({ runner, catalog, limits }) {
|
|
|
38
41
|
const model = await catalog.resolve(request.model);
|
|
39
42
|
const prompt = buildPrompt(request, model);
|
|
40
43
|
await assertPublicUrls(prompt.parts);
|
|
41
|
-
|
|
42
|
-
await req.withSlot((signal) =>
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
setIgnoredParams(res, request.ignored);
|
|
45
|
+
await req.withSlot(async (signal, parallel) => {
|
|
46
|
+
const ready = await inlineRemoteAttachments(prompt, { ...limits, signal });
|
|
47
|
+
const context = { req, res, runner, request, prompt: ready, model, signal, parallel };
|
|
48
|
+
return request.stream ? streamChat(context) : jsonChat(context);
|
|
49
|
+
}, request.n);
|
|
45
50
|
};
|
|
46
51
|
}
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
53
|
+
const choiceIndexes = (n) => Array.from({ length: n }, (_, index) => index);
|
|
54
|
+
|
|
55
|
+
async function jsonChat({ req, res, runner, request, prompt, model, signal, parallel }) {
|
|
56
|
+
const results = await mapLimited(choiceIndexes(request.n), parallel, () => generate({ runner, prompt, request, signal, onFirstToken: req.markFirstToken }));
|
|
50
57
|
res.json({
|
|
51
58
|
id: completionId(),
|
|
52
59
|
object: 'chat.completion',
|
|
@@ -60,7 +67,7 @@ async function jsonChat({ res, runner, request, prompt, model, signal }) {
|
|
|
60
67
|
});
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
async function streamChat({ req, res, runner, request, prompt, model, signal }) {
|
|
70
|
+
async function streamChat({ req, res, runner, request, prompt, model, signal, parallel }) {
|
|
64
71
|
const id = completionId();
|
|
65
72
|
const created = now();
|
|
66
73
|
const sse = openSse(res);
|
|
@@ -72,8 +79,8 @@ async function streamChat({ req, res, runner, request, prompt, model, signal })
|
|
|
72
79
|
|
|
73
80
|
for (let index = 0; index < request.n; index += 1) chunk(index, { role: 'assistant', content: '', refusal: null });
|
|
74
81
|
try {
|
|
75
|
-
const results = await
|
|
76
|
-
runner, prompt, request, signal,
|
|
82
|
+
const results = await mapLimited(choiceIndexes(request.n), parallel, (index) => generate({
|
|
83
|
+
runner, prompt, request, signal, onFirstToken: req.markFirstToken,
|
|
77
84
|
onText: (text) => chunk(index, { content: text }),
|
|
78
85
|
onReasoning: (text) => chunk(index, { reasoning_content: text }),
|
|
79
86
|
}).then((result) => {
|
|
@@ -82,7 +89,7 @@ async function streamChat({ req, res, runner, request, prompt, model, signal })
|
|
|
82
89
|
else if (calls.length) chunk(index, { tool_calls: calls.map((call, position) => ({ index: position, ...call })) });
|
|
83
90
|
chunk(index, {}, finishReason(result, request.legacyFunctions));
|
|
84
91
|
return result;
|
|
85
|
-
}))
|
|
92
|
+
}));
|
|
86
93
|
if (request.includeUsage) {
|
|
87
94
|
sse.send({ id, object: 'chat.completion.chunk', created, model: model.id, system_fingerprint: null, choices: [], usage: chatUsage(results.map((r) => r.usage)) });
|
|
88
95
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { invalidRequest, toApiError, unsupported } from '../server/errors.js';
|
|
3
|
+
import { MAX_CHOICES, parseStop } from './chat-request.js';
|
|
4
|
+
import { chatUsage, generate } from './generate.js';
|
|
5
|
+
import { setIgnoredParams } from './ignored-params.js';
|
|
6
|
+
import { mapLimited } from './map-limited.js';
|
|
7
|
+
import { parseMaxTokens } from './length-limit.js';
|
|
8
|
+
import { buildPrompt } from './prompt.js';
|
|
9
|
+
import { openSse } from './sse-writer.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Legacy text completions (POST /v1/completions) on top of chat models: the
|
|
13
|
+
* model is asked to continue the prompt and reply with the continuation only.
|
|
14
|
+
*/
|
|
15
|
+
const CONTINUE_INSTRUCTION = 'Continue the text in the user message from exactly where it stops. '
|
|
16
|
+
+ 'Reply with only the continuation: no preamble, no quotes, and do not repeat the given text.';
|
|
17
|
+
const IGNORED = new Set(['temperature', 'top_p', 'presence_penalty', 'frequency_penalty', 'seed', 'logit_bias', 'user', 'best_of']);
|
|
18
|
+
const HANDLED = new Set(['model', 'prompt', 'stream', 'stream_options', 'n', 'stop', 'max_tokens', 'echo', 'suffix', 'logprobs']);
|
|
19
|
+
const MAX_PROMPT_CHARS = 4 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
const completionId = () => `cmpl-${crypto.randomBytes(12).toString('hex')}`;
|
|
22
|
+
const now = () => Math.floor(Date.now() / 1000);
|
|
23
|
+
|
|
24
|
+
function parsePrompts(prompt) {
|
|
25
|
+
const list = Array.isArray(prompt) ? prompt : [prompt];
|
|
26
|
+
if (list.length === 0 || list.some((item) => typeof item !== 'string')) {
|
|
27
|
+
throw invalidRequest('prompt must be a string or an array of strings (token arrays are not supported).', 'prompt');
|
|
28
|
+
}
|
|
29
|
+
if (list.reduce((sum, item) => sum + item.length, 0) > MAX_PROMPT_CHARS) throw invalidRequest('prompt is too long.', 'prompt');
|
|
30
|
+
return list;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate a completions request. Each prompt becomes its own single-turn
|
|
35
|
+
* chat request; `n` completions are made for each.
|
|
36
|
+
* @param {unknown} body
|
|
37
|
+
*/
|
|
38
|
+
export function parseCompletionRequest(body) {
|
|
39
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) throw invalidRequest('Request body must be a JSON object.');
|
|
40
|
+
if (typeof body.model !== 'string' || !body.model.trim()) throw invalidRequest('model is required (see GET /v1/models).', 'model');
|
|
41
|
+
if (body.prompt === undefined) throw invalidRequest('prompt is required.', 'prompt');
|
|
42
|
+
if (body.suffix !== undefined && body.suffix !== null && body.suffix !== '') throw unsupported('suffix (fill-in-the-middle) is not supported.', 'suffix');
|
|
43
|
+
if (body.logprobs !== undefined && body.logprobs !== null) throw unsupported('logprobs are not available through OpenCode.', 'logprobs');
|
|
44
|
+
const prompts = parsePrompts(body.prompt);
|
|
45
|
+
const n = body.n ?? 1;
|
|
46
|
+
if (!Number.isInteger(n) || n < 1 || n * prompts.length > MAX_CHOICES) {
|
|
47
|
+
throw invalidRequest(`n times the number of prompts must be from 1 to ${MAX_CHOICES}.`, 'n');
|
|
48
|
+
}
|
|
49
|
+
const shared = {
|
|
50
|
+
model: body.model.trim(),
|
|
51
|
+
tools: [],
|
|
52
|
+
toolChoice: 'auto',
|
|
53
|
+
parallelToolCalls: false,
|
|
54
|
+
format: null,
|
|
55
|
+
reasoningEffort: null,
|
|
56
|
+
stop: parseStop(body.stop),
|
|
57
|
+
maxTokens: parseMaxTokens(body, ['max_tokens']),
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
...shared,
|
|
61
|
+
prompts,
|
|
62
|
+
requests: prompts.map((text) => ({
|
|
63
|
+
...shared,
|
|
64
|
+
messages: [{ role: 'system', content: CONTINUE_INSTRUCTION, media: [] }, { role: 'user', content: text, media: [] }],
|
|
65
|
+
})),
|
|
66
|
+
n,
|
|
67
|
+
echo: body.echo === true,
|
|
68
|
+
stream: body.stream === true,
|
|
69
|
+
includeUsage: body.stream_options?.include_usage === true,
|
|
70
|
+
ignored: Object.keys(body).filter((key) => IGNORED.has(key) || !HANDLED.has(key)),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* POST /v1/completions
|
|
76
|
+
* @param {{ runner: object, catalog: object }} deps
|
|
77
|
+
*/
|
|
78
|
+
export function completionsHandler({ runner, catalog }) {
|
|
79
|
+
return async (req, res) => {
|
|
80
|
+
const parsed = parseCompletionRequest(req.body);
|
|
81
|
+
const model = await catalog.resolve(parsed.model);
|
|
82
|
+
const jobs = parsed.requests.flatMap((request, promptIndex) => Array.from({ length: parsed.n }, () => ({
|
|
83
|
+
request, prompt: buildPrompt(request, model), echo: parsed.echo ? parsed.prompts[promptIndex] : '',
|
|
84
|
+
})));
|
|
85
|
+
setIgnoredParams(res, parsed.ignored);
|
|
86
|
+
await req.withSlot((signal, parallel) => (parsed.stream
|
|
87
|
+
? streamCompletion({ req, res, runner, parsed, jobs, model, signal, parallel })
|
|
88
|
+
: jsonCompletion({ req, res, runner, jobs, model, signal, parallel })), jobs.length);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const run = ({ req, runner, job, signal, onText }) => generate({
|
|
93
|
+
runner, prompt: job.prompt, request: job.request, signal, onText, onFirstToken: req.markFirstToken,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
async function jsonCompletion({ req, res, runner, jobs, model, signal, parallel }) {
|
|
97
|
+
const results = await mapLimited(jobs, parallel, (job) => run({ req, runner, job, signal }));
|
|
98
|
+
res.json({
|
|
99
|
+
id: completionId(),
|
|
100
|
+
object: 'text_completion',
|
|
101
|
+
created: now(),
|
|
102
|
+
model: model.id,
|
|
103
|
+
system_fingerprint: null,
|
|
104
|
+
choices: results.map((result, index) => ({
|
|
105
|
+
text: `${jobs[index].echo}${result.content}`, index, logprobs: null, finish_reason: result.finish,
|
|
106
|
+
})),
|
|
107
|
+
usage: chatUsage(results.map((r) => r.usage)),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function streamCompletion({ req, res, runner, parsed, jobs, model, signal, parallel }) {
|
|
112
|
+
const id = completionId();
|
|
113
|
+
const created = now();
|
|
114
|
+
const sse = openSse(res);
|
|
115
|
+
const chunk = (choices, usage) => sse.send({
|
|
116
|
+
id, object: 'text_completion', created, model: model.id, system_fingerprint: null, choices,
|
|
117
|
+
...(usage ? { usage } : {}),
|
|
118
|
+
});
|
|
119
|
+
const text = (index, value, finishReason = null) => chunk([{ text: value, index, logprobs: null, finish_reason: finishReason }]);
|
|
120
|
+
try {
|
|
121
|
+
const results = await mapLimited(jobs, parallel, (job, index) => {
|
|
122
|
+
if (job.echo) text(index, job.echo);
|
|
123
|
+
return run({ req, runner, job, signal, onText: (delta) => text(index, delta) }).then((result) => {
|
|
124
|
+
text(index, '', result.finish);
|
|
125
|
+
return result;
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
if (parsed.includeUsage) chunk([], chatUsage(results.map((r) => r.usage)));
|
|
129
|
+
sse.raw('data: [DONE]\n\n');
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (!signal.aborted || error?.status) {
|
|
132
|
+
const apiError = toApiError(error);
|
|
133
|
+
req.log.warn('Completion stream failed', { status: apiError.status, code: apiError.code, error: apiError.message });
|
|
134
|
+
sse.send(apiError.toJSON());
|
|
135
|
+
sse.raw('data: [DONE]\n\n');
|
|
136
|
+
}
|
|
137
|
+
} finally {
|
|
138
|
+
sse.end();
|
|
139
|
+
}
|
|
140
|
+
}
|