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