opencode-plugboard 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +228 -2
- package/index.ts +99 -0
- package/package.json +32 -4
- package/src/config.ts +150 -0
- package/src/contract.ts +155 -0
- package/src/http.ts +324 -0
- package/src/log.ts +28 -0
- package/src/models.ts +100 -0
- package/src/openai/request.ts +334 -0
- package/src/openai/response.ts +286 -0
- package/src/openai/route.ts +214 -0
- package/src/runtime.ts +463 -0
- package/src/session/caller.ts +192 -0
- package/src/session/context.ts +175 -0
- package/src/session/events.ts +344 -0
- package/src/session/history.ts +89 -0
- package/src/session/host.ts +175 -0
- package/src/session/media.ts +89 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +134 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jjjermiah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,229 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-plugboard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
An OpenCode V2 plugin that turns your OpenCode server into an OpenAI-compatible endpoint, backed by the providers you already connected there.
|
|
4
|
+
|
|
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.
|
|
6
|
+
|
|
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: v0.1, in development and not published yet. Chat Completions and the model list only. There is no `/v1/responses`, see [Known gaps](#known-gaps) and the [roadmap](#roadmap).
|
|
10
|
+
|
|
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).
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
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.
|
|
16
|
+
|
|
17
|
+
```jsonc
|
|
18
|
+
{
|
|
19
|
+
"plugins": [
|
|
20
|
+
{
|
|
21
|
+
"package": "opencode-plugboard",
|
|
22
|
+
"options": {
|
|
23
|
+
"keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
|
|
24
|
+
"models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
|
|
25
|
+
"aliases": { "fast": "opencode-go/glm-5.3-flash" }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
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`.
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string # before starting OpenCode
|
|
36
|
+
|
|
37
|
+
curl http://127.0.0.1:4010/v1/chat/completions \
|
|
38
|
+
-H "Authorization: Bearer $PLUGBOARD_KEY_MY_APP" \
|
|
39
|
+
-H "Content-Type: application/json" \
|
|
40
|
+
-d '{"model": "fast", "messages": [{"role": "user", "content": "reply with exactly: pong"}]}'
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Any OpenAI SDK works by changing the base URL and key:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from openai import OpenAI
|
|
47
|
+
|
|
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
|
+
```
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
import OpenAI from "openai"
|
|
58
|
+
|
|
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
|
+
```
|
|
67
|
+
|
|
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).
|
|
69
|
+
|
|
70
|
+
## Clients
|
|
71
|
+
|
|
72
|
+
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).
|
|
73
|
+
|
|
74
|
+
| Client | Page | Last run (2026-10-11) |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| OpenAI Python SDK | [openai-python](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-python.md) | pass |
|
|
77
|
+
| OpenAI JS SDK | [openai-js](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-js.md) | pass |
|
|
78
|
+
| 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 `@ai-sdk/openai` call needs `/v1/responses` |
|
|
79
|
+
| LangChain Python | [langchain-python](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/langchain-python.md) | pass; `json_mode` on `fast` was unreliable |
|
|
80
|
+
| 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` |
|
|
81
|
+
| pydantic-ai | [pydantic-ai](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/pydantic-ai.md) | pass with `OpenAIChatModel` |
|
|
82
|
+
| 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 |
|
|
83
|
+
| n8n | [n8n](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/n8n.md) | pass with "Use Responses API" off |
|
|
84
|
+
| Anything else | [any-app](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md) | base URL, key, model id |
|
|
85
|
+
|
|
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 release-candidate record has not been written yet.
|
|
87
|
+
|
|
88
|
+
## Known gaps
|
|
89
|
+
|
|
90
|
+
- **No `/v1/responses` yet.** Clients that default to the Responses API fail with `404 No route for POST /v1/responses`. That includes the Vercel AI SDK's `@ai-sdk/openai` default call (use `createOpenAICompatible` or `.chat(id)`), LangChain JS for `gpt-5.6*` ids, and n8n's "Use Responses API" option (turn it off).
|
|
91
|
+
- Only `n=1`, no `logprobs`, no embeddings, audio or image generation.
|
|
92
|
+
- No history pruning, no per-app concurrency limit.
|
|
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
|
+
| `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. |
|
|
108
|
+
|
|
109
|
+
Changes to `listen` need a server restart; other option changes apply on config reload.
|
|
110
|
+
|
|
111
|
+
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.
|
|
112
|
+
|
|
113
|
+
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).
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
## Models
|
|
118
|
+
|
|
119
|
+
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`:
|
|
120
|
+
|
|
121
|
+
- `provider/model` allows that model with any variant.
|
|
122
|
+
- `provider/model#variant` allows exactly that variant.
|
|
123
|
+
- `provider/*` allows every model of the provider.
|
|
124
|
+
|
|
125
|
+
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.
|
|
126
|
+
|
|
127
|
+
`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.
|
|
128
|
+
|
|
129
|
+
## Apps and keys
|
|
130
|
+
|
|
131
|
+
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.
|
|
132
|
+
|
|
133
|
+
## History
|
|
134
|
+
|
|
135
|
+
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.
|
|
136
|
+
|
|
137
|
+
Each session is filed under a per-app directory, so it never shows up in your real projects:
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
$XDG_DATA_HOME/opencode-plugboard/<app>/ # default ~/.local/share/opencode-plugboard/<app>/
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
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).
|
|
144
|
+
|
|
145
|
+
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. Nothing deletes them yet ([pruning is a roadmap item](https://github.com/jjjermiah/opencode-plugboard/issues/8)).
|
|
146
|
+
|
|
147
|
+
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.
|
|
148
|
+
|
|
149
|
+
History records every prompt and reply, so treat OpenCode's database as sensitive.
|
|
150
|
+
|
|
151
|
+
## Warm-up for always-on servers
|
|
152
|
+
|
|
153
|
+
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.
|
|
154
|
+
|
|
155
|
+
On Linux, in the OpenCode systemd unit (retry until the server answers; pass the same server credentials your service uses, if you set any):
|
|
156
|
+
|
|
157
|
+
```ini
|
|
158
|
+
[Service]
|
|
159
|
+
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'
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
```xml
|
|
165
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
166
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
167
|
+
<plist version="1.0">
|
|
168
|
+
<dict>
|
|
169
|
+
<key>Label</key><string>dev.plugboard.warmup</string>
|
|
170
|
+
<key>ProgramArguments</key>
|
|
171
|
+
<array>
|
|
172
|
+
<string>/bin/sh</string>
|
|
173
|
+
<string>-c</string>
|
|
174
|
+
<string>for i in $(seq 1 30); do opencode api get /api/agent >/dev/null 2>&1 && exit 0; sleep 1; done; exit 1</string>
|
|
175
|
+
</array>
|
|
176
|
+
<key>RunAtLoad</key><true/>
|
|
177
|
+
</dict>
|
|
178
|
+
</plist>
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
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)).
|
|
182
|
+
|
|
183
|
+
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.
|
|
184
|
+
|
|
185
|
+
## Limits and errors
|
|
186
|
+
|
|
187
|
+
Errors use the OpenAI error body shape with the matching HTTP status.
|
|
188
|
+
|
|
189
|
+
| Status | `code` | When | Message |
|
|
190
|
+
| --- | --- | --- | --- |
|
|
191
|
+
| 401 | `invalid_api_key` | Missing or wrong key | Missing or invalid API key |
|
|
192
|
+
| 404 | `model_not_found` | Unknown model or not on the allowlist | Model <m> is not available |
|
|
193
|
+
| 403 | `no_models_configured` | The `models` option is empty | No models are allowed: set the plugboard `models` option |
|
|
194
|
+
| 400 | `too_many_tools` | More tools than `maxTools` | Request has <n> tools; plugboard allows <max> (option `maxTools`) |
|
|
195
|
+
| 400 | `unsupported_parameter` | `n` above 1, `logprobs`, `top_logprobs` | names the parameter |
|
|
196
|
+
|
|
197
|
+
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.
|
|
198
|
+
|
|
199
|
+
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`).
|
|
200
|
+
|
|
201
|
+
Behaviours worth knowing:
|
|
202
|
+
|
|
203
|
+
- `parallel_tool_calls` and `seed` are accepted and ignored.
|
|
204
|
+
- `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.
|
|
205
|
+
- `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.
|
|
206
|
+
- 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.
|
|
207
|
+
- A streamed tool call arrives as one delta carrying the whole arguments string.
|
|
208
|
+
|
|
209
|
+
## How it works
|
|
210
|
+
|
|
211
|
+
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.
|
|
212
|
+
|
|
213
|
+
1. A front-end parses the OpenAI request into a format-neutral request, and the core checks the key and resolves the model.
|
|
214
|
+
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.
|
|
215
|
+
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.
|
|
216
|
+
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.
|
|
217
|
+
5. The front-end encodes the events as JSON or SSE, and the session stays as history.
|
|
218
|
+
|
|
219
|
+
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.
|
|
220
|
+
|
|
221
|
+
## Roadmap
|
|
222
|
+
|
|
223
|
+
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/responses`, `/v1/messages` and Gemini front-ends, agent mode, a native backend, history pruning and an upstream request to boot plugins at server start.
|
|
224
|
+
|
|
225
|
+
Not planned: V1 support, embeddings, audio, image generation.
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-plugboard: an OpenAI-compatible HTTP endpoint served from inside an OpenCode V2
|
|
3
|
+
* server, backed by the server's own providers.
|
|
4
|
+
*
|
|
5
|
+
* Install in the GLOBAL OpenCode config. OpenCode loads the plugin once per Location:
|
|
6
|
+
* - everywhere, a generation asks to join the process-wide listener (src/runtime.ts); an
|
|
7
|
+
* accepted one can serve requests (a confirmed app-directory Location is preferred, else the
|
|
8
|
+
* newest), a rejected one does nothing at all;
|
|
9
|
+
* - every accepted generation also registers the context hook, the agent deny and the seed
|
|
10
|
+
* tools, but all three are no-ops until a request has confirmed, asynchronously, that the
|
|
11
|
+
* Location is a plugboard app directory (`.../opencode-plugboard/<app>/`, real path strictly
|
|
12
|
+
* inside the data root; `ensureBackend` in src/runtime.ts). Until then, and in every other
|
|
13
|
+
* project, nothing is rewritten.
|
|
14
|
+
*
|
|
15
|
+
* Freeze safety: this effect only registers and returns. It makes no `ctx` call that waits on
|
|
16
|
+
* location readiness, touches no filesystem and never blocks, and its finalizer is synchronous
|
|
17
|
+
* and calls nothing on `ctx` (a plugin effect that blocked once froze every prompt in a project).
|
|
18
|
+
* The event pump starts lazily from request handling. See docs/architecture.md.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Plugin } from "@opencode/plugin/effect"
|
|
22
|
+
import { Effect } from "effect"
|
|
23
|
+
import { parseOptions } from "./src/config.js"
|
|
24
|
+
import { errorMessage, logError } from "./src/log.js"
|
|
25
|
+
import { createOpenAiRoute } from "./src/openai/route.js"
|
|
26
|
+
import { ensureBackend, ensurePump, loadServingCatalog, registerGeneration, sessionStore } from "./src/runtime.js"
|
|
27
|
+
import { createSessionCaller } from "./src/session/caller.js"
|
|
28
|
+
import { installSessionHooks } from "./src/session/context.js"
|
|
29
|
+
import { createSessionPort, loadCatalog } from "./src/session/host.js"
|
|
30
|
+
|
|
31
|
+
export default Plugin.define({
|
|
32
|
+
id: "opencode-plugboard",
|
|
33
|
+
effect: (ctx) =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
let options
|
|
36
|
+
try {
|
|
37
|
+
options = parseOptions(ctx.options)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
// Invalid options: stay inert and say why, instead of failing the Location's plugin load.
|
|
40
|
+
logError("options.invalid", { error: errorMessage(error) })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---- every Location: join the shared listener (sync registration only).
|
|
45
|
+
const sessions = sessionStore()
|
|
46
|
+
// Started lazily from request handling (handler, catalog load); never from setup.
|
|
47
|
+
const startPump = () => ensurePump(() => ctx.event.subscribe())
|
|
48
|
+
const ownCatalog = () => loadCatalog(ctx)
|
|
49
|
+
// Request handling only: an agent lookup scoped to another directory makes the host build that
|
|
50
|
+
// Location, which loads this plugin there. Plugin setup never calls it.
|
|
51
|
+
const bootLocation = (directory: string) => Effect.runPromise(ctx.agent.list({ location: { directory } }))
|
|
52
|
+
const callerDeps = {
|
|
53
|
+
port: createSessionPort(ctx),
|
|
54
|
+
sessions,
|
|
55
|
+
maxTools: options.maxTools,
|
|
56
|
+
remoteImages: options.remoteImages,
|
|
57
|
+
// The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
|
|
58
|
+
ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
|
|
59
|
+
}
|
|
60
|
+
const handle = createOpenAiRoute({
|
|
61
|
+
options,
|
|
62
|
+
caller: createSessionCaller(callerDeps),
|
|
63
|
+
// The catalog comes from the preferred accepted generation (a data-root Location when there is
|
|
64
|
+
// one), not from whichever Location registered last; this generation's own is the fallback.
|
|
65
|
+
loadCatalog: async () => {
|
|
66
|
+
startPump()
|
|
67
|
+
return loadServingCatalog(ownCatalog)
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
const registration = registerGeneration({
|
|
71
|
+
listen: options.listen,
|
|
72
|
+
options,
|
|
73
|
+
// The host's string, compared with the real path `ensureBackend` confirms; no filesystem access here.
|
|
74
|
+
directory: ctx.location.directory,
|
|
75
|
+
// Rebuild the transforms below once a request has confirmed this Location as an app directory.
|
|
76
|
+
refresh: () => Effect.runPromise(Effect.all([ctx.agent.reload(), ctx.tool.reload()], { discard: true })),
|
|
77
|
+
handler: (request, response) => {
|
|
78
|
+
startPump()
|
|
79
|
+
handle(request, response).catch((error) => {
|
|
80
|
+
logError("request.unhandled", { error: errorMessage(error) })
|
|
81
|
+
if (response.headersSent) return void response.end()
|
|
82
|
+
response.writeHead(500, { "content-type": "application/json" })
|
|
83
|
+
response.end(JSON.stringify({ error: { message: "Internal server error", type: "server_error" } }))
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
loadCatalog: ownCatalog,
|
|
87
|
+
})
|
|
88
|
+
yield* Effect.addFinalizer(() => Effect.sync(() => registration.dispose()))
|
|
89
|
+
|
|
90
|
+
// ---- a rejected generation (different options or `listen`, foreign runtime) serves nothing and installs nothing.
|
|
91
|
+
if (!registration.accepted) return
|
|
92
|
+
|
|
93
|
+
// ---- the context hook, the agent deny and the seed tools. Ownership of the Location is only
|
|
94
|
+
// known after an asynchronous check (`ensureBackend`), so all three are no-ops until
|
|
95
|
+
// `registration.confirmed()`: in real projects nothing is ever added or rewritten.
|
|
96
|
+
yield* installSessionHooks(ctx, { maxTools: options.maxTools, sessions, active: registration.confirmed })
|
|
97
|
+
registration.ready()
|
|
98
|
+
}),
|
|
99
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
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.",
|
|
7
|
+
"keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions"],
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jjjermiah/opencode-plugboard.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/jjjermiah/opencode-plugboard/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/jjjermiah/opencode-plugboard#readme",
|
|
16
|
+
"main": "./index.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": ["index.ts", "src/", "README.md", "LICENSE"],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@opencode/plugin": "2.0.26",
|
|
26
|
+
"@types/node": "^26.6.2",
|
|
27
|
+
"effect": "4.0.0-rc.112",
|
|
28
|
+
"typescript": "^5.9.3"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@opencode/plugin": ">=2.0.26 <2.1",
|
|
32
|
+
"effect": "4.0.0-rc.112"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
|
|
2
|
+
|
|
3
|
+
import type { PlugError } from "./contract.js"
|
|
4
|
+
|
|
5
|
+
/** Plugin options, after defaults and validation (see `parseOptions`). */
|
|
6
|
+
export interface PlugboardOptions {
|
|
7
|
+
readonly listen: { readonly host: string; readonly port: number }
|
|
8
|
+
/** App name -> API key. App names match APP_NAME_PATTERN; "default" is reserved. */
|
|
9
|
+
readonly keys: Readonly<Record<string, string>>
|
|
10
|
+
/** Allowlist: exact `provider/model`, `provider/model#variant`, or `provider/*` patterns. Empty = nothing allowed. */
|
|
11
|
+
readonly models: ReadonlyArray<string>
|
|
12
|
+
/** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
|
|
13
|
+
readonly aliases: Readonly<Record<string, string>>
|
|
14
|
+
readonly maxTools: number
|
|
15
|
+
readonly requestTimeoutMs: number
|
|
16
|
+
/** Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network. */
|
|
17
|
+
readonly remoteImages: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
21
|
+
export const DEFAULT_APP = "default"
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
host: "127.0.0.1",
|
|
24
|
+
port: 4010,
|
|
25
|
+
maxTools: 32,
|
|
26
|
+
requestTimeoutMs: 600_000,
|
|
27
|
+
} as const
|
|
28
|
+
|
|
29
|
+
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages"])
|
|
30
|
+
const MODEL_PATTERN = /^[^/#*\s]+\/(\*|[^#*\s]+(#[^#*\s]+)?)$/
|
|
31
|
+
const ALIAS_TARGET = /^[^/#*\s]+\/[^#*\s]+(#[^#*\s]+)?$/
|
|
32
|
+
/** `provider/model...` shaped: an alias like this would collide with real model ids in listing and resolution. */
|
|
33
|
+
const CANONICAL_SHAPE = /^[^/\s]+\/\S/
|
|
34
|
+
/** Largest delay Node timers accept (2^31 - 1); longer values overflow and fire immediately. */
|
|
35
|
+
const MAX_TIMER_MS = 2_147_483_647
|
|
36
|
+
|
|
37
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The hosts plugboard treats as loopback: `localhost`, `127.0.0.1` and `::1` (brackets and case
|
|
43
|
+
* ignored). This is exactly the set the Host header check in `src/http.ts` accepts, so a listen
|
|
44
|
+
* address that passes the keyless-loopback rule can also be reached by a request that passes the
|
|
45
|
+
* Host check; other loopback addresses (`127.0.0.2`) are not loopback here and need `keys`.
|
|
46
|
+
*/
|
|
47
|
+
export function isLoopbackHost(host: string): boolean {
|
|
48
|
+
const h = host.toLowerCase().replace(/^\[|\]$/g, "")
|
|
49
|
+
return h === "localhost" || h === "127.0.0.1" || h === "::1"
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function positiveInt(value: unknown, name: string, fallback: number, max = Number.MAX_SAFE_INTEGER): number {
|
|
53
|
+
if (value === undefined) return fallback
|
|
54
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max)
|
|
55
|
+
throw new Error(`plugboard option \`${name}\` must be an integer between 1 and ${max}`)
|
|
56
|
+
return value
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
|
|
60
|
+
export function parseOptions(raw: unknown): PlugboardOptions {
|
|
61
|
+
if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
|
|
62
|
+
const input: Record<string, unknown> = raw ?? {}
|
|
63
|
+
for (const key of Object.keys(input))
|
|
64
|
+
if (!KNOWN.has(key)) throw new Error(`Unknown plugboard option \`${key}\` (known: ${[...KNOWN].join(", ")})`)
|
|
65
|
+
|
|
66
|
+
const listenInput = input.listen
|
|
67
|
+
if (listenInput !== undefined && !isRecord(listenInput)) throw new Error("plugboard option `listen` must be an object")
|
|
68
|
+
const host = listenInput?.host ?? DEFAULTS.host
|
|
69
|
+
if (typeof host !== "string" || host.length === 0) throw new Error("plugboard option `listen.host` must be a non-empty string")
|
|
70
|
+
const port = listenInput?.port
|
|
71
|
+
if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535))
|
|
72
|
+
throw new Error("plugboard option `listen.port` must be an integer between 1 and 65535")
|
|
73
|
+
|
|
74
|
+
const keyEntries: Array<[string, string]> = []
|
|
75
|
+
if (input.keys !== undefined && !isRecord(input.keys)) throw new Error("plugboard option `keys` must be an object of app name -> key")
|
|
76
|
+
const appOfKey = new Map<string, string>()
|
|
77
|
+
for (const [app, key] of Object.entries(input.keys ?? {})) {
|
|
78
|
+
if (app === DEFAULT_APP) throw new Error(`plugboard key name \`${DEFAULT_APP}\` is reserved for keyless loopback use`)
|
|
79
|
+
if (!APP_NAME_PATTERN.test(app)) throw new Error(`plugboard key name \`${app}\` must match ${APP_NAME_PATTERN}`)
|
|
80
|
+
if (typeof key !== "string" || key.length === 0)
|
|
81
|
+
throw new Error(`plugboard key \`${app}\` must be a non-empty string (is its {env:VAR} set?)`)
|
|
82
|
+
// Two apps sharing a key value would be indistinguishable: the last one would win silently.
|
|
83
|
+
const other = appOfKey.get(key)
|
|
84
|
+
if (other !== undefined) throw new Error(`plugboard keys \`${other}\` and \`${app}\` have the same value; every app needs its own key`)
|
|
85
|
+
appOfKey.set(key, app)
|
|
86
|
+
keyEntries.push([app, key])
|
|
87
|
+
}
|
|
88
|
+
// Own-property construction: a key such as `__proto__` must stay a key, never reach the prototype.
|
|
89
|
+
const keys: Record<string, string> = Object.fromEntries(keyEntries)
|
|
90
|
+
if (keyEntries.length === 0 && !isLoopbackHost(host))
|
|
91
|
+
throw new Error(`plugboard refuses to listen on non-loopback host \`${host}\` without \`keys\``)
|
|
92
|
+
|
|
93
|
+
const models = input.models ?? []
|
|
94
|
+
if (!Array.isArray(models)) throw new Error("plugboard option `models` must be an array of strings")
|
|
95
|
+
for (const pattern of models)
|
|
96
|
+
if (typeof pattern !== "string" || !MODEL_PATTERN.test(pattern))
|
|
97
|
+
throw new Error(`plugboard model \`${String(pattern)}\` must be \`provider/model\`, \`provider/model#variant\` or \`provider/*\``)
|
|
98
|
+
|
|
99
|
+
const aliasEntries: Array<[string, string]> = []
|
|
100
|
+
if (input.aliases !== undefined && !isRecord(input.aliases)) throw new Error("plugboard option `aliases` must be an object of alias -> model")
|
|
101
|
+
for (const [alias, target] of Object.entries(input.aliases ?? {})) {
|
|
102
|
+
if (alias.length === 0 || /[\s]/.test(alias)) throw new Error(`plugboard alias \`${alias}\` must be a non-empty string without whitespace`)
|
|
103
|
+
// Plain-object lookups downstream would hit the prototype accessor instead of the alias.
|
|
104
|
+
if (alias === "__proto__") throw new Error("plugboard alias `__proto__` is not allowed")
|
|
105
|
+
if (typeof target !== "string" || !ALIAS_TARGET.test(target))
|
|
106
|
+
throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\``)
|
|
107
|
+
if (CANONICAL_SHAPE.test(alias))
|
|
108
|
+
throw new Error(`plugboard alias \`${alias}\` is written like a \`provider/model\` id; aliases must not contain \`/\` (use a short name such as \`fast\`)`)
|
|
109
|
+
aliasEntries.push([alias, target])
|
|
110
|
+
}
|
|
111
|
+
const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
|
|
112
|
+
|
|
113
|
+
if (input.remoteImages !== undefined && typeof input.remoteImages !== "boolean")
|
|
114
|
+
throw new Error("plugboard option `remoteImages` must be a boolean")
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
listen: { host, port: port ?? DEFAULTS.port },
|
|
118
|
+
keys,
|
|
119
|
+
models: models as string[],
|
|
120
|
+
aliases,
|
|
121
|
+
maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
|
|
122
|
+
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
123
|
+
remoteImages: input.remoteImages === true,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const UNAUTHORIZED: PlugError = {
|
|
128
|
+
status: 401,
|
|
129
|
+
message: "Missing or invalid API key",
|
|
130
|
+
kind: "authentication",
|
|
131
|
+
code: "invalid_api_key",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function same(a: string, b: string): boolean {
|
|
135
|
+
let diff = a.length ^ b.length
|
|
136
|
+
const length = Math.max(a.length, b.length)
|
|
137
|
+
for (let i = 0; i < length; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0)
|
|
138
|
+
return diff === 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** No keys -> app `default`; with keys every request needs `Authorization: Bearer <key>`. */
|
|
142
|
+
export function authenticate(options: PlugboardOptions, authorization: string | undefined): { app: string } | PlugError {
|
|
143
|
+
const entries = Object.entries(options.keys)
|
|
144
|
+
if (entries.length === 0) return { app: DEFAULT_APP }
|
|
145
|
+
const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
|
|
146
|
+
if (!token) return UNAUTHORIZED
|
|
147
|
+
let app: string | undefined
|
|
148
|
+
for (const [name, key] of entries) if (same(token, key)) app = name
|
|
149
|
+
return app === undefined ? UNAUTHORIZED : { app }
|
|
150
|
+
}
|