zengate 1.0.4 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 `max_tokens` cannot be forwarded through OpenCode. The gateway accepts them and lists them in an `x-gateway-ignored-params` response header.
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, up to `RESPONSES_STORE_MAX` entries |
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
- | `GET /metrics` | Request counters and slot usage, as JSON (needs the key) |
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` (`0` turns storage off). |
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 (every permission set to `"ask"`). The managed default is safer. Remote servers must use `https://` unless you set `ALLOW_INSECURE_BACKEND_HTTP`.
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
- - Rate limiting, a bounded queue, body and attachment size limits, and per-request timeouts all apply. A request with `n` choices uses `n` concurrency slots, and slots are always released on disconnect or timeout.
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`. |
@@ -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
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import './src/node-version.js';
2
3
  import fs from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zengate",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "Keyless OpenAI-compatible API (Chat Completions + Responses) for OpenCode's free Zen models, served through the real OpenCode CLI.",
5
5
  "author": {
6
6
  "name": "developingchet",
@@ -52,7 +52,7 @@
52
52
  "license": "MIT",
53
53
  "dependencies": {
54
54
  "express": "^5.1.0",
55
- "opencode-ai": "^1.18.32"
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
- fs.writeFileSync(temp, `${JSON.stringify(data, null, 4)}\n`, { mode: 0o600 });
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 DRAIN_TIMEOUT_MS = 10000;
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
- function drain(server) {
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(), DRAIN_TIMEOUT_MS);
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 is injectable for tests
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
- // Generations can legitimately run for minutes; REQUEST_TIMEOUT_MS bounds them instead.
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
- await drain(server);
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', 'max_tokens', 'max_completion_tokens', 'presence_penalty', 'frequency_penalty',
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(String(part.text ?? '')); break;
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(String(part.refusal ?? '')); break;
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(`\n<file name="${file.filename || 'attachment'}">\n${file.text}\n</file>\n`);
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,
@@ -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
- if (request.ignored.length) res.set('x-gateway-ignored-params', request.ignored.join(','));
42
- await req.withSlot((signal) => (request.stream
43
- ? streamChat({ req, res, runner, request, prompt, model, signal })
44
- : jsonChat({ res, runner, request, prompt, model, signal })), request.n);
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
- async function jsonChat({ res, runner, request, prompt, model, signal }) {
49
- const results = await Promise.all(Array.from({ length: request.n }, () => generate({ runner, prompt, request, signal })));
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 Promise.all(Array.from({ length: request.n }, (_, index) => generate({
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
+ }