opencode-plugboard 0.0.0-stage → 0.2.1

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,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
+ }