opencode-plugboard 0.2.1 → 0.4.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 +9 -7
- package/index.ts +3 -1
- package/package.json +3 -2
- package/src/config.ts +20 -1
- package/src/http.ts +54 -11
- package/src/openai/request.ts +1 -0
- package/src/openai/response.ts +50 -1
- package/src/openai/route.ts +25 -8
- package/src/openai/sampling.ts +96 -0
- package/src/responses/request.ts +377 -0
- package/src/responses/response.ts +225 -0
- package/src/responses/route.ts +72 -0
- package/src/runtime.ts +76 -4
- package/src/session/host.ts +40 -0
- package/src/session/prune.ts +175 -0
- package/src/session/state.ts +11 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ You logged in to ChatGPT, Claude, Copilot or OpenCode Go once, in OpenCode. Your
|
|
|
6
6
|
|
|
7
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
8
|
|
|
9
|
-
Status:
|
|
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).
|
|
10
10
|
|
|
11
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
12
|
|
|
@@ -75,7 +75,7 @@ One short page per client, with a config snippet, a chat, a tool call, a structu
|
|
|
75
75
|
| --- | --- | --- |
|
|
76
76
|
| OpenAI Python SDK | [openai-python](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/openai-python.md) | pass |
|
|
77
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
|
|
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 `openai(id)` call works through `/v1/responses` (set `store: false` for multi-turn) |
|
|
79
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
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
81
|
| pydantic-ai | [pydantic-ai](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/pydantic-ai.md) | pass with `OpenAIChatModel` |
|
|
@@ -83,13 +83,13 @@ One short page per client, with a config snippet, a chat, a tool call, a structu
|
|
|
83
83
|
| n8n | [n8n](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/n8n.md) | pass with "Use Responses API" off |
|
|
84
84
|
| Anything else | [any-app](https://github.com/jjjermiah/opencode-plugboard/blob/main/docs/clients/any-app.md) | base URL, key, model id |
|
|
85
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
|
|
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
87
|
|
|
88
88
|
## Known gaps
|
|
89
89
|
|
|
90
|
-
-
|
|
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
91
|
- Only `n=1`, no `logprobs`, no embeddings, audio or image generation.
|
|
92
|
-
- No
|
|
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
93
|
|
|
94
94
|
## Options
|
|
95
95
|
|
|
@@ -104,6 +104,8 @@ Options live under `options` in the plugin entry. `{env:VAR}` is expanded by Ope
|
|
|
104
104
|
| `aliases` | none | Map of alias to `provider/model[#variant]`. Each target must also pass `models`. |
|
|
105
105
|
| `maxTools` | `32` | Most tools one request may carry. Fixed when the plugin loads. |
|
|
106
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
|
+
| `dropUnsupportedParams` | `true` | Drop `temperature`, `top_p` and penalties the target model is known to reject, and list them in the `x-plugboard-dropped-params` response header. `false` forwards them as sent. |
|
|
107
109
|
| `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
110
|
|
|
109
111
|
Changes to `listen` need a server restart; other option changes apply on config reload.
|
|
@@ -142,7 +144,7 @@ $XDG_DATA_HOME/opencode-plugboard/<app>/ # default ~/.local/share/opencod
|
|
|
142
144
|
|
|
143
145
|
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
146
|
|
|
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.
|
|
147
|
+
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`.
|
|
146
148
|
|
|
147
149
|
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
150
|
|
|
@@ -220,7 +222,7 @@ The model call sits behind one interface, `ModelCaller`, so a native backend can
|
|
|
220
222
|
|
|
221
223
|
## Roadmap
|
|
222
224
|
|
|
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/
|
|
225
|
+
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.
|
|
224
226
|
|
|
225
227
|
Not planned: V1 support, embeddings, audio, image generation.
|
|
226
228
|
|
package/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { createOpenAiRoute } from "./src/openai/route.js"
|
|
|
26
26
|
import { ensureBackend, ensurePump, loadServingCatalog, registerGeneration, sessionStore } from "./src/runtime.js"
|
|
27
27
|
import { createSessionCaller } from "./src/session/caller.js"
|
|
28
28
|
import { installSessionHooks } from "./src/session/context.js"
|
|
29
|
-
import { createSessionPort, loadCatalog } from "./src/session/host.js"
|
|
29
|
+
import { createHistoryPort, createSessionPort, loadCatalog } from "./src/session/host.js"
|
|
30
30
|
|
|
31
31
|
export default Plugin.define({
|
|
32
32
|
id: "opencode-plugboard",
|
|
@@ -74,6 +74,8 @@ export default Plugin.define({
|
|
|
74
74
|
directory: ctx.location.directory,
|
|
75
75
|
// Rebuild the transforms below once a request has confirmed this Location as an app directory.
|
|
76
76
|
refresh: () => Effect.runPromise(Effect.all([ctx.agent.reload(), ctx.tool.reload()], { discard: true })),
|
|
77
|
+
// Host handles for indexing and pruning request sessions; only called from request handling.
|
|
78
|
+
history: { port: createHistoryPort(ctx), limits: options.history },
|
|
77
79
|
handler: (request, response) => {
|
|
78
80
|
startPump()
|
|
79
81
|
handle(request, response).catch((error) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugboard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": ["index.ts", "src/", "README.md", "LICENSE"],
|
|
21
21
|
"scripts": {
|
|
22
|
-
"typecheck": "tsc --noEmit"
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"release": "bun scripts/release.ts"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@opencode/plugin": "2.0.26",
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
|
|
2
2
|
|
|
3
3
|
import type { PlugError } from "./contract.js"
|
|
4
|
+
import type { HistoryLimits } from "./session/prune.js"
|
|
4
5
|
|
|
5
6
|
/** Plugin options, after defaults and validation (see `parseOptions`). */
|
|
6
7
|
export interface PlugboardOptions {
|
|
@@ -15,6 +16,10 @@ export interface PlugboardOptions {
|
|
|
15
16
|
readonly requestTimeoutMs: number
|
|
16
17
|
/** Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network. */
|
|
17
18
|
readonly remoteImages: boolean
|
|
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
|
+
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
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
|
|
@@ -26,7 +31,10 @@ const DEFAULTS = {
|
|
|
26
31
|
requestTimeoutMs: 600_000,
|
|
27
32
|
} as const
|
|
28
33
|
|
|
29
|
-
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages"])
|
|
34
|
+
const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams"])
|
|
35
|
+
const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
|
|
36
|
+
/** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
|
|
37
|
+
const MAX_AGE_DAYS = 36_500
|
|
30
38
|
const MODEL_PATTERN = /^[^/#*\s]+\/(\*|[^#*\s]+(#[^#*\s]+)?)$/
|
|
31
39
|
const ALIAS_TARGET = /^[^/#*\s]+\/[^#*\s]+(#[^#*\s]+)?$/
|
|
32
40
|
/** `provider/model...` shaped: an alias like this would collide with real model ids in listing and resolution. */
|
|
@@ -112,6 +120,15 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
112
120
|
|
|
113
121
|
if (input.remoteImages !== undefined && typeof input.remoteImages !== "boolean")
|
|
114
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")
|
|
125
|
+
|
|
126
|
+
const historyInput = input.history
|
|
127
|
+
if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
|
|
128
|
+
for (const key of Object.keys(historyInput ?? {}))
|
|
129
|
+
if (!HISTORY_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`history.${key}\` (known: ${[...HISTORY_KNOWN].join(", ")})`)
|
|
130
|
+
const maxSessions = historyInput?.maxSessions === undefined ? undefined : positiveInt(historyInput.maxSessions, "history.maxSessions", 0)
|
|
131
|
+
const maxAgeDays = historyInput?.maxAgeDays === undefined ? undefined : positiveInt(historyInput.maxAgeDays, "history.maxAgeDays", 0, MAX_AGE_DAYS)
|
|
115
132
|
|
|
116
133
|
return {
|
|
117
134
|
listen: { host, port: port ?? DEFAULTS.port },
|
|
@@ -121,6 +138,8 @@ export function parseOptions(raw: unknown): PlugboardOptions {
|
|
|
121
138
|
maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
|
|
122
139
|
requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
|
|
123
140
|
remoteImages: input.remoteImages === true,
|
|
141
|
+
history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
|
|
142
|
+
dropUnsupportedParams: input.dropUnsupportedParams !== false,
|
|
124
143
|
}
|
|
125
144
|
}
|
|
126
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
|
-
|
|
230
|
-
|
|
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
|
},
|
package/src/openai/request.ts
CHANGED
|
@@ -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
|
}
|
package/src/openai/response.ts
CHANGED
|
@@ -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
|
-
?
|
|
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))
|
package/src/openai/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
|
|
3
|
-
* `POST /v1/chat/completions`.
|
|
3
|
+
* `POST /v1/chat/completions`, and the dispatch of `POST /v1/responses` to `src/responses/route.ts`.
|
|
4
4
|
*
|
|
5
5
|
* The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
|
|
6
6
|
* the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
|
|
@@ -30,8 +30,10 @@ import {
|
|
|
30
30
|
} from "../http.js"
|
|
31
31
|
import { errorMessage, log, logError } from "../log.js"
|
|
32
32
|
import { listAvailableModels, resolveModel } from "../models.js"
|
|
33
|
+
import { serveResponses } from "../responses/route.js"
|
|
33
34
|
import { parseChatRequest } from "./request.js"
|
|
34
35
|
import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
|
|
36
|
+
import { dropRejected } from "./sampling.js"
|
|
35
37
|
|
|
36
38
|
/** What the serving generation supplies per request. */
|
|
37
39
|
export interface RouteHost {
|
|
@@ -42,8 +44,9 @@ export interface RouteHost {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const SESSION_HEADER = "x-plugboard-session"
|
|
47
|
+
const DROPPED_HEADER = "x-plugboard-dropped-params"
|
|
45
48
|
const MODEL_PREFIX = "/v1/models/"
|
|
46
|
-
const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions"]
|
|
49
|
+
const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses"]
|
|
47
50
|
|
|
48
51
|
const decodeSegment = (raw: string): string => {
|
|
49
52
|
try {
|
|
@@ -60,6 +63,8 @@ interface RequestInfo {
|
|
|
60
63
|
model?: string
|
|
61
64
|
/** Set when a response committed as 200 and then failed (a stream error frame): the log outcome and the error code. */
|
|
62
65
|
failed?: string
|
|
66
|
+
/** Sampling parameters dropped for this model (comma list), when any. */
|
|
67
|
+
dropped?: string
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export function createOpenAiRoute(host: RouteHost) {
|
|
@@ -119,13 +124,20 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
119
124
|
if (unsupported) return fail(response, unsupported)
|
|
120
125
|
|
|
121
126
|
const body = await readJsonBody(request, scope.signal)
|
|
127
|
+
if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
|
|
122
128
|
const parsed = parseChatRequest(body, host.options)
|
|
123
129
|
if (isPlugError(parsed)) return fail(response, parsed)
|
|
124
130
|
const catalog = await scope.race(host.loadCatalog())
|
|
125
131
|
const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
|
|
126
132
|
if (isPlugError(ref)) return fail(response, ref)
|
|
127
133
|
info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
128
|
-
|
|
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 }
|
|
129
141
|
const { exchange } = parsed
|
|
130
142
|
|
|
131
143
|
// Abandoned requests (client gone, deadline passed) must never reach the model caller.
|
|
@@ -143,14 +155,19 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
143
155
|
)
|
|
144
156
|
|
|
145
157
|
if (exchange.stream) {
|
|
146
|
-
// Nothing is committed until the
|
|
147
|
-
//
|
|
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.
|
|
148
161
|
const sse = sseWriter(response, scope.signal)
|
|
149
|
-
const { failure } = await streamCompletion(sse.paced(events), exchange, sse.write)
|
|
162
|
+
const { failure, started } = await streamCompletion(sse.paced(events), exchange, sse.write)
|
|
150
163
|
if (!sse.started && !response.headersSent) {
|
|
151
164
|
if (!scope.signal.aborted || scope.timedOut) fail(response, failure ?? { status: 502, kind: "server", message: "No response from the model" })
|
|
152
165
|
} else {
|
|
153
|
-
if (failure)
|
|
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
|
+
}
|
|
154
171
|
if (!response.writableEnded) response.end()
|
|
155
172
|
}
|
|
156
173
|
return
|
|
@@ -205,7 +222,7 @@ export function createOpenAiRoute(host: RouteHost) {
|
|
|
205
222
|
finishRequest(request, response)
|
|
206
223
|
} catch {}
|
|
207
224
|
if (info.route !== "GET /healthz" || outcome !== "ok") {
|
|
208
|
-
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 }
|
|
209
226
|
if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
|
|
210
227
|
else log("request", fields)
|
|
211
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
|
+
}
|