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 +8 -7
- package/index.ts +3 -1
- package/package.json +3 -2
- package/src/config.ts +15 -1
- package/src/openai/route.ts +4 -2
- 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,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.
|
|
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/
|
|
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.
|
|
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
|
|
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,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
|
-
/**
|
|
87
|
-
const VERSION =
|
|
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 = {
|
|
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),
|
package/src/session/host.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/session/state.ts
CHANGED
|
@@ -67,7 +67,12 @@ interface Entry {
|
|
|
67
67
|
timer?: ReturnType<typeof setTimeout>
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export
|
|
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,
|