opencode-plugboard 0.0.0-stage → 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.
@@ -0,0 +1,216 @@
1
+ /**
2
+ * OpenAI front-end route table: `GET /healthz`, `GET /v1/models`, `GET /v1/models/{id}`,
3
+ * `POST /v1/chat/completions`, and the dispatch of `POST /v1/responses` to `src/responses/route.ts`.
4
+ *
5
+ * The flow is: establish cancellation and the deadline, check origin, authenticate, parse, resolve
6
+ * the model, call the ModelCaller, encode. Parsing, policy and encoding are imported directly;
7
+ * only effects (the model caller, the catalog and the serving generation's options) arrive
8
+ * through `RouteHost`.
9
+ *
10
+ * Response header `x-plugboard-session: <id>` carries the id of the history record the call
11
+ * writes (the `ModelCaller` reports it through `CallContext.onRecord`). It is set before the
12
+ * response headers go out (JSON body or first SSE chunk); an id reported after that is ignored.
13
+ */
14
+
15
+ import type { IncomingMessage, ServerResponse } from "node:http"
16
+ import { authenticate, type PlugboardOptions } from "../config.js"
17
+ import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
18
+ import {
19
+ BodyError,
20
+ RequestAborted,
21
+ beginRequest,
22
+ boundEvents,
23
+ checkJsonContentType,
24
+ checkOrigin,
25
+ finishRequest,
26
+ readJsonBody,
27
+ sendJson,
28
+ sseWriter,
29
+ type RequestScope,
30
+ } from "../http.js"
31
+ import { errorMessage, log, logError } from "../log.js"
32
+ import { listAvailableModels, resolveModel } from "../models.js"
33
+ import { serveResponses } from "../responses/route.js"
34
+ import { parseChatRequest } from "./request.js"
35
+ import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
36
+
37
+ /** What the serving generation supplies per request. */
38
+ export interface RouteHost {
39
+ readonly options: PlugboardOptions
40
+ readonly caller: ModelCaller
41
+ /** The serving Location's enabled models. */
42
+ loadCatalog(): Promise<ReadonlyArray<CatalogModel>>
43
+ }
44
+
45
+ const SESSION_HEADER = "x-plugboard-session"
46
+ const MODEL_PREFIX = "/v1/models/"
47
+ const KNOWN_ROUTES = ["GET /v1/models", "POST /v1/chat/completions", "POST /v1/responses"]
48
+
49
+ const decodeSegment = (raw: string): string => {
50
+ try {
51
+ return decodeURIComponent(raw)
52
+ } catch {
53
+ return raw
54
+ }
55
+ }
56
+
57
+ /** What a request log line may carry: ids and outcomes only. */
58
+ interface RequestInfo {
59
+ route: string
60
+ app?: string
61
+ model?: string
62
+ /** Set when a response committed as 200 and then failed (a stream error frame): the log outcome and the error code. */
63
+ failed?: string
64
+ }
65
+
66
+ export function createOpenAiRoute(host: RouteHost) {
67
+ const fail = (response: ServerResponse, error: PlugError, close = false) =>
68
+ sendJson(response, error.status, encodeError(error), { close })
69
+
70
+ /** The route proper. Throws `BodyError`, `RequestAborted` or an unexpected error; `handle` maps them. */
71
+ async function serve(request: IncomingMessage, response: ServerResponse, scope: RequestScope, info: RequestInfo): Promise<void> {
72
+ let url: URL
73
+ try {
74
+ url = new URL(request.url ?? "/", "http://127.0.0.1")
75
+ } catch {
76
+ return fail(response, { status: 400, kind: "invalid_request", code: "invalid_request_target", message: "Malformed request target" })
77
+ }
78
+ const path = url.pathname
79
+ const modelId = request.method === "GET" && path.startsWith(MODEL_PREFIX) && path.length > MODEL_PREFIX.length ? decodeSegment(path.slice(MODEL_PREFIX.length)) : undefined
80
+ const route = `${request.method} ${modelId !== undefined ? "/v1/models/{id}" : path}`
81
+ // Arbitrary client paths stay out of the log.
82
+ info.route = modelId !== undefined || KNOWN_ROUTES.includes(route) || route === "GET /healthz" ? route : "unknown"
83
+
84
+ const rejected = checkOrigin(request, host.options)
85
+ if (rejected) return fail(response, rejected)
86
+
87
+ if (route === "GET /healthz") return sendJson(response, 200, { ok: true })
88
+
89
+ if (modelId === undefined && !KNOWN_ROUTES.includes(route)) {
90
+ return fail(response, {
91
+ status: 404,
92
+ kind: "not_found",
93
+ code: "not_found",
94
+ message: `No route for ${request.method} ${path}`,
95
+ })
96
+ }
97
+
98
+ const auth = authenticate(host.options, request.headers.authorization)
99
+ if (isPlugError(auth)) return fail(response, auth)
100
+ info.app = auth.app
101
+
102
+ if (route === "GET /v1/models" || modelId !== undefined) {
103
+ const listed = listAvailableModels(host.options, await scope.race(host.loadCatalog()))
104
+ if (modelId === undefined) return sendJson(response, 200, encodeModelList(listed))
105
+ const item = listed.find((m) => m.id === modelId)
106
+ if (!item) {
107
+ return fail(response, {
108
+ status: 404,
109
+ kind: "not_found",
110
+ code: "model_not_found",
111
+ param: "model",
112
+ message: `Model ${modelId} is not available`,
113
+ })
114
+ }
115
+ info.model = item.id
116
+ return sendJson(response, 200, (encodeModelList([item]) as { data: object[] }).data[0])
117
+ }
118
+
119
+ const unsupported = checkJsonContentType(request)
120
+ if (unsupported) return fail(response, unsupported)
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 })
124
+ const parsed = parseChatRequest(body, host.options)
125
+ if (isPlugError(parsed)) return fail(response, parsed)
126
+ const catalog = await scope.race(host.loadCatalog())
127
+ const ref = resolveModel(host.options, parsed.request.model, catalog, parsed.reasoningEffort)
128
+ if (isPlugError(ref)) return fail(response, ref)
129
+ info.model = `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
130
+ const call: PlugRequest = { ...parsed.request, ref }
131
+ const { exchange } = parsed
132
+
133
+ // Abandoned requests (client gone, deadline passed) must never reach the model caller.
134
+ if (scope.signal.aborted) throw scope.signal.reason
135
+ const events = boundEvents(
136
+ host.caller.stream(call, {
137
+ app: auth.app,
138
+ signal: scope.signal,
139
+ onRecord: (recordID) => {
140
+ if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
141
+ response.setHeader(SESSION_HEADER, recordID)
142
+ },
143
+ }),
144
+ scope,
145
+ )
146
+
147
+ if (exchange.stream) {
148
+ // Nothing is committed until the encoder writes its first frame, so a failure before any
149
+ // wire-visible output (including suppressed reasoning or structured prose) is a real HTTP error.
150
+ const sse = sseWriter(response, scope.signal)
151
+ const { failure } = await streamCompletion(sse.paced(events), exchange, sse.write)
152
+ if (!sse.started && !response.headersSent) {
153
+ if (!scope.signal.aborted || scope.timedOut) fail(response, failure ?? { status: 502, kind: "server", message: "No response from the model" })
154
+ } else {
155
+ if (failure) info.failed = failure.code ?? failure.kind
156
+ if (!response.writableEnded) response.end()
157
+ }
158
+ return
159
+ }
160
+
161
+ const result = await collectCompletion(events, exchange)
162
+ sendJson(response, result.status, result.body)
163
+ }
164
+
165
+ /** Never rejects: every failure ends in a response (or a closed socket) and one log line. */
166
+ return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
167
+ const started = Date.now()
168
+ const id = crypto.randomUUID().slice(0, 8)
169
+ const info: RequestInfo = { route: "unparsed" }
170
+ const scope = beginRequest(request, response, host.options.requestTimeoutMs)
171
+ let outcome = "ok"
172
+ let error: string | undefined
173
+ try {
174
+ try {
175
+ await serve(request, response, scope, info)
176
+ } catch (caught) {
177
+ if (caught instanceof RequestAborted) {
178
+ outcome = caught.kind
179
+ if (caught.kind === "timeout") {
180
+ if (response.headersSent) response.end()
181
+ else fail(response, scope.timeoutError, true)
182
+ }
183
+ } else if (caught instanceof BodyError) {
184
+ outcome = `body_${caught.status}`
185
+ // 413: the client may still be sending; answer, then close (see `finishRequest`).
186
+ fail(response, { status: caught.status, kind: "invalid_request", message: caught.message }, caught.status === 413)
187
+ } else {
188
+ // Unexpected: the message goes to the log only; the client gets a generic one.
189
+ outcome = "error"
190
+ error = errorMessage(caught)
191
+ if (response.headersSent) {
192
+ if (!response.writableEnded) response.end()
193
+ } else fail(response, { status: 500, kind: "server", message: "Internal server error" })
194
+ }
195
+ }
196
+ if (outcome === "ok" && scope.signal.aborted) outcome = scope.timedOut ? "timeout" : "disconnected"
197
+ else if (outcome === "ok" && info.failed !== undefined) {
198
+ outcome = "error"
199
+ error = info.failed
200
+ } else if (outcome === "ok" && response.statusCode >= 400) outcome = `status_${response.statusCode}`
201
+ } catch (caught) {
202
+ outcome = "error"
203
+ error = errorMessage(caught)
204
+ } finally {
205
+ scope.dispose()
206
+ try {
207
+ finishRequest(request, response)
208
+ } catch {}
209
+ if (info.route !== "GET /healthz" || outcome !== "ok") {
210
+ const fields = { id, route: info.route, app: info.app, model: info.model, status: response.statusCode, ms: Date.now() - started, outcome, error }
211
+ if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
212
+ else log("request", fields)
213
+ }
214
+ }
215
+ }
216
+ }
@@ -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
+ }