opencode-plugboard 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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: 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).
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 `@ai-sdk/openai` call needs `/v1/responses` |
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 release-candidate record has not been written yet.
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
- - **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).
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 history pruning, no per-app concurrency limit.
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,7 @@ 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. |
107
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. |
108
109
 
109
110
  Changes to `listen` need a server restart; other option changes apply on config reload.
@@ -142,7 +143,7 @@ $XDG_DATA_HOME/opencode-plugboard/<app>/ # default ~/.local/share/opencod
142
143
 
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).
144
145
 
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
+ 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
147
 
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.
148
149
 
@@ -220,7 +221,7 @@ The model call sits behind one interface, `ModelCaller`, so a native backend can
220
221
 
221
222
  ## Roadmap
222
223
 
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
+ 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
225
 
225
226
  Not planned: V1 support, embeddings, audio, image generation.
226
227
 
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.2.1",
3
+ "version": "0.3.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,8 @@ 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
18
21
  }
19
22
 
20
23
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
@@ -26,7 +29,10 @@ const DEFAULTS = {
26
29
  requestTimeoutMs: 600_000,
27
30
  } as const
28
31
 
29
- const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages"])
32
+ const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history"])
33
+ const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
34
+ /** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
35
+ const MAX_AGE_DAYS = 36_500
30
36
  const MODEL_PATTERN = /^[^/#*\s]+\/(\*|[^#*\s]+(#[^#*\s]+)?)$/
31
37
  const ALIAS_TARGET = /^[^/#*\s]+\/[^#*\s]+(#[^#*\s]+)?$/
32
38
  /** `provider/model...` shaped: an alias like this would collide with real model ids in listing and resolution. */
@@ -113,6 +119,13 @@ export function parseOptions(raw: unknown): PlugboardOptions {
113
119
  if (input.remoteImages !== undefined && typeof input.remoteImages !== "boolean")
114
120
  throw new Error("plugboard option `remoteImages` must be a boolean")
115
121
 
122
+ const historyInput = input.history
123
+ if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
124
+ for (const key of Object.keys(historyInput ?? {}))
125
+ if (!HISTORY_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`history.${key}\` (known: ${[...HISTORY_KNOWN].join(", ")})`)
126
+ const maxSessions = historyInput?.maxSessions === undefined ? undefined : positiveInt(historyInput.maxSessions, "history.maxSessions", 0)
127
+ const maxAgeDays = historyInput?.maxAgeDays === undefined ? undefined : positiveInt(historyInput.maxAgeDays, "history.maxAgeDays", 0, MAX_AGE_DAYS)
128
+
116
129
  return {
117
130
  listen: { host, port: port ?? DEFAULTS.port },
118
131
  keys,
@@ -121,6 +134,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
121
134
  maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
122
135
  requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
123
136
  remoteImages: input.remoteImages === true,
137
+ history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
124
138
  }
125
139
  }
126
140
 
@@ -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,6 +30,7 @@ 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"
35
36
 
@@ -43,7 +44,7 @@ export interface RouteHost {
43
44
 
44
45
  const SESSION_HEADER = "x-plugboard-session"
45
46
  const MODEL_PREFIX = "/v1/models/"
46
- const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions"]
47
+ const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses"]
47
48
 
48
49
  const decodeSegment = (raw: string): string => {
49
50
  try {
@@ -119,6 +120,7 @@ export function createOpenAiRoute(host: RouteHost) {
119
120
  if (unsupported) return fail(response, unsupported)
120
121
 
121
122
  const body = await readJsonBody(request, scope.signal)
123
+ if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
122
124
  const parsed = parseChatRequest(body, host.options)
123
125
  if (isPlugError(parsed)) return fail(response, parsed)
124
126
  const catalog = await scope.race(host.loadCatalog())
@@ -0,0 +1,377 @@
1
+ /**
2
+ * OpenAI Responses request -> neutral `PlugRequest` plus the `ResponsesExchange` the response
3
+ * encoder needs.
4
+ *
5
+ * Pure function: no I/O. Stateless subset: the caller sends the whole conversation in `input`
6
+ * each time, which is how history already works. Anything that needs server-side state
7
+ * (`previous_response_id`, `conversation`, `item_reference`, `background`) or a hosted tool is
8
+ * rejected with 400 `unsupported_parameter` naming the field. The route resolves `model` to a
9
+ * `ModelRef` afterwards, which is why the request omits `ref`.
10
+ */
11
+ import type { PlugboardOptions } from "../config.js"
12
+ import {
13
+ isPlugError,
14
+ type PlugError,
15
+ type PlugGeneration,
16
+ type PlugMessage,
17
+ type PlugPart,
18
+ type PlugRequest,
19
+ type PlugTool,
20
+ type PlugToolChoice,
21
+ } from "../contract.js"
22
+ import type { ResponsesExchange } from "./response.js"
23
+
24
+ type JsonObject = Record<string, unknown>
25
+
26
+ export interface ParsedResponses {
27
+ /** The model call, minus the model ref the route resolves from `request.model`. */
28
+ readonly request: Omit<PlugRequest, "ref">
29
+ /** Response presentation state for the encoder; never reaches the model call. */
30
+ readonly exchange: ResponsesExchange
31
+ readonly reasoningEffort?: string
32
+ }
33
+
34
+ /** Name of the forced tool that carries structured output (same as the Chat Completions front-end). */
35
+ const RESPOND_TOOL = "respond"
36
+
37
+ /** System instruction for `json_object`: no schema, so the caller's prompt describes the keys. */
38
+ const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
39
+
40
+ const isRecord = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value)
41
+
42
+ const bad = (message: string, param?: string, code?: string): PlugError => ({
43
+ status: 400,
44
+ kind: "invalid_request",
45
+ message,
46
+ ...(code ? { code } : {}),
47
+ ...(param ? { param } : {}),
48
+ })
49
+
50
+ const unsupported = (message: string, param: string) => bad(message, param, "unsupported_parameter")
51
+
52
+ const dataUrlType = (url: string) => /^data:([^;,]+)[;,]/.exec(url)?.[1]
53
+
54
+ const parseJson = (value: string): unknown => {
55
+ try {
56
+ return JSON.parse(value)
57
+ } catch {
58
+ return value
59
+ }
60
+ }
61
+
62
+ /** Text of a string or a list of `input_text` / `output_text` parts (system, developer, tool output). */
63
+ const textOf = (content: unknown): string | undefined => {
64
+ if (typeof content === "string") return content
65
+ if (!Array.isArray(content)) return undefined
66
+ const texts: string[] = []
67
+ for (const item of content) {
68
+ if (!isRecord(item) || typeof item.text !== "string") return undefined
69
+ texts.push(item.text)
70
+ }
71
+ return texts.join("\n")
72
+ }
73
+
74
+ /** Content of a user message: string, or parts of `input_text` / `input_image` / `input_file`. */
75
+ const userParts = (content: unknown, param: string): PlugPart[] | PlugError => {
76
+ if (typeof content === "string") return [{ type: "text", text: content }]
77
+ if (!Array.isArray(content)) return bad("Message content must be a string or an array of content parts", param)
78
+ const parts: PlugPart[] = []
79
+ for (const [index, item] of content.entries()) {
80
+ const at = `${param}[${index}]`
81
+ if (!isRecord(item)) return bad("Content part must be an object", at)
82
+ if ((item.type === "input_text" || item.type === "output_text") && typeof item.text === "string") {
83
+ parts.push({ type: "text", text: item.text })
84
+ } else if (item.type === "input_image" || item.type === "image_url") {
85
+ // `image_url` is the Chat Completions part type; @langchain/openai sends it in Responses input for standard image blocks.
86
+ const url = typeof item.image_url === "string" ? item.image_url : isRecord(item.image_url) ? item.image_url.url : undefined
87
+ if (typeof url !== "string")
88
+ return unsupported(item.file_id != null ? "input_image.file_id is not supported (stateless); send image_url" : "input_image needs an image_url", `${at}.image_url`)
89
+ parts.push({ type: "media", url, ...(dataUrlType(url) ? { mediaType: dataUrlType(url) } : {}) })
90
+ } else if (item.type === "input_file") {
91
+ if (typeof item.file_data === "string") {
92
+ const data = item.file_data
93
+ const filename = typeof item.filename === "string" ? item.filename.toLowerCase() : ""
94
+ const url = data.startsWith("data:") ? data : `data:${filename.endsWith(".pdf") ? "application/pdf" : "application/octet-stream"};base64,${data}`
95
+ parts.push({ type: "media", url, mediaType: dataUrlType(url) })
96
+ } else if (typeof item.file_url === "string") {
97
+ parts.push({ type: "media", url: item.file_url })
98
+ } else return unsupported("input_file needs file_data or file_url; file_id is not supported (stateless)", `${at}.file_data`)
99
+ } else return unsupported(`Unsupported content part type "${String(item.type)}"`, `${at}.type`)
100
+ }
101
+ return parts
102
+ }
103
+
104
+ /** Assistant content: string, or `output_text` parts (refusals are dropped, like the Chat Completions front-end). */
105
+ const assistantText = (content: unknown, param: string): string | PlugError => {
106
+ if (typeof content === "string") return content
107
+ if (!Array.isArray(content)) return bad("Assistant content must be a string or an array of output_text parts", param)
108
+ const texts: string[] = []
109
+ for (const [index, item] of content.entries()) {
110
+ if (!isRecord(item)) return bad("Content part must be an object", `${param}[${index}]`)
111
+ if (item.type === "refusal") continue
112
+ if ((item.type !== "output_text" && item.type !== "input_text") || typeof item.text !== "string")
113
+ return unsupported(`Unsupported assistant content part type "${String(item.type)}"`, `${param}[${index}].type`)
114
+ texts.push(item.text)
115
+ }
116
+ return texts.join("")
117
+ }
118
+
119
+ /**
120
+ * `input` items -> neutral messages. Adjacent assistant items (text, function calls) become one
121
+ * assistant message, the shape the Chat Completions front-end produces, so a parallel-call turn
122
+ * is one message followed by its results.
123
+ */
124
+ const parseInput = (input: unknown): { system: string[]; messages: PlugMessage[] } | PlugError => {
125
+ if (typeof input === "string") return { system: [], messages: [{ role: "user", content: [{ type: "text", text: input }] }] }
126
+ if (!Array.isArray(input) || input.length === 0) return bad("input must be a string or a non-empty array of items", "input")
127
+
128
+ const system: string[] = []
129
+ const messages: PlugMessage[] = []
130
+ const toolNames = new Map<string, string>()
131
+ let pending: PlugPart[] | undefined
132
+ const flush = () => {
133
+ if (pending && pending.length > 0) messages.push({ role: "assistant", content: pending })
134
+ pending = undefined
135
+ }
136
+
137
+ for (const [index, item] of input.entries()) {
138
+ const param = `input[${index}]`
139
+ if (!isRecord(item)) return bad("Each input item must be an object", param)
140
+ const type = item.type ?? (item.role !== undefined ? "message" : undefined)
141
+ switch (type) {
142
+ case "message": {
143
+ switch (item.role) {
144
+ case "system":
145
+ case "developer": {
146
+ const value = textOf(item.content)
147
+ if (value === undefined) return bad("System message content must be a string or text parts", `${param}.content`)
148
+ if (value.length > 0) system.push(value)
149
+ break
150
+ }
151
+ case "user": {
152
+ flush()
153
+ const parts = userParts(item.content, `${param}.content`)
154
+ if (!Array.isArray(parts)) return parts
155
+ messages.push({ role: "user", content: parts })
156
+ break
157
+ }
158
+ case "assistant": {
159
+ const text = assistantText(item.content, `${param}.content`)
160
+ if (typeof text !== "string") return text
161
+ if (text.length > 0) (pending ??= []).push({ type: "text", text })
162
+ break
163
+ }
164
+ default:
165
+ return bad(`Unsupported message role "${String(item.role)}"`, `${param}.role`)
166
+ }
167
+ break
168
+ }
169
+ case "function_call": {
170
+ if (typeof item.call_id !== "string" || item.call_id.length === 0) return bad("function_call needs a non-empty call_id", `${param}.call_id`)
171
+ if (typeof item.name !== "string" || item.name.length === 0) return bad("function_call needs a name", `${param}.name`)
172
+ toolNames.set(item.call_id, item.name)
173
+ ;(pending ??= []).push({
174
+ type: "tool-call",
175
+ id: item.call_id,
176
+ name: item.name,
177
+ input: typeof item.arguments === "string" ? parseJson(item.arguments) : (item.arguments ?? {}),
178
+ })
179
+ break
180
+ }
181
+ case "function_call_output": {
182
+ flush()
183
+ if (typeof item.call_id !== "string") return bad("function_call_output needs a call_id", `${param}.call_id`)
184
+ const result = textOf(item.output)
185
+ if (result === undefined) return bad("function_call_output output must be a string or an array of text parts", `${param}.output`)
186
+ const name = toolNames.get(item.call_id)
187
+ if (name === undefined)
188
+ return bad(`function_call_output answers call_id "${item.call_id}", which no earlier function_call item has`, `${param}.call_id`)
189
+ messages.push({ role: "tool", content: [{ type: "tool-result", id: item.call_id, name, result }] })
190
+ break
191
+ }
192
+ // Reasoning items carry no content plugboard can replay; they are dropped.
193
+ case "reasoning":
194
+ break
195
+ case "item_reference":
196
+ return unsupported(
197
+ "item_reference needs server-side state, which plugboard does not keep; send the full items instead (Vercel AI SDK: providerOptions.openai.store = false)",
198
+ `${param}.type`,
199
+ )
200
+ default:
201
+ return unsupported(`Unsupported input item type "${String(type)}"`, `${param}.type`)
202
+ }
203
+ }
204
+ flush()
205
+ if (messages.length === 0) return bad("input has no user, assistant or tool items", "input")
206
+ return { system, messages }
207
+ }
208
+
209
+ const parseTools = (raw: unknown): PlugTool[] | PlugError => {
210
+ if (raw == null) return []
211
+ if (!Array.isArray(raw)) return bad("tools must be an array", "tools")
212
+ const tools: PlugTool[] = []
213
+ const names = new Set<string>()
214
+ for (const [index, entry] of raw.entries()) {
215
+ const at = `tools[${index}]`
216
+ if (!isRecord(entry)) return bad("Each tool must be an object", at)
217
+ if (entry.type !== "function")
218
+ return unsupported(`Unsupported tool type "${String(entry.type)}": only function tools are supported (built-in tools such as web_search and file_search run on OpenAI's servers)`, `${at}.type`)
219
+ if (typeof entry.name !== "string" || entry.name.length === 0) return bad('Each function tool needs a "name"', `${at}.name`)
220
+ if (names.has(entry.name)) return bad(`Duplicate tool name "${entry.name}"`, `${at}.name`)
221
+ if (entry.description != null && typeof entry.description !== "string") return bad("Tool description must be a string", `${at}.description`)
222
+ // `parameters` may be null (strict mode with no arguments): a tool without arguments.
223
+ if (entry.parameters != null && !isRecord(entry.parameters)) return bad("Tool parameters must be a JSON Schema object", `${at}.parameters`)
224
+ names.add(entry.name)
225
+ tools.push({
226
+ name: entry.name,
227
+ description: typeof entry.description === "string" ? entry.description : "",
228
+ parameters: isRecord(entry.parameters) ? entry.parameters : { type: "object", properties: {} },
229
+ })
230
+ }
231
+ return tools
232
+ }
233
+
234
+ const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError => {
235
+ if (raw == null) return undefined
236
+ if (raw === "auto" || raw === "none" || raw === "required") return raw
237
+ if (isRecord(raw) && raw.type === "function" && typeof raw.name === "string") return { type: "tool", name: raw.name }
238
+ return unsupported("Unsupported tool_choice: use auto, none, required or {type:\"function\", name}", "tool_choice")
239
+ }
240
+
241
+ /**
242
+ * `text.format`: `json_schema` -> the `respond` tool (`structured`, forced unless the caller also
243
+ * sends tools); `json_object` -> a system instruction (`instruction`).
244
+ */
245
+ const parseTextFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
246
+ if (raw == null) return {}
247
+ if (!isRecord(raw)) return bad("text must be an object", "text")
248
+ const format = raw.format
249
+ if (format == null || (isRecord(format) && format.type === "text")) return {}
250
+ if (!isRecord(format)) return bad("text.format must be an object", "text.format")
251
+ if (format.type === "json_object") return { instruction: JSON_OBJECT_INSTRUCTION }
252
+ if (format.type === "json_schema") {
253
+ if (format.name != null && typeof format.name !== "string") return bad("text.format.name must be a string", "text.format.name")
254
+ if (format.description != null && typeof format.description !== "string") return bad("text.format.description must be a string", "text.format.description")
255
+ if (!isRecord(format.schema)) return bad("text.format.schema must be a JSON Schema object", "text.format.schema")
256
+ return {
257
+ structured: {
258
+ name: RESPOND_TOOL,
259
+ // With caller tools also on offer, this description is what tells the model which call is the final answer.
260
+ description:
261
+ "Give your final answer by calling this tool with JSON matching its schema." +
262
+ (typeof format.description === "string" ? ` ${format.description}` : ""),
263
+ parameters: format.schema,
264
+ },
265
+ }
266
+ }
267
+ return bad(`Unsupported text.format type "${String(format.type)}"`, "text.format.type")
268
+ }
269
+
270
+ const parseGeneration = (body: JsonObject): PlugGeneration | PlugError => {
271
+ const out: { -readonly [K in keyof PlugGeneration]: PlugGeneration[K] } = {}
272
+ for (const [key, field, min, max, integer] of [
273
+ ["temperature", "temperature", 0, 2, false],
274
+ ["top_p", "topP", 0, 1, false],
275
+ ["max_output_tokens", "maxTokens", 1, Number.MAX_SAFE_INTEGER, true],
276
+ ] as const) {
277
+ const value = body[key]
278
+ if (value == null) continue
279
+ if (typeof value !== "number" || !Number.isFinite(value)) return bad(`${key} must be a number`, key)
280
+ if (integer && !Number.isInteger(value)) return bad(`${key} must be an integer`, key)
281
+ if (value < min || value > max)
282
+ return bad(max === Number.MAX_SAFE_INTEGER ? `${key} must be at least ${min}` : `${key} must be between ${min} and ${max}`, key)
283
+ out[field] = value
284
+ }
285
+ return out
286
+ }
287
+
288
+ /** Fields that need server-side state or features plugboard does not have. */
289
+ const checkUnsupported = (body: JsonObject): PlugError | undefined => {
290
+ if (body.previous_response_id != null)
291
+ return unsupported("previous_response_id is not supported: plugboard is stateless, send the full conversation in input", "previous_response_id")
292
+ if (body.conversation != null) return unsupported("conversation is not supported: plugboard is stateless", "conversation")
293
+ if (body.background === true) return unsupported("background mode is not supported", "background")
294
+ if (body.prompt != null) return unsupported("Stored prompts (prompt) are not supported", "prompt")
295
+ if (body.top_logprobs != null && body.top_logprobs !== 0) return unsupported("top_logprobs is not supported", "top_logprobs")
296
+ if (body.truncation != null && body.truncation !== "disabled") return unsupported('Only truncation "disabled" is supported', "truncation")
297
+ }
298
+
299
+ export function parseResponsesRequest(body: unknown, options: PlugboardOptions): ParsedResponses | PlugError {
300
+ if (!isRecord(body)) return bad("Request body must be a JSON object")
301
+ if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
302
+ if (body.input == null) return bad("input is required", "input")
303
+ const refused = checkUnsupported(body)
304
+ if (refused) return refused
305
+ if (body.instructions != null && typeof body.instructions !== "string") return bad("instructions must be a string", "instructions")
306
+ if (body.reasoning != null && !isRecord(body.reasoning)) return bad("reasoning must be an object", "reasoning")
307
+
308
+ const parsed = parseInput(body.input)
309
+ if (isPlugError(parsed)) return parsed
310
+ const callerTools = parseTools(body.tools)
311
+ if (!Array.isArray(callerTools)) return callerTools
312
+ const toolChoice = parseToolChoice(body.tool_choice)
313
+ if (isPlugError(toolChoice)) return toolChoice
314
+ const format = parseTextFormat(body.text)
315
+ if (isPlugError(format)) return format
316
+ const respond = format.structured
317
+ const generation = parseGeneration(body)
318
+ if (isPlugError(generation)) return generation
319
+
320
+ if (typeof toolChoice === "object" && !callerTools.some((tool) => tool.name === toolChoice.name))
321
+ return bad(`tool_choice names "${toolChoice.name}", which is not in tools`, "tool_choice.name")
322
+ if (respond && callerTools.some((tool) => tool.name === RESPOND_TOOL))
323
+ return bad(
324
+ `A tool named "${RESPOND_TOOL}" collides with text.format json_schema, which plugboard answers through that name`,
325
+ "tools",
326
+ "reserved_tool_name",
327
+ )
328
+ // json_schema + caller tools: the model may call a caller tool or `respond`, so `tool_choice` is
329
+ // `required` (not forced to `respond`). `tool_choice: none` leaves only `respond`; naming a tool
330
+ // contradicts a structured answer.
331
+ if (respond && typeof toolChoice === "object")
332
+ return unsupported("A tool_choice that names a tool cannot be combined with text.format json_schema", "tool_choice")
333
+ const withCallerTools = respond !== undefined && callerTools.length > 0 && toolChoice !== "none"
334
+ const tools = respond ? (withCallerTools ? [...callerTools, respond] : [respond]) : callerTools
335
+ if (tools.length > options.maxTools)
336
+ return bad(`Request has ${tools.length} tools; plugboard allows ${options.maxTools} (option \`maxTools\`)`, "tools", "too_many_tools")
337
+
338
+ const forced: PlugToolChoice | undefined = respond ? (withCallerTools ? "required" : { type: "tool", name: RESPOND_TOOL }) : toolChoice
339
+ const instructions = typeof body.instructions === "string" && body.instructions.length > 0 ? [body.instructions] : []
340
+ const system = [...instructions, ...parsed.system, ...(format.instruction ? [format.instruction] : [])]
341
+ const request: Omit<PlugRequest, "ref"> = {
342
+ model: body.model,
343
+ system,
344
+ messages: parsed.messages,
345
+ tools,
346
+ ...(forced && tools.length > 0 ? { toolChoice: forced } : {}),
347
+ generation,
348
+ }
349
+ const reasoningEffort = isRecord(body.reasoning) && typeof body.reasoning.effort === "string" ? body.reasoning.effort : undefined
350
+ const text = isRecord(body.text) ? body.text : undefined
351
+ return {
352
+ request,
353
+ exchange: {
354
+ model: body.model,
355
+ stream: body.stream === true,
356
+ // Echoed in the response object, as OpenAI does. `store` is always false: nothing is kept for retrieval.
357
+ envelope: {
358
+ instructions: typeof body.instructions === "string" ? body.instructions : null,
359
+ max_output_tokens: generation.maxTokens ?? null,
360
+ metadata: isRecord(body.metadata) ? body.metadata : {},
361
+ parallel_tool_calls: body.parallel_tool_calls !== false,
362
+ previous_response_id: null,
363
+ reasoning: { effort: reasoningEffort ?? null, summary: null },
364
+ store: false,
365
+ temperature: generation.temperature ?? null,
366
+ text: { format: isRecord(text?.format) ? text.format : { type: "text" } },
367
+ tool_choice: body.tool_choice ?? "auto",
368
+ tools: Array.isArray(body.tools) ? body.tools : [],
369
+ top_p: generation.topP ?? null,
370
+ truncation: "disabled",
371
+ user: typeof body.user === "string" ? body.user : null,
372
+ },
373
+ ...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
374
+ },
375
+ ...(reasoningEffort ? { reasoningEffort } : {}),
376
+ }
377
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Neutral results -> OpenAI Responses wire objects: one response object, or the named SSE
3
+ * events of a stream.
4
+ *
5
+ * One state machine (`runResponse`) serves both: it emits stream events as it goes and returns
6
+ * the final response object, so the JSON body and the stream never disagree. A non-streaming
7
+ * call passes a no-op `emit`. Reasoning deltas are dropped (no reasoning items are produced).
8
+ */
9
+ import type { PlugError, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
+
11
+ type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
12
+
13
+ /** What the encoder needs besides the model events: Responses presentation state. The request decoder produces it. */
14
+ export interface ResponsesExchange {
15
+ /** The model string the client sent; echoed in every response object. */
16
+ readonly model: string
17
+ readonly stream: boolean
18
+ /** Request fields echoed into the response object (instructions, tools, temperature, ...). */
19
+ readonly envelope: Readonly<Record<string, unknown>>
20
+ /** Name of the forced tool that carries `text.format` json_schema output; its input becomes the message text. */
21
+ readonly structuredTool?: string
22
+ /**
23
+ * The caller's own tools are offered next to the structured tool. A call to any of them is the
24
+ * response (function_call items, prose and the structured answer dropped); the structured
25
+ * answer is the message text only when the step called no caller tool.
26
+ */
27
+ readonly callerTools?: boolean
28
+ }
29
+
30
+ /** A stream event as emitted: `type` is also the SSE event name. */
31
+ export type ResponsesStreamEvent = { readonly type: string; readonly sequence_number: number } & Record<string, unknown>
32
+
33
+ /** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
34
+ export interface ResponseRun {
35
+ readonly failure?: PlugError
36
+ /** Whether `response.created` was emitted (a streamed response is committed as a 200 SSE stream). */
37
+ readonly started: boolean
38
+ /** The final response object (completed, incomplete or failed once started). */
39
+ readonly response?: object
40
+ }
41
+
42
+ type Item = Record<string, unknown>
43
+
44
+ const args = (input: unknown) => (typeof input === "string" ? input : JSON.stringify(input ?? {}))
45
+
46
+ const newId = (prefix: string) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`
47
+
48
+ const encodeUsage = (usage: PlugUsage) => {
49
+ const input = usage.inputTokens ?? 0
50
+ const output = usage.outputTokens ?? 0
51
+ return {
52
+ input_tokens: input,
53
+ input_tokens_details: { cached_tokens: usage.cacheReadTokens ?? 0 },
54
+ output_tokens: output,
55
+ output_tokens_details: { reasoning_tokens: usage.reasoningTokens ?? 0 },
56
+ total_tokens: input + output,
57
+ }
58
+ }
59
+
60
+ const noFinish = (): PlugError => ({ status: 500, kind: "server", message: "Model stream ended without a finish event" })
61
+ const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
62
+
63
+ /** SSE frame of one stream event: `event: <type>` then `data: <json>`. */
64
+ export const sseFrame = (event: ResponsesStreamEvent): string => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`
65
+
66
+ /**
67
+ * Structured-output projection, same rules as the Chat Completions front-end: in structured mode
68
+ * the message text is exactly the arguments of the first call to the `respond` tool; prose and
69
+ * other calls are suppressed. Without an answer the exchange fails, except when the model
70
+ * stopped for `length` or `content-filter`, which keep their status with empty output.
71
+ */
72
+ const structuredFailure = (exchange: ResponsesExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
73
+ exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
74
+ ? undefined
75
+ : {
76
+ status: 502,
77
+ kind: "server",
78
+ code: "structured_output_missing",
79
+ message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
80
+ }
81
+
82
+ export async function runResponse(
83
+ events: AsyncIterable<PlugEvent>,
84
+ exchange: ResponsesExchange,
85
+ emit: (event: ResponsesStreamEvent) => void,
86
+ ): Promise<ResponseRun> {
87
+ const id = newId("resp")
88
+ const createdAt = Math.floor(Date.now() / 1000)
89
+ let sequence = 0
90
+ const send = (type: string, data: Record<string, unknown>) => emit({ type, sequence_number: sequence++, ...data })
91
+ const envelope = (status: string, output: Item[], extra: Record<string, unknown> = {}) => ({
92
+ id,
93
+ object: "response",
94
+ created_at: createdAt,
95
+ status,
96
+ error: null,
97
+ incomplete_details: null,
98
+ model: exchange.model,
99
+ output,
100
+ ...exchange.envelope,
101
+ ...extra,
102
+ })
103
+
104
+ const items: Item[] = []
105
+ /** Output items that are open or done: the next item's `output_index`. */
106
+ let nextIndex = 0
107
+ let open: { id: string; index: number; text: string } | undefined
108
+ let started = false
109
+ let calls = 0
110
+ let answered = false
111
+ /** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
112
+ let held: ToolCall | undefined
113
+ let finish: Exclude<PlugFinish, "error"> | undefined
114
+ let usage: PlugUsage | undefined
115
+ let failure: PlugError | undefined
116
+
117
+ const start = () => {
118
+ if (started) return
119
+ started = true
120
+ send("response.created", { response: envelope("in_progress", []) })
121
+ send("response.in_progress", { response: envelope("in_progress", []) })
122
+ }
123
+ const messageItem = (itemId: string, status: string, text: string | undefined): Item => ({
124
+ id: itemId,
125
+ type: "message",
126
+ status,
127
+ role: "assistant",
128
+ content: text === undefined ? [] : [{ type: "output_text", text, annotations: [], logprobs: [] }],
129
+ })
130
+ const text = (delta: string) => {
131
+ if (delta.length === 0) return
132
+ start()
133
+ if (!open) {
134
+ open = { id: newId("msg"), index: nextIndex++, text: "" }
135
+ send("response.output_item.added", { output_index: open.index, item: messageItem(open.id, "in_progress", undefined) })
136
+ send("response.content_part.added", {
137
+ item_id: open.id,
138
+ output_index: open.index,
139
+ content_index: 0,
140
+ part: { type: "output_text", text: "", annotations: [], logprobs: [] },
141
+ })
142
+ }
143
+ open.text += delta
144
+ send("response.output_text.delta", { item_id: open.id, output_index: open.index, content_index: 0, delta, logprobs: [] })
145
+ }
146
+ const closeMessage = () => {
147
+ if (!open) return
148
+ const part = { type: "output_text", text: open.text, annotations: [], logprobs: [] }
149
+ send("response.output_text.done", { item_id: open.id, output_index: open.index, content_index: 0, text: open.text, logprobs: [] })
150
+ send("response.content_part.done", { item_id: open.id, output_index: open.index, content_index: 0, part })
151
+ const item = messageItem(open.id, "completed", open.text)
152
+ send("response.output_item.done", { output_index: open.index, item })
153
+ items.push(item)
154
+ open = undefined
155
+ }
156
+ /** Tool calls arrive whole, so each goes out as one arguments delta. */
157
+ const toolCall = (call: ToolCall) => {
158
+ start()
159
+ closeMessage()
160
+ const index = nextIndex++
161
+ const itemId = newId("fc")
162
+ const argumentsText = args(call.input)
163
+ const item = (status: string, text: string): Item => ({ id: itemId, type: "function_call", status, call_id: call.id, name: call.name, arguments: text })
164
+ send("response.output_item.added", { output_index: index, item: item("in_progress", "") })
165
+ send("response.function_call_arguments.delta", { item_id: itemId, output_index: index, delta: argumentsText })
166
+ send("response.function_call_arguments.done", { item_id: itemId, output_index: index, name: call.name, arguments: argumentsText })
167
+ const done = item("completed", argumentsText)
168
+ send("response.output_item.done", { output_index: index, item: done })
169
+ items.push(done)
170
+ calls++
171
+ }
172
+
173
+ for await (const event of events) {
174
+ if (event.type === "error") {
175
+ failure = event.error
176
+ break
177
+ }
178
+ if (event.type === "finish") {
179
+ usage = event.usage ?? usage
180
+ if (event.reason === "error") failure ??= failedRun()
181
+ else finish = event.reason
182
+ continue
183
+ }
184
+ if (event.type === "reasoning-delta") continue
185
+ if (exchange.structuredTool !== undefined) {
186
+ // Structured mode: only the first answer is content; prose and other tool calls are
187
+ // suppressed, except the caller's own tools when they are offered.
188
+ if (event.type === "tool-call" && event.name === exchange.structuredTool) {
189
+ if (!answered) {
190
+ answered = true
191
+ if (exchange.callerTools) held = event
192
+ else text(args(event.input))
193
+ }
194
+ continue
195
+ }
196
+ if (!(exchange.callerTools && event.type === "tool-call")) continue
197
+ }
198
+ if (event.type === "text-delta") text(event.text)
199
+ else toolCall(event)
200
+ }
201
+
202
+ const passthrough = exchange.callerTools === true && calls > 0
203
+ const structuredAnswer = exchange.structuredTool !== undefined && !passthrough
204
+ failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
205
+ if (failure) {
206
+ if (!started) return { failure, started }
207
+ closeMessage()
208
+ const response = envelope("failed", items, {
209
+ error: { code: failure.code ?? failure.kind, message: failure.message },
210
+ ...(usage ? { usage: encodeUsage(usage) } : {}),
211
+ })
212
+ send("response.failed", { response })
213
+ return { failure, started, response }
214
+ }
215
+ start()
216
+ if (held && structuredAnswer) text(args(held.input))
217
+ closeMessage()
218
+ const incomplete = finish === "length" ? "max_output_tokens" : finish === "content-filter" ? "content_filter" : undefined
219
+ const response = envelope(incomplete ? "incomplete" : "completed", items, {
220
+ ...(incomplete ? { incomplete_details: { reason: incomplete } } : {}),
221
+ ...(usage ? { usage: encodeUsage(usage) } : {}),
222
+ })
223
+ send(incomplete ? "response.incomplete" : "response.completed", { response })
224
+ return { started, response }
225
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `POST /v1/responses`: the Responses front-end's half of the route. `src/openai/route.ts` has
3
+ * already checked origin, authenticated, and read the JSON body (the same handling and
4
+ * deadline as Chat Completions) and hands over here.
5
+ *
6
+ * Same flow as Chat Completions: parse, resolve the model, call the ModelCaller, encode. Nothing
7
+ * is committed until the encoder emits its first event, so a failure before any output is a
8
+ * real HTTP error even when the client asked for a stream.
9
+ */
10
+
11
+ import type { ServerResponse } from "node:http"
12
+ import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
+ import { boundEvents, sendJson, sseWriter, type RequestScope } from "../http.js"
14
+ import { resolveModel } from "../models.js"
15
+ import { encodeError } from "../openai/response.js"
16
+ import type { RouteHost } from "../openai/route.js"
17
+ import { parseResponsesRequest } from "./request.js"
18
+ import { runResponse, sseFrame } from "./response.js"
19
+
20
+ const SESSION_HEADER = "x-plugboard-session"
21
+
22
+ export interface ResponsesCall {
23
+ readonly app: string
24
+ readonly body: unknown
25
+ readonly response: ServerResponse
26
+ readonly scope: RequestScope
27
+ /** The route's log fields: the resolved model, and the outcome of a stream that failed after it committed. */
28
+ readonly info: { model?: string; failed?: string }
29
+ }
30
+
31
+ export async function serveResponses(host: RouteHost, { app, body, response, scope, info }: ResponsesCall): Promise<void> {
32
+ const fail = (error: PlugError) => sendJson(response, error.status, encodeError(error))
33
+
34
+ const parsed = parseResponsesRequest(body, host.options)
35
+ if (isPlugError(parsed)) return fail(parsed)
36
+ const catalog = await scope.race(host.loadCatalog())
37
+ const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
38
+ if (isPlugError(ref)) return fail(ref)
39
+ info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
40
+ const call: PlugRequest = { ...parsed.request, ref }
41
+ const { exchange } = parsed
42
+
43
+ // Abandoned requests (client gone, deadline passed) must never reach the model caller.
44
+ if (scope.signal.aborted) throw scope.signal.reason
45
+ const events = boundEvents(
46
+ host.caller.stream(call, {
47
+ app,
48
+ signal: scope.signal,
49
+ onRecord: (recordID) => {
50
+ if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
51
+ response.setHeader(SESSION_HEADER, recordID)
52
+ },
53
+ }),
54
+ scope,
55
+ )
56
+
57
+ if (exchange.stream) {
58
+ const sse = sseWriter(response, scope.signal)
59
+ const { failure } = await runResponse(sse.paced(events), exchange, (event) => sse.write(sseFrame(event)))
60
+ if (!sse.started && !response.headersSent) {
61
+ if (!scope.signal.aborted || scope.timedOut) fail(failure ?? { status: 502, kind: "server", message: "No response from the model" })
62
+ } else {
63
+ if (failure) info.failed = failure.code ?? failure.kind
64
+ if (!response.writableEnded) response.end()
65
+ }
66
+ return
67
+ }
68
+
69
+ const run = await runResponse(events, exchange, () => {})
70
+ if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
71
+ sendJson(response, 200, run.response)
72
+ }
package/src/runtime.ts CHANGED
@@ -18,7 +18,10 @@
18
18
  * scope, forwarding bus events of request sessions to the session store. If it dies, the
19
19
  * requests in flight fail with a 502 instead of waiting for their deadline;
20
20
  * - the session store (src/session/state.ts), which outlives the listener and owns the request
21
- * guards, their cleanup timers included.
21
+ * guards, their cleanup timers included;
22
+ * - the history sweep state: every registered request session is indexed in the host's plugin
23
+ * storage and, at most every `SWEEP_INTERVAL_MS`, a sweep prunes old ones past the `history`
24
+ * limits (src/session/prune.ts). Both start from request handling (the store's `register`).
22
25
  *
23
26
  * Registration is explicit: `registerGeneration` returns whether the generation was accepted. A
24
27
  * rejected one (different `listen`, options that differ from the live ones, foreign runtime) never
@@ -42,6 +45,8 @@ import { Cause, Effect, Stream } from "effect"
42
45
  import { isPlugError, type CatalogModel, type PlugError } from "./contract.js"
43
46
  import { errorMessage, log, logError } from "./log.js"
44
47
  import { ensureAppDir } from "./session/history.js"
48
+ import type { HistoryPort } from "./session/host.js"
49
+ import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
45
50
  import { createSessionStore, type SessionStore } from "./session/state.js"
46
51
 
47
52
  export type Handler = (request: IncomingMessage, response: ServerResponse) => void
@@ -76,15 +81,23 @@ export interface GenerationInput {
76
81
  * never from setup or a finalizer.
77
82
  */
78
83
  readonly refresh: () => Promise<void>
84
+ /** The host storage and session deletion used to index and prune request sessions, with the configured limits (identical in every accepted generation). */
85
+ readonly history: { readonly port: HistoryPort; readonly limits: HistoryLimits }
79
86
  }
80
87
 
81
88
  const GRACE_MS = 10_000
89
+ /** Earliest next history sweep after one ran (sweeps are triggered by requests, never by a timer). */
90
+ const SWEEP_INTERVAL_MS = 10 * 60_000
91
+ /** Earliest next sweep when one left work behind (its budget ran out). */
92
+ const SWEEP_BACKLOG_MS = 60_000
93
+ /** A sweep stops starting host calls after this long; a hung one stops blocking later sweeps 5 s after. */
94
+ const SWEEP_DEADLINE_MS = 60_000
82
95
  /** How long `ensureBackend` waits for a Location to boot and its generation to become ready. */
83
96
  const BACKEND_TIMEOUT_MS = 15_000
84
97
  const BACKEND_POLL_MS = 25
85
98
 
86
- /** 3: the runtime tracks confirmed directories and rejected generations (a version-2 runtime lacks both). */
87
- const VERSION = 3
99
+ /** 4: adds the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
100
+ const VERSION = 4
88
101
  const KEY = Symbol.for("opencode-plugboard.runtime")
89
102
 
90
103
  interface Listener {
@@ -102,6 +115,7 @@ interface Generation {
102
115
  readonly handler: Handler
103
116
  readonly loadCatalog: GenerationInput["loadCatalog"]
104
117
  readonly refresh: GenerationInput["refresh"]
118
+ readonly history: GenerationInput["history"]
105
119
  /** The context hook and transforms are installed (setup got to its end). */
106
120
  hooked: boolean
107
121
  /** The transforms have been rebuilt since `confirmed` became true for the directory. */
@@ -124,6 +138,8 @@ interface Runtime {
124
138
  readonly confirmed: Set<string>
125
139
  /** Live rejected generations per directory: tells "rejected" from "no generation yet". */
126
140
  readonly rejected: Map<string, number>
141
+ /** History sweep rate limit: no sweep starts before `nextAt` (epoch ms) or while one `running`. */
142
+ readonly sweep: { nextAt: number; running: boolean }
127
143
  listener?: Listener
128
144
  /** Present while a fiber is subscribed to the server event bus. */
129
145
  pump?: Pump
@@ -140,7 +156,14 @@ function runtime(): Runtime | undefined {
140
156
  const slot = globalThis as Record<symbol, unknown>
141
157
  const existing = slot[KEY] as { readonly version: number } | undefined
142
158
  if (!existing) {
143
- const created: Runtime = { version: VERSION, sessions: createSessionStore(), generations: [], confirmed: new Set(), rejected: new Map() }
159
+ const created: Runtime = {
160
+ version: VERSION,
161
+ sessions: createSessionStore({ onRegister: (state) => trackSession(created, state.app, state.sessionID) }),
162
+ generations: [],
163
+ confirmed: new Set(),
164
+ rejected: new Map(),
165
+ sweep: { nextAt: 0, running: false },
166
+ }
144
167
  slot[KEY] = created
145
168
  return created
146
169
  }
@@ -152,6 +175,54 @@ function runtime(): Runtime | undefined {
152
175
  return undefined
153
176
  }
154
177
 
178
+ /**
179
+ * A request session was just created: index it, then sweep if due. Runs from request handling
180
+ * (the store's `register`), never awaited, never throws. Uses the preferred generation's host
181
+ * handles; the storage and `session.remove` are server-wide, so any live generation works.
182
+ */
183
+ function trackSession(rt: Runtime, app: string, sessionID: string): void {
184
+ const history = preferred(rt)?.history
185
+ if (!history) return logError("history.index_skipped", { app, sessionID, reason: "no live generation" })
186
+ // No limits, no index: an unbounded index would grow opencode.db by a row per request forever (#85).
187
+ if (history.limits.maxSessions === undefined && history.limits.maxAgeDays === undefined) return
188
+ void recordSession(history.port, app, sessionID, Date.now())
189
+ .catch((error) => logError("history.index_failed", { app, sessionID, error: errorMessage(error) }))
190
+ .then(() => maybeSweep(rt, history))
191
+ }
192
+
193
+ /**
194
+ * Run a history sweep when limits are set, none is running and the rate limit allows. Bounded:
195
+ * see `sweepHistory`. A sweep that leaves work behind is followed up after `SWEEP_BACKLOG_MS`,
196
+ * a complete one after `SWEEP_INTERVAL_MS`. Logs one line with counts only.
197
+ */
198
+ async function maybeSweep(rt: Runtime, history: GenerationInput["history"]): Promise<void> {
199
+ const { limits, port } = history
200
+ if (limits.maxSessions === undefined && limits.maxAgeDays === undefined) return
201
+ const now = Date.now()
202
+ if (rt.sweep.running || now < rt.sweep.nextAt) return
203
+ rt.sweep.running = true
204
+ rt.sweep.nextAt = now + SWEEP_INTERVAL_MS
205
+ const deadline = AbortSignal.timeout(SWEEP_DEADLINE_MS)
206
+ let timer: ReturnType<typeof setTimeout> | undefined
207
+ try {
208
+ const result = await Promise.race([
209
+ sweepHistory({ port, limits, sessions: rt.sessions, now, signal: deadline }),
210
+ new Promise<undefined>((resolve) => {
211
+ timer = setTimeout(() => resolve(undefined), SWEEP_DEADLINE_MS + 5_000)
212
+ timer.unref?.()
213
+ }),
214
+ ])
215
+ if (!result) return logError("history.sweep_timeout", { limit: SWEEP_DEADLINE_MS })
216
+ if (result.more) rt.sweep.nextAt = Date.now() + SWEEP_BACKLOG_MS
217
+ log("history.swept", { ...result, durationMs: Date.now() - now })
218
+ } catch (error) {
219
+ logError("history.sweep_failed", { error: errorMessage(error) })
220
+ } finally {
221
+ clearTimeout(timer)
222
+ rt.sweep.running = false
223
+ }
224
+ }
225
+
155
226
  /** The session store shared by every generation in the process. */
156
227
  export const sessionStore = (): SessionStore => runtime()?.sessions ?? detachedSessions
157
228
 
@@ -290,6 +361,7 @@ export function registerGeneration(input: GenerationInput): Registration {
290
361
  handler: input.handler,
291
362
  loadCatalog: input.loadCatalog,
292
363
  refresh: input.refresh,
364
+ history: input.history,
293
365
  hooked: false,
294
366
  // A directory confirmed before this Location booted needs no rebuild: its transforms already read `confirmed()` as true.
295
367
  activated: rt.confirmed.has(directory),
@@ -89,6 +89,46 @@ export function createSessionPort(ctx: HostContext): SessionPort {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * What the history index needs from the host: the plugin's own key-value storage (`ctx.storage`,
94
+ * namespaced by plugin id and shared by every Location of the server) and `session.remove`. The
95
+ * plugin session API has no `list`, so plugboard indexes its own request sessions in that storage
96
+ * and prunes from the index (src/session/prune.ts).
97
+ */
98
+ export interface HistoryPort {
99
+ put(key: string, value: string): Promise<void>
100
+ drop(key: string): Promise<void>
101
+ /** Keys under `prefix` in ascending order, after `after` (exclusive); `next` is set when more remain. */
102
+ scan(input: { readonly prefix: string; readonly after?: string; readonly limit: number }): Promise<{ readonly keys: ReadonlyArray<string>; readonly next?: string }>
103
+ /** `missing` when the session no longer exists (the host's `Session.NotFoundError`); any other failure rejects. */
104
+ removeSession(sessionID: string): Promise<"removed" | "missing">
105
+ }
106
+
107
+ const failureTag = (error: unknown): unknown => (typeof error === "object" && error !== null ? (error as { _tag?: unknown })._tag : undefined)
108
+
109
+ /** `ctx.storage` and `ctx.session.remove` as a Promise port. The branded session ID is the same host-issued string as above. */
110
+ export function createHistoryPort(ctx: HostContext): HistoryPort {
111
+ return {
112
+ put: (key, value) => Effect.runPromise(ctx.storage.set(key, value)),
113
+ drop: (key) => Effect.runPromise(ctx.storage.remove(key)),
114
+ scan: (input) =>
115
+ Effect.runPromise(ctx.storage.scan(input)).then((result) => ({
116
+ keys: result.entries.map((entry) => entry.key),
117
+ ...(result.next === undefined ? {} : { next: result.next }),
118
+ })),
119
+ removeSession: (sessionID) =>
120
+ Effect.runPromise(
121
+ ctx.session.remove({ sessionID: hostSessionID(sessionID) } satisfies Parameters<HostSession["remove"]>[0]).pipe(
122
+ Effect.match({ onSuccess: () => undefined, onFailure: (error) => ({ error }) }),
123
+ ),
124
+ ).then((failed) => {
125
+ if (failed === undefined) return "removed" as const
126
+ if (failureTag(failed.error) === "Session.NotFoundError") return "missing" as const
127
+ throw failed.error
128
+ }),
129
+ }
130
+ }
131
+
92
132
  const record = (value: unknown): Record<string, unknown> | undefined =>
93
133
  typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined
94
134
 
@@ -0,0 +1,175 @@
1
+ /**
2
+ * History pruning: an index of plugboard's own request sessions and the sweep that deletes the
3
+ * old ones.
4
+ *
5
+ * The plugin session API has no `list`, so plugboard cannot ask the host which sessions it owns.
6
+ * It keeps an index in the plugin's own host storage (`ctx.storage`, a key-value table in the
7
+ * host database, shared by every Location): one key per request session,
8
+ *
9
+ * history/<app>/<created, 15 digits of epoch ms>/<sessionID>
10
+ *
11
+ * Everything the sweep needs is in the key, so a scan returns an app's sessions oldest first.
12
+ * Sessions created before this index existed are not in it and are never pruned.
13
+ *
14
+ * Both functions run from request handling only (the runtime calls them), never from plugin
15
+ * setup or a finalizer. A sweep does bounded work: it reads at most `maxScan` index entries per
16
+ * app, makes at most `maxDeletes` host deletions and stops at its deadline; what is left waits for
17
+ * the next sweep. A session whose request guard is still in the session store (the host
18
+ * execution has not ended) is never deleted.
19
+ */
20
+
21
+ import type { HistoryPort } from "./host.js"
22
+ import type { SessionStore } from "./state.js"
23
+
24
+ /** Pruning limits. A field that is unset is not applied; with none set nothing is ever deleted. */
25
+ export interface HistoryLimits {
26
+ /** Keep at most this many sessions per app (the newest). */
27
+ readonly maxSessions?: number
28
+ /** Delete sessions older than this many days. */
29
+ readonly maxAgeDays?: number
30
+ }
31
+
32
+ export interface SweepResult {
33
+ readonly apps: number
34
+ /** Index entries read. */
35
+ readonly scanned: number
36
+ readonly removed: number
37
+ /** Already gone from the host (deleted by hand); only the index entry was dropped. */
38
+ readonly missing: number
39
+ /** Host deletions or index updates that failed; retried by the next sweep. */
40
+ readonly failed: number
41
+ /** Over a limit but skipped because the request guard is still active. */
42
+ readonly active: number
43
+ /** Work was left (budget, deadline or scan cap reached): run the next sweep soon. */
44
+ readonly more: boolean
45
+ }
46
+
47
+ export interface SweepInput {
48
+ readonly port: HistoryPort
49
+ readonly limits: HistoryLimits
50
+ readonly sessions: SessionStore
51
+ readonly now: number
52
+ /** Aborts the sweep between host calls. */
53
+ readonly signal: AbortSignal
54
+ /** Most host deletions per sweep. */
55
+ readonly maxDeletes?: number
56
+ /** Most index entries read per app per sweep. */
57
+ readonly maxScan?: number
58
+ }
59
+
60
+ const PREFIX = "history/"
61
+ const AT_WIDTH = 15
62
+ const DAY_MS = 86_400_000
63
+ const PAGE = 500
64
+ const MAX_DELETES = 200
65
+ const MAX_SCAN = 20_000
66
+
67
+ export const indexKey = (app: string, createdAt: number, sessionID: string): string =>
68
+ `${PREFIX}${app}/${String(Math.max(0, Math.floor(createdAt))).padStart(AT_WIDTH, "0")}/${sessionID}`
69
+
70
+ /** Index one new request session. The value repeats the ID; the key carries app and time. */
71
+ export const recordSession = (port: HistoryPort, app: string, sessionID: string, createdAt: number): Promise<void> =>
72
+ port.put(indexKey(app, createdAt, sessionID), sessionID)
73
+
74
+ interface Entry {
75
+ readonly key: string
76
+ readonly sessionID: string
77
+ readonly createdAt: number
78
+ }
79
+
80
+ function parseKey(key: string): Entry | undefined {
81
+ const [, at, ...rest] = key.slice(PREFIX.length).split("/")
82
+ const createdAt = Number(at)
83
+ const sessionID = rest.join("/")
84
+ if (!/^\d+$/.test(at ?? "") || !Number.isFinite(createdAt) || sessionID.length === 0) return undefined
85
+ return { key, sessionID, createdAt }
86
+ }
87
+
88
+ /** App names in the index, found by hopping over each app's keys (one tiny scan per app). */
89
+ async function indexedApps(port: HistoryPort, signal: AbortSignal): Promise<string[]> {
90
+ const apps: string[] = []
91
+ let after: string | undefined
92
+ while (!signal.aborted) {
93
+ const page = await port.scan({ prefix: PREFIX, ...(after === undefined ? {} : { after }), limit: 1 })
94
+ const first = page.keys[0]
95
+ if (first === undefined) break
96
+ const app = first.slice(PREFIX.length).split("/")[0] ?? ""
97
+ if (app.length > 0) apps.push(app)
98
+ // `\uffff` sorts after every character of an ID or timestamp, so this skips the rest of the app.
99
+ after = `${PREFIX}${app}/\uffff`
100
+ }
101
+ return apps
102
+ }
103
+
104
+ /** One app's entries, oldest first, at most `maxScan`; `capped` when more remain in the index. */
105
+ async function readApp(port: HistoryPort, app: string, maxScan: number, signal: AbortSignal): Promise<{ entries: Entry[]; capped: boolean }> {
106
+ const entries: Entry[] = []
107
+ const prefix = `${PREFIX}${app}/`
108
+ let after: string | undefined
109
+ while (!signal.aborted) {
110
+ const page = await port.scan({ prefix, ...(after === undefined ? {} : { after }), limit: Math.min(PAGE, maxScan - entries.length) })
111
+ for (const key of page.keys) {
112
+ const entry = parseKey(key)
113
+ if (entry) entries.push(entry)
114
+ }
115
+ if (page.next === undefined) return { entries, capped: false }
116
+ if (entries.length >= maxScan) return { entries, capped: true }
117
+ after = page.next
118
+ }
119
+ return { entries, capped: true }
120
+ }
121
+
122
+ /**
123
+ * One pass over the index. Per app, the sessions to delete are a prefix of its oldest-first list:
124
+ * as many as exceed `maxSessions`, or are older than `maxAgeDays`, whichever is more. A session
125
+ * with an active request guard is skipped (it still counts as kept). Entries of sessions the host
126
+ * no longer has are dropped. A scan that hit `maxScan` only undercounts the app, so it can only
127
+ * delete fewer sessions than the limit asks for, never more.
128
+ */
129
+ export async function sweepHistory(input: SweepInput): Promise<SweepResult> {
130
+ const { port, limits, sessions, signal } = input
131
+ const maxDeletes = input.maxDeletes ?? MAX_DELETES
132
+ const maxScan = input.maxScan ?? MAX_SCAN
133
+ const cutoff = limits.maxAgeDays === undefined ? undefined : input.now - limits.maxAgeDays * DAY_MS
134
+ let scanned = 0
135
+ let removed = 0
136
+ let missing = 0
137
+ let failed = 0
138
+ let active = 0
139
+ let attempts = 0
140
+ let more = false
141
+
142
+ const apps = await indexedApps(port, signal)
143
+ for (const app of apps) {
144
+ if (signal.aborted) {
145
+ more = true
146
+ break
147
+ }
148
+ const { entries, capped } = await readApp(port, app, maxScan, signal)
149
+ scanned += entries.length
150
+ if (capped) more = true
151
+ const excess = limits.maxSessions === undefined ? 0 : Math.max(0, entries.length - limits.maxSessions)
152
+ const expired = cutoff === undefined ? 0 : entries.findIndex((entry) => entry.createdAt >= cutoff)
153
+ const count = Math.max(excess, cutoff === undefined ? 0 : expired === -1 ? entries.length : expired)
154
+ for (const entry of entries.slice(0, count)) {
155
+ if (signal.aborted || attempts >= maxDeletes) {
156
+ more = true
157
+ break
158
+ }
159
+ // Checked right before the host call: a guard that is still registered means the host execution is running.
160
+ if (sessions.lookup(entry.sessionID)) {
161
+ active++
162
+ continue
163
+ }
164
+ attempts++
165
+ try {
166
+ if ((await port.removeSession(entry.sessionID)) === "removed") removed++
167
+ else missing++
168
+ await port.drop(entry.key)
169
+ } catch {
170
+ failed++
171
+ }
172
+ }
173
+ }
174
+ return { apps: apps.length, scanned, removed, missing, failed, active, more }
175
+ }
@@ -67,7 +67,12 @@ interface Entry {
67
67
  timer?: ReturnType<typeof setTimeout>
68
68
  }
69
69
 
70
- export function createSessionStore(): SessionStore {
70
+ export interface SessionStoreOptions {
71
+ /** Called once per `register`, from request handling (the runtime indexes the session for pruning). Must not block; errors are logged. */
72
+ readonly onRegister?: (state: RequestState) => void
73
+ }
74
+
75
+ export function createSessionStore(options: SessionStoreOptions = {}): SessionStore {
71
76
  const entries = new Map<string, Entry>()
72
77
 
73
78
  const release = (sessionID: string): boolean => {
@@ -82,6 +87,11 @@ export function createSessionStore(): SessionStore {
82
87
  const previous = entries.get(state.sessionID)
83
88
  if (previous?.timer) clearTimeout(previous.timer)
84
89
  entries.set(state.sessionID, { state, startedAt: Date.now() })
90
+ try {
91
+ options.onRegister?.(state)
92
+ } catch (error) {
93
+ logError("session.register_hook_failed", { error: errorMessage(error) })
94
+ }
85
95
  },
86
96
  lookup: (sessionID) => entries.get(sessionID)?.state,
87
97
  release,