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 +458 -39
- package/index.ts +27 -14
- package/package.json +36 -9
- package/rpc.ts +3 -0
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +2 -0
- package/src/budgets.ts +200 -0
- package/src/completions/request.ts +1 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +98 -1
- package/src/contract.ts +14 -0
- package/src/dashboard/app.css +209 -0
- package/src/dashboard/app.js +444 -0
- package/src/dashboard/assets.ts +72 -0
- package/src/dashboard/page.ts +87 -0
- package/src/discovery.ts +8 -2
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +2 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/openai/fanout.ts +4 -0
- package/src/openai/request.ts +1 -0
- package/src/openai/response.ts +21 -3
- package/src/openai/route.ts +35 -4
- package/src/responses/request.ts +28 -14
- package/src/responses/route.ts +9 -4
- package/src/responses/store.ts +51 -0
- package/src/runtime.ts +10 -2
- package/src/stop.ts +54 -0
- package/src/tui-format.ts +217 -0
- package/src/tui.tsx +166 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +43 -3
- package/tui.ts +4 -0
package/README.md
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
[](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
|
|
5
5
|
[](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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`.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
|
|
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
|
-
|
|
189
|
+
Every target must pass `models`, or the plugin refuses to load.
|
|
73
190
|
|
|
74
|
-
|
|
191
|
+
#### UI-safe model ids
|
|
75
192
|
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
-
|
|
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
|
-
|
|
207
|
+
`capability` is `vision` or `tools`. Any other value is rejected.
|
|
85
208
|
|
|
86
|
-
|
|
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
|
-
|
|
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
|
|
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]
|
|
97
|
-
| OpenAI JS SDK | [openai-js]
|
|
98
|
-
| Vercel AI SDK | [vercel-ai-sdk]
|
|
99
|
-
| LangChain Python | [langchain-python]
|
|
100
|
-
| LangChain JS | [langchain-js]
|
|
101
|
-
| pydantic-ai | [pydantic-ai]
|
|
102
|
-
| Open WebUI | [open-webui]
|
|
103
|
-
| n8n | [n8n]
|
|
104
|
-
| Anything else | [any-app]
|
|
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]
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
107
|
+
options.budgets,
|
|
108
|
+
usage,
|
|
109
|
+
processBudgets(),
|
|
97
110
|
{
|
|
98
|
-
|
|
99
|
-
if (options.metrics) processMetrics().limited(app,
|
|
111
|
+
onRefused: (app) => {
|
|
112
|
+
if (options.metrics) processMetrics().limited(app, "budget")
|
|
100
113
|
usage?.limited(app)
|
|
101
114
|
},
|
|
102
115
|
},
|