opencode-plugboard 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,24 +1,36 @@
1
1
  # opencode-plugboard
2
2
 
3
- An OpenCode V2 plugin that turns your OpenCode server into an OpenAI-compatible endpoint, backed by the providers you already connected there.
3
+ [![npm](https://img.shields.io/npm/v/opencode-plugboard)](https://www.npmjs.com/package/opencode-plugboard)
4
+ [![license](https://img.shields.io/npm/l/opencode-plugboard)](https://github.com/jjjermiah/opencode-plugboard/blob/main/LICENSE)
5
+ [![CI](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml/badge.svg)](https://github.com/jjjermiah/opencode-plugboard/actions/workflows/ci.yml)
4
6
 
5
- You logged in to ChatGPT, Claude, Copilot or OpenCode Go once, in OpenCode. Your other tools (a Discord bot, n8n, Open WebUI, a script) only speak the OpenAI API, so each one wants its own key or its own gateway with its own logins. Plugboard serves `/v1/chat/completions` and `/v1/models` from inside OpenCode, so those tools can reuse the logins you already have. Tool calls go back to the caller to execute. OpenCode never runs your tools.
7
+ An OpenCode V2 plugin that serves an OpenAI-compatible API from your OpenCode server, so your other apps reuse the provider logins you already made in OpenCode.
6
8
 
7
- OpenCode V2 has no such endpoint, and the V1 plugins that did this stopped working when the plugin API changed. Plugboard is V2 only. It was tested on OpenCode 2.0.26, and the peer range `>=2.0.26 <2.1` allows 2.0.x patch releases.
8
-
9
- Status: early (0.x), [published on npm](https://www.npmjs.com/package/opencode-plugboard). Chat Completions, the Responses API and the model list. `/v1/responses` is a stateless subset, see [docs/responses.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md).
9
+ ```
10
+ client ──▶ plugboard listener ──▶ OpenCode session ──▶ provider
11
+ ▲ │
12
+ └──── reply; tool calls go back to the caller ◀──────────┘
13
+ ```
10
14
 
11
- Docs: [architecture](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/architecture.md), [development](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/development.md), [clients](#clients).
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 `/v1/chat/completions`, `/v1/responses` and `/v1/models` from inside OpenCode. OpenCode never runs your tools.
12
16
 
13
17
  ## Quick start
14
18
 
15
- Install it in your global OpenCode config (`~/.config/opencode/opencode.jsonc`). It has to be global. See [History](#history) for why. OpenCode installs it from npm the first time it loads; to run a local checkout instead, set `package` to its path.
19
+ Tested on OpenCode 2.0.26. The peer range `>=2.0.26 <2.1` allows 2.0.x patch releases.
20
+
21
+ **1. Pick a key** for your app, in the environment OpenCode starts from:
22
+
23
+ ```sh
24
+ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
25
+ ```
26
+
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.
16
28
 
17
29
  ```jsonc
18
30
  {
19
31
  "plugins": [
20
32
  {
21
- "package": "opencode-plugboard",
33
+ "package": "opencode-plugboard@0.4.1",
22
34
  "options": {
23
35
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
24
36
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -29,43 +41,52 @@ Install it in your global OpenCode config (`~/.config/opencode/opencode.jsonc`).
29
41
  }
30
42
  ```
31
43
 
32
- Every alias target must also be allowed in `models`, which is why the example lists `opencode-go/glm-5.3-flash`. Restart OpenCode, then touch the server once so it loads the plugin (see [Warm-up](#warm-up-for-always-on-servers)). The endpoint listens on `127.0.0.1:4010`.
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).
45
+
46
+ **3. Restart OpenCode, then touch the server once.** OpenCode loads no plugin until a client connects, so the port stays closed until then:
33
47
 
34
48
  ```sh
35
- export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string # before starting OpenCode
49
+ opencode api get /api/agent
50
+ ```
51
+
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).
53
+
54
+ **4. Call it.** The endpoint listens on `127.0.0.1:4010`. This prints `pong`:
36
55
 
37
- curl http://127.0.0.1:4010/v1/chat/completions \
56
+ ```sh
57
+ curl -s http://127.0.0.1:4010/v1/chat/completions \
38
58
  -H "Authorization: Bearer $PLUGBOARD_KEY_MY_APP" \
39
59
  -H "Content-Type: application/json" \
40
- -d '{"model": "fast", "messages": [{"role": "user", "content": "reply with exactly: pong"}]}'
60
+ -d '{"model": "fast", "messages": [{"role": "user", "content": "reply with exactly: pong"}]}' \
61
+ | jq -r '.choices[0].message.content'
41
62
  ```
42
63
 
43
- Any OpenAI SDK works by changing the base URL and key:
64
+ Any OpenAI SDK works by changing the base URL (`http://127.0.0.1:4010/v1`) and the key. Next:
44
65
 
45
- ```python
46
- from openai import OpenAI
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).
47
71
 
48
- client = OpenAI(base_url="http://127.0.0.1:4010/v1", api_key="<your key>")
49
- reply = client.chat.completions.create(
50
- model="anthropic/claude-sonnet-4-6",
51
- messages=[{"role": "user", "content": "Say hi."}],
52
- )
53
- print(reply.choices[0].message.content)
54
- ```
72
+ `GET /healthz` returns OK when the listener is up and needs no key.
55
73
 
56
- ```js
57
- import OpenAI from "openai"
74
+ ## What this is not
58
75
 
59
- const client = new OpenAI({ baseURL: "http://127.0.0.1:4010/v1", apiKey: "<your key>" })
60
- const stream = await client.chat.completions.create({
61
- model: "fast",
62
- messages: [{ role: "user", content: "Say hi." }],
63
- stream: true,
64
- })
65
- for await (const chunk of stream) process.stdout.write(chunk.choices[0]?.delta?.content ?? "")
66
- ```
76
+ Plugboard is early (0.x) and narrow on purpose.
77
+
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. Only `n=1`, no `logprobs`.
81
+ - **No concurrency limit per app** yet ([#54](https://github.com/jjjermiah/opencode-plugboard/issues/54), [#86](https://github.com/jjjermiah/opencode-plugboard/issues/86)).
82
+ - **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
67
83
 
68
- `GET /healthz` returns OK when the listener is up and needs no key. `GET /v1/models/{id}` returns one model from the list. Every chat response carries an `x-plugboard-session` header with the id of the OpenCode session that recorded it, so you can find the request in [History](#history). To call plugboard from another machine or a container, see [reaching plugboard from another host](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md#reaching-plugboard-from-another-host-or-container).
84
+ How it differs from the alternatives:
85
+
86
+ - [KochC/opencode-llm-proxy](https://github.com/KochC/opencode-llm-proxy) serves more API formats but targets OpenCode V1 only. Plugboard is V2 only and adds per-app keys and history, an explicit model allowlist, and rules that keep it from freezing the host (see [AGENTS.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/AGENTS.md)).
87
+ - [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.
88
+
89
+ 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.
69
90
 
70
91
  ## Clients
71
92
 
@@ -83,147 +104,15 @@ One short page per client, with a config snippet, a chat, a tool call, a structu
83
104
  | n8n | [n8n](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/n8n.md) | pass with "Use Responses API" off |
84
105
  | Anything else | [any-app](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md) | base URL, key, model id |
85
106
 
86
- 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 here is not a clean sweep, and the notes in the table and on each page say where it was not. The latest full acceptance run is in [compat/results/release-candidate.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/compat/results/release-candidate.md).
87
-
88
- ## Known gaps
89
-
90
- - **`/v1/responses` is stateless.** `previous_response_id`, `conversation`, `item_reference`, `background` and hosted tools return 400. See [docs/responses.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/responses.md).
91
- - Only `n=1`, no `logprobs`, no embeddings, audio or image generation.
92
- - No per-app concurrency limit yet ([#54](https://github.com/jjjermiah/opencode-plugboard/issues/54), [#86](https://github.com/jjjermiah/opencode-plugboard/issues/86)).
93
-
94
- ## Options
95
-
96
- Options live under `options` in the plugin entry. `{env:VAR}` is expanded by OpenCode.
97
-
98
- | Option | Default | Meaning |
99
- | --- | --- | --- |
100
- | `listen.host` | `127.0.0.1` | Interface to bind. A non-loopback host without `keys` refuses to start. |
101
- | `listen.port` | `4010` | Port to bind. |
102
- | `keys` | none | Map of app name to API key. The app name groups that app's history sessions. Names match `[a-z0-9-]+`. `default` is reserved. |
103
- | `models` | none (required) | Allowlist of models callers may use. See [Models](#models). |
104
- | `aliases` | none | Map of alias to `provider/model[#variant]`. Each target must also pass `models`. |
105
- | `maxTools` | `32` | Most tools one request may carry. Fixed when the plugin loads. |
106
- | `requestTimeoutMs` | `600000` | A request that runs longer is interrupted. |
107
- | `history` | none (keep forever) | `{ maxSessions, maxAgeDays }`. `maxSessions` keeps the newest N sessions per app, `maxAgeDays` deletes older ones; with both, the stricter wins. |
108
- | `remoteImages` | `false` | Let the server fetch `http(s)` image URLs from requests (20 MB cap, no redirects, `image/*` only). Off by default because it lets callers make the host request internal addresses; callers can always send `data:` URLs. |
109
-
110
- Changes to `listen` need a server restart; other option changes apply on config reload.
111
-
112
- OpenCode loads the plugin once per project directory, and all of those loads share one listener. The first one to load decides the options. A load elsewhere with different options logs once and stays inert, so a project entry cannot serve with weaker auth than the live one. When none is live, for example during a config reload, the next load's options are adopted. Requests are served from plugboard's own data directory when it is loaded, so a project's provider config doesn't change what callers get. So install it in the global config, which every project loads, and don't repeat it per project.
113
-
114
- On a loopback `listen.host`, requests with a `Host` header other than `127.0.0.1`, `localhost` or `[::1]` (on the listen port) get 403 `invalid_host`, and without `keys` a request with an `Origin` header gets 403 `cross_origin_forbidden`. The `Host` check only applies when `listen.host` is loopback. `POST` bodies must be `application/json` (415 otherwise).
115
-
116
- Without `keys`, only a loopback `listen.host` is accepted and every caller is the app `default`. Once any key exists, every `/v1` request needs a valid key, loopback included, and `/healthz` stays open. Callers send the key as `Authorization: Bearer <key>`. They never see or need the OpenCode server password.
117
-
118
- ## Models
119
-
120
- Callers name models as `provider/model`, optionally `provider/model#variant` (for example `openai/gpt-6-luna#low`, since ChatGPT models need a variant). Nothing is exposed by accident, so you must list what callers may use in `models`:
121
-
122
- - `provider/model` allows that model with any variant.
123
- - `provider/model#variant` allows exactly that variant.
124
- - `provider/*` allows every model of the provider.
125
-
126
- With no `models`, `/v1/models` is empty and chat calls return 403. `/v1/models` lists the allowlist intersected with the models enabled on your server, plus your aliases. Each entry has `owned_by` set to the provider and an extra `plugboard` object (`capabilities`, `contextLimit`). The ids contain `/` and `#`, which some UIs mishandle, so define an alias for those.
127
-
128
- `reasoning_effort` in a request picks a variant when the model name has none and the model has one by that name. An alias can point at a model with a variant, which is the easy way to give a client a fixed effort level.
129
-
130
- ## Apps and keys
131
-
132
- Each key name is an app. A request made with the `sparkyfitness` key is the app `sparkyfitness`, and its sessions are grouped under that app. Give every tool its own key. You can tell their traffic apart, and revoking one means removing one line.
133
-
134
- ## History
135
-
136
- Every request is kept as an OpenCode session, so you can see what each app asked and what the model answered. Plugboard keeps the full caller transcript in each session (system prompt, messages, images as attachments, tool calls and results) plus the reply. The title is the first user message. A ten-turn chat is ten sessions, each complete.
137
-
138
- Each session is filed under a per-app directory, so it never shows up in your real projects:
139
-
140
- ```
141
- $XDG_DATA_HOME/opencode-plugboard/<app>/ # default ~/.local/share/opencode-plugboard/<app>/
142
- ```
143
-
144
- That directory only groups sessions: it is the `directory` a session is filed under. The prompts and replies are not stored in it. OpenCode keeps them in its own database, `opencode.db` in its data directory (`opencode debug paths` prints the exact location, and `OPENCODE_DB` can override it). So deleting, locking down or backing up the app directory removes, protects or saves no history. To back up, restrict or purge history, work on that database, or remove sessions through OpenCode ([`compat/tools/sessions.py`](https://github.com/jjjermiah/opencode-plugboard/blob/main/compat/tools/sessions.py) does that for one app).
145
-
146
- In OpenCode the sessions appear as one group per app in the desktop and phone app home screen. In the TUI they are visible in the all-projects view (ctrl+a), not in a project's own list. Set `history` to delete old ones: sweeps run on requests, at most every 10 minutes, in bounded batches, and never touch a running session. Only sessions created while a limit is set are pruned; remove older ones with `compat/tools/sessions.py`.
147
-
148
- OpenCode loads plugin hooks per directory, so plugboard has to be loaded in those app directories, which is why it needs the global config. Everywhere outside the data root it is inert: it only joins the shared listener and registers no hooks, tools or agent rules, so your real projects behave as before.
149
-
150
- History records every prompt and reply, so treat OpenCode's database as sensitive.
151
-
152
- ## Warm-up for always-on servers
153
-
154
- OpenCode loads no plugin until a client touches the server, so after a restart the port stays closed until something connects. On a machine that runs `opencode serve` all the time, add one call right after start. Any request that makes OpenCode load a project works. `opencode api get /api/agent` does.
155
-
156
- On Linux, in the OpenCode systemd unit (retry until the server answers; pass the same server credentials your service uses, if you set any):
157
-
158
- ```ini
159
- [Service]
160
- ExecStartPost=-/bin/sh -c 'for i in $(seq 1 30); do opencode api get /api/agent >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1'
161
- ```
162
-
163
- On macOS, a launchd job (`~/Library/LaunchAgents/dev.plugboard.warmup.plist`). It runs once when launchd loads it, which is at login and after boot. It does not run again when OpenCode restarts by itself, so after an OpenCode restart on its own, touch the server yourself (`launchctl kickstart -k gui/$(id -u)/dev.plugboard.warmup` reruns it) or run the same command from whatever restarts OpenCode.
164
-
165
- ```xml
166
- <?xml version="1.0" encoding="UTF-8"?>
167
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
168
- <plist version="1.0">
169
- <dict>
170
- <key>Label</key><string>dev.plugboard.warmup</string>
171
- <key>ProgramArguments</key>
172
- <array>
173
- <string>/bin/sh</string>
174
- <string>-c</string>
175
- <string>for i in $(seq 1 30); do opencode api get /api/agent >/dev/null 2>&amp;1 &amp;&amp; exit 0; sleep 1; done; exit 1</string>
176
- </array>
177
- <key>RunAtLoad</key><true/>
178
- </dict>
179
- </plist>
180
- ```
181
-
182
- If `opencode` is not on the job's `PATH`, use its full path. A request to `/healthz` before the warm-up gets connection refused, which is expected. I'd like OpenCode to boot plugins at server start so none of this is needed ([upstream request](https://github.com/jjjermiah/opencode-plugboard/issues/2)).
183
-
184
- When config reloads happen, plugboard keeps the port open through a short grace period, so a reload does not drop connections. If no plugboard generation is alive when a request arrives, the caller gets a 503.
185
-
186
- ## Limits and errors
187
-
188
- Errors use the OpenAI error body shape with the matching HTTP status.
189
-
190
- | Status | `code` | When | Message |
191
- | --- | --- | --- | --- |
192
- | 401 | `invalid_api_key` | Missing or wrong key | Missing or invalid API key |
193
- | 404 | `model_not_found` | Unknown model or not on the allowlist | Model &lt;m&gt; is not available |
194
- | 403 | `no_models_configured` | The `models` option is empty | No models are allowed: set the plugboard `models` option |
195
- | 400 | `too_many_tools` | More tools than `maxTools` | Request has &lt;n&gt; tools; plugboard allows &lt;max&gt; (option `maxTools`) |
196
- | 400 | `unsupported_parameter` | `n` above 1, `logprobs`, `top_logprobs` | names the parameter |
197
-
198
- Provider errors map to an OpenAI error body with the provider's status, including a provider's own rejection of a sampling parameter (ChatGPT models reject `temperature`, for example). Once a stream has started, a failure arrives as an `error` event followed by `[DONE]`. A client that disconnects, SSE included, interrupts the request. There is no per-app concurrency limit in v0.1.
199
-
200
- Supported request fields: messages with system, developer, user text and images, assistant `tool_calls` and tool results; `tools`, `tool_choice`, `response_format` (`json_object`, `json_schema`), `temperature`, `top_p`, `max_tokens`, `max_completion_tokens`, `stop`, `presence_penalty`, `frequency_penalty`, `reasoning_effort`, and streaming with a final usage chunk (`stream_options.include_usage`).
201
-
202
- Behaviours worth knowing:
203
-
204
- - `parallel_tool_calls` and `seed` are accepted and ignored.
205
- - `tool_choice` `auto` and `none` work as expected. `required` and a named function are applied as an instruction to the model, because the OpenCode session API has no tool-choice control. A model can still answer in text.
206
- - `response_format` `json_schema` works through a `respond` tool the model is instructed to call, and the arguments come back as the message content. The provider does not enforce the schema, so validate the result. `json_object` adds a system instruction instead and carries no schema, so name the keys in your prompt; weak models may still ignore them.
207
- - A request that carries both `tools` and a `json_schema` response format is changing: it will return the caller's tool calls ([#34](https://github.com/jjjermiah/opencode-plugboard/issues/34)). Don't rely on the current behaviour for that combination.
208
- - A streamed tool call arrives as one delta carrying the whole arguments string.
107
+ 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).
209
108
 
210
109
  ## How it works
211
110
 
212
- Each request becomes a session in the app's directory, using OpenCode's own request path, so provider fixes and auth plugins (Claude via an auth plugin, ChatGPT, Copilot) still apply.
213
-
214
- 1. A front-end parses the OpenAI request into a format-neutral request, and the core checks the key and resolves the model.
215
- 2. The session backend creates a session in the app directory and swaps the caller's system prompt, messages and tools in for OpenCode's own with a `context` hook.
216
- 3. Caller tools map onto a pool of `maxTools` placeholder tools. A placeholder settles at once with a fixed no-op result, so history shows a completed call. Nothing of yours runs.
217
- 4. When the model has emitted its tool calls, the plugin interrupts the turn and returns the calls to the caller. Text streams out as it arrives.
218
- 5. The front-end encodes the events as JSON or SSE, and the session stays as history.
219
-
220
- The model call sits behind one interface, `ModelCaller`, so a native backend can replace the session backend later without touching the front-ends ([roadmap](https://github.com/jjjermiah/opencode-plugboard/issues/3)). [docs/architecture.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/architecture.md) has the details: lifetimes, process-wide state and the freeze-safety rules.
111
+ 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.
221
112
 
222
113
  ## Roadmap
223
114
 
224
- Work is tracked in GitHub issues. The [v0.1 milestone](https://github.com/jjjermiah/opencode-plugboard/milestone/1) lists what is needed before the first release, and the [later milestone](https://github.com/jjjermiah/opencode-plugboard/milestone/2) holds the rest: 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.
225
-
226
- Not planned: V1 support, embeddings, audio, image generation.
115
+ 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.
227
116
 
228
117
  ## License
229
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "OpenCode V2 plugin that serves an OpenAI-compatible /v1/chat/completions and /v1/models from your OpenCode server, backed by the providers you already connected there.",
@@ -20,7 +20,7 @@
20
20
  "files": ["index.ts", "src/", "README.md", "LICENSE"],
21
21
  "scripts": {
22
22
  "typecheck": "tsc --noEmit",
23
- "release": "bun scripts/release.ts"
23
+ "release": "bun .github/scripts/release.ts"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@opencode/plugin": "2.0.26",
package/src/config.ts CHANGED
@@ -18,6 +18,8 @@ export interface PlugboardOptions {
18
18
  readonly remoteImages: boolean
19
19
  /** Prune old request sessions (see `src/session/prune.ts`). A limit that is unset is not applied; with neither, history is kept forever. Applies per app. */
20
20
  readonly history: HistoryLimits
21
+ /** Drop sampling parameters the target model is known to reject (reported in `x-plugboard-dropped-params`). On by default; off forwards them as sent. */
22
+ readonly dropUnsupportedParams: boolean
21
23
  }
22
24
 
23
25
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
@@ -29,7 +31,7 @@ const DEFAULTS = {
29
31
  requestTimeoutMs: 600_000,
30
32
  } as const
31
33
 
32
- const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history"])
34
+ const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams"])
33
35
  const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
34
36
  /** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
35
37
  const MAX_AGE_DAYS = 36_500
@@ -118,6 +120,8 @@ export function parseOptions(raw: unknown): PlugboardOptions {
118
120
 
119
121
  if (input.remoteImages !== undefined && typeof input.remoteImages !== "boolean")
120
122
  throw new Error("plugboard option `remoteImages` must be a boolean")
123
+ if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
124
+ throw new Error("plugboard option `dropUnsupportedParams` must be a boolean")
121
125
 
122
126
  const historyInput = input.history
123
127
  if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
@@ -135,6 +139,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
135
139
  requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
136
140
  remoteImages: input.remoteImages === true,
137
141
  history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
142
+ dropUnsupportedParams: input.dropUnsupportedParams !== false,
138
143
  }
139
144
  }
140
145
 
package/src/http.ts CHANGED
@@ -15,6 +15,8 @@ const MAX_TIMER_MS = 2 ** 31 - 1
15
15
  const LINGER_MS = 5_000
16
16
  /** After the deadline passes, a response that has not finished flushing (a client that stopped reading) is destroyed after this long. */
17
17
  const DEADLINE_GRACE_MS = 2_000
18
+ /** An SSE stream idle for this long gets a comment line, so a client with a header or idle timeout keeps waiting for a model that is still reasoning. */
19
+ const KEEPALIVE_MS = 15_000
18
20
 
19
21
  /** A request body that cannot be accepted: too large, or not JSON. `status` is the HTTP status to answer with. */
20
22
  export class BodyError extends Error {
@@ -196,10 +198,18 @@ export const sendJson = (response: ServerResponse, status: number, payload: unkn
196
198
  * is something to send; chunks after the client is gone are dropped. `write` is synchronous (the
197
199
  * encoder's contract); backpressure is honoured by `paced`, which holds back the next event while
198
200
  * the socket buffer is full. `signal` (the request scope's) ends those waits when it aborts.
201
+ *
202
+ * Keepalive: `paced` commits the response (200 and SSE headers) at the first event that is not a
203
+ * terminal one (reasoning counts), then writes an SSE comment whenever the stream has been idle
204
+ * for `keepaliveMs`, until the events end. A failure before that first event still gets its real
205
+ * HTTP status; after it, the caller must report failures as SSE frames. A tick is skipped while
206
+ * the socket buffer is full, and stops when the client is gone or the scope aborted.
199
207
  */
200
- export function sseWriter(response: ServerResponse, signal?: AbortSignal) {
208
+ export function sseWriter(response: ServerResponse, signal?: AbortSignal, keepaliveMs = KEEPALIVE_MS) {
201
209
  let started = false
202
210
  let blocked = false
211
+ let lastWrite = Date.now()
212
+ let timer: ReturnType<typeof setTimeout> | undefined
203
213
  const waiters: Array<() => void> = []
204
214
  const release = () => {
205
215
  blocked = false
@@ -207,6 +217,45 @@ export function sseWriter(response: ServerResponse, signal?: AbortSignal) {
207
217
  }
208
218
  response.on("drain", release)
209
219
  response.on("close", release)
220
+ const commit = () => {
221
+ if (started || response.destroyed || response.writableEnded) return
222
+ started = true
223
+ response.writeHead(200, {
224
+ "content-type": "text/event-stream; charset=utf-8",
225
+ "cache-control": "no-cache, no-transform",
226
+ connection: "keep-alive",
227
+ "x-accel-buffering": "no",
228
+ })
229
+ // `writeHead` alone queues the headers until the first body write; a keepalive commit has no body yet.
230
+ response.flushHeaders()
231
+ }
232
+ const send = (chunk: string) => {
233
+ if (!response.write(chunk)) blocked = true
234
+ lastWrite = Date.now()
235
+ }
236
+ const stopKeepalive = () => {
237
+ clearTimeout(timer)
238
+ timer = undefined
239
+ }
240
+ const arm = (delay: number) => {
241
+ timer = setTimeout(tick, delay)
242
+ timer.unref?.()
243
+ }
244
+ const tick = () => {
245
+ timer = undefined
246
+ if (response.destroyed || response.writableEnded || signal?.aborted) return
247
+ const idle = Date.now() - lastWrite
248
+ if (idle < keepaliveMs) return arm(keepaliveMs - idle)
249
+ if (!blocked) send(": keepalive\n\n")
250
+ arm(keepaliveMs)
251
+ }
252
+ const beginKeepalive = () => {
253
+ commit()
254
+ if (!started || timer !== undefined) return
255
+ lastWrite = Date.now()
256
+ arm(keepaliveMs)
257
+ }
258
+ response.on("close", stopKeepalive)
210
259
  const drained = (): Promise<void> => {
211
260
  if (!blocked || response.destroyed || signal?.aborted) return Promise.resolve()
212
261
  return new Promise((resolve) => {
@@ -226,16 +275,8 @@ export function sseWriter(response: ServerResponse, signal?: AbortSignal) {
226
275
  },
227
276
  write(chunk: string) {
228
277
  if (response.destroyed || response.writableEnded) return
229
- if (!started) {
230
- started = true
231
- response.writeHead(200, {
232
- "content-type": "text/event-stream; charset=utf-8",
233
- "cache-control": "no-cache, no-transform",
234
- connection: "keep-alive",
235
- "x-accel-buffering": "no",
236
- })
237
- }
238
- if (!response.write(chunk)) blocked = true
278
+ commit()
279
+ send(chunk)
239
280
  },
240
281
  /** Resolves when the socket buffer has room again, the client is gone, or the request scope aborted. */
241
282
  drained,
@@ -252,9 +293,11 @@ export function sseWriter(response: ServerResponse, signal?: AbortSignal) {
252
293
  if (response.destroyed) return
253
294
  const next = await iterator.next()
254
295
  if (next.done) return
296
+ if (next.value.type !== "error" && next.value.type !== "finish") beginKeepalive()
255
297
  yield next.value
256
298
  }
257
299
  } finally {
300
+ stopKeepalive()
258
301
  void iterator.return?.(undefined)?.catch?.(() => {})
259
302
  }
260
303
  },
@@ -328,6 +328,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
328
328
  stream: body.stream === true,
329
329
  includeUsage: streamOptions.include_usage === true,
330
330
  ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
331
+ ...(format.instruction ? { jsonObject: true } : {}),
331
332
  },
332
333
  ...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
333
334
  }
@@ -27,6 +27,12 @@ export interface OpenAiExchange {
27
27
  * answer is content only when the step called no caller tool.
28
28
  */
29
29
  readonly callerTools?: boolean
30
+ /**
31
+ * `response_format: json_object`: the model is only asked in a system instruction, and sometimes
32
+ * wraps the answer in a markdown code fence. A single fence around text that parses as JSON is
33
+ * stripped from the content (`unfence`).
34
+ */
35
+ readonly jsonObject?: boolean
30
36
  }
31
37
 
32
38
  /** How a streamed exchange ended. Nothing was written when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
@@ -123,6 +129,24 @@ const structured = {
123
129
  },
124
130
  }
125
131
 
132
+ /** One markdown code fence (```json or a bare ```) around the whole text. */
133
+ const FENCED = /^```[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n?[ \t]*```$/i
134
+
135
+ /**
136
+ * The inside of a single code fence that wraps the whole text, when the inside parses as JSON;
137
+ * anything else comes back unchanged (prose around the fence, two fences, non-JSON).
138
+ */
139
+ export function unfence(text: string): string {
140
+ const inside = FENCED.exec(text.trim())?.[1]
141
+ if (inside === undefined) return text
142
+ try {
143
+ JSON.parse(inside)
144
+ return inside.trim()
145
+ } catch {
146
+ return text
147
+ }
148
+ }
149
+
126
150
  const newId = () => `chatcmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`
127
151
  const now = () => Math.floor(Date.now() / 1000)
128
152
 
@@ -169,7 +193,9 @@ export async function collectCompletion(
169
193
  ? structured.content(answer)
170
194
  : null
171
195
  : exchange.structuredTool === undefined && text.length > 0
172
- ? text
196
+ ? exchange.jsonObject
197
+ ? unfence(text)
198
+ : text
173
199
  : null,
174
200
  refusal: null,
175
201
  }
@@ -229,6 +255,18 @@ export async function streamCompletion(
229
255
  started = true
230
256
  chunk({ role: "assistant", content: "" })
231
257
  }
258
+ // json_object: text that opens with a backtick may be a fenced answer, so it waits (as `pending`)
259
+ // until a tool call or the finish shows the whole text; anything else streams as it comes. A
260
+ // fenced answer therefore arrives as one chunk.
261
+ let pending = ""
262
+ let passing = false
263
+ const flush = () => {
264
+ if (pending.length === 0) return
265
+ const text = unfence(pending)
266
+ pending = ""
267
+ start()
268
+ chunk({ content: text })
269
+ }
232
270
 
233
271
  for await (const event of events) {
234
272
  if (event.type === "error") {
@@ -258,6 +296,16 @@ export async function streamCompletion(
258
296
  }
259
297
  if (!(exchange.callerTools && event.type === "tool-call")) continue
260
298
  }
299
+ if (exchange.jsonObject && event.type === "text-delta" && !passing) {
300
+ pending += event.text
301
+ const lead = pending.trimStart()
302
+ if (lead.length > 0 && !lead.startsWith("`")) {
303
+ passing = true
304
+ flush()
305
+ }
306
+ continue
307
+ }
308
+ if (event.type !== "text-delta") flush()
261
309
  start()
262
310
  if (event.type === "text-delta") {
263
311
  if (event.text.length > 0) chunk({ content: event.text })
@@ -276,6 +324,7 @@ export async function streamCompletion(
276
324
  write("data: [DONE]\n\n")
277
325
  return { failure, started }
278
326
  }
327
+ flush()
279
328
  start()
280
329
  if (held && isStructured) chunk({ content: structured.content(held) })
281
330
  chunk({}, finishReason(finish!, calls, isStructured))
@@ -33,6 +33,7 @@ import { listAvailableModels, resolveModel } from "../models.js"
33
33
  import { serveResponses } from "../responses/route.js"
34
34
  import { parseChatRequest } from "./request.js"
35
35
  import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
36
+ import { dropRejected } from "./sampling.js"
36
37
 
37
38
  /** What the serving generation supplies per request. */
38
39
  export interface RouteHost {
@@ -43,6 +44,7 @@ export interface RouteHost {
43
44
  }
44
45
 
45
46
  const SESSION_HEADER = "x-plugboard-session"
47
+ const DROPPED_HEADER = "x-plugboard-dropped-params"
46
48
  const MODEL_PREFIX = "/v1/models/"
47
49
  const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses"]
48
50
 
@@ -61,6 +63,8 @@ interface RequestInfo {
61
63
  model?: string
62
64
  /** Set when a response committed as 200 and then failed (a stream error frame): the log outcome and the error code. */
63
65
  failed?: string
66
+ /** Sampling parameters dropped for this model (comma list), when any. */
67
+ dropped?: string
64
68
  }
65
69
 
66
70
  export function createOpenAiRoute(host: RouteHost) {
@@ -127,7 +131,13 @@ export function createOpenAiRoute(host: RouteHost) {
127
131
  const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
128
132
  if (isPlugError(ref)) return fail(response, ref)
129
133
  info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
130
- const call: PlugRequest = { ...parsed.request, ref }
134
+ // Sampling parameters the model is known to reject are dropped and reported (header and log), before any response bytes.
135
+ const sampled = host.options.dropUnsupportedParams ? dropRejected(ref, parsed.request.generation) : undefined
136
+ if (sampled && sampled.dropped.length > 0) {
137
+ info.dropped = sampled.dropped.join(",")
138
+ response.setHeader(DROPPED_HEADER, info.dropped)
139
+ }
140
+ const call: PlugRequest = { ...parsed.request, ...(sampled ? { generation: sampled.generation } : {}), ref }
131
141
  const { exchange } = parsed
132
142
 
133
143
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
@@ -145,14 +155,19 @@ export function createOpenAiRoute(host: RouteHost) {
145
155
  )
146
156
 
147
157
  if (exchange.stream) {
148
- // Nothing is committed until the encoder writes its first frame, so a failure before any
149
- // wire-visible output (including suppressed reasoning or structured prose) is a real HTTP error.
158
+ // Nothing is committed until the first model event that is not a terminal one (reasoning
159
+ // counts: `paced` then commits 200 and sends keepalive comments), so a failure before that is a
160
+ // real HTTP error. After it, a failure is an SSE error frame.
150
161
  const sse = sseWriter(response, scope.signal)
151
- const { failure } = await streamCompletion(sse.paced(events), exchange, sse.write)
162
+ const { failure, started } = await streamCompletion(sse.paced(events), exchange, sse.write)
152
163
  if (!sse.started && !response.headersSent) {
153
164
  if (!scope.signal.aborted || scope.timedOut) fail(response, failure ?? { status: 502, kind: "server", message: "No response from the model" })
154
165
  } else {
155
- if (failure) info.failed = failure.code ?? failure.kind
166
+ if (failure) {
167
+ info.failed = failure.code ?? failure.kind
168
+ // Committed by a keepalive before the encoder wrote anything: the encoder left the error for us.
169
+ if (!started && !response.writableEnded) sse.write(`data: ${JSON.stringify(encodeError(failure))}\n\ndata: [DONE]\n\n`)
170
+ }
156
171
  if (!response.writableEnded) response.end()
157
172
  }
158
173
  return
@@ -207,7 +222,7 @@ export function createOpenAiRoute(host: RouteHost) {
207
222
  finishRequest(request, response)
208
223
  } catch {}
209
224
  if (info.route !== "GET /healthz" || outcome !== "ok") {
210
- const fields = { id, route: info.route, app: info.app, model: info.model, status: response.statusCode, ms: Date.now() - started, outcome, error }
225
+ const fields = { id, route: info.route, app: info.app, model: info.model, dropped: info.dropped, status: response.statusCode, ms: Date.now() - started, outcome, error }
211
226
  if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
212
227
  else log("request", fields)
213
228
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Sampling parameters a model is known to reject.
3
+ *
4
+ * OpenAI clients send `temperature`, `top_p` and the penalties as if every model took them, but
5
+ * some providers answer a 400 for them. The OpenCode host passes them through untouched, so
6
+ * plugboard drops the ones a rule below says the model refuses and reports each drop in the
7
+ * `x-plugboard-dropped-params` response header (option `dropUnsupportedParams` turns this off).
8
+ *
9
+ * The rules are an observed table, not a specification: each line says what the provider answered
10
+ * when it was probed (OpenCode 2.0.26, October 2026). A model no rule matches keeps every
11
+ * parameter, so a new model that rejects one still fails with the provider's own 400.
12
+ */
13
+ import type { ModelRef, PlugGeneration } from "../contract.js"
14
+
15
+ type Param = "temperature" | "topP" | "frequencyPenalty" | "presencePenalty"
16
+
17
+ /** Wire names, in the order the header lists them. */
18
+ const WIRE: ReadonlyArray<readonly [Param, string]> = [
19
+ ["temperature", "temperature"],
20
+ ["topP", "top_p"],
21
+ ["frequencyPenalty", "frequency_penalty"],
22
+ ["presencePenalty", "presence_penalty"],
23
+ ]
24
+
25
+ interface Rule {
26
+ readonly applies: (ref: ModelRef) => boolean
27
+ /** `null`: the model takes no value; a number: the only value it takes (any other is dropped). */
28
+ readonly only?: Readonly<Partial<Record<Param, number | null>>>
29
+ /** The model takes the key or the one it is listed under, not both: the listed one is dropped when the other is set. */
30
+ readonly notWith?: Readonly<Partial<Record<Param, Param>>>
31
+ }
32
+
33
+ /** `claude-opus-4-7`, `claude-sonnet-5.5`, `claude-haiku-5-5-20260101`: family and version. */
34
+ const claude = (id: string): { modern: boolean } | undefined => {
35
+ if (/^claude-fable/.test(id)) return { modern: true }
36
+ const match = /^claude-(?:opus|sonnet|haiku)-(\d+)(?:[-.](\d{1,2})(?!\d))?/.exec(id)
37
+ if (!match) return undefined
38
+ const major = Number(match[1])
39
+ const minor = Number(match[2] ?? 0)
40
+ // Opus 4.7 and newer, and every 5.x: temperature and top_p are deprecated, or limited to
41
+ // their defaults while adaptive thinking is on (which is the default).
42
+ return { modern: major > 4 || (major === 4 && minor >= 7) }
43
+ }
44
+
45
+ const gpt = (id: string) => /^gpt-[56]/.test(id)
46
+
47
+ const RULES: ReadonlyArray<Rule> = [
48
+ // Claude through the Anthropic protocol: 400 "`temperature` is deprecated for this model" /
49
+ // "`temperature` may only be set to 1 when thinking is enabled or in adaptive mode".
50
+ // Probed: anthropic opus-4-7, opus-4-8, opus-5, opus-5-5, sonnet-5, sonnet-5-5, haiku-5-5,
51
+ // fable-5, fable-5-1; opencode-go claude-haiku-5-5. GitHub Copilot's haiku-5.5 took them.
52
+ {
53
+ applies: (ref) => ref.providerID !== "github-copilot" && claude(ref.id)?.modern === true,
54
+ only: { temperature: null, topP: null },
55
+ },
56
+ // Older Claude: 400 "`temperature` and `top_p` cannot both be specified for this model".
57
+ // Probed: anthropic haiku-4-5, sonnet-4-5, sonnet-4-6, opus-4-5, opus-4-6; copilot haiku-4.5.
58
+ { applies: (ref) => claude(ref.id)?.modern === false, notWith: { topP: "temperature" } },
59
+ // GPT-5 and 6 reasoning models: 400 "Unsupported parameter: temperature" (and the other three).
60
+ // Probed: openai gpt-5.5, gpt-5.6-luna, gpt-6.1-sol; opencode-go gpt-5.6-luna, gpt-6-luna.
61
+ { applies: (ref) => gpt(ref.id) && ref.providerID !== "github-copilot", only: { temperature: null, topP: null, frequencyPenalty: null, presencePenalty: null } },
62
+ // Copilot's GPT-5/6 refuse only temperature and top_p (gpt-5-mini, gpt-5.5, gpt-6-luna; penalties passed).
63
+ { applies: (ref) => gpt(ref.id) && ref.providerID === "github-copilot", only: { temperature: null, topP: null } },
64
+ // opencode-go glm-5.3-flash: 400 "only frequency_penalty=0 is supported".
65
+ { applies: (ref) => ref.providerID === "opencode-go" && ref.id === "glm-5.3-flash", only: { frequencyPenalty: 0, presencePenalty: 0 } },
66
+ // opencode-go kimi-k2.7-code: 400 "invalid temperature: only 1 is allowed for this model" (and the other three).
67
+ {
68
+ applies: (ref) => ref.providerID === "opencode-go" && ref.id === "kimi-k2.7-code",
69
+ only: { temperature: 1, topP: 0.95, frequencyPenalty: 0, presencePenalty: 0 },
70
+ },
71
+ ]
72
+
73
+ export interface Sampled {
74
+ readonly generation: PlugGeneration
75
+ /** Wire names of the dropped parameters, in header order (`temperature`, `top_p`, `frequency_penalty`, `presence_penalty`). */
76
+ readonly dropped: ReadonlyArray<string>
77
+ }
78
+
79
+ /** `generation` without the parameters the model is known to reject, and which ones those were. */
80
+ export function dropRejected(ref: ModelRef, generation: PlugGeneration): Sampled {
81
+ const rules = RULES.filter((rule) => rule.applies(ref))
82
+ if (rules.length === 0) return { generation, dropped: [] }
83
+ const out: { -readonly [K in keyof PlugGeneration]: PlugGeneration[K] } = { ...generation }
84
+ const drop = new Set<Param>()
85
+ for (const rule of rules)
86
+ for (const [param, allowed] of Object.entries(rule.only ?? {}) as Array<[Param, number | null]>) {
87
+ const value = generation[param]
88
+ if (value !== undefined && value !== allowed) drop.add(param)
89
+ }
90
+ // `notWith` looks at what survives the rules above.
91
+ for (const rule of rules)
92
+ for (const [param, other] of Object.entries(rule.notWith ?? {}) as Array<[Param, Param]>)
93
+ if (generation[param] !== undefined && !drop.has(param) && generation[other] !== undefined && !drop.has(other)) drop.add(param)
94
+ for (const param of drop) delete out[param]
95
+ return { generation: out, dropped: WIRE.filter(([param]) => drop.has(param)).map(([, wire]) => wire) }
96
+ }