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