opencode-plugboard 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/index.ts +3 -1
- package/package.json +3 -2
- package/src/config.ts +20 -1
- package/src/http.ts +54 -11
- package/src/openai/request.ts +1 -0
- package/src/openai/response.ts +50 -1
- package/src/openai/route.ts +25 -8
- package/src/openai/sampling.ts +96 -0
- package/src/responses/request.ts +377 -0
- package/src/responses/response.ts +225 -0
- package/src/responses/route.ts +72 -0
- package/src/runtime.ts +76 -4
- package/src/session/host.ts +40 -0
- package/src/session/prune.ts +175 -0
- package/src/session/state.ts +11 -1
|
@@ -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
|
+
}
|