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.
- package/LICENSE +21 -0
- package/README.md +229 -2
- package/index.ts +101 -0
- package/package.json +33 -4
- package/src/config.ts +164 -0
- package/src/contract.ts +155 -0
- package/src/http.ts +324 -0
- package/src/log.ts +28 -0
- package/src/models.ts +100 -0
- package/src/openai/request.ts +334 -0
- package/src/openai/response.ts +286 -0
- package/src/openai/route.ts +216 -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 +535 -0
- package/src/session/caller.ts +192 -0
- package/src/session/context.ts +175 -0
- package/src/session/events.ts +344 -0
- package/src/session/history.ts +89 -0
- package/src/session/host.ts +215 -0
- package/src/session/media.ts +89 -0
- package/src/session/prune.ts +175 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +144 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Chat Completions request -> neutral `PlugRequest` plus the `OpenAiExchange` the
|
|
3
|
+
* response encoders need.
|
|
4
|
+
*
|
|
5
|
+
* Pure function: no I/O. The route resolves `model` to a `ModelRef` afterwards, which is why
|
|
6
|
+
* the request omits `ref`.
|
|
7
|
+
*/
|
|
8
|
+
import type { PlugboardOptions } from "../config.js"
|
|
9
|
+
import {
|
|
10
|
+
isPlugError,
|
|
11
|
+
type PlugError,
|
|
12
|
+
type PlugGeneration,
|
|
13
|
+
type PlugMessage,
|
|
14
|
+
type PlugPart,
|
|
15
|
+
type PlugRequest,
|
|
16
|
+
type PlugTool,
|
|
17
|
+
type PlugToolChoice,
|
|
18
|
+
} from "../contract.js"
|
|
19
|
+
import type { OpenAiExchange } from "./response.js"
|
|
20
|
+
|
|
21
|
+
type JsonObject = Record<string, unknown>
|
|
22
|
+
|
|
23
|
+
export interface ParsedChat {
|
|
24
|
+
/** The model call, minus the model ref the route resolves from `request.model`. */
|
|
25
|
+
readonly request: Omit<PlugRequest, "ref">
|
|
26
|
+
/** Response presentation state for the encoders; never reaches the model call. */
|
|
27
|
+
readonly exchange: OpenAiExchange
|
|
28
|
+
readonly reasoningEffort?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Name of the forced tool that carries structured output. */
|
|
32
|
+
const RESPOND_TOOL = "respond"
|
|
33
|
+
|
|
34
|
+
/** The most stop sequences OpenAI accepts. */
|
|
35
|
+
const MAX_STOP = 4
|
|
36
|
+
|
|
37
|
+
const isRecord = (value: unknown): value is JsonObject => typeof value === "object" && value !== null && !Array.isArray(value)
|
|
38
|
+
|
|
39
|
+
const bad = (message: string, param?: string, code?: string): PlugError => ({
|
|
40
|
+
status: 400,
|
|
41
|
+
kind: "invalid_request",
|
|
42
|
+
message,
|
|
43
|
+
...(code ? { code } : {}),
|
|
44
|
+
...(param ? { param } : {}),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** Text of a string or an array of `{type:"text"}` parts (system, developer, tool and assistant content). */
|
|
48
|
+
const textOf = (content: unknown): string | undefined => {
|
|
49
|
+
if (typeof content === "string") return content
|
|
50
|
+
if (!Array.isArray(content)) return undefined
|
|
51
|
+
const texts: string[] = []
|
|
52
|
+
for (const item of content) {
|
|
53
|
+
if (!isRecord(item) || typeof item.text !== "string") return undefined
|
|
54
|
+
texts.push(item.text)
|
|
55
|
+
}
|
|
56
|
+
return texts.join("\n")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dataUrlType = (url: string) => /^data:([^;,]+)[;,]/.exec(url)?.[1]
|
|
60
|
+
|
|
61
|
+
const parseJson = (value: string): unknown => {
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(value)
|
|
64
|
+
} catch {
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** User content: string, or parts of text / image_url / file. */
|
|
70
|
+
const userParts = (content: unknown, param: string): PlugPart[] | PlugError => {
|
|
71
|
+
if (typeof content === "string") return [{ type: "text", text: content }]
|
|
72
|
+
if (!Array.isArray(content)) return bad("Message content must be a string or an array of content parts", param)
|
|
73
|
+
const parts: PlugPart[] = []
|
|
74
|
+
for (const [index, item] of content.entries()) {
|
|
75
|
+
const at = `${param}[${index}]`
|
|
76
|
+
if (!isRecord(item)) return bad("Content part must be an object", at)
|
|
77
|
+
if (item.type === "text" && typeof item.text === "string") parts.push({ type: "text", text: item.text })
|
|
78
|
+
else if (item.type === "image_url" && isRecord(item.image_url) && typeof item.image_url.url === "string") {
|
|
79
|
+
const url = item.image_url.url
|
|
80
|
+
parts.push({ type: "media", url, ...(dataUrlType(url) ? { mediaType: dataUrlType(url) } : {}) })
|
|
81
|
+
} else if (item.type === "file" && isRecord(item.file) && typeof item.file.file_data === "string") {
|
|
82
|
+
const data = item.file.file_data
|
|
83
|
+
const url = data.startsWith("data:") ? data : `data:application/octet-stream;base64,${data}`
|
|
84
|
+
parts.push({ type: "media", url, mediaType: dataUrlType(url) })
|
|
85
|
+
} else return bad(`Unsupported content part type "${String(item.type)}"`, `${at}.type`, "unsupported_parameter")
|
|
86
|
+
}
|
|
87
|
+
return parts
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const parseMessages = (raw: unknown[]): { system: string[]; messages: PlugMessage[] } | PlugError => {
|
|
91
|
+
const system: string[] = []
|
|
92
|
+
const messages: PlugMessage[] = []
|
|
93
|
+
// `role:"tool"` messages carry only `tool_call_id`; tool results need the tool's name.
|
|
94
|
+
const toolNames = new Map<string, string>()
|
|
95
|
+
|
|
96
|
+
for (const [index, message] of raw.entries()) {
|
|
97
|
+
const param = `messages[${index}]`
|
|
98
|
+
if (!isRecord(message)) return bad("Each message must be an object", param)
|
|
99
|
+
switch (message.role) {
|
|
100
|
+
case "system":
|
|
101
|
+
case "developer": {
|
|
102
|
+
const value = textOf(message.content)
|
|
103
|
+
if (value === undefined) return bad("System message content must be a string or text parts", `${param}.content`)
|
|
104
|
+
if (value.length > 0) system.push(value)
|
|
105
|
+
break
|
|
106
|
+
}
|
|
107
|
+
case "user": {
|
|
108
|
+
const parts = userParts(message.content, `${param}.content`)
|
|
109
|
+
if (!Array.isArray(parts)) return parts
|
|
110
|
+
messages.push({ role: "user", content: parts })
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
case "assistant": {
|
|
114
|
+
const parts: PlugPart[] = []
|
|
115
|
+
if (message.content != null) {
|
|
116
|
+
// Assistant content may mix text and refusal parts; keep the text.
|
|
117
|
+
const value = Array.isArray(message.content)
|
|
118
|
+
? textOf(message.content.filter((p) => isRecord(p) && p.type === "text"))
|
|
119
|
+
: textOf(message.content)
|
|
120
|
+
if (value === undefined) return bad("Assistant content must be a string or text parts", `${param}.content`)
|
|
121
|
+
if (value.length > 0) parts.push({ type: "text", text: value })
|
|
122
|
+
}
|
|
123
|
+
if (message.tool_calls != null) {
|
|
124
|
+
if (!Array.isArray(message.tool_calls)) return bad("tool_calls must be an array", `${param}.tool_calls`)
|
|
125
|
+
for (const [i, call] of message.tool_calls.entries()) {
|
|
126
|
+
const fn = isRecord(call) && isRecord(call.function) ? call.function : undefined
|
|
127
|
+
if (!isRecord(call) || !fn || typeof fn.name !== "string")
|
|
128
|
+
return bad("Each tool call needs an id and a function name", `${param}.tool_calls[${i}]`)
|
|
129
|
+
if (typeof call.id !== "string" || call.id.length === 0)
|
|
130
|
+
return bad("Each tool call needs a non-empty id", `${param}.tool_calls[${i}].id`)
|
|
131
|
+
const id = call.id
|
|
132
|
+
toolNames.set(id, fn.name)
|
|
133
|
+
parts.push({
|
|
134
|
+
type: "tool-call",
|
|
135
|
+
id,
|
|
136
|
+
name: fn.name,
|
|
137
|
+
input: typeof fn.arguments === "string" ? parseJson(fn.arguments) : (fn.arguments ?? {}),
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (parts.length > 0) messages.push({ role: "assistant", content: parts })
|
|
142
|
+
break
|
|
143
|
+
}
|
|
144
|
+
case "tool": {
|
|
145
|
+
const result = textOf(message.content)
|
|
146
|
+
if (typeof message.tool_call_id !== "string" || result === undefined)
|
|
147
|
+
return bad("Tool messages need a tool_call_id and string or text-part content", param)
|
|
148
|
+
const id = message.tool_call_id
|
|
149
|
+
const name = toolNames.get(id)
|
|
150
|
+
if (name === undefined)
|
|
151
|
+
return bad(`Tool message answers tool_call_id "${id}", which no earlier assistant tool call has`, `${param}.tool_call_id`)
|
|
152
|
+
messages.push({ role: "tool", content: [{ type: "tool-result", id, name, result }] })
|
|
153
|
+
break
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
return bad(`Unsupported message role "${String(message.role)}"`, `${param}.role`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { system, messages }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const parseTools = (raw: unknown): PlugTool[] | PlugError => {
|
|
163
|
+
if (raw == null) return []
|
|
164
|
+
if (!Array.isArray(raw)) return bad("tools must be an array", "tools")
|
|
165
|
+
const tools: PlugTool[] = []
|
|
166
|
+
const names = new Set<string>()
|
|
167
|
+
for (const [index, entry] of raw.entries()) {
|
|
168
|
+
const fn = isRecord(entry) && entry.type === "function" && isRecord(entry.function) ? entry.function : undefined
|
|
169
|
+
if (!fn || typeof fn.name !== "string" || fn.name.length === 0)
|
|
170
|
+
return bad('Each tool must be {"type":"function","function":{"name":...}}', `tools[${index}]`)
|
|
171
|
+
if (names.has(fn.name)) return bad(`Duplicate tool name "${fn.name}"`, `tools[${index}].function.name`)
|
|
172
|
+
if (fn.description != null && typeof fn.description !== "string")
|
|
173
|
+
return bad("Tool description must be a string", `tools[${index}].function.description`)
|
|
174
|
+
// An omitted `parameters` is a tool without arguments; a present one must be a JSON Schema object.
|
|
175
|
+
if (fn.parameters != null && !isRecord(fn.parameters))
|
|
176
|
+
return bad("Tool parameters must be a JSON Schema object", `tools[${index}].function.parameters`)
|
|
177
|
+
names.add(fn.name)
|
|
178
|
+
tools.push({
|
|
179
|
+
name: fn.name,
|
|
180
|
+
description: typeof fn.description === "string" ? fn.description : "",
|
|
181
|
+
parameters: isRecord(fn.parameters) ? fn.parameters : { type: "object", properties: {} },
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
return tools
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const parseToolChoice = (raw: unknown): PlugToolChoice | undefined | PlugError => {
|
|
188
|
+
if (raw == null) return undefined
|
|
189
|
+
if (raw === "auto" || raw === "none" || raw === "required") return raw
|
|
190
|
+
if (isRecord(raw) && raw.type === "function" && isRecord(raw.function) && typeof raw.function.name === "string")
|
|
191
|
+
return { type: "tool", name: raw.function.name }
|
|
192
|
+
return bad("Unsupported tool_choice", "tool_choice")
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** System instruction for `json_object`: no schema, so the caller's prompt describes the keys (OpenAI semantics). */
|
|
196
|
+
const JSON_OBJECT_INSTRUCTION = "Respond with a single valid JSON object only."
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* `json_schema` -> the `respond` tool (`structured`; forced unless the caller also sends tools).
|
|
200
|
+
* `json_object` -> a system instruction and plain text content (`instruction`): there is no schema
|
|
201
|
+
* to force a tool with.
|
|
202
|
+
*/
|
|
203
|
+
const parseResponseFormat = (raw: unknown): { structured?: PlugTool; instruction?: string } | PlugError => {
|
|
204
|
+
if (raw == null) return {}
|
|
205
|
+
if (!isRecord(raw)) return bad("response_format must be an object", "response_format")
|
|
206
|
+
if (raw.type === "text") return {}
|
|
207
|
+
if (raw.type === "json_object") return { instruction: JSON_OBJECT_INSTRUCTION }
|
|
208
|
+
if (raw.type === "json_schema") {
|
|
209
|
+
const spec = raw.json_schema
|
|
210
|
+
if (!isRecord(spec)) return bad("response_format.json_schema must be an object", "response_format.json_schema")
|
|
211
|
+
if (spec.name != null && typeof spec.name !== "string") return bad("response_format.json_schema.name must be a string", "response_format.json_schema.name")
|
|
212
|
+
if (spec.description != null && typeof spec.description !== "string")
|
|
213
|
+
return bad("response_format.json_schema.description must be a string", "response_format.json_schema.description")
|
|
214
|
+
if (!isRecord(spec.schema)) return bad("response_format.json_schema.schema must be a JSON Schema object", "response_format.json_schema.schema")
|
|
215
|
+
return {
|
|
216
|
+
structured: {
|
|
217
|
+
name: RESPOND_TOOL,
|
|
218
|
+
// With caller tools also on offer, this description is what tells the model which call is the final answer.
|
|
219
|
+
description:
|
|
220
|
+
"Give your final answer by calling this tool with JSON matching its schema." +
|
|
221
|
+
(typeof spec.description === "string" ? ` ${spec.description}` : ""),
|
|
222
|
+
parameters: spec.schema,
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return bad(`Unsupported response_format type "${String(raw.type)}"`, "response_format.type")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const parseGeneration = (body: JsonObject): PlugGeneration | PlugError => {
|
|
230
|
+
const out: { -readonly [K in keyof PlugGeneration]: PlugGeneration[K] } = {}
|
|
231
|
+
const number = (key: string, min: number, max: number, integer: boolean): number | undefined | PlugError => {
|
|
232
|
+
const value = body[key]
|
|
233
|
+
if (value == null) return undefined
|
|
234
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return bad(`${key} must be a number`, key)
|
|
235
|
+
if (integer && !Number.isInteger(value)) return bad(`${key} must be an integer`, key)
|
|
236
|
+
if (value < min || value > max)
|
|
237
|
+
return bad(max === Number.MAX_SAFE_INTEGER ? `${key} must be at least ${min}` : `${key} must be between ${min} and ${max}`, key)
|
|
238
|
+
return value
|
|
239
|
+
}
|
|
240
|
+
for (const [key, field, min, max, integer] of [
|
|
241
|
+
["temperature", "temperature", 0, 2, false],
|
|
242
|
+
["top_p", "topP", 0, 1, false],
|
|
243
|
+
["presence_penalty", "presencePenalty", -2, 2, false],
|
|
244
|
+
["frequency_penalty", "frequencyPenalty", -2, 2, false],
|
|
245
|
+
// max_completion_tokens is the current name; max_tokens the deprecated one. Prefer the former.
|
|
246
|
+
["max_tokens", "maxTokens", 1, Number.MAX_SAFE_INTEGER, true],
|
|
247
|
+
["max_completion_tokens", "maxTokens", 1, Number.MAX_SAFE_INTEGER, true],
|
|
248
|
+
] as const) {
|
|
249
|
+
const value = number(key, min, max, integer)
|
|
250
|
+
if (typeof value === "object") return value
|
|
251
|
+
if (value !== undefined) out[field] = value
|
|
252
|
+
}
|
|
253
|
+
if (body.stop != null) {
|
|
254
|
+
const stop = typeof body.stop === "string" ? [body.stop] : body.stop
|
|
255
|
+
if (!Array.isArray(stop) || !stop.every((s) => typeof s === "string")) return bad("stop must be a string or an array of strings", "stop")
|
|
256
|
+
if (stop.length > MAX_STOP) return bad(`stop takes at most ${MAX_STOP} sequences`, "stop")
|
|
257
|
+
out.stop = stop as string[]
|
|
258
|
+
}
|
|
259
|
+
return out
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function parseChatRequest(body: unknown, options: PlugboardOptions): ParsedChat | PlugError {
|
|
263
|
+
if (!isRecord(body)) return bad("Request body must be a JSON object")
|
|
264
|
+
if (typeof body.model !== "string" || body.model.length === 0) return bad("model is required", "model")
|
|
265
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) return bad("messages must be a non-empty array", "messages")
|
|
266
|
+
|
|
267
|
+
// Parameters the session backend cannot honour are rejected, not ignored.
|
|
268
|
+
if (body.n != null) {
|
|
269
|
+
if (typeof body.n !== "number" || !Number.isInteger(body.n) || body.n < 1) return bad("n must be a positive integer", "n")
|
|
270
|
+
if (body.n > 1) return bad("Only n=1 is supported", "n", "unsupported_parameter")
|
|
271
|
+
}
|
|
272
|
+
if (body.logprobs === true) return bad("logprobs is not supported", "logprobs", "unsupported_parameter")
|
|
273
|
+
if (body.top_logprobs != null) return bad("top_logprobs is not supported", "top_logprobs", "unsupported_parameter")
|
|
274
|
+
|
|
275
|
+
const parsed = parseMessages(body.messages)
|
|
276
|
+
if (isPlugError(parsed)) return parsed
|
|
277
|
+
const callerTools = parseTools(body.tools)
|
|
278
|
+
if (!Array.isArray(callerTools)) return callerTools
|
|
279
|
+
const toolChoice = parseToolChoice(body.tool_choice)
|
|
280
|
+
if (isPlugError(toolChoice)) return toolChoice
|
|
281
|
+
const format = parseResponseFormat(body.response_format)
|
|
282
|
+
if (isPlugError(format)) return format
|
|
283
|
+
const respond = format.structured
|
|
284
|
+
const generation = parseGeneration(body)
|
|
285
|
+
if (isPlugError(generation)) return generation
|
|
286
|
+
|
|
287
|
+
if (typeof toolChoice === "object" && !callerTools.some((tool) => tool.name === toolChoice.name))
|
|
288
|
+
return bad(`tool_choice names "${toolChoice.name}", which is not in tools`, "tool_choice.function.name")
|
|
289
|
+
if (respond && callerTools.some((tool) => tool.name === RESPOND_TOOL))
|
|
290
|
+
return bad(
|
|
291
|
+
`A tool named "${RESPOND_TOOL}" collides with response_format json_schema, which plugboard answers through that name`,
|
|
292
|
+
"tools",
|
|
293
|
+
"reserved_tool_name",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
// json_schema + caller tools: the model may call a caller tool or `respond`, so `tool_choice` is
|
|
297
|
+
// `required` (not forced to `respond`). `tool_choice: none` leaves only `respond`; naming a tool
|
|
298
|
+
// contradicts a structured answer.
|
|
299
|
+
if (respond && typeof toolChoice === "object")
|
|
300
|
+
return bad(
|
|
301
|
+
"A tool_choice that names a tool cannot be combined with response_format json_schema",
|
|
302
|
+
"tool_choice",
|
|
303
|
+
"unsupported_parameter",
|
|
304
|
+
)
|
|
305
|
+
const withCallerTools = respond !== undefined && callerTools.length > 0 && toolChoice !== "none"
|
|
306
|
+
const tools = respond ? (withCallerTools ? [...callerTools, respond] : [respond]) : callerTools
|
|
307
|
+
if (tools.length > options.maxTools)
|
|
308
|
+
return bad(
|
|
309
|
+
`Request has ${tools.length} tools; plugboard allows ${options.maxTools} (option \`maxTools\`)`,
|
|
310
|
+
"tools",
|
|
311
|
+
"too_many_tools",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
const forced: PlugToolChoice | undefined = respond ? (withCallerTools ? "required" : { type: "tool", name: RESPOND_TOOL }) : toolChoice
|
|
315
|
+
const request: Omit<PlugRequest, "ref"> = {
|
|
316
|
+
model: body.model,
|
|
317
|
+
system: format.instruction ? [...parsed.system, format.instruction] : parsed.system,
|
|
318
|
+
messages: parsed.messages,
|
|
319
|
+
tools,
|
|
320
|
+
...(forced && tools.length > 0 ? { toolChoice: forced } : {}),
|
|
321
|
+
generation,
|
|
322
|
+
}
|
|
323
|
+
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {}
|
|
324
|
+
return {
|
|
325
|
+
request,
|
|
326
|
+
exchange: {
|
|
327
|
+
model: body.model,
|
|
328
|
+
stream: body.stream === true,
|
|
329
|
+
includeUsage: streamOptions.include_usage === true,
|
|
330
|
+
...(respond ? { structuredTool: RESPOND_TOOL, ...(withCallerTools ? { callerTools: true } : {}) } : {}),
|
|
331
|
+
},
|
|
332
|
+
...(typeof body.reasoning_effort === "string" ? { reasoningEffort: body.reasoning_effort } : {}),
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Neutral results -> OpenAI wire objects: Chat Completions as one JSON body or SSE chunks, the
|
|
3
|
+
* `/v1/models` list, and error bodies.
|
|
4
|
+
*
|
|
5
|
+
* Reasoning deltas are dropped: Chat Completions has no standard field for them.
|
|
6
|
+
*/
|
|
7
|
+
import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
|
|
8
|
+
import type { ListedModel } from "../models.js"
|
|
9
|
+
|
|
10
|
+
type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the encoders need to know about the exchange besides the model events: Chat Completions
|
|
14
|
+
* presentation state. The request decoder produces it; the model call never sees it.
|
|
15
|
+
*/
|
|
16
|
+
export interface OpenAiExchange {
|
|
17
|
+
/** The model string the client sent; echoed in every response object. */
|
|
18
|
+
readonly model: string
|
|
19
|
+
readonly stream: boolean
|
|
20
|
+
/** `stream_options.include_usage`: the stream carries a usage field per chunk and a final usage chunk. */
|
|
21
|
+
readonly includeUsage: boolean
|
|
22
|
+
/** Name of the forced tool that carries `response_format` output; its input is unwrapped into the message content. */
|
|
23
|
+
readonly structuredTool?: string
|
|
24
|
+
/**
|
|
25
|
+
* The caller's own tools are offered next to the structured tool. A call to any of them is the
|
|
26
|
+
* response (normal `tool_calls`, prose and the structured answer dropped); the structured
|
|
27
|
+
* answer is content only when the step called no caller tool.
|
|
28
|
+
*/
|
|
29
|
+
readonly callerTools?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** How a streamed exchange ended. Nothing was written when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
|
|
33
|
+
export interface StreamResult {
|
|
34
|
+
readonly failure?: PlugError
|
|
35
|
+
/** Whether any frame was written (the response is committed as a 200 SSE stream). */
|
|
36
|
+
readonly started: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ERROR_TYPES: Record<PlugErrorKind, string> = {
|
|
40
|
+
invalid_request: "invalid_request_error",
|
|
41
|
+
authentication: "authentication_error",
|
|
42
|
+
permission: "permission_error",
|
|
43
|
+
not_found: "not_found_error",
|
|
44
|
+
rate_limit: "rate_limit_error",
|
|
45
|
+
server: "server_error",
|
|
46
|
+
timeout: "timeout_error",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FINISH_REASONS: Record<Exclude<PlugFinish, "error">, string> = {
|
|
50
|
+
stop: "stop",
|
|
51
|
+
length: "length",
|
|
52
|
+
"tool-calls": "tool_calls",
|
|
53
|
+
"content-filter": "content_filter",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const encodeError = (error: PlugError): object => ({
|
|
57
|
+
error: { message: error.message, type: ERROR_TYPES[error.kind], param: error.param ?? null, code: error.code ?? null },
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** `GET /v1/models` body. */
|
|
61
|
+
export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
|
|
62
|
+
object: "list",
|
|
63
|
+
data: models.map(({ id, ref, model }) => ({
|
|
64
|
+
id,
|
|
65
|
+
object: "model",
|
|
66
|
+
created: 0,
|
|
67
|
+
owned_by: ref.providerID,
|
|
68
|
+
plugboard: { capabilities: model.capabilities ?? {}, contextLimit: model.contextLimit },
|
|
69
|
+
})),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const encodeUsage = (usage: PlugUsage) => {
|
|
73
|
+
const prompt = usage.inputTokens ?? 0
|
|
74
|
+
const completion = usage.outputTokens ?? 0
|
|
75
|
+
return {
|
|
76
|
+
prompt_tokens: prompt,
|
|
77
|
+
completion_tokens: completion,
|
|
78
|
+
total_tokens: prompt + completion,
|
|
79
|
+
prompt_tokens_details: { cached_tokens: usage.cacheReadTokens ?? 0 },
|
|
80
|
+
completion_tokens_details: { reasoning_tokens: usage.reasoningTokens ?? 0 },
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const args = (input: unknown) => (typeof input === "string" ? input : JSON.stringify(input ?? {}))
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Final finish_reason. Tool calls force `tool_calls` even when the upstream says `stop`;
|
|
88
|
+
* structured output ends as a plain `stop`.
|
|
89
|
+
*/
|
|
90
|
+
const finishReason = (reason: Exclude<PlugFinish, "error">, calls: number, structured: boolean) => {
|
|
91
|
+
if (reason === "length" || reason === "content-filter") return FINISH_REASONS[reason]
|
|
92
|
+
if (structured) return "stop"
|
|
93
|
+
return calls > 0 ? "tool_calls" : FINISH_REASONS[reason]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const noFinish = (): PlugError => ({
|
|
97
|
+
status: 500,
|
|
98
|
+
kind: "server",
|
|
99
|
+
message: "Model stream ended without a finish event",
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const failedRun = (): PlugError => ({ status: 500, kind: "server", message: "Model run failed" })
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Structured-output projection, shared by the JSON and SSE encoders so both give the same body.
|
|
106
|
+
* In structured mode (`exchange.structuredTool` set) the content is exactly the arguments of
|
|
107
|
+
* the first call to that tool: prose, reasoning and any other tool call are suppressed, and
|
|
108
|
+
* later calls to it are ignored. Without an answer the exchange fails, except when the model
|
|
109
|
+
* stopped for `length` or `content-filter`, which keep their finish reasons with empty content.
|
|
110
|
+
*/
|
|
111
|
+
const structured = {
|
|
112
|
+
isAnswer: (exchange: OpenAiExchange, call: ToolCall) => exchange.structuredTool !== undefined && call.name === exchange.structuredTool,
|
|
113
|
+
content: (call: ToolCall) => args(call.input),
|
|
114
|
+
/** The failure for a finished structured exchange, if any. */
|
|
115
|
+
failure: (exchange: OpenAiExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
|
|
116
|
+
exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
|
|
117
|
+
? undefined
|
|
118
|
+
: {
|
|
119
|
+
status: 502,
|
|
120
|
+
kind: "server",
|
|
121
|
+
code: "structured_output_missing",
|
|
122
|
+
message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const newId = () => `chatcmpl-${crypto.randomUUID().replaceAll("-", "").slice(0, 29)}`
|
|
127
|
+
const now = () => Math.floor(Date.now() / 1000)
|
|
128
|
+
|
|
129
|
+
export async function collectCompletion(
|
|
130
|
+
events: AsyncIterable<PlugEvent>,
|
|
131
|
+
exchange: OpenAiExchange,
|
|
132
|
+
): Promise<{ status: number; body: object }> {
|
|
133
|
+
let text = ""
|
|
134
|
+
const calls: ToolCall[] = []
|
|
135
|
+
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
136
|
+
let usage: PlugUsage | undefined
|
|
137
|
+
let failure: PlugError | undefined
|
|
138
|
+
|
|
139
|
+
for await (const event of events) {
|
|
140
|
+
if (event.type === "text-delta") text += event.text
|
|
141
|
+
else if (event.type === "tool-call") calls.push(event)
|
|
142
|
+
else if (event.type === "finish") {
|
|
143
|
+
usage = event.usage ?? usage
|
|
144
|
+
if (event.reason === "error") failure ??= failedRun()
|
|
145
|
+
else finish = event.reason
|
|
146
|
+
} else if (event.type === "error") {
|
|
147
|
+
failure = event.error
|
|
148
|
+
break
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!failure && !finish) failure = noFinish()
|
|
152
|
+
if (failure || !finish) {
|
|
153
|
+
const error = failure ?? noFinish()
|
|
154
|
+
return { status: error.status, body: encodeError(error) }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const answer = calls.find((call) => structured.isAnswer(exchange, call))
|
|
158
|
+
const others = calls.filter((call) => !structured.isAnswer(exchange, call))
|
|
159
|
+
// With the caller's tools offered, a call to one of them wins over the structured answer.
|
|
160
|
+
const passthrough = exchange.callerTools === true && others.length > 0
|
|
161
|
+
const isStructured = exchange.structuredTool !== undefined && !passthrough
|
|
162
|
+
const missing = passthrough ? undefined : structured.failure(exchange, finish, answer !== undefined)
|
|
163
|
+
if (missing) return { status: missing.status, body: encodeError(missing) }
|
|
164
|
+
const toolCalls = isStructured ? [] : others
|
|
165
|
+
const message: Record<string, unknown> = {
|
|
166
|
+
role: "assistant",
|
|
167
|
+
content: isStructured
|
|
168
|
+
? answer
|
|
169
|
+
? structured.content(answer)
|
|
170
|
+
: null
|
|
171
|
+
: exchange.structuredTool === undefined && text.length > 0
|
|
172
|
+
? text
|
|
173
|
+
: null,
|
|
174
|
+
refusal: null,
|
|
175
|
+
}
|
|
176
|
+
if (toolCalls.length > 0)
|
|
177
|
+
message.tool_calls = toolCalls.map((call) => ({
|
|
178
|
+
id: call.id,
|
|
179
|
+
type: "function",
|
|
180
|
+
function: { name: call.name, arguments: args(call.input) },
|
|
181
|
+
}))
|
|
182
|
+
return {
|
|
183
|
+
status: 200,
|
|
184
|
+
body: {
|
|
185
|
+
id: newId(),
|
|
186
|
+
object: "chat.completion",
|
|
187
|
+
created: now(),
|
|
188
|
+
model: exchange.model,
|
|
189
|
+
choices: [{ index: 0, message, logprobs: null, finish_reason: finishReason(finish, toolCalls.length, isStructured) }],
|
|
190
|
+
...(usage ? { usage: encodeUsage(usage) } : {}),
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Writes SSE frames via `write` (each frame is one complete `data: ...\n\n` string) and reports
|
|
197
|
+
* how the exchange ended. A failure before the first frame is not written (the first wire-visible
|
|
198
|
+
* output decides the HTTP status: see `StreamResult`); a failure after it is written as an
|
|
199
|
+
* `error` frame and then `data: [DONE]`.
|
|
200
|
+
*/
|
|
201
|
+
export async function streamCompletion(
|
|
202
|
+
events: AsyncIterable<PlugEvent>,
|
|
203
|
+
exchange: OpenAiExchange,
|
|
204
|
+
write: (chunk: string) => void,
|
|
205
|
+
): Promise<StreamResult> {
|
|
206
|
+
const id = newId()
|
|
207
|
+
const created = now()
|
|
208
|
+
const frame = (value: object) => write(`data: ${JSON.stringify(value)}\n\n`)
|
|
209
|
+
const chunk = (delta: Record<string, unknown>, finish: string | null = null) =>
|
|
210
|
+
frame({
|
|
211
|
+
id,
|
|
212
|
+
object: "chat.completion.chunk",
|
|
213
|
+
created,
|
|
214
|
+
model: exchange.model,
|
|
215
|
+
choices: [{ index: 0, delta, finish_reason: finish, logprobs: null }],
|
|
216
|
+
...(exchange.includeUsage ? { usage: null } : {}),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
let started = false
|
|
220
|
+
let calls = 0
|
|
221
|
+
let answered = false
|
|
222
|
+
/** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
|
|
223
|
+
let held: ToolCall | undefined
|
|
224
|
+
let finish: Exclude<PlugFinish, "error"> | undefined
|
|
225
|
+
let usage: PlugUsage | undefined
|
|
226
|
+
let failure: PlugError | undefined
|
|
227
|
+
const start = () => {
|
|
228
|
+
if (started) return
|
|
229
|
+
started = true
|
|
230
|
+
chunk({ role: "assistant", content: "" })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for await (const event of events) {
|
|
234
|
+
if (event.type === "error") {
|
|
235
|
+
failure = event.error
|
|
236
|
+
break
|
|
237
|
+
}
|
|
238
|
+
if (event.type === "finish") {
|
|
239
|
+
usage = event.usage ?? usage
|
|
240
|
+
if (event.reason === "error") failure ??= failedRun()
|
|
241
|
+
else finish = event.reason
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (event.type === "reasoning-delta") continue
|
|
245
|
+
if (exchange.structuredTool !== undefined) {
|
|
246
|
+
// Structured mode: only the first answer is content; prose and other tool calls are
|
|
247
|
+
// suppressed, except the caller's own tools when they are offered.
|
|
248
|
+
if (event.type === "tool-call" && structured.isAnswer(exchange, event)) {
|
|
249
|
+
if (!answered) {
|
|
250
|
+
answered = true
|
|
251
|
+
if (exchange.callerTools) held = event
|
|
252
|
+
else {
|
|
253
|
+
start()
|
|
254
|
+
chunk({ content: structured.content(event) })
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
if (!(exchange.callerTools && event.type === "tool-call")) continue
|
|
260
|
+
}
|
|
261
|
+
start()
|
|
262
|
+
if (event.type === "text-delta") {
|
|
263
|
+
if (event.text.length > 0) chunk({ content: event.text })
|
|
264
|
+
} else {
|
|
265
|
+
// Tool calls arrive whole, so each goes out as one chunk with the next index.
|
|
266
|
+
chunk({ tool_calls: [{ index: calls++, id: event.id, type: "function", function: { name: event.name, arguments: args(event.input) } }] })
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const passthrough = exchange.callerTools === true && calls > 0
|
|
271
|
+
const isStructured = exchange.structuredTool !== undefined && !passthrough
|
|
272
|
+
failure ??= finish ? (passthrough ? undefined : structured.failure(exchange, finish, answered)) : noFinish()
|
|
273
|
+
if (failure) {
|
|
274
|
+
if (!started) return { failure, started }
|
|
275
|
+
frame(encodeError(failure))
|
|
276
|
+
write("data: [DONE]\n\n")
|
|
277
|
+
return { failure, started }
|
|
278
|
+
}
|
|
279
|
+
start()
|
|
280
|
+
if (held && isStructured) chunk({ content: structured.content(held) })
|
|
281
|
+
chunk({}, finishReason(finish!, calls, isStructured))
|
|
282
|
+
if (exchange.includeUsage && usage)
|
|
283
|
+
frame({ id, object: "chat.completion.chunk", created, model: exchange.model, choices: [], usage: encodeUsage(usage) })
|
|
284
|
+
write("data: [DONE]\n\n")
|
|
285
|
+
return { started }
|
|
286
|
+
}
|