opencode-plugboard 0.17.0 → 0.19.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 CHANGED
@@ -4,7 +4,11 @@
4
4
  [![license](https://img.shields.io/npm/l/opencode-plugboard)](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
5
5
  [![CI](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml/badge.svg)](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
6
6
 
7
- An OpenCode V2 plugin that turns your OpenCode server into an OpenAI-, Anthropic- and Gemini-compatible API, so every other app reuses the provider logins you already made in OpenCode. Point Claude Code at a GPT model, or n8n at your Claude subscription.
7
+ Plugboard is an OpenCode V2 plugin. It turns your OpenCode server into one HTTP API that OpenAI, Anthropic and Gemini compatible apps can call with their own key.
8
+
9
+ It is for people who run several tools, such as a Discord bot, n8n and Open WebUI, and want them to share the provider logins already in OpenCode (Claude, ChatGPT, Copilot, OpenCode Go) without giving each one a provider key.
10
+
11
+ OpenCode never runs your tools.
8
12
 
9
13
  ```
10
14
  client ──▶ plugboard listener ──▶ OpenCode session ──▶ provider
@@ -12,7 +16,16 @@ client ──▶ plugboard listener ──▶ OpenCode session ──▶ provide
12
16
  └──── reply; tool calls go back to the caller ◀──────────┘
13
17
  ```
14
18
 
15
- Tools like n8n, Open WebUI or a Discord bot only speak the OpenAI API, so each wants its own key or its own gateway. Plugboard serves OpenAI Chat Completions (`/v1/chat/completions`), the Responses API (`/v1/responses`), the Anthropic Messages API (`/v1/messages`), the Gemini API (`/v1beta/models/{model}:generateContent`), legacy completions (`/v1/completions`) and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
19
+ ## Endpoints
20
+
21
+ | API | Endpoint | Typical clients |
22
+ | --- | --- | --- |
23
+ | OpenAI Chat Completions | `POST /v1/chat/completions` | OpenAI SDKs, n8n, Open WebUI, LangChain, most apps |
24
+ | OpenAI Responses | `POST /v1/responses` | `openai` SDK `responses.create`, Vercel AI SDK `openai(id)` |
25
+ | Anthropic Messages | `POST /v1/messages`, `POST /v1/messages/count_tokens` | Anthropic SDKs, Claude Code |
26
+ | Gemini | `POST /v1beta/models/{model}:generateContent`, `:streamGenerateContent`, `:countTokens` | `@google/genai`, Gemini SDKs |
27
+ | Legacy completions | `POST /v1/completions` | Older scripts |
28
+ | Models | `GET /v1/models`, `GET /v1/models/{id}` | Any client that lists models |
16
29
 
17
30
  ## Quick start
18
31
 
@@ -24,13 +37,13 @@ Tested on OpenCode 2.0.26. The peer range `>=2.0.26 <2.1` allows 2.0.x patch rel
24
37
  export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
25
38
  ```
26
39
 
27
- **2. Install it** in your global config (`~/.config/opencode/opencode.jsonc`). It has to be global, see [History](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/history.md). OpenCode installs it from npm the first time it loads.
40
+ **2. Install it** in your global config, `~/.config/opencode/opencode.jsonc`. Keep it global: every project loads it, and a per-project copy is not needed. OpenCode installs it from npm the first time it loads.
28
41
 
29
42
  ```jsonc
30
43
  {
31
44
  "plugins": [
32
45
  {
33
- "package": "opencode-plugboard@0.17.0",
46
+ "package": "opencode-plugboard@0.19.0",
34
47
  "options": {
35
48
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
36
49
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -41,7 +54,9 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
41
54
  }
42
55
  ```
43
56
 
44
- Every alias target must also be allowed in `models`. Pin the version as shown. A bare `"opencode-plugboard"` means `latest`, so a cache refresh or `opencode plugin update` can pull code you have not read. A global plugin runs inside OpenCode in every project, next to your provider logins, so update the number on purpose after reading the [changelog](https://github.com/jjjermiah/opencode-plugboard/blob/main/CHANGELOG.md).
57
+ Every alias target must also be allowed in `models`.
58
+
59
+ Keep the version pinned. A bare `"opencode-plugboard"` means `latest`, so a cache refresh can load code you have not read. Change the number on purpose, after reading the [changelog](https://github.com/jjjermiah/opencode-plugboard/blob/main/CHANGELOG.md).
45
60
 
46
61
  **3. Restart OpenCode, then touch the server once.** OpenCode loads no plugin until a client connects, so the port stays closed until then:
47
62
 
@@ -49,9 +64,9 @@ Every alias target must also be allowed in `models`. Pin the version as shown. A
49
64
  opencode api get /api/agent
50
65
  ```
51
66
 
52
- For an always-on server, run that from the service unit, see [warm-up](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/warm-up.md).
67
+ For an always-on server, run that from the service unit. See [warm-up](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/warm-up.md).
53
68
 
54
- **4. Call it.** The endpoint listens on `127.0.0.1:4010`. This prints `pong`:
69
+ **4. Call it.** The listener is on `127.0.0.1:4010`. This prints `pong`:
55
70
 
56
71
  ```sh
57
72
  curl -s http://127.0.0.1:4010/v1/chat/completions \
@@ -61,58 +76,462 @@ curl -s http://127.0.0.1:4010/v1/chat/completions \
61
76
  | jq -r '.choices[0].message.content'
62
77
  ```
63
78
 
64
- Any OpenAI SDK works by changing the base URL (`http://127.0.0.1:4010/v1`) and the key. Next:
79
+ The same call with the OpenAI Python SDK. Only the base URL and the key change:
80
+
81
+ ```python
82
+ import os
83
+ from openai import OpenAI
84
+
85
+ client = OpenAI(base_url="http://127.0.0.1:4010/v1", api_key=os.environ["PLUGBOARD_KEY_MY_APP"])
86
+ r = client.chat.completions.create(model="fast", messages=[{"role": "user", "content": "reply with exactly: pong"}])
87
+ print(r.choices[0].message.content)
88
+ ```
89
+
90
+ Next: [client setup](#clients), the [features](#features) below, or the [configuration reference][configuration].
91
+
92
+ ## Features
93
+
94
+ Each feature lists a minimal config. Every option goes under `options` in the plugin entry. The full list is in the [configuration reference][configuration].
95
+
96
+ ### Access and policy
97
+
98
+ #### Apps and keys
99
+
100
+ Give every tool its own key. Traffic, history and usage are then separated by app, and revoking one tool means removing one line.
101
+
102
+ ```jsonc
103
+ "keys": {
104
+ "discord-bot": "{env:PLUGBOARD_KEY_DISCORD}",
105
+ "n8n": "{env:PLUGBOARD_KEY_N8N}",
106
+ "open-webui": "{env:PLUGBOARD_KEY_WEBUI}"
107
+ }
108
+ ```
109
+
110
+ Callers send the key as `Authorization: Bearer <key>` (or `x-api-key` on the Anthropic routes). They never need the OpenCode server password. Removing a key takes effect on the running server at once.
111
+
112
+ #### Model allowlist
113
+
114
+ Nothing is exposed by accident. A model is callable only if `models` lists it, and `/v1/models` shows only those.
115
+
116
+ ```jsonc
117
+ "models": [
118
+ "anthropic/*", // every Claude model
119
+ "openai/gpt-5.6-luna#low", // one ChatGPT variant
120
+ "github-copilot/*", // every Copilot model
121
+ "opencode-go/glm-5.3-flash" // one exact model
122
+ ]
123
+ ```
124
+
125
+ A model outside the list gets a 404:
126
+
127
+ ```json
128
+ {"error": {"message": "Model openai/gpt-5 is not available", "code": "model_not_found", "type": "not_found_error", "param": null}}
129
+ ```
130
+
131
+ #### Per-app policy
132
+
133
+ `apps` fixes an app's behaviour on the server, so the app does not have to be trusted with it. Each policy can narrow the model list, add or replace the system prompt, drop tools, or lower the tool cap.
134
+
135
+ ```jsonc
136
+ "apps": {
137
+ "discord-bot": { "models": ["fast"], "system": "Answer in two sentences.", "tools": false },
138
+ "coding": { "models": ["anthropic/*"], "maxTools": 16 }
139
+ }
140
+ ```
141
+
142
+ The `discord-bot` app can only use `fast`, gets the system prompt added, and has its tools removed. The request still succeeds, and the response says what changed:
143
+
144
+ ```
145
+ x-plugboard-policy: tools-stripped
146
+ ```
147
+
148
+ `systemMode` is `prepend` (the default), `append` or `replace`. An app with a policy still needs its key in `keys` (or `apps.<name>.key`).
149
+
150
+ ### Routing
151
+
152
+ #### Aliases
153
+
154
+ An alias is a short name a caller can use as `model`. Change the target in one line, and every app that uses the alias moves with it.
155
+
156
+ ```jsonc
157
+ "aliases": {
158
+ "smart": "anthropic/claude-sonnet-4-6",
159
+ "fast": "opencode-go/glm-5.3-flash"
160
+ }
161
+ ```
162
+
163
+ ```sh
164
+ curl -s -D - http://127.0.0.1:4010/v1/chat/completions -H "Authorization: Bearer $KEY" \
165
+ -H "Content-Type: application/json" -d '{"model": "fast", "messages": [{"role": "user", "content": "hi"}]}'
166
+ # ...
167
+ # x-plugboard-model: opencode-go/glm-5.3-flash
168
+ ```
169
+
170
+ The `x-plugboard-model` header names the model that actually answered.
171
+
172
+ #### Fallback chains
173
+
174
+ Point an alias at a list. If the first target fails before it sends any output, plugboard tries the next one.
175
+
176
+ ```jsonc
177
+ "aliases": {
178
+ "fast": ["opencode-go/glm-5.3-flash", "anthropic/claude-haiku-4-5"]
179
+ }
180
+ ```
181
+
182
+ A rate limit, quota error, provider outage or timeout moves the request on. Once any text or tool call has reached the client, a failure is not retried, so a partly streamed answer is never restarted. A retried answer shows the change in headers:
65
183
 
66
- - [Clients](#clients): config for Python, JS, Vercel AI SDK, LangChain, pydantic-ai, Open WebUI, n8n.
67
- - [Configuration](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md): options, models, keys, errors.
68
- - [Reaching plugboard from another host or container](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md#reaching-plugboard-from-another-host-or-container).
69
- - [History](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/history.md): every request is kept as an OpenCode session.
70
- - [Architecture](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/architecture.md) and [development](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/development.md).
184
+ ```
185
+ x-plugboard-model: anthropic/claude-haiku-4-5
186
+ x-plugboard-fallbacks: 1
187
+ ```
71
188
 
72
- `GET /healthz` returns OK when the listener is up and needs no key.
189
+ Every target must pass `models`, or the plugin refuses to load.
73
190
 
74
- ## What this is not
191
+ #### UI-safe model ids
75
192
 
76
- Plugboard is early (0.x) and narrow on purpose.
193
+ Some chat UIs break on the `/` and `#` in model ids. `slugs` lists a UI-safe spelling, and requests accept either spelling.
77
194
 
78
- - **V2 only.** OpenCode V2 has no OpenAI endpoint, and the V1 plugins that had one stopped working when the plugin API changed. V1 is not planned.
79
- - **Two formats:** Chat Completions and Responses. `/v1/responses` is a stateless subset, see [docs/responses.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md). Anthropic Messages and Gemini front-ends are on the [roadmap](#roadmap).
80
- - **Not planned:** embeddings, audio, image generation, `logprobs`.
81
- - **Not a distributed gateway.** Limits, the response cache and metrics live in one OpenCode server process; two servers don't share them.
82
- - **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
195
+ ```jsonc
196
+ "modelIds": "both" // canonical and slug, for each model
197
+ ```
198
+
199
+ With `slugs`, `anthropic/claude-haiku-4-5#high` is listed as `anthropic--claude-haiku-4-5__high`. Models whose ids contain `--` have no unambiguous slug, so they stay listed under their canonical id.
200
+
201
+ To list only the models that can do something, filter the catalog:
202
+
203
+ ```sh
204
+ curl -s "http://127.0.0.1:4010/v1/models?capability=vision" -H "Authorization: Bearer $KEY"
205
+ ```
83
206
 
84
- How it differs from a standalone gateway:
207
+ `capability` is `vision` or `tools`. Any other value is rejected.
85
208
 
86
- - [LiteLLM](https://github.com/BerriAI/litellm) is a separate gateway you run and give its own provider keys. It covers many more providers and API shapes. Pick it when you want a standalone service. Pick plugboard when OpenCode already holds the logins you want to reuse, such as a ChatGPT or Copilot subscription. Plugboard also gives each app its own key and history, an explicit model allowlist, and rules that keep it from ever freezing the host.
209
+ ### Limits and caching
210
+
211
+ #### Concurrency and rate limits
212
+
213
+ `limits` caps how many model calls run at once, and how many an app may start per minute. Nothing queues. A call over a cap gets a 429 at once, so the provider never sees it.
214
+
215
+ ```jsonc
216
+ "limits": {
217
+ "maxConcurrent": 8,
218
+ "perApp": { "maxConcurrent": 2, "requestsPerMinute": 30 },
219
+ "apps": { "coding": { "maxConcurrent": 4 } }
220
+ }
221
+ ```
222
+
223
+ `coding` gets four calls at once and still 30 per minute, because `apps.<name>` overrides `perApp` field by field. A refused call carries `Retry-After`:
224
+
225
+ ```
226
+ HTTP/1.1 429 Too Many Requests
227
+ retry-after: 12
228
+
229
+ {"error": {"message": "Too many requests per minute", "code": "rate_limit_exceeded", "type": "rate_limit_error", "param": null}}
230
+ ```
231
+
232
+ A full concurrency cap returns `too_many_requests` with `retry-after: 1`. The limits live in the server process, so a restart clears them.
233
+
234
+ #### Response cache
235
+
236
+ Pollers and bots often send the same prompt again and again. With `cache`, an identical request from the same app gets the stored answer. No session is created and no provider call is made.
237
+
238
+ ```jsonc
239
+ "cache": { "ttlSeconds": 300, "apps": ["discord-bot"] }
240
+ ```
241
+
242
+ Every answer says whether it came from the cache:
243
+
244
+ ```
245
+ x-plugboard-cache: hit
246
+ ```
87
247
 
88
- The most common first failure is an SDK defaulting to `/v1/responses` for newer models. Plugboard serves it, but some clients need `store: false` or a "use chat completions" switch. Check the client's page below.
248
+ Only normal text answers are kept. Answers with tool calls, errors, structured output and `n` above 1 are not. With `temperature` above 0, a hit repeats the first answer, so limit the cache to apps that want that. The cache is in memory and a restart empties it.
249
+
250
+ ### Requests and responses
251
+
252
+ #### Streaming
253
+
254
+ Set `stream: true` and read the server-sent events. Ask for usage in the last chunk with `stream_options`.
255
+
256
+ ```sh
257
+ curl -N http://127.0.0.1:4010/v1/chat/completions -H "Authorization: Bearer $KEY" \
258
+ -H "Content-Type: application/json" \
259
+ -d '{"model": "fast", "stream": true, "stream_options": {"include_usage": true},
260
+ "messages": [{"role": "user", "content": "count to three"}]}'
261
+ ```
262
+
263
+ If the stream fails after it has started, the client gets an `error` event and then `data: [DONE]`.
264
+
265
+ #### Tools
266
+
267
+ Plugboard returns the model's tool calls to the caller and never runs them. The caller runs the tool and sends the result back, which is the normal Chat Completions loop.
268
+
269
+ ```json
270
+ {"choices": [{"finish_reason": "tool_calls", "message": {"role": "assistant", "tool_calls": [
271
+ {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]}}]}
272
+ ```
273
+
274
+ Send the answer back as a `{"role": "tool", "tool_call_id": "call_1", "content": "..."}` message. Tool call ids differ by model, so do not parse them. `maxTools` (default 32) caps the tools one request may carry.
275
+
276
+ #### Structured output
277
+
278
+ `response_format` with `json_schema` returns the arguments as the message content. The model is asked to answer through a `respond` tool. The provider does not enforce the schema, so validate the result.
279
+
280
+ ```sh
281
+ curl -s http://127.0.0.1:4010/v1/chat/completions -H "Authorization: Bearer $KEY" \
282
+ -H "Content-Type: application/json" -d '{
283
+ "model": "fast",
284
+ "messages": [{"role": "user", "content": "Extract: Ada, 36, London"}],
285
+ "response_format": {"type": "json_schema", "json_schema": {"name": "person", "schema": {
286
+ "type": "object",
287
+ "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
288
+ "required": ["name", "age"]}}}
289
+ }' | jq -r '.choices[0].message.content'
290
+ ```
291
+
292
+ `json_object` also works, but it carries no schema. Name the keys in the prompt.
293
+
294
+ #### Images
295
+
296
+ Send images as `data:` URLs, which always work. Plain `http(s)` URLs are fetched by the server only when you allow it.
297
+
298
+ ```jsonc
299
+ "remoteImages": { "allow": ["images.internal"] } // or true for public addresses only
300
+ ```
301
+
302
+ With `true` or an `allow` list, the server fetches only public addresses by default, and refuses loopback, private, link-local and CGNAT ranges. Turn it on only if you need it, because it makes the server fetch URLs that callers send.
303
+
304
+ #### Multiple choices (`n`)
305
+
306
+ `n` above 1 returns several answers from one request. Each choice is a full model call, so `n: 3` costs three times as much.
307
+
308
+ ```jsonc
309
+ "maxN": 4 // the most calls one request may fan out to; default 1
310
+ ```
311
+
312
+ Above the cap the request gets a 400 `too_many_choices`. `n` works on Chat Completions and on `/v1/completions`.
313
+
314
+ #### Token counting
315
+
316
+ Clients that budget context before they send, such as Claude Code and the Anthropic and Gemini SDKs, can count tokens first. The count is an estimate, because plugboard has no tokenizer.
317
+
318
+ ```sh
319
+ curl -s http://127.0.0.1:4010/v1/messages/count_tokens -H "Authorization: Bearer $KEY" \
320
+ -H "Content-Type: application/json" -d '{"model": "fast", "messages": [{"role": "user", "content": "hello"}]}'
321
+ # {"input_tokens": 12}
322
+ ```
323
+
324
+ #### Dropped sampling parameters
325
+
326
+ Some models reject `temperature`, `top_p` or the penalties. Plugboard drops the ones it knows a model refuses, and lists them in a header, so the request still succeeds.
327
+
328
+ ```
329
+ x-plugboard-dropped-params: temperature,top_p
330
+ ```
331
+
332
+ Models plugboard does not know keep every parameter, and the provider's own 400 comes back. Set `"dropUnsupportedParams": false` to forward everything as sent.
333
+
334
+ ### Operations
335
+
336
+ #### Usage statistics
337
+
338
+ Usage is recorded per app and per UTC day: requests, errors, latency, tokens, estimated cost, cache hits, fallbacks and 429 refusals. It is on by default. An admin key reads every app, and an app key reads only its own.
339
+
340
+ ```jsonc
341
+ "adminKey": "{env:PLUGBOARD_ADMIN_KEY}", // reads every app; not a model key
342
+ "usage": { "retentionDays": 90 } // the default; { "enabled": false } turns it off
343
+ ```
344
+
345
+ ```sh
346
+ curl -s "http://127.0.0.1:4010/v1/usage?from=2026-10-01&to=2026-10-11" \
347
+ -H "Authorization: Bearer $PLUGBOARD_ADMIN_KEY"
348
+ ```
349
+
350
+ A trimmed response:
351
+
352
+ ```json
353
+ {"from": "2026-10-01", "to": "2026-10-11",
354
+ "totals": {"requests": 40, "tokens": {"input": 8120, "output": 2600}, "costUsd": 0.12, "cacheHits": 9},
355
+ "apps": {"discord-bot": {"totals": {"requests": 31}, "models": {"opencode-go/glm-5.3-flash": {"requests": 31}}}}}
356
+ ```
357
+
358
+ Cost is an estimate at list prices, so use it to compare apps, not as a bill. The same report is available to OpenCode clients through the `opencode-plugboard/rpc` entry. Counts reach storage every ten seconds, so a restart can lose the last few seconds.
359
+
360
+ #### History
361
+
362
+ Every request is kept as an OpenCode session in a directory for its app, so it never mixes with your projects. You can read what each app asked and what the model answered. Chat responses carry the session id as `x-plugboard-session`.
363
+
364
+ ```jsonc
365
+ "history": { "maxSessions": 500, "maxAgeDays": 30 } // per app; the stricter limit wins
366
+ ```
367
+
368
+ Without `history`, sessions are kept forever.
369
+
370
+ #### Metrics and logs
371
+
372
+ Prometheus metrics are off by default. Turn them on, and get structured logs when a pipeline reads them.
373
+
374
+ ```jsonc
375
+ "metrics": true,
376
+ "logFormat": "json" // one object per line; ids and outcomes only, never keys or bodies
377
+ ```
378
+
379
+ ```sh
380
+ curl -s http://127.0.0.1:4010/metrics -H "Authorization: Bearer $KEY" | grep plugboard_requests_total
381
+ ```
382
+
383
+ Every response has an `x-plugboard-request-id`, the same id that appears on that request's log lines. Useful log events include `request.finished`, `request.limited` and `model.fallback`. The metrics have no model label, so their cardinality stays bounded.
384
+
385
+ ## Full example
386
+
387
+ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most of the features above:
388
+
389
+ ```jsonc
390
+ {
391
+ "plugins": [
392
+ {
393
+ "package": "opencode-plugboard@0.19.0",
394
+ "options": {
395
+ // Reachable from the LAN. Non-loopback hosts refuse to start without keys.
396
+ "listen": { "host": "10.0.1.50", "port": 4010 },
397
+
398
+ // One key per app. Each app's history, usage and limits are separate.
399
+ "keys": {
400
+ "discord-bot": "{env:PLUGBOARD_KEY_DISCORD}",
401
+ "n8n": "{env:PLUGBOARD_KEY_N8N}",
402
+ "open-webui": "{env:PLUGBOARD_KEY_WEBUI}",
403
+ "coding": "{env:PLUGBOARD_KEY_CODING}"
404
+ },
405
+ "adminKey": "{env:PLUGBOARD_ADMIN_KEY}",
406
+
407
+ // What may be called at all. Everything else is a 404.
408
+ "models": ["anthropic/*", "github-copilot/*", "openai/gpt-5.6-luna#low", "opencode-go/glm-5.3-flash"],
409
+
410
+ // Short names. A list is a fallback chain; every target must pass `models`.
411
+ "aliases": {
412
+ "smart": "anthropic/claude-sonnet-4-6",
413
+ "fast": ["opencode-go/glm-5.3-flash", "anthropic/claude-haiku-4-5"]
414
+ },
415
+
416
+ // Per-app rules, enforced whatever the app sends.
417
+ "apps": {
418
+ "discord-bot": { "models": ["fast"], "system": "Answer in two sentences.", "tools": false },
419
+ "coding": { "maxTools": 16 }
420
+ },
421
+
422
+ // Shared caps, and a per-app budget for the bot.
423
+ "limits": {
424
+ "maxConcurrent": 8,
425
+ "perApp": { "maxConcurrent": 2, "requestsPerMinute": 30 },
426
+ "apps": { "coding": { "maxConcurrent": 4 } }
427
+ },
428
+
429
+ // Repeated bot prompts are answered from memory for five minutes.
430
+ "cache": { "ttlSeconds": 300, "apps": ["discord-bot"] },
431
+
432
+ // Chat UIs get ids they can handle; requests accept either spelling.
433
+ "modelIds": "both",
434
+
435
+ "maxN": 2,
436
+ "remoteImages": { "allow": ["images.internal"] },
437
+
438
+ "history": { "maxSessions": 500, "maxAgeDays": 30 },
439
+ "usage": { "retentionDays": 90 },
440
+
441
+ "metrics": true,
442
+ "logFormat": "json"
443
+ }
444
+ }
445
+ ]
446
+ }
447
+ ```
89
448
 
90
449
  ## Clients
91
450
 
92
- One short page per client, with a config snippet, a chat, a tool call, a structured-output note and the gotchas found in testing. Versions and dates are on each page; the raw evidence is in [`compat/results`](https://github.com/jjjermiah/opencode-plugboard/tree/main/compat/results).
451
+ One page per client, with the config, a chat, a tool call, structured output and the gotchas found in testing. Each page says which version was tested and when.
93
452
 
94
453
  | Client | Page | Last run (2026-10-11) |
95
454
  | --- | --- | --- |
96
- | OpenAI Python SDK | [openai-python](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-python.md) | pass |
97
- | OpenAI JS SDK | [openai-js](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-js.md) | pass |
98
- | Vercel AI SDK | [vercel-ai-sdk](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/vercel-ai-sdk.md) | pass with `createOpenAICompatible` or `.chat()`; structured output needs `supportsStructuredOutputs: true` there; the default `openai(id)` call works through `/v1/responses` (set `store: false` for multi-turn) |
99
- | LangChain Python | [langchain-python](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/langchain-python.md) | pass; `json_mode` on `fast` was unreliable |
100
- | LangChain JS | [langchain-js](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/langchain-js.md) | pass except `gpt-5.6*` ids, which force the Responses API, and `json_mode` on `fast` |
101
- | pydantic-ai | [pydantic-ai](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/pydantic-ai.md) | pass with `OpenAIChatModel` |
102
- | Open WebUI | [open-webui](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/open-webui.md) | pass over its HTTP API; no browser, no structured output in its UI |
103
- | n8n | [n8n](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/n8n.md) | pass with "Use Responses API" off |
104
- | Anything else | [any-app](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md) | base URL, key, model id |
105
-
106
- Each run drove the client's own SDK or API against a dev server at an earlier commit: a plain chat, a tool round trip, streaming, an image and structured output on `fast`, a Claude model and a ChatGPT model. A pass is not a clean sweep, and the notes say where it was not. The latest full run is in [compat/results/release-candidate.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/compat/results/release-candidate.md).
455
+ | OpenAI Python SDK | [openai-python][client-openai-python] | pass |
456
+ | OpenAI JS SDK | [openai-js][client-openai-js] | pass |
457
+ | Vercel AI SDK | [vercel-ai-sdk][client-vercel-ai-sdk] | pass with `createOpenAICompatible` or `.chat()`; structured output needs `supportsStructuredOutputs: true` there; the default `openai(id)` call works through `/v1/responses` (set `store: false` for multi-turn) |
458
+ | LangChain Python | [langchain-python][client-langchain-python] | pass; `json_mode` on `fast` was unreliable |
459
+ | LangChain JS | [langchain-js][client-langchain-js] | pass except `gpt-5.6*` ids, which force the Responses API, and `json_mode` on `fast` |
460
+ | pydantic-ai | [pydantic-ai][client-pydantic-ai] | pass with `OpenAIChatModel` |
461
+ | Open WebUI | [open-webui][client-open-webui] | pass over its HTTP API; no browser, no structured output in its UI |
462
+ | n8n | [n8n][client-n8n] | pass with "Use Responses API" off |
463
+ | Anything else | [any-app][client-any-app] | base URL, key, model id |
464
+
465
+ Each run drove the client's own SDK or API against a dev server at an earlier commit: a plain chat, a tool round trip, streaming, an image and structured output on `fast`, a Claude model and a ChatGPT model. A pass is not a clean sweep, and the notes say where it was not. The latest full run is in [compat/results/release-candidate.md][release-candidate].
466
+
467
+ The protocol-level results are in the [compatibility matrix][compatibility]. It runs in CI against a scripted model, so it checks wire formats, not a provider's behaviour.
468
+
469
+ ## Known gaps
470
+
471
+ - **Responses is stateless.** Every request carries the whole conversation, and nothing is stored. Clients that default to `store: true` fail on a second turn ([#147](https://github.com/jjjermiah/opencode-plugboard/issues/147)). Set `store: false`. For n8n, turn "Use Responses API" off.
472
+ - **Stop sequences** are removed from the answer only on `/v1/messages` ([#145](https://github.com/jjjermiah/opencode-plugboard/issues/145)). Other endpoints return the matched text.
473
+ - **Anthropic structured outputs** (`output_config.format`) are refused with a 400. Use `json_schema` on the OpenAI or Gemini endpoints.
474
+ - **Gemini `candidateCount` above 1** is refused. The OpenAI endpoints serve `n`.
475
+ - **Gemini model ids with a `/`** sent by the AI SDK's Google provider or LangChain's `google-genai` lack the `models/` prefix and get a 401. The `@google/genai` SDK works.
476
+ - **Tools with `json_schema`** in one request return the caller's tool calls, not the schema answer ([#34](https://github.com/jjjermiah/opencode-plugboard/issues/34)).
477
+ - **State is per server.** Limits, the cache and usage live in one OpenCode server process. Two servers do not share them, and a restart clears limits and the cache.
478
+ - **Token counts and costs are estimates.**
479
+ - **Not planned:** embeddings, audio, image generation and `logprobs`.
480
+ - **V2 only.** OpenCode V1 is not supported and not planned.
481
+ - **Early (0.x).** A minor bump can change an option or a default. Read the [changelog](https://github.com/jjjermiah/opencode-plugboard/blob/main/CHANGELOG.md) before you upgrade.
482
+
483
+ Open work is tracked in the [v0.1 milestone](https://github.com/jjjermiah/opencode-plugboard/milestone/1) and the [later milestone](https://github.com/jjjermiah/opencode-plugboard/milestone/2).
484
+
485
+ ## Security
486
+
487
+ - **Bind address.** The listener binds `127.0.0.1` by default. A non-loopback `listen.host` refuses to start without `keys`. On a loopback bind, a `Host` header that is not `localhost`, `127.0.0.1` or `[::1]` gets a 403.
488
+ - **Keys cross the network in clear text.** The endpoint speaks plain HTTP. For remote access, allow only known addresses in the firewall, tunnel over SSH, or put a TLS-terminating proxy in front. See [reaching plugboard from another host or container][reach].
489
+ - **Keep keys out of the config file.** Use `{env:VAR}` and keep the values in the environment or a secret store. Every app needs its own key.
490
+ - **`adminKey` reads usage only.** It cannot call the model routes, and it must differ from every app key.
491
+ - **Keyless mode is loopback only.** Without `keys`, the one app is `default`, and only local callers can reach it.
492
+ - **Remote images are off by default.** Turning them on makes the server fetch URLs chosen by callers.
493
+ - **The plugin runs inside OpenCode.** It sees your provider logins, so pin the version and update it on purpose.
107
494
 
108
495
  ## How it works
109
496
 
110
- Each request becomes an OpenCode session in the app's own directory, using OpenCode's request path, so provider fixes and auth plugins still apply. The plugin swaps the caller's prompt, messages and tools in for OpenCode's own. Caller tools map to placeholder tools that settle at once with a no-op result. When the model has emitted its tool calls, the plugin interrupts the turn and returns them to the caller. Text streams out as it arrives. [docs/architecture.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/architecture.md) has the lifetimes and the freeze-safety rules.
497
+ Each request becomes an OpenCode session in the app's own directory, using OpenCode's request path, so provider fixes and auth plugins still apply.
111
498
 
112
- ## Roadmap
499
+ The plugin swaps the caller's prompt, messages and tools in for OpenCode's own. Caller tools map to placeholder tools that settle at once with a no-op result. When the model has emitted its tool calls, the plugin interrupts the turn and returns them to the caller. Text streams out as it arrives. The [architecture][architecture] doc covers the lifetimes and the freeze-safety rules.
113
500
 
114
- Work is tracked in GitHub issues: the [v0.1 milestone](https://github.com/jjjermiah/opencode-plugboard/milestone/1) and the [later milestone](https://github.com/jjjermiah/opencode-plugboard/milestone/2), which holds the `/v1/messages` and Gemini front-ends, agent mode, a native backend, usage accounting, rate limits and an upstream request to boot plugins at server start.
501
+ ## Documentation
502
+
503
+ - [Configuration reference][configuration]: every option, with its syntax and defaults.
504
+ - [Apps and keys][configuration-apps], [aliases and fallbacks][configuration-aliases], [limits][configuration-limits], [usage statistics][configuration-usage].
505
+ - Endpoints: [Responses][responses], [Anthropic Messages][messages], [Gemini][gemini], [legacy completions and `n`][completions].
506
+ - [History][history] and [warm-up][warm-up] for always-on servers.
507
+ - [Architecture][architecture] and [development][development].
115
508
 
116
509
  ## License
117
510
 
118
511
  MIT
512
+
513
+ [configuration]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md
514
+ [configuration-apps]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#apps-and-keys
515
+ [configuration-aliases]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#aliases-and-fallbacks
516
+ [configuration-limits]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#concurrency-and-rate-limits
517
+ [configuration-usage]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/configuration.md#usage-statistics
518
+ [responses]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md
519
+ [messages]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/messages.md
520
+ [gemini]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/gemini.md
521
+ [completions]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/completions.md
522
+ [history]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/history.md
523
+ [warm-up]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/warm-up.md
524
+ [architecture]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/architecture.md
525
+ [development]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/development.md
526
+ [compatibility]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/compatibility.md
527
+ [release-candidate]: https://github.com/jjjermiah/opencode-plugboard/blob/main/compat/results/release-candidate.md
528
+ [reach]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md#reaching-plugboard-from-another-host-or-container
529
+ [client-openai-python]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-python.md
530
+ [client-openai-js]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-js.md
531
+ [client-vercel-ai-sdk]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/vercel-ai-sdk.md
532
+ [client-langchain-python]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/langchain-python.md
533
+ [client-langchain-js]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/langchain-js.md
534
+ [client-pydantic-ai]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/pydantic-ai.md
535
+ [client-open-webui]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/open-webui.md
536
+ [client-n8n]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/n8n.md
537
+ [client-any-app]: https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md
package/index.ts CHANGED
@@ -23,9 +23,10 @@ import { Effect } from "effect"
23
23
  import { parseOptions } from "./src/config.js"
24
24
  import { errorMessage, logError } from "./src/log.js"
25
25
  import { withFallbacks } from "./src/fallback.js"
26
+ import { withBudgets } from "./src/budgets.js"
26
27
  import { withLimits } from "./src/limits.js"
27
28
  import { createOpenAiRoute } from "./src/openai/route.js"
28
- import { ensureBackend, ensurePump, loadServingCatalog, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
29
+ import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
29
30
  import { createSessionCaller } from "./src/session/caller.js"
30
31
  import { installSessionHooks } from "./src/session/context.js"
31
32
  import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
@@ -80,23 +81,35 @@ export default Plugin.define({
80
81
  // Aliases written as a list retry the next target before the first output (src/fallback.ts).
81
82
  // The response cache sits outside the fallbacks, so a hit never reaches a provider (src/cache.ts).
82
83
  caller: withUsage(
83
- withLimits(
84
- withResponseCache(
85
- withFallbacks(createSessionCaller(callerDeps), {
84
+ // Budgets sit outside the limiter, so a budget refusal never takes a slot (src/budgets.ts).
85
+ withBudgets(
86
+ withLimits(
87
+ withResponseCache(
88
+ withFallbacks(createSessionCaller(callerDeps), {
89
+ options,
90
+ loadCatalog: servingCatalog,
91
+ onFallback: (app) => {
92
+ if (options.metrics) processMetrics().fallback(app)
93
+ usage?.fallback(app)
94
+ },
95
+ }),
86
96
  options,
87
- loadCatalog: servingCatalog,
88
- onFallback: (app) => {
89
- if (options.metrics) processMetrics().fallback(app)
90
- usage?.fallback(app)
97
+ ),
98
+ () => limits,
99
+ processLimits(),
100
+ {
101
+ onLimited: (app, reason) => {
102
+ if (options.metrics) processMetrics().limited(app, reason)
103
+ usage?.limited(app)
91
104
  },
92
- }),
93
- options,
105
+ },
94
106
  ),
95
- () => limits,
96
- processLimits(),
107
+ options.budgets,
108
+ usage,
109
+ processBudgets(),
97
110
  {
98
- onLimited: (app, reason) => {
99
- if (options.metrics) processMetrics().limited(app, reason)
111
+ onRefused: (app) => {
112
+ if (options.metrics) processMetrics().limited(app, "budget")
100
113
  usage?.limited(app)
101
114
  },
102
115
  },