zengate 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/config.json.example +23 -0
  4. package/index.js +84 -0
  5. package/package.json +57 -0
  6. package/scripts/setup.mjs +24 -0
  7. package/src/bootstrap.js +66 -0
  8. package/src/cli.js +55 -0
  9. package/src/config.js +193 -0
  10. package/src/gateway.js +90 -0
  11. package/src/logger.js +35 -0
  12. package/src/openai/chat-request.js +183 -0
  13. package/src/openai/chat.js +100 -0
  14. package/src/openai/generate.js +143 -0
  15. package/src/openai/media.js +104 -0
  16. package/src/openai/models.js +17 -0
  17. package/src/openai/prompt.js +101 -0
  18. package/src/openai/response-builder.js +135 -0
  19. package/src/openai/responses-request.js +176 -0
  20. package/src/openai/responses-store.js +78 -0
  21. package/src/openai/responses.js +65 -0
  22. package/src/openai/sse-writer.js +37 -0
  23. package/src/openai/stop.js +41 -0
  24. package/src/openai/tool-calls.js +187 -0
  25. package/src/openai/url-guard.js +61 -0
  26. package/src/opencode/backend.js +201 -0
  27. package/src/opencode/binary.js +75 -0
  28. package/src/opencode/catalog.js +83 -0
  29. package/src/opencode/client.js +84 -0
  30. package/src/opencode/events.js +168 -0
  31. package/src/opencode/isolation.js +119 -0
  32. package/src/opencode/model-errors.js +66 -0
  33. package/src/opencode/runner.js +224 -0
  34. package/src/opencode/sse-reader.js +40 -0
  35. package/src/paths.js +40 -0
  36. package/src/server/app.js +96 -0
  37. package/src/server/errors.js +56 -0
  38. package/src/server/limiter.js +72 -0
  39. package/src/server/metrics.js +31 -0
  40. package/src/server/middleware.js +94 -0
  41. package/src/server/slot.js +32 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The zengate contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # zengate
2
+
3
+ [![CI](https://github.com/developingchet/zengate/actions/workflows/ci.yml/badge.svg)](https://github.com/developingchet/zengate/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/zengate)](https://www.npmjs.com/package/zengate)
5
+ [![Docker Hub](https://img.shields.io/docker/v/developingchet/zengate?label=docker&sort=semver)](https://hub.docker.com/r/developingchet/zengate)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ An **OpenAI-compatible API for OpenCode's free Zen models: no Zen account or upstream API key needed.**
9
+ Point any OpenAI SDK or tool at it and use models like `big-pickle`. It supports Chat Completions, the Responses API, streaming, function calling, JSON output and image, audio, video and PDF input.
10
+
11
+ ```
12
+ your app ──(OpenAI API + gateway key)──▶ zengate ──▶ opencode serve (private, isolated) ──▶ OpenCode Zen free models
13
+ ```
14
+
15
+ - [How it works](#how-it-works-and-why-it-is-keyless)
16
+ - [Install](#install)
17
+ - [Use it from your client](#use-it-from-your-client)
18
+ - [API compatibility](#api-compatibility)
19
+ - [Configuration](#configuration)
20
+ - [Deployment](#deployment)
21
+ - [Security](#security)
22
+ - [Troubleshooting](#troubleshooting)
23
+ - [Development](#development)
24
+
25
+ ## How it works (and why it is keyless)
26
+
27
+ OpenCode's free Zen models only work from inside OpenCode. So this gateway does not imitate OpenCode. It **runs the real OpenCode CLI** (`opencode serve`, installed automatically as an npm dependency) as a private backend and translates between the OpenAI API and OpenCode sessions:
28
+
29
+ - Every API request becomes a short-lived OpenCode session, which is deleted afterwards.
30
+ - OpenCode runs sandboxed. It listens only on `127.0.0.1` with a random password, and its home and config directories are throwaway temp dirs, so your own OpenCode or Claude settings, agents and plugins are never loaded.
31
+ - **OpenCode's own tools are always refused.** Every tool permission is set to "ask" and the gateway rejects each request automatically. Nothing ever runs a command, edits a file or fetches a URL on your machine.
32
+ - Your API key protects *the gateway*. Nothing upstream needs a key.
33
+
34
+ Trade-offs to know about:
35
+
36
+ - **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.
37
+ - **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.
38
+ - **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/).
39
+
40
+ > **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.
41
+
42
+ ## Install
43
+
44
+ Pick one. All of them need nothing but Node.js 24+ or Docker.
45
+
46
+ **npx (quickest)**
47
+
48
+ ```bash
49
+ npx zengate
50
+ ```
51
+
52
+ **npm (global command)**
53
+
54
+ ```bash
55
+ npm install -g zengate
56
+ zengate
57
+ ```
58
+
59
+ **Docker**
60
+
61
+ ```bash
62
+ docker run -d --name zengate -p 127.0.0.1:8083:8083 -v zengate:/data developingchet/zengate
63
+ docker logs zengate # shows the generated key once
64
+ ```
65
+
66
+ Images are published for `linux/amd64` and `linux/arm64`, tagged `latest`, `1`, `1.2` and `1.2.3`.
67
+
68
+ **Docker Compose**
69
+
70
+ ```yaml
71
+ services:
72
+ zengate:
73
+ image: developingchet/zengate:1
74
+ restart: unless-stopped
75
+ ports:
76
+ - "127.0.0.1:8083:8083"
77
+ volumes:
78
+ - zengate:/data
79
+ volumes:
80
+ zengate:
81
+ ```
82
+
83
+ **From source**
84
+
85
+ ```bash
86
+ git clone https://github.com/developingchet/zengate.git
87
+ cd zengate
88
+ npm ci
89
+ npm start
90
+ ```
91
+
92
+ ### First start
93
+
94
+ The gateway creates a config file with a random API key and prints the key once:
95
+
96
+ ```
97
+ Created a gateway API key (saved to /home/you/.config/zengate/config.json):
98
+
99
+ sk-zg-...
100
+
101
+ OpenAI-compatible API on http://127.0.0.1:8083/v1 (auth: API key)
102
+ 7 models: big-pickle, ...
103
+ ```
104
+
105
+ Where the config file lives:
106
+
107
+ | How you run it | Config file |
108
+ |---|---|
109
+ | From source | `config.json` in the project folder |
110
+ | npm / npx on Linux | `$XDG_CONFIG_HOME/zengate/config.json` (usually `~/.config/zengate/config.json`) |
111
+ | npm / npx on macOS | `~/Library/Application Support/zengate/config.json` |
112
+ | npm / npx on Windows | `%APPDATA%\zengate\config.json` |
113
+ | Docker | `/data/config.json` (the volume) |
114
+
115
+ Set `CONFIG_FILE` to use any other path.
116
+
117
+ Key commands (`npm run setup -- <flag>` from a source checkout):
118
+
119
+ ```bash
120
+ zengate setup # create a key if none exists
121
+ zengate setup --rotate # replace the key
122
+ zengate setup --print # print a fresh key without saving it (for env vars and secret stores)
123
+ zengate --help
124
+ ```
125
+
126
+ ### Running without a key
127
+
128
+ If only trusted local programs can reach the port, you can turn authentication off explicitly:
129
+
130
+ ```bash
131
+ ALLOW_NO_AUTH=true zengate
132
+ ```
133
+
134
+ The gateway warns at startup when auth is off. Keep `HOST=127.0.0.1` in that case: with auth off, any local process can use the API, and so can any web page if you enable CORS.
135
+
136
+ ## Use it from your client
137
+
138
+ Anything that accepts an OpenAI base URL and API key works: the official SDKs, LangChain, LlamaIndex, Open WebUI, Continue, Aider, LiteLLM and so on.
139
+
140
+ | Setting | Value |
141
+ |---|---|
142
+ | Base URL | `http://127.0.0.1:8083/v1` |
143
+ | API key | the `sk-zg-...` key from first start |
144
+ | Model | an id from `GET /v1/models`, for example `big-pickle` |
145
+
146
+ ```bash
147
+ export OPENAI_BASE_URL=http://127.0.0.1:8083/v1
148
+ export OPENAI_API_KEY=sk-zg-...
149
+ ```
150
+
151
+ **Python**
152
+
153
+ ```python
154
+ from openai import OpenAI
155
+ client = OpenAI() # reads the two variables above
156
+ r = client.chat.completions.create(model="big-pickle", messages=[{"role": "user", "content": "Hello!"}])
157
+ print(r.choices[0].message.content)
158
+ ```
159
+
160
+ **JavaScript / TypeScript**
161
+
162
+ ```js
163
+ import OpenAI from "openai";
164
+ const client = new OpenAI(); // reads the two variables above
165
+ const stream = await client.responses.create({ model: "big-pickle", input: "Write a haiku about gates.", stream: true });
166
+ for await (const event of stream) if (event.type === "response.output_text.delta") process.stdout.write(event.delta);
167
+ ```
168
+
169
+ **curl**
170
+
171
+ ```bash
172
+ curl http://127.0.0.1:8083/v1/chat/completions \
173
+ -H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
174
+ -d '{"model":"big-pickle","messages":[{"role":"user","content":"Hello!"}]}'
175
+ ```
176
+
177
+ ## API compatibility
178
+
179
+ | Endpoint | Status |
180
+ |---|---|
181
+ | `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 |
182
+ | `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` |
183
+ | `GET` / `DELETE /v1/responses/{id}` | Stored in memory for 1 hour, up to `RESPONSES_STORE_MAX` entries |
184
+ | `GET /v1/models`, `GET /v1/models/{id}` | Live list from OpenCode |
185
+ | `GET /health`, `GET /ready` | Public liveness and readiness probes |
186
+ | `GET /metrics` | Request counters and slot usage, as JSON (needs the key) |
187
+
188
+ 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).
189
+
190
+ **Inputs** (validated against each model's capabilities; a model that can't take an input returns `400 unsupported_modality`):
191
+
192
+ | Kind | Chat Completions | Responses |
193
+ |---|---|---|
194
+ | Text | `text` parts | `input_text` |
195
+ | Images | `image_url` (data URI or https) | `input_image` |
196
+ | Audio | `input_audio` (base64) | `input_audio` |
197
+ | Video | `video_url` | `input_video` |
198
+ | PDF / files | `file` (`file_data` data URI) | `input_file` (`file_data` / `file_url`) |
199
+
200
+ Text files (plain text, markdown, JSON, CSV and so on) are inlined as text, so every model can read them.
201
+
202
+ **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.
203
+
204
+ **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`.
205
+
206
+ ## Configuration
207
+
208
+ Set values as environment variables or in the config file (same names; see [`config.json.example`](config.json.example)). Environment variables win. Invalid values stop startup with a clear message.
209
+
210
+ | Setting | Default | Meaning |
211
+ |---|---|---|
212
+ | `API_KEY` / `API_KEYS` | generated | Gateway key(s), at least 16 characters. `API_KEYS` takes a list for rotation. |
213
+ | `ALLOW_NO_AUTH` | `false` | Serve without any key (explicit opt-out). |
214
+ | `HOST` / `PORT` | `127.0.0.1` / `8083` | Listen address. |
215
+ | `MAX_CONCURRENT` / `MAX_QUEUE` | `8` / `32` | Parallel generations, and how many requests may wait (then `429`). |
216
+ | `RATE_LIMIT_PER_MINUTE` | `120` | Per client IP (`0` turns it off). |
217
+ | `REQUEST_TIMEOUT_MS` | `300000` | Per generation (then `504`). |
218
+ | `MAX_BODY_MB` / `MAX_MEDIA_MB` | `25` / `20` | Request body limit, and the limit per attachment. |
219
+ | `RESPONSES_STORE_MAX` | `500` | Stored responses for `previous_response_id` (`0` turns storage off). |
220
+ | `CORS_ORIGINS` | none | Browser origins allowed to call the API (explicit list; `*` is refused). |
221
+ | `TRUST_PROXY` | `0` | Number of reverse-proxy hops to trust for client IPs. |
222
+ | `LOG_LEVEL` / `LOG_JSON` | `info` / `false` | Log verbosity, and JSON-lines output. |
223
+ | `OPENCODE_AGENT` | `plan` | The OpenCode agent each session uses. |
224
+ | `OPENCODE_PATH` | bundled | Use a different `opencode` binary. |
225
+ | `OPENCODE_SERVER_URL` | none | Attach to an existing `opencode serve` instead of starting one (see below). |
226
+ | `OPENCODE_SERVER_USERNAME` / `OPENCODE_SERVER_PASSWORD` | `opencode` / none | Basic auth for that server. |
227
+ | `ALLOW_INSECURE_BACKEND_HTTP` | `false` | Allow a non-loopback `http://` server URL. |
228
+ | `CONFIG_FILE` | see [First start](#first-start) | Where the config file lives (environment variable only). |
229
+
230
+ **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`.
231
+
232
+ ## Deployment
233
+
234
+ **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.
235
+
236
+ **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`.
237
+
238
+ **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.
239
+
240
+ ### Verifying releases
241
+
242
+ Docker images are signed with [cosign](https://github.com/sigstore/cosign) (keyless, from this repository's release workflow) and carry a CycloneDX SBOM attestation:
243
+
244
+ ```bash
245
+ cosign verify developingchet/zengate:1 \
246
+ --certificate-identity-regexp '^https://github.com/developingchet/zengate/.github/workflows/release.yml@refs/tags/v' \
247
+ --certificate-oidc-issuer https://token.actions.githubusercontent.com
248
+ ```
249
+
250
+ npm releases are published from GitHub Actions with [provenance](https://docs.npmjs.com/generating-provenance-statements); `npm audit signatures` checks it. Each GitHub release also lists SHA-256 checksums.
251
+
252
+ ## Security
253
+
254
+ - 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.
255
+ - The gateway binds to loopback by default and warns when it listens elsewhere, because traffic is plain HTTP, so put TLS in front.
256
+ - OpenCode tools are never executed, and OpenCode runs isolated from your home directory and configuration.
257
+ - 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.
258
+ - 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.
259
+ - Stored responses (`previous_response_id`, `GET /v1/responses/{id}`) are visible only to the API key that created them.
260
+ - The gateway sends no telemetry and never logs request bodies. OpenCode's auto-update and session sharing are disabled.
261
+
262
+ To report a vulnerability, see [SECURITY.md](SECURITY.md).
263
+
264
+ ## Troubleshooting
265
+
266
+ | Symptom | Fix |
267
+ |---|---|
268
+ | `401 invalid_api_key` | Send `Authorization: Bearer <key from the config file>`. |
269
+ | `404 model_not_found` | Use an id from `GET /v1/models`. The free model list changes over time. |
270
+ | `400 unsupported_modality` | That model can't take this input kind. Pick one that does. |
271
+ | `429 server_busy` / `rate_limit_exceeded` | Raise `MAX_CONCURRENT` / `MAX_QUEUE` / `RATE_LIMIT_PER_MINUTE`, or slow down. |
272
+ | `429 upstream_rate_limited`, `502 upstream_*` | OpenCode Zen is limiting or failing. Retry later or try another model. |
273
+ | `503 backend_unavailable` | OpenCode is still starting or restarting. Check `GET /ready`. Run with `LOG_LEVEL=debug` to see OpenCode's own logs. |
274
+ | Startup: `Port ... in use` | Another process has the port. Set `PORT`. |
275
+ | Startup: `could not be written` | The config folder is read-only. Set `API_KEY`, or point `CONFIG_FILE` somewhere writable. |
276
+
277
+ ## Development
278
+
279
+ ```bash
280
+ npm test # unit + integration tests (fake OpenCode server)
281
+ npm run test:coverage
282
+ npm run lint # syntax, file size and hygiene checks
283
+ GATEWAY_URL=http://127.0.0.1:8083/v1 API_KEY=sk-zg-... npm run test:live # real models
284
+ ```
285
+
286
+ Code map:
287
+ - `index.js` and `src/cli.js`: the `zengate` command.
288
+ - `src/opencode/`: backend supervisor, HTTP client, event hub, session runner and model catalog.
289
+ - `src/openai/`: request parsing, prompt building, streaming, and the Chat and Responses handlers.
290
+ - `src/server/`: Express app, auth, limits and errors.
291
+
292
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for how to propose changes and cut releases, and [CHANGELOG.md](CHANGELOG.md) for release notes.
293
+
294
+ ## License
295
+
296
+ MIT. zengate is an independent project, not affiliated with OpenCode or OpenAI.
@@ -0,0 +1,23 @@
1
+ {
2
+ "API_KEY": "",
3
+ "ALLOW_NO_AUTH": false,
4
+ "HOST": "127.0.0.1",
5
+ "PORT": 8083,
6
+ "MAX_CONCURRENT": 8,
7
+ "MAX_QUEUE": 32,
8
+ "RATE_LIMIT_PER_MINUTE": 120,
9
+ "REQUEST_TIMEOUT_MS": 300000,
10
+ "MAX_BODY_MB": 25,
11
+ "MAX_MEDIA_MB": 20,
12
+ "RESPONSES_STORE_MAX": 500,
13
+ "CORS_ORIGINS": [],
14
+ "TRUST_PROXY": 0,
15
+ "LOG_LEVEL": "info",
16
+ "LOG_JSON": false,
17
+ "OPENCODE_PATH": "",
18
+ "OPENCODE_SERVER_URL": "",
19
+ "OPENCODE_SERVER_USERNAME": "opencode",
20
+ "OPENCODE_SERVER_PASSWORD": "",
21
+ "ALLOW_INSECURE_BACKEND_HTTP": false,
22
+ "OPENCODE_AGENT": "plan"
23
+ }
package/index.js ADDED
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadOrProvisionConfig, securityWarnings } from './src/bootstrap.js';
6
+ import { HELP_TEXT, parseCommand, runSetup } from './src/cli.js';
7
+ import { ConfigError } from './src/config.js';
8
+ import { startGateway } from './src/gateway.js';
9
+ import { createLogger } from './src/logger.js';
10
+ import { defaultConfigPath } from './src/paths.js';
11
+
12
+ const root = path.dirname(fileURLToPath(import.meta.url));
13
+ const configPath = defaultConfigPath({ root });
14
+ const out = (line = '') => process.stdout.write(`${line}\n`);
15
+
16
+ /** Handle the non-server commands. Returns an exit code, or null to start the server. */
17
+ function runCommand(args) {
18
+ const { command, flags } = parseCommand(args);
19
+ if (command === 'serve') return null;
20
+ if (command === 'help') {
21
+ out(HELP_TEXT);
22
+ return 0;
23
+ }
24
+ if (command === 'version') {
25
+ out(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version);
26
+ return 0;
27
+ }
28
+ if (command === 'setup') {
29
+ try {
30
+ return runSetup({ configPath, flags, out });
31
+ } catch (error) {
32
+ process.stderr.write(`${error.message}\n`);
33
+ return 1;
34
+ }
35
+ }
36
+ process.stderr.write(`Unknown arguments: ${args.join(' ')}\n\n${HELP_TEXT}\n`);
37
+ return 2;
38
+ }
39
+
40
+ async function main() {
41
+ const exitCode = runCommand(process.argv.slice(2));
42
+ if (exitCode !== null) {
43
+ process.exitCode = exitCode;
44
+ return;
45
+ }
46
+ const bootLogger = createLogger({ level: process.env.LOG_LEVEL || 'info', json: /^(1|true|yes|on)$/i.test(process.env.LOG_JSON || '') });
47
+ let config;
48
+ try {
49
+ config = loadOrProvisionConfig({ configPath, logger: bootLogger });
50
+ } catch (error) {
51
+ bootLogger.error(error instanceof ConfigError ? error.message : `Could not load configuration: ${error.message}`);
52
+ process.exit(1);
53
+ }
54
+ const logger = createLogger({ level: config.LOG_LEVEL, json: config.LOG_JSON });
55
+ securityWarnings(config).forEach((warning) => logger.warn(warning));
56
+
57
+ let gateway;
58
+ try {
59
+ gateway = await startGateway(config, { logger });
60
+ } catch (error) {
61
+ logger.error(`Startup failed: ${error.message}`);
62
+ process.exit(1);
63
+ }
64
+ const { address, port } = gateway.address;
65
+ const host = address.includes(':') ? `[${address}]` : address;
66
+ logger.info(`OpenAI-compatible API on http://${host}:${port}/v1 (auth: ${config.ALLOW_NO_AUTH ? 'OFF' : 'API key'})`);
67
+
68
+ let shuttingDown = false;
69
+ const shutdown = (signal) => {
70
+ if (shuttingDown) {
71
+ process.exit(1);
72
+ }
73
+ shuttingDown = true;
74
+ logger.info(`${signal} received; finishing in-flight requests`);
75
+ gateway.stop().then(() => process.exit(0), (error) => {
76
+ logger.error('Shutdown error', { error: error.message });
77
+ process.exit(1);
78
+ });
79
+ };
80
+ process.on('SIGINT', () => shutdown('SIGINT'));
81
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
82
+ }
83
+
84
+ main();
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "zengate",
3
+ "version": "1.0.0",
4
+ "description": "Keyless OpenAI-compatible API (Chat Completions + Responses) for OpenCode's free Zen models, served through the real OpenCode CLI.",
5
+ "homepage": "https://github.com/developingchet/zengate#readme",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/developingchet/zengate.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/developingchet/zengate/issues"
12
+ },
13
+ "main": "index.js",
14
+ "type": "module",
15
+ "bin": {
16
+ "zengate": "index.js"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "src/",
21
+ "scripts/setup.mjs",
22
+ "config.json.example"
23
+ ],
24
+ "scripts": {
25
+ "start": "node index.js",
26
+ "setup": "node scripts/setup.mjs",
27
+ "lint": "node scripts/quality.mjs",
28
+ "test": "node --test \"test/**/*.test.mjs\"",
29
+ "test:coverage": "node --test --experimental-test-coverage \"test/**/*.test.mjs\"",
30
+ "test:live": "node scripts/live-e2e.mjs",
31
+ "verify": "npm run lint && npm test",
32
+ "audit": "npm audit --omit=dev --audit-level=high"
33
+ },
34
+ "keywords": [
35
+ "opencode",
36
+ "openai",
37
+ "zen",
38
+ "keyless",
39
+ "proxy",
40
+ "gateway",
41
+ "chat-completions",
42
+ "responses-api",
43
+ "openai-compatible",
44
+ "llm",
45
+ "api-gateway",
46
+ "self-hosted"
47
+ ],
48
+ "license": "MIT",
49
+ "engines": {
50
+ "node": ">=24.0.0"
51
+ },
52
+ "dependencies": {
53
+ "cors": "^2.8.5",
54
+ "express": "^5.1.0",
55
+ "opencode-ai": "^1.18.32"
56
+ }
57
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ // Gateway key helper (same as `zengate setup`).
3
+ // npm run setup create config.json with a key (keeps an existing key)
4
+ // npm run setup -- --rotate replace the key in config.json
5
+ // npm run setup -- --print only print a fresh key (for env vars / secret stores)
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { parseCommand, runSetup } from '../src/cli.js';
9
+ import { defaultConfigPath } from '../src/paths.js';
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const { command, flags } = parseCommand(['setup', ...process.argv.slice(2)]);
13
+
14
+ if (command !== 'setup') {
15
+ process.stderr.write(`Unknown option: ${process.argv.slice(2).join(' ')}. Use --rotate or --print.\n`);
16
+ process.exitCode = 2;
17
+ } else {
18
+ try {
19
+ process.exitCode = runSetup({ configPath: defaultConfigPath({ root }), flags, out: (line = '') => process.stdout.write(`${line}\n`) });
20
+ } catch (error) {
21
+ process.stderr.write(`${error.message}\n`);
22
+ process.exitCode = 1;
23
+ }
24
+ }
@@ -0,0 +1,66 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ConfigError, isLoopbackHost, loadConfig, needsApiKey, readConfigFile } from './config.js';
5
+
6
+ export const KEY_PREFIX = 'sk-zg-';
7
+
8
+ export function generateApiKey() {
9
+ return `${KEY_PREFIX}${crypto.randomBytes(24).toString('base64url')}`;
10
+ }
11
+
12
+ /** Write config.json with owner-only permissions (atomic rename), creating its directory if needed. */
13
+ export function writeConfigFile(filePath, data) {
14
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
15
+ const temp = `${filePath}.${process.pid}.tmp`;
16
+ fs.writeFileSync(temp, `${JSON.stringify(data, null, 4)}\n`, { mode: 0o600 });
17
+ fs.renameSync(temp, filePath);
18
+ }
19
+
20
+ /**
21
+ * Load the config, provisioning a gateway API key on first run so that a
22
+ * fresh `npm start` is secure by default without any manual setup.
23
+ * @param {{ configPath: string, env?: object, logger: object, print?: (line: string) => void }} options
24
+ * @returns {object} validated, frozen config
25
+ */
26
+ export function loadOrProvisionConfig({ configPath, env = process.env, logger, print = (line) => process.stdout.write(`${line}\n`) }) {
27
+ const fileConfig = readConfigFile(configPath);
28
+ const config = loadConfig(fileConfig, env);
29
+ if (!needsApiKey(config)) return config;
30
+
31
+ const key = generateApiKey();
32
+ try {
33
+ writeConfigFile(configPath, { ...fileConfig, API_KEY: key });
34
+ } catch (error) {
35
+ throw new ConfigError([
36
+ `no API_KEY is configured and ${configPath} could not be written (${error.code || error.message}).`,
37
+ 'Set API_KEY (at least 16 characters, e.g. from `zengate setup --print`) in the environment, or set CONFIG_FILE to a writable path,',
38
+ 'or set ALLOW_NO_AUTH=true to deliberately serve without a key.',
39
+ ]);
40
+ }
41
+ print('');
42
+ print(` Created a gateway API key (saved to ${configPath}):`);
43
+ print('');
44
+ print(` ${key}`);
45
+ print('');
46
+ print(' Use it as the OpenAI API key in your client. It is not shown again;');
47
+ print(' read it from that file, or run `zengate setup --rotate` to replace it.');
48
+ print('');
49
+ logger.debug('Provisioned a new API key', { configPath });
50
+ return loadConfig({ ...fileConfig, API_KEY: key }, env);
51
+ }
52
+
53
+ /** Startup warnings for configurations that are allowed but risky. */
54
+ export function securityWarnings(config) {
55
+ const warnings = [];
56
+ const exposed = !isLoopbackHost(config.HOST);
57
+ if (config.ALLOW_NO_AUTH) {
58
+ warnings.push(exposed
59
+ ? `ALLOW_NO_AUTH is on and HOST=${config.HOST}: anyone who can reach this port can use the gateway.`
60
+ : 'ALLOW_NO_AUTH is on: any local process or web page can use the gateway.');
61
+ }
62
+ if (exposed && !config.ALLOW_NO_AUTH) {
63
+ warnings.push(`Listening on ${config.HOST}. Traffic is plain HTTP; put TLS (a reverse proxy) in front before exposing it beyond a trusted network.`);
64
+ }
65
+ return warnings;
66
+ }
package/src/cli.js ADDED
@@ -0,0 +1,55 @@
1
+ import { generateApiKey, writeConfigFile } from './bootstrap.js';
2
+ import { readConfigFile } from './config.js';
3
+
4
+ const SETUP_FLAGS = new Set(['--rotate', '--print']);
5
+
6
+ export const HELP_TEXT = `zengate: an OpenAI-compatible API for OpenCode's free Zen models
7
+
8
+ Usage:
9
+ zengate start the gateway (creates an API key on first start)
10
+ zengate setup create config.json with an API key (keeps an existing key)
11
+ zengate setup --rotate replace the API key
12
+ zengate setup --print print a fresh key without saving it
13
+ zengate --version print the version
14
+ zengate --help show this help
15
+
16
+ Settings come from environment variables or config.json.
17
+ Docs: https://github.com/developingchet/zengate#readme`;
18
+
19
+ /**
20
+ * @param {string[]} args command-line arguments after the script name
21
+ * @returns {{ command: 'serve'|'setup'|'help'|'version'|'unknown', flags: string[] }}
22
+ */
23
+ export function parseCommand(args) {
24
+ const [first, ...rest] = args;
25
+ if (first === undefined) return { command: 'serve', flags: [] };
26
+ if (['--help', '-h', 'help'].includes(first)) return { command: 'help', flags: [] };
27
+ if (['--version', '-v', 'version'].includes(first)) return { command: 'version', flags: [] };
28
+ if (first === 'setup' && rest.every((flag) => SETUP_FLAGS.has(flag))) return { command: 'setup', flags: rest };
29
+ return { command: 'unknown', flags: args };
30
+ }
31
+
32
+ /**
33
+ * Create, keep or rotate the gateway API key in config.json.
34
+ * @param {{ configPath: string, flags: string[], out: (line?: string) => void }} options
35
+ * @returns {number} process exit code
36
+ */
37
+ export function runSetup({ configPath, flags, out }) {
38
+ if (flags.includes('--print')) {
39
+ out(generateApiKey());
40
+ return 0;
41
+ }
42
+ const current = readConfigFile(configPath);
43
+ if (current.API_KEY && !flags.includes('--rotate')) {
44
+ out(`${configPath} already has an API_KEY. Use --rotate to replace it.`);
45
+ return 0;
46
+ }
47
+ const key = generateApiKey();
48
+ writeConfigFile(configPath, { ...current, API_KEY: key });
49
+ out(`Saved a new API key to ${configPath}:`);
50
+ out();
51
+ out(` ${key}`);
52
+ out();
53
+ out('Restart the gateway if it is running.');
54
+ return 0;
55
+ }