You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2be4813
Browse filesBrowse the repository at this point in the historyBrowse files
feat: multi-user API keys with per-key usage metering (#22)
* feat: multi-user API keys with per-key usage metering and /admin/keys management
- API_KEYS (config.json array of {name, key}) / OPENCODE_API_KEYS env
(comma-separated name:key, bare keys auto-named key-N); duplicate
names or keys fail at startup
- Auth: any API_KEYS hit tags req.apiKeyName; requests otherwise fall
back to the legacy API_KEY, so single-key deployments are unchanged
- In-memory per-key usage registry: requests (including failures),
prompt/completion/total tokens for successful business requests on
/v1/chat/completions and /v1/responses (chars/4 estimation,
reasoning included in completion), lastUsedAt/createdAt
- Admin endpoints GET/POST/DELETE /admin/keys, mounted only when
API_KEYS is configured; admin auth = Bearer API_KEY (user keys 401,
no PUBLIC_DIAGNOSTICS bypass, 501 when API_KEY empty); keyHint shows
only the last 4 characters
- /health/details gains usage {byKey, total} and /metrics emits
opencode_proxy_key_requests_total / opencode_proxy_key_tokens_total
in multi-user mode only; 501 admin behavior covered by tests
- 35 new tests in tests/unit/multiuser.test.js (192 total)
* fix(test): mock http in multiuser suites so ensureBackend never hits the network
checkHealth() probes OPENCODE_SERVER_URL via http.get; the new suites
mocked only the SDK, so on any machine without a passwordless 1.x
backend on 127.0.0.1:10001 every business request hung in
ensureBackend() until the 25s lock timeout (13 tests failed / suite
hung ~5min). With http mocked: 40/40 in ~3s, matching app.test.js.
* fix(multiuser): validate key names and scope diagnostics per caller role
* fix(multiuser): constant-time user key lookup, reject API_KEY collisions, and enforce auth when API_KEY is empty
* test(multiuser): reset sessionPrompt mock in okSession helper
Copy file name to clipboardExpand all lines: CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
10
10
### Added
11
11
12
+
- **Multi-User API Keys & Per-Key Usage (`API_KEYS` / `OPENCODE_API_KEYS`)**: configure a list of named user keys — a `[{name, key}]` array in config.json or a comma-separated `name:key` string in the environment (bare keys are auto-named `key-N`; duplicates fail at startup). Requests matching any user key authenticate like `API_KEY` and are metered per name in an in-memory registry (`requests`, prompt/completion/total tokens using the existing chars/4 estimation, `lastUsedAt`; failed requests count toward `requests` but never tokens). New admin endpoints `GET`/`POST`/`DELETE /admin/keys` (mounted once `API_KEYS` is set, admin-authenticated with the legacy `API_KEY` — user keys get 401, no `PUBLIC_DIAGNOSTICS` bypass, `501` when `API_KEY` is empty) let operators hot-add or revoke keys at runtime; `GET` responses expose only a `keyHint` with the key's last 4 characters. `/health/details` gains a `usage: {byKey, total}` section and `/metrics` emits `opencode_proxy_key_requests_total{key}` / `opencode_proxy_key_tokens_total{key,type}` in multi-user mode only — single-key deployments keep their exact previous behavior and snapshot shapes.
12
13
-**Anthropic Messages API**: `POST /v1/messages` implements the Anthropic Messages protocol (Claude SDK compatible) on top of the same OpenCode backend — `x-api-key` or Bearer auth, `system` (string or blocks), text/image/tool_use/tool_result content blocks, `tools` with `input_schema`, `tool_choice`, `stop_sequences`, and Anthropic-shaped errors (`{type:'error',error:{type,message}}`). Tool calls surface as `tool_use` content blocks with the original client tool names (no `external__` namespace). Streaming (`stream: true`) emits the Anthropic SSE sequence — `message_start`, `content_block_start/delta/stop` (`thinking_delta`, `text_delta`, `input_json_delta`), `message_delta`, `message_stop` — with keepalives and polling fallback, mirroring the chat-completions pipeline including transient-error retry. Documented in both READMEs; covered by `tests/unit/anthropic.test.js`.
13
14
- **Built-in Web Panel (`WEB_PANEL_ENABLED` / `OPENCODE_WEB_PANEL_ENABLED`, default off)**: An optional read-only dashboard served by the proxy itself. `GET /panel` returns a self-contained dark-themed HTML page (zero external assets, CSP locked to same-origin, inline style/script only) showing runtime status (status, uptime, backend URL), the model list, and backend diagnostics from `/health/details` (tool-mode counters, tool ID cache). The page fetches `GET /panel/data.json` — a JSON feed assembled from the models list, the `/health/details` payload and `process.uptime()` — with a Bearer key kept in the browser's localStorage. Auth matches the diagnostics endpoints: with `API_KEY` set, `data.json` requires the Bearer key (the HTML shell is public but carries no data), and `PUBLIC_DIAGNOSTICS=true` or an empty `API_KEY` lifts auth. When disabled, both routes 404 and cost nothing. The feed never returns `OPENCODE_SERVER_PASSWORD`, `ZEN_API_KEY`, `API_KEY` or `API_KEYS` values, and responses carry `X-Content-Type-Options: nosniff` plus `Cache-Control: no-store`. Added `tests/unit/panel.test.js` (10 tests: disabled 404s, headers, 401 gating, secret-leak assertions, public/no-key modes) and full configuration docs.
|`POST`|`/v1/messages`| Anthropic Messages API (Claude SDK compatible) |
194
+
|`GET`|`/admin/keys`| Multi-user key list and usage (admin auth, available once `API_KEYS` is configured) |
195
+
|`POST`|`/admin/keys`| Add a user key at runtime (admin auth) |
196
+
|`DELETE`|`/admin/keys/:name`| Remove a user key (admin auth) |
193
197
194
198
Model names: `opencode/big-pickle`, `gpt5-nano` (auto-resolved to `gpt-5-nano`), `opencode/gpt5-nano`.
195
199
196
200
> 📖 See [API Reference](./docs/en/api-reference.md)
201
+
>
202
+
> 👥 Full details on multi-user keys, per-key usage metering, and the admin endpoints: [Configuration](./docs/en/configuration.md#-multi-user-api-keys).
|`API_KEY`|`API_KEY`| (empty) | Proxy Bearer key, no auth when unset |
18
+
|`API_KEY`|`API_KEY`| (empty) | Proxy Bearer key (also the admin credential for `/admin/keys`), no auth when unset |
19
+
|`OPENCODE_API_KEYS`|`API_KEYS`| (empty) | Multi-user API key list: a comma-separated `name:key` string for env, a `[{name, key}]` array for config.json. See [Multi-user API Keys](#-multi-user-api-keys)|
19
20
|`OPENCODE_PROXY_MANAGE_BACKEND`|`MANAGE_BACKEND`|`true`| Proxy starts and manages the OpenCode backend process, which loads the tool-lock plugin |
|`OPENCODE_ZEN_API_KEY`|`ZEN_API_KEY`| (empty) | Zen API key, passed to the managed backend as `OPENCODE_API_KEY` for paid models |
@@ -57,6 +58,10 @@ Env vars use the `OPENCODE_` prefix; config.json uses the short names (see table
57
58
{
58
59
"PORT": 10000,
59
60
"API_KEY": "your-secret-api-key",
61
+
"API_KEYS": [
62
+
{ "name": "alice", "key": "sk-alice-..." },
63
+
{ "key": "sk-bare-key-..." }
64
+
],
60
65
"BIND_HOST": "0.0.0.0",
61
66
"DISABLE_TOOLS": true,
62
67
"INTERNAL_ALLOWED_TOOLS": ["web_fetch"],
@@ -107,6 +112,115 @@ When a request has no `tools`, `opencode.internal_allowed_tools` in the request
107
112
}
108
113
```
109
114
115
+
## 👥 Multi-user API Keys
116
+
117
+
Besides the single `API_KEY`, you can configure a set of **named user keys** (`API_KEYS`) for multi-user distribution with per-key usage metering. Both can be configured at once:
118
+
119
+
- A request matching any `API_KEYS` entry authenticates and is metered under that key's name;
120
+
- Otherwise the request falls back to `API_KEY` (existing single-key deployments are unchanged);
121
+
- Both empty → no auth (current behavior).
122
+
123
+
### Configuration formats
124
+
125
+
`OPENCODE_API_KEYS` (env) is a **comma-separated `name:key`** string (`=` also works as the separator):
config.json uses an `API_KEYS` array of `{name, key}` entries (`name` optional):
132
+
133
+
```json
134
+
{
135
+
"API_KEYS": [
136
+
{ "name": "alice", "key": "sk-alice-xxxx" },
137
+
{ "key": "sk-bare-key-xxxx" }
138
+
]
139
+
}
140
+
```
141
+
142
+
**Name semantics**:
143
+
144
+
- Bare keys without a name are auto-named `key-N` (N = 1-based position of the entry in the list);
145
+
- Names are the primary key for usage metering and `/admin/keys` and must be unique; key values must be unique too;
146
+
- Duplicate names or duplicate keys **fail at startup** with an error instead of running misconfigured.
147
+
148
+
### Authentication behavior
149
+
150
+
- User keys carry exactly the same business-endpoint access (`/v1/chat/completions`, `/v1/responses`, ...) as `API_KEY`;
151
+
-`/health/details` and `/metrics` auth semantics are unchanged (Bearer by default, public with `OPENCODE_PUBLIC_DIAGNOSTICS=true`; user keys and `API_KEY` both pass). In multi-user mode, the admin `API_KEY` sees all keys in `usage.byKey` / per-key metrics, a user key sees only its own entry, and unauthenticated `OPENCODE_PUBLIC_DIAGNOSTICS=true` requests see only the aggregate `usage.total` (no key names are exposed publicly);
152
+
- Deleting (revoking) a user key makes that key's requests fail with 401 immediately.
153
+
154
+
### Admin endpoints (/admin/keys)
155
+
156
+
Mounted only when at least one `API_KEYS` entry is configured. Every endpoint requires the **admin credential = `Bearer API_KEY`** (the legacy single admin key — user keys always get 401), and there is no `OPENCODE_PUBLIC_DIAGNOSTICS` bypass. With an empty `API_KEY` the admin surface answers `501` (unavailable).
157
+
158
+
| Method | Path | Description |
159
+
|:-----|:-----|:-----|
160
+
|`GET`|`/admin/keys`| List all user keys with their usage |
161
+
|`POST`|`/admin/keys`| Hot-add a key at runtime, body `{"name": "carol", "key": "sk-..."}`; duplicate name or key → `400`|
162
+
|`DELETE`|`/admin/keys/:name`| Remove a key; unknown name → `404`, success → `204`|
163
+
164
+
`GET /admin/keys` response example (`keyHint` shows only the last 4 characters; the full key is never retrievable):
165
+
166
+
```json
167
+
[
168
+
{
169
+
"name": "alice",
170
+
"keyHint": "…xxxx",
171
+
"requests": 12,
172
+
"promptTokens": 8451,
173
+
"completionTokens": 2210,
174
+
"totalTokens": 10661,
175
+
"lastUsedAt": 1789000000000,
176
+
"createdAt": 1788900000000
177
+
}
178
+
]
179
+
```
180
+
181
+
### Usage accounting
182
+
183
+
Usage lives in memory and resets on process restart (no persistence, by design):
184
+
185
+
-**requests**: requests that authenticated and entered a business handler (`/v1/chat/completions`, `/v1/responses`); failed requests (4xx/5xx) also count;
186
+
-**promptTokens / completionTokens**: accumulated for successful requests only, using the same `Math.ceil(chars / 4)` estimation as the response usage field; completionTokens include reasoning tokens;
187
+
-**lastUsedAt / createdAt**: millisecond timestamps of the last use and of key creation (process start or runtime add).
188
+
189
+
`/health/details` gains a `usage` field in multi-user mode (absent for single-key deployments, preserving the legacy snapshot shape):
190
+
191
+
```json
192
+
{
193
+
"status": "ok",
194
+
"proxy": true,
195
+
"usage": {
196
+
"byKey": {
197
+
"alice": {
198
+
"requests": 12,
199
+
"promptTokens": 8451,
200
+
"completionTokens": 2210,
201
+
"totalTokens": 10661,
202
+
"lastUsedAt": 1789000000000,
203
+
"createdAt": 1788900000000
204
+
}
205
+
},
206
+
"total": {
207
+
"requests": 12,
208
+
"promptTokens": 8451,
209
+
"completionTokens": 2210,
210
+
"totalTokens": 10661
211
+
}
212
+
}
213
+
}
214
+
```
215
+
216
+
`/metrics` emits per-key Prometheus counters in multi-user mode:
0 commit comments