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.
- package/LICENSE +21 -0
- package/README.md +228 -2
- package/index.ts +99 -0
- package/package.json +32 -4
- package/src/config.ts +150 -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 +214 -0
- package/src/runtime.ts +463 -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 +175 -0
- package/src/session/media.ts +89 -0
- package/src/session/seeds.ts +47 -0
- package/src/session/state.ts +134 -0
package/src/contract.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The neutral vocabulary between plugboard's layers.
|
|
3
|
+
*
|
|
4
|
+
* Front-ends (OpenAI Chat Completions now; others later) translate their wire format to and
|
|
5
|
+
* from these types. A `ModelCaller` turns a `PlugRequest` into a stream of `PlugEvent`s. Names
|
|
6
|
+
* mirror `@opencode/ai` (request parts, `LLMEvent`, `Usage`) so a native `ctx.llm.stream`
|
|
7
|
+
* backend could replace the session backend without touching the front-ends.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here is specific to a wire protocol: response-shaping choices (streaming, usage
|
|
10
|
+
* chunks, structured-output unwrapping) belong to the front-end, not to the request.
|
|
11
|
+
*
|
|
12
|
+
* Types plus one discriminator (`isPlugError`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------- models
|
|
16
|
+
|
|
17
|
+
export interface ModelRef {
|
|
18
|
+
readonly providerID: string
|
|
19
|
+
readonly id: string
|
|
20
|
+
readonly variant?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One enabled model as the server sees it: what model listing and the request resolver work from. */
|
|
24
|
+
export interface CatalogModel {
|
|
25
|
+
readonly providerID: string
|
|
26
|
+
readonly id: string
|
|
27
|
+
readonly name?: string
|
|
28
|
+
readonly variants: ReadonlyArray<string>
|
|
29
|
+
readonly capabilities?: { readonly tools?: boolean; readonly image?: boolean }
|
|
30
|
+
readonly contextLimit?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------- request
|
|
34
|
+
|
|
35
|
+
export interface TextPart {
|
|
36
|
+
readonly type: "text"
|
|
37
|
+
readonly text: string
|
|
38
|
+
}
|
|
39
|
+
/** Image or file. `url` is a data: URL or http(s) URL; the backend turns it into a host media asset. */
|
|
40
|
+
export interface MediaPart {
|
|
41
|
+
readonly type: "media"
|
|
42
|
+
readonly url: string
|
|
43
|
+
readonly mediaType?: string
|
|
44
|
+
}
|
|
45
|
+
export interface ToolCallPart {
|
|
46
|
+
readonly type: "tool-call"
|
|
47
|
+
readonly id: string
|
|
48
|
+
readonly name: string
|
|
49
|
+
readonly input: unknown
|
|
50
|
+
}
|
|
51
|
+
export interface ToolResultPart {
|
|
52
|
+
readonly type: "tool-result"
|
|
53
|
+
readonly id: string
|
|
54
|
+
readonly name: string
|
|
55
|
+
/** Text content as the caller sent it (JSON strings stay strings). */
|
|
56
|
+
readonly result: string
|
|
57
|
+
readonly isError?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type PlugPart = TextPart | MediaPart | ToolCallPart | ToolResultPart
|
|
61
|
+
|
|
62
|
+
export interface PlugMessage {
|
|
63
|
+
readonly role: "user" | "assistant" | "tool"
|
|
64
|
+
readonly content: ReadonlyArray<PlugPart>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PlugTool {
|
|
68
|
+
readonly name: string
|
|
69
|
+
readonly description: string
|
|
70
|
+
/** JSON Schema object. */
|
|
71
|
+
readonly parameters: Readonly<Record<string, unknown>>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type PlugToolChoice = "auto" | "none" | "required" | { readonly type: "tool"; readonly name: string }
|
|
75
|
+
|
|
76
|
+
export interface PlugGeneration {
|
|
77
|
+
readonly maxTokens?: number
|
|
78
|
+
readonly temperature?: number
|
|
79
|
+
readonly topP?: number
|
|
80
|
+
readonly stop?: ReadonlyArray<string>
|
|
81
|
+
readonly presencePenalty?: number
|
|
82
|
+
readonly frequencyPenalty?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Format-neutral model call. Front-ends produce it; the `ModelCaller` consumes it. */
|
|
86
|
+
export interface PlugRequest {
|
|
87
|
+
/** The model string the caller sent (id or alias). Informational for the backend (session title, metadata). */
|
|
88
|
+
readonly model: string
|
|
89
|
+
/** Resolved by the core before reaching the `ModelCaller`. */
|
|
90
|
+
readonly ref: ModelRef
|
|
91
|
+
readonly system: ReadonlyArray<string>
|
|
92
|
+
readonly messages: ReadonlyArray<PlugMessage>
|
|
93
|
+
readonly tools: ReadonlyArray<PlugTool>
|
|
94
|
+
readonly toolChoice?: PlugToolChoice
|
|
95
|
+
readonly generation: PlugGeneration
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------- events
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Token usage for one model call. Totals are inclusive, like OpenAI's:
|
|
102
|
+
* `inputTokens` includes `cacheReadTokens` and `cacheWriteTokens`;
|
|
103
|
+
* `outputTokens` includes `reasoningTokens`.
|
|
104
|
+
*/
|
|
105
|
+
export interface PlugUsage {
|
|
106
|
+
readonly inputTokens?: number
|
|
107
|
+
readonly outputTokens?: number
|
|
108
|
+
readonly reasoningTokens?: number
|
|
109
|
+
readonly cacheReadTokens?: number
|
|
110
|
+
readonly cacheWriteTokens?: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type PlugFinish = "stop" | "length" | "tool-calls" | "content-filter" | "error"
|
|
114
|
+
|
|
115
|
+
export type PlugEvent =
|
|
116
|
+
| { readonly type: "text-delta"; readonly text: string }
|
|
117
|
+
| { readonly type: "reasoning-delta"; readonly text: string }
|
|
118
|
+
| { readonly type: "tool-call"; readonly id: string; readonly name: string; readonly input: unknown }
|
|
119
|
+
| { readonly type: "finish"; readonly reason: PlugFinish; readonly usage?: PlugUsage }
|
|
120
|
+
| { readonly type: "error"; readonly error: PlugError }
|
|
121
|
+
|
|
122
|
+
export type PlugErrorKind = "invalid_request" | "authentication" | "permission" | "not_found" | "rate_limit" | "server" | "timeout"
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* An error with an HTTP status and a transport-neutral kind. Front-ends map `kind` to their wire
|
|
126
|
+
* vocabulary (OpenAI: `src/openai/response.ts`). `code` is a machine-readable detail and `param`
|
|
127
|
+
* names the offending request field, when there is one.
|
|
128
|
+
*/
|
|
129
|
+
export interface PlugError {
|
|
130
|
+
readonly status: number
|
|
131
|
+
readonly kind: PlugErrorKind
|
|
132
|
+
readonly message: string
|
|
133
|
+
readonly code?: string
|
|
134
|
+
readonly param?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The one discriminator for `PlugError` values returned in place of a result. */
|
|
138
|
+
export const isPlugError = (value: unknown): value is PlugError =>
|
|
139
|
+
typeof value === "object" && value !== null && "status" in value && "kind" in value && "message" in value
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------- caller
|
|
142
|
+
|
|
143
|
+
export interface CallContext {
|
|
144
|
+
/** App name (key name, or the default app). Decides the history directory. */
|
|
145
|
+
readonly app: string
|
|
146
|
+
/** Aborted on client disconnect or timeout. */
|
|
147
|
+
readonly signal: AbortSignal
|
|
148
|
+
/** Called once, before the first event, with the id of the history record this call writes (if any). */
|
|
149
|
+
readonly onRecord?: (recordID: string) => void
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The seam between the core and a backend (the session backend now, `ctx.llm.stream` possibly later). */
|
|
153
|
+
export interface ModelCaller {
|
|
154
|
+
stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent>
|
|
155
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP ingress and transport helpers: request scope (disconnect abort and the one end-to-end
|
|
3
|
+
* deadline), body reading, header checks, JSON and SSE responses. Protocol-neutral: callers pass
|
|
4
|
+
* in already-encoded payloads, and checks return a `PlugError` for the caller to encode.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { IncomingMessage, ServerResponse } from "node:http"
|
|
8
|
+
import { isLoopbackHost, type PlugboardOptions } from "./config.js"
|
|
9
|
+
import type { PlugError, PlugEvent } from "./contract.js"
|
|
10
|
+
|
|
11
|
+
const MAX_BODY_BYTES = 32 * 1024 * 1024
|
|
12
|
+
/** Largest delay `setTimeout` accepts; anything larger fires at once. */
|
|
13
|
+
const MAX_TIMER_MS = 2 ** 31 - 1
|
|
14
|
+
/** After answering a request whose body was not fully read, keep discarding it this long before cutting the socket. */
|
|
15
|
+
const LINGER_MS = 5_000
|
|
16
|
+
/** After the deadline passes, a response that has not finished flushing (a client that stopped reading) is destroyed after this long. */
|
|
17
|
+
const DEADLINE_GRACE_MS = 2_000
|
|
18
|
+
|
|
19
|
+
/** A request body that cannot be accepted: too large, or not JSON. `status` is the HTTP status to answer with. */
|
|
20
|
+
export class BodyError extends Error {
|
|
21
|
+
constructor(
|
|
22
|
+
readonly status: number,
|
|
23
|
+
message: string,
|
|
24
|
+
) {
|
|
25
|
+
super(message)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Why a request's work was abandoned: the client went away, or the deadline passed. It is the abort reason of `RequestScope.signal`. */
|
|
30
|
+
export class RequestAborted extends Error {
|
|
31
|
+
constructor(readonly kind: "disconnected" | "timeout") {
|
|
32
|
+
super(kind === "timeout" ? "Request deadline exceeded" : "Client disconnected")
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Cancellation for one HTTP request: aborts on client disconnect and at the single end-to-end deadline. */
|
|
37
|
+
export interface RequestScope {
|
|
38
|
+
readonly signal: AbortSignal
|
|
39
|
+
readonly timedOut: boolean
|
|
40
|
+
/** The `PlugError` to answer a timeout with. */
|
|
41
|
+
readonly timeoutError: PlugError
|
|
42
|
+
/** Settles like `promise`, or rejects with the abort reason (`RequestAborted`) as soon as the scope aborts. `promise` is always observed. */
|
|
43
|
+
race<T>(promise: Promise<T>): Promise<T>
|
|
44
|
+
/** Clears the deadline timer; call once the request is finished. */
|
|
45
|
+
dispose(): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Establishes cancellation and the deadline. Call before the first `await` of a request: a socket
|
|
50
|
+
* that is already gone aborts immediately, so abandoned work never starts.
|
|
51
|
+
*/
|
|
52
|
+
export function beginRequest(request: IncomingMessage, response: ServerResponse, timeoutMs: number): RequestScope {
|
|
53
|
+
const controller = new AbortController()
|
|
54
|
+
const abort = (kind: RequestAborted["kind"]) => {
|
|
55
|
+
if (!controller.signal.aborted) controller.abort(new RequestAborted(kind))
|
|
56
|
+
}
|
|
57
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
58
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
59
|
+
const onDeadline = () => {
|
|
60
|
+
abort("timeout")
|
|
61
|
+
// The error response (or the rest of a stream) may never flush to a client that stopped reading: cut the transport.
|
|
62
|
+
// Cleared only once the socket is gone: on some runtimes the response reports finished/closed
|
|
63
|
+
// after `end()` while bytes are still queued for a client that stopped reading, so neither
|
|
64
|
+
// `writableFinished`, `writableLength` nor the response's `close` shows a flush. An idle
|
|
65
|
+
// connection cut here is harmless.
|
|
66
|
+
graceTimer = setTimeout(() => {
|
|
67
|
+
response.destroy()
|
|
68
|
+
request.socket?.destroy()
|
|
69
|
+
}, DEADLINE_GRACE_MS)
|
|
70
|
+
graceTimer.unref?.()
|
|
71
|
+
}
|
|
72
|
+
timer = setTimeout(onDeadline, Math.min(Math.max(timeoutMs, 1), MAX_TIMER_MS))
|
|
73
|
+
timer.unref?.()
|
|
74
|
+
const dispose = () => {
|
|
75
|
+
clearTimeout(timer)
|
|
76
|
+
timer = undefined
|
|
77
|
+
}
|
|
78
|
+
response.on("close", () => {
|
|
79
|
+
if (!response.writableFinished) abort("disconnected")
|
|
80
|
+
dispose()
|
|
81
|
+
if (request.socket?.destroyed) clearTimeout(graceTimer)
|
|
82
|
+
})
|
|
83
|
+
request.on("error", () => abort("disconnected"))
|
|
84
|
+
if (response.destroyed || request.destroyed || request.socket?.destroyed) abort("disconnected")
|
|
85
|
+
|
|
86
|
+
const { signal } = controller
|
|
87
|
+
const timedOut = () => signal.reason instanceof RequestAborted && signal.reason.kind === "timeout"
|
|
88
|
+
return {
|
|
89
|
+
signal,
|
|
90
|
+
get timedOut() {
|
|
91
|
+
return timedOut()
|
|
92
|
+
},
|
|
93
|
+
timeoutError: {
|
|
94
|
+
status: 504,
|
|
95
|
+
kind: "timeout",
|
|
96
|
+
code: "timeout",
|
|
97
|
+
message: `Request timed out after ${timeoutMs} ms (option requestTimeoutMs)`,
|
|
98
|
+
},
|
|
99
|
+
race<T>(promise: Promise<T>): Promise<T> {
|
|
100
|
+
promise.catch(() => {})
|
|
101
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
102
|
+
return new Promise<T>((resolve, reject) => {
|
|
103
|
+
const onAbort = () => reject(signal.reason)
|
|
104
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
105
|
+
promise.then(
|
|
106
|
+
(value) => {
|
|
107
|
+
signal.removeEventListener("abort", onAbort)
|
|
108
|
+
resolve(value)
|
|
109
|
+
},
|
|
110
|
+
(error) => {
|
|
111
|
+
signal.removeEventListener("abort", onAbort)
|
|
112
|
+
reject(error)
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
})
|
|
116
|
+
},
|
|
117
|
+
dispose,
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reads the body as JSON (an empty body is `{}`). Rejects with `BodyError` (413, 400), the abort
|
|
123
|
+
* reason of `signal` or the stream's error. On rejection the rest of the body is discarded, not
|
|
124
|
+
* buffered, so the response can still be delivered (see `finishRequest`).
|
|
125
|
+
*/
|
|
126
|
+
export function readJsonBody(request: IncomingMessage, signal: AbortSignal): Promise<unknown> {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const declared = Number(request.headers["content-length"])
|
|
129
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
130
|
+
request.resume()
|
|
131
|
+
return reject(new BodyError(413, "Request body too large"))
|
|
132
|
+
}
|
|
133
|
+
if (signal.aborted) return reject(signal.reason)
|
|
134
|
+
const chunks: Buffer[] = []
|
|
135
|
+
let size = 0
|
|
136
|
+
const cleanup = () => {
|
|
137
|
+
request.off("data", onData)
|
|
138
|
+
request.off("error", onError)
|
|
139
|
+
request.off("end", onEnd)
|
|
140
|
+
signal.removeEventListener("abort", onAbort)
|
|
141
|
+
}
|
|
142
|
+
const stop = (error: unknown) => {
|
|
143
|
+
cleanup()
|
|
144
|
+
chunks.length = 0
|
|
145
|
+
request.resume()
|
|
146
|
+
reject(error)
|
|
147
|
+
}
|
|
148
|
+
const onData = (chunk: Buffer) => {
|
|
149
|
+
size += chunk.length
|
|
150
|
+
if (size > MAX_BODY_BYTES) return stop(new BodyError(413, "Request body too large"))
|
|
151
|
+
chunks.push(chunk)
|
|
152
|
+
}
|
|
153
|
+
const onError = (error: Error) => stop(error)
|
|
154
|
+
const onAbort = () => stop(signal.reason)
|
|
155
|
+
const onEnd = () => {
|
|
156
|
+
cleanup()
|
|
157
|
+
try {
|
|
158
|
+
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString("utf8")))
|
|
159
|
+
} catch {
|
|
160
|
+
reject(new BodyError(400, "Request body is not valid JSON"))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
request.on("data", onData)
|
|
164
|
+
request.on("error", onError)
|
|
165
|
+
request.on("end", onEnd)
|
|
166
|
+
signal.addEventListener("abort", onAbort, { once: true })
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Call when a request is done. If the client is still sending a body that was not read, keep
|
|
172
|
+
* discarding it briefly so the response is not lost to a connection reset, then cut the socket.
|
|
173
|
+
*/
|
|
174
|
+
export function finishRequest(request: IncomingMessage, response: ServerResponse): void {
|
|
175
|
+
if (request.complete || request.destroyed) return
|
|
176
|
+
request.resume()
|
|
177
|
+
const timer = setTimeout(() => request.destroy(), LINGER_MS)
|
|
178
|
+
timer.unref?.()
|
|
179
|
+
response.once("close", () => clearTimeout(timer))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `close`: also tell the client to drop the connection (after an unread or abandoned body). */
|
|
183
|
+
export const sendJson = (response: ServerResponse, status: number, payload: unknown, options?: { close?: boolean }) => {
|
|
184
|
+
if (response.headersSent || response.destroyed) return
|
|
185
|
+
const body = JSON.stringify(payload)
|
|
186
|
+
response.writeHead(status, {
|
|
187
|
+
"content-type": "application/json",
|
|
188
|
+
"content-length": Buffer.byteLength(body),
|
|
189
|
+
...(options?.close ? { connection: "close" } : {}),
|
|
190
|
+
})
|
|
191
|
+
response.end(body)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* SSE response writer. Headers go out with the first chunk, so nothing is committed until there
|
|
196
|
+
* is something to send; chunks after the client is gone are dropped. `write` is synchronous (the
|
|
197
|
+
* encoder's contract); backpressure is honoured by `paced`, which holds back the next event while
|
|
198
|
+
* the socket buffer is full. `signal` (the request scope's) ends those waits when it aborts.
|
|
199
|
+
*/
|
|
200
|
+
export function sseWriter(response: ServerResponse, signal?: AbortSignal) {
|
|
201
|
+
let started = false
|
|
202
|
+
let blocked = false
|
|
203
|
+
const waiters: Array<() => void> = []
|
|
204
|
+
const release = () => {
|
|
205
|
+
blocked = false
|
|
206
|
+
for (const resolve of waiters.splice(0)) resolve()
|
|
207
|
+
}
|
|
208
|
+
response.on("drain", release)
|
|
209
|
+
response.on("close", release)
|
|
210
|
+
const drained = (): Promise<void> => {
|
|
211
|
+
if (!blocked || response.destroyed || signal?.aborted) return Promise.resolve()
|
|
212
|
+
return new Promise((resolve) => {
|
|
213
|
+
const done = () => {
|
|
214
|
+
signal?.removeEventListener("abort", done)
|
|
215
|
+
const at = waiters.indexOf(done)
|
|
216
|
+
if (at >= 0) waiters.splice(at, 1)
|
|
217
|
+
resolve()
|
|
218
|
+
}
|
|
219
|
+
waiters.push(done)
|
|
220
|
+
signal?.addEventListener("abort", done, { once: true })
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
get started() {
|
|
225
|
+
return started
|
|
226
|
+
},
|
|
227
|
+
write(chunk: string) {
|
|
228
|
+
if (response.destroyed || response.writableEnded) return
|
|
229
|
+
if (!started) {
|
|
230
|
+
started = true
|
|
231
|
+
response.writeHead(200, {
|
|
232
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
233
|
+
"cache-control": "no-cache, no-transform",
|
|
234
|
+
connection: "keep-alive",
|
|
235
|
+
"x-accel-buffering": "no",
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
if (!response.write(chunk)) blocked = true
|
|
239
|
+
},
|
|
240
|
+
/** Resolves when the socket buffer has room again, the client is gone, or the request scope aborted. */
|
|
241
|
+
drained,
|
|
242
|
+
/**
|
|
243
|
+
* Passes events through, waiting for `drain` before pulling each one, and stops when the client
|
|
244
|
+
* is gone. A scope abort ends the wait but not the stream: the next pull yields the timeout
|
|
245
|
+
* error (or ends), and the request deadline's grace timer cuts a socket that cannot take it.
|
|
246
|
+
*/
|
|
247
|
+
async *paced(events: AsyncIterable<PlugEvent>): AsyncGenerator<PlugEvent> {
|
|
248
|
+
const iterator = events[Symbol.asyncIterator]()
|
|
249
|
+
try {
|
|
250
|
+
while (true) {
|
|
251
|
+
await drained()
|
|
252
|
+
if (response.destroyed) return
|
|
253
|
+
const next = await iterator.next()
|
|
254
|
+
if (next.done) return
|
|
255
|
+
yield next.value
|
|
256
|
+
}
|
|
257
|
+
} finally {
|
|
258
|
+
void iterator.return?.(undefined)?.catch?.(() => {})
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Bound the event stream by the request scope. On timeout a 504 error event ends the stream; on
|
|
266
|
+
* disconnect the stream just ends. Either way the scope's abort already interrupted the caller.
|
|
267
|
+
*/
|
|
268
|
+
export async function* boundEvents(events: AsyncIterable<PlugEvent>, scope: RequestScope): AsyncGenerator<PlugEvent> {
|
|
269
|
+
const iterator = events[Symbol.asyncIterator]()
|
|
270
|
+
try {
|
|
271
|
+
while (true) {
|
|
272
|
+
let next: IteratorResult<PlugEvent>
|
|
273
|
+
try {
|
|
274
|
+
next = await scope.race(iterator.next())
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!(error instanceof RequestAborted)) throw error
|
|
277
|
+
if (error.kind === "timeout") yield { type: "error", error: scope.timeoutError }
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
if (next.done) return
|
|
281
|
+
yield next.value
|
|
282
|
+
}
|
|
283
|
+
} finally {
|
|
284
|
+
// Not awaited: when the scope aborted, `next()` is still pending until the abort propagates.
|
|
285
|
+
void iterator.return?.(undefined)?.catch?.(() => {})
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"])
|
|
290
|
+
|
|
291
|
+
/** Host header check against DNS rebinding: only meaningful when listening on loopback. */
|
|
292
|
+
function hostAllowed(options: PlugboardOptions, hostHeader: string | undefined): boolean {
|
|
293
|
+
if (!isLoopbackHost(options.listen.host)) return true
|
|
294
|
+
const match = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/.exec((hostHeader ?? "").trim().toLowerCase())
|
|
295
|
+
if (!match || !LOOPBACK_HOSTS.has(match[1]!)) return false
|
|
296
|
+
return (match[2] ?? "80") === String(options.listen.port)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Browser-reachable loopback endpoints: block DNS rebinding (Host header) and, on a keyless
|
|
301
|
+
* endpoint, cross-site calls (any Origin header).
|
|
302
|
+
*/
|
|
303
|
+
export function checkOrigin(request: IncomingMessage, options: PlugboardOptions): PlugError | undefined {
|
|
304
|
+
if (!hostAllowed(options, request.headers.host))
|
|
305
|
+
return { status: 403, kind: "permission", code: "invalid_host", message: "Host header not allowed" }
|
|
306
|
+
if (Object.keys(options.keys).length === 0 && request.headers.origin !== undefined)
|
|
307
|
+
return {
|
|
308
|
+
status: 403,
|
|
309
|
+
kind: "permission",
|
|
310
|
+
code: "cross_origin_forbidden",
|
|
311
|
+
message: "Cross-origin requests are not allowed on a keyless endpoint; configure `keys`",
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function checkJsonContentType(request: IncomingMessage): PlugError | undefined {
|
|
316
|
+
const contentType = request.headers["content-type"]?.split(";")[0]?.trim().toLowerCase()
|
|
317
|
+
if (contentType !== "application/json")
|
|
318
|
+
return {
|
|
319
|
+
status: 415,
|
|
320
|
+
kind: "invalid_request",
|
|
321
|
+
code: "unsupported_media_type",
|
|
322
|
+
message: "Content-Type must be application/json",
|
|
323
|
+
}
|
|
324
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny structured logger: one line per event, `[opencode-plugboard] event key=value ...`.
|
|
3
|
+
*
|
|
4
|
+
* Callers pass only identifiers and outcomes (event name, request id, app, model, duration, an
|
|
5
|
+
* error message). Never pass API keys, request or response bodies, URLs or raw provider
|
|
6
|
+
* objects: nothing here redacts them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type LogFields = Readonly<Record<string, string | number | boolean | undefined>>
|
|
10
|
+
|
|
11
|
+
const PREFIX = "[opencode-plugboard]"
|
|
12
|
+
|
|
13
|
+
function formatValue(value: string | number | boolean): string {
|
|
14
|
+
return typeof value === "string" && !/^[\w.:/#-]+$/.test(value) ? JSON.stringify(value) : String(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function format(event: string, fields: LogFields | undefined): string {
|
|
18
|
+
let line = `${PREFIX} ${event}`
|
|
19
|
+
for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined) line += ` ${key}=${formatValue(value)}`
|
|
20
|
+
return line
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const log = (event: string, fields?: LogFields): void => console.log(format(event, fields))
|
|
24
|
+
|
|
25
|
+
export const logError = (event: string, fields?: LogFields): void => console.error(format(event, fields))
|
|
26
|
+
|
|
27
|
+
/** The message of a thrown value, without its stack or attached properties. */
|
|
28
|
+
export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Model refs and the allowlist/alias policy over a neutral catalog. Protocol-neutral: front-ends format the results. */
|
|
2
|
+
|
|
3
|
+
import type { PlugboardOptions } from "./config.js"
|
|
4
|
+
import type { CatalogModel, ModelRef, PlugError } from "./contract.js"
|
|
5
|
+
|
|
6
|
+
/** `provider/model[#variant]`. The model id may contain slashes (`openrouter/vendor/model`). */
|
|
7
|
+
function parseModelRef(s: string): ModelRef | undefined {
|
|
8
|
+
if (typeof s !== "string") return undefined
|
|
9
|
+
const slash = s.indexOf("/")
|
|
10
|
+
if (slash <= 0) return undefined
|
|
11
|
+
const hash = s.indexOf("#", slash + 1)
|
|
12
|
+
const id = s.slice(slash + 1, hash === -1 ? undefined : hash)
|
|
13
|
+
const variant = hash === -1 ? undefined : s.slice(hash + 1)
|
|
14
|
+
if (!id || s.slice(0, slash).includes("#")) return undefined
|
|
15
|
+
if (variant !== undefined && (!variant || variant.includes("#"))) return undefined
|
|
16
|
+
return { providerID: s.slice(0, slash), id, ...(variant ? { variant } : {}) }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function refToString(ref: ModelRef): string {
|
|
20
|
+
return `${ref.providerID}/${ref.id}${ref.variant ? `#${ref.variant}` : ""}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `provider/*` any model of the provider; `provider/model` any variant; `provider/model#variant` exactly. */
|
|
24
|
+
function allowed(options: PlugboardOptions, ref: ModelRef): boolean {
|
|
25
|
+
return options.models.some((pattern) => {
|
|
26
|
+
if (pattern.endsWith("/*")) return pattern.slice(0, -2) === ref.providerID
|
|
27
|
+
const p = parseModelRef(pattern)
|
|
28
|
+
return p !== undefined && p.providerID === ref.providerID && p.id === ref.id && (p.variant === undefined || p.variant === ref.variant)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function find(catalog: ReadonlyArray<CatalogModel>, ref: ModelRef): CatalogModel | undefined {
|
|
33
|
+
const model = catalog.find((m) => m.providerID === ref.providerID && m.id === ref.id)
|
|
34
|
+
return model && (ref.variant === undefined || model.variants.includes(ref.variant)) ? model : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function own(map: Readonly<Record<string, string>>, key: string): string | undefined {
|
|
38
|
+
return Object.hasOwn(map, key) ? map[key] : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Alias or `provider/model[#variant]` -> ModelRef. `reasoningEffort` picks the variant of that name when the
|
|
43
|
+
* ref has none and the model offers it. Errors: 403 when nothing is allowed, 404 for unknown/not-allowed models.
|
|
44
|
+
*/
|
|
45
|
+
export function resolveModel(
|
|
46
|
+
options: PlugboardOptions,
|
|
47
|
+
model: string,
|
|
48
|
+
catalog: ReadonlyArray<CatalogModel>,
|
|
49
|
+
reasoningEffort?: string,
|
|
50
|
+
): ModelRef | PlugError {
|
|
51
|
+
if (options.models.length === 0)
|
|
52
|
+
return {
|
|
53
|
+
status: 403,
|
|
54
|
+
message: "No models are allowed: set the plugboard `models` option",
|
|
55
|
+
kind: "permission",
|
|
56
|
+
code: "no_models_configured",
|
|
57
|
+
}
|
|
58
|
+
const notAvailable: PlugError = {
|
|
59
|
+
status: 404,
|
|
60
|
+
message: `Model ${model} is not available`,
|
|
61
|
+
kind: "not_found",
|
|
62
|
+
code: "model_not_found",
|
|
63
|
+
param: "model",
|
|
64
|
+
}
|
|
65
|
+
let ref = parseModelRef(own(options.aliases, model) ?? model)
|
|
66
|
+
if (!ref) return notAvailable
|
|
67
|
+
if (!ref.variant && reasoningEffort && find(catalog, { ...ref, variant: reasoningEffort }))
|
|
68
|
+
ref = { ...ref, variant: reasoningEffort }
|
|
69
|
+
return allowed(options, ref) && find(catalog, ref) ? ref : notAvailable
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One servable model id: a catalog id (`provider/model[#variant]`) or an alias, with the catalog model behind it. */
|
|
73
|
+
export interface ListedModel {
|
|
74
|
+
readonly id: string
|
|
75
|
+
readonly ref: ModelRef
|
|
76
|
+
readonly model: CatalogModel
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The allowlist intersected with the catalog, plus aliases whose target qualifies. Ids are unique except an alias may repeat one. */
|
|
80
|
+
export function listAvailableModels(options: PlugboardOptions, catalog: ReadonlyArray<CatalogModel>): ListedModel[] {
|
|
81
|
+
const entry = (id: string, ref: ModelRef, model: CatalogModel): ListedModel => ({ id, ref, model })
|
|
82
|
+
const data: ListedModel[] = []
|
|
83
|
+
// The catalog can list a model more than once (and repeat variants); ids must be unique.
|
|
84
|
+
const seen = new Set<string>()
|
|
85
|
+
for (const model of catalog) {
|
|
86
|
+
const base = { providerID: model.providerID, id: model.id }
|
|
87
|
+
for (const ref of [base, ...model.variants.map((variant) => ({ ...base, variant }))]) {
|
|
88
|
+
const id = refToString(ref)
|
|
89
|
+
if (seen.has(id) || !allowed(options, ref)) continue
|
|
90
|
+
seen.add(id)
|
|
91
|
+
data.push(entry(id, ref, model))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const [alias, target] of Object.entries(options.aliases)) {
|
|
95
|
+
const ref = parseModelRef(target)
|
|
96
|
+
const model = ref && allowed(options, ref) ? find(catalog, ref) : undefined
|
|
97
|
+
if (ref && model) data.push(entry(alias, ref, model))
|
|
98
|
+
}
|
|
99
|
+
return data
|
|
100
|
+
}
|