opencode-plugboard 0.4.0 → 0.5.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.
@@ -0,0 +1,280 @@
1
+ /**
2
+ * First-run discovery and status documents: `GET /v1` (JSON), `GET /` (small HTML page) and the
3
+ * authenticated `GET /v1/status`. Pure builders over the options, the catalog and a runtime
4
+ * snapshot; no I/O, no host calls. `src/openai/route.ts` loads the catalog and sends the result.
5
+ *
6
+ * Disclosure rule: the discovery pages are reachable without a key, so they only describe the
7
+ * configuration when that is safe. On a keyless endpoint (loopback only) everything is shown.
8
+ * When `keys` are configured, a caller without an `Authorization` header sees the address, the
9
+ * authentication requirement, the endpoint list and a curl example, and nothing about apps,
10
+ * allowlist, aliases or the catalog. A caller with a valid key additionally sees its own app name
11
+ * (never the other apps), the allowlist, the aliases and the catalog suggestion. Keys themselves
12
+ * never appear anywhere, and nothing here echoes a request header or path.
13
+ *
14
+ * The HTML page is built from the same document as the JSON, has no scripts or external assets,
15
+ * and every dynamic value goes through `escapeHtml`.
16
+ */
17
+
18
+ import type { ServerResponse } from "node:http"
19
+ import type { PlugboardOptions } from "./config.js"
20
+ import type { CatalogModel } from "./contract.js"
21
+ import { listAvailableModels } from "./models.js"
22
+ import type { RuntimeSnapshot } from "./metrics.js"
23
+
24
+ const NAME = "opencode-plugboard"
25
+ /** Providers listed in the catalog summary; the rest are counted, not listed. */
26
+ const MAX_PROVIDERS = 50
27
+ const EXAMPLES_PER_PROVIDER = 3
28
+ const PLACEHOLDER_MODEL = "provider/model"
29
+
30
+ /** A text response with the headers every page of this module shares. */
31
+ export function sendText(response: ServerResponse, status: number, contentType: string, body: string, headers: Record<string, string> = {}): void {
32
+ if (response.headersSent || response.destroyed) return
33
+ response.writeHead(status, {
34
+ "content-type": contentType,
35
+ "content-length": Buffer.byteLength(body),
36
+ "x-content-type-options": "nosniff",
37
+ "cache-control": "no-store",
38
+ ...headers,
39
+ })
40
+ response.end(body)
41
+ }
42
+
43
+ const escapeHtml = (value: unknown): string =>
44
+ String(value).replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char] ?? char)
45
+
46
+ /** `http://host:port` for a listen address; an IPv6 literal gets brackets. */
47
+ function origin(listen: PlugboardOptions["listen"]): string {
48
+ const host = listen.host.includes(":") && !listen.host.startsWith("[") ? `[${listen.host}]` : listen.host
49
+ return `http://${host}:${listen.port}`
50
+ }
51
+
52
+ const shellQuote = (text: string): string => `'${text.replace(/'/g, `'\\''`)}'`
53
+
54
+ export interface Endpoint {
55
+ readonly method: string
56
+ readonly path: string
57
+ readonly auth: boolean
58
+ readonly description: string
59
+ }
60
+
61
+ function endpoints(options: PlugboardOptions): Endpoint[] {
62
+ return [
63
+ { method: "GET", path: "/", auth: false, description: "This page." },
64
+ { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
65
+ { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
66
+ { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call." },
67
+ { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
68
+ { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
69
+ { method: "POST", path: "/v1/responses", auth: true, description: "OpenAI Responses (stateless)." },
70
+ { method: "GET", path: "/v1/status", auth: true, description: "Runtime status: generations, address, model count, uptime." },
71
+ ...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
72
+ ]
73
+ }
74
+
75
+ type ModelsState = "ready" | "no_models_configured" | "none_available" | "catalog_unavailable"
76
+
77
+ interface ProviderSummary {
78
+ readonly id: string
79
+ readonly models: number
80
+ readonly examples: ReadonlyArray<string>
81
+ }
82
+
83
+ /** The `GET /v1` document. Fields marked "detail" are present only when the caller may see the configuration. */
84
+ export interface Discovery {
85
+ readonly name: string
86
+ readonly baseUrl: string
87
+ readonly listen: { readonly host: string; readonly port: number }
88
+ readonly authentication: { readonly required: boolean; readonly scheme?: "Bearer" }
89
+ readonly hint?: string
90
+ /** detail: the caller's own app name, or `default` on a keyless endpoint. */
91
+ readonly apps?: ReadonlyArray<string>
92
+ /** detail */
93
+ readonly models?: {
94
+ readonly state: ModelsState
95
+ readonly allowlist: ReadonlyArray<string>
96
+ readonly aliases: Readonly<Record<string, string>>
97
+ /** Ids `GET /v1/models` lists (allowlist intersected with the catalog, plus aliases). */
98
+ readonly available?: number
99
+ readonly hint?: string
100
+ }
101
+ /** detail: a paste-ready `models` option built from the catalog (one wildcard per provider). */
102
+ readonly suggestedModels?: ReadonlyArray<string>
103
+ /** detail: what the catalog offers, by provider. */
104
+ readonly catalog?: { readonly providers: ReadonlyArray<ProviderSummary>; readonly moreProviders?: number }
105
+ readonly endpoints: ReadonlyArray<Endpoint>
106
+ readonly example: { readonly curl: string }
107
+ }
108
+
109
+ export interface DiscoveryInput {
110
+ readonly options: PlugboardOptions
111
+ /** The authenticated app; undefined when the caller sent no credentials (only possible when `keys` are set). */
112
+ readonly app?: string
113
+ /** The serving Location's catalog; undefined when it could not be loaded (or was not asked for). */
114
+ readonly catalog?: ReadonlyArray<CatalogModel>
115
+ }
116
+
117
+ function summarize(catalog: ReadonlyArray<CatalogModel>): { providers: ProviderSummary[]; more: number } {
118
+ const byProvider = new Map<string, string[]>()
119
+ for (const model of catalog) {
120
+ const ids = byProvider.get(model.providerID) ?? []
121
+ if (!ids.includes(model.id)) ids.push(model.id)
122
+ byProvider.set(model.providerID, ids)
123
+ }
124
+ const all = [...byProvider.entries()].sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
125
+ return {
126
+ providers: all.slice(0, MAX_PROVIDERS).map(([id, ids]) => ({ id, models: ids.length, examples: ids.slice(0, EXAMPLES_PER_PROVIDER).map((model) => `${id}/${model}`) })),
127
+ more: Math.max(0, all.length - MAX_PROVIDERS),
128
+ }
129
+ }
130
+
131
+ export function buildDiscovery({ options, app, catalog }: DiscoveryInput): Discovery {
132
+ const keyed = Object.keys(options.keys).length > 0
133
+ const base = `${origin(options.listen)}/v1`
134
+ const detail = !keyed || app !== undefined
135
+ const auth = keyed ? ({ required: true, scheme: "Bearer" } as const) : { required: false }
136
+
137
+ const listed = detail && catalog ? listAvailableModels(options, catalog) : []
138
+ const summary = detail && catalog ? summarize(catalog) : undefined
139
+ const suggested = summary?.providers.map((provider) => `${provider.id}/*`)
140
+
141
+ let state: ModelsState = "ready"
142
+ let modelsHint: string | undefined
143
+ const example = JSON.stringify(suggested && suggested.length > 0 ? { models: suggested } : { models: ["provider/*"] })
144
+ if (options.models.length === 0) {
145
+ state = "no_models_configured"
146
+ modelsHint = `The \`models\` option is empty, so every model call fails with 403 no_models_configured. Add an allowlist to the plugin options, for example ${example}`
147
+ } else if (!catalog) {
148
+ state = "catalog_unavailable"
149
+ modelsHint = "The model catalog could not be loaded from the OpenCode server."
150
+ } else if (listed.length === 0) {
151
+ state = "none_available"
152
+ modelsHint = "Nothing in the `models` allowlist matches a model the server offers. Check the provider and model ids against the catalog below."
153
+ }
154
+
155
+ const first = listed[0]?.id ?? PLACEHOLDER_MODEL
156
+ const body = JSON.stringify({ model: first, messages: [{ role: "user", content: "Say hello." }] })
157
+ const curl = [`curl -s ${base}/chat/completions`, `-H "Content-Type: application/json"`, ...(keyed ? [`-H "Authorization: Bearer $PLUGBOARD_KEY"`] : []), `-d ${shellQuote(body)}`].join(" ")
158
+
159
+ return {
160
+ name: NAME,
161
+ baseUrl: base,
162
+ listen: { host: options.listen.host, port: options.listen.port },
163
+ authentication: auth,
164
+ ...(detail
165
+ ? {}
166
+ : { hint: "Authentication is required. Send `Authorization: Bearer <key>` to see this app's configuration: the allowlist, aliases and a model suggestion." }),
167
+ ...(detail ? { apps: [app ?? "default"] } : {}),
168
+ ...(detail
169
+ ? {
170
+ models: {
171
+ state,
172
+ allowlist: options.models,
173
+ aliases: options.aliases,
174
+ ...(catalog ? { available: listed.length } : {}),
175
+ ...(modelsHint ? { hint: modelsHint } : {}),
176
+ },
177
+ }
178
+ : {}),
179
+ ...(suggested && suggested.length > 0 ? { suggestedModels: suggested } : {}),
180
+ ...(summary ? { catalog: { providers: summary.providers, ...(summary.more > 0 ? { moreProviders: summary.more } : {}) } } : {}),
181
+ endpoints: endpoints(options),
182
+ example: { curl },
183
+ }
184
+ }
185
+
186
+ const pre = (text: string) => `<pre>${escapeHtml(text)}</pre>`
187
+ const list = (items: ReadonlyArray<string>) => `<ul>${items.map((item) => `<li><code>${escapeHtml(item)}</code></li>`).join("")}</ul>`
188
+
189
+ /** The `GET /` page: the discovery document as small, script-free HTML. */
190
+ export function renderDiscoveryHtml(doc: Discovery): string {
191
+ const sections: string[] = []
192
+ sections.push(`<p>Base URL: <code>${escapeHtml(doc.baseUrl)}</code></p>`)
193
+ sections.push(
194
+ `<p>Authentication: ${doc.authentication.required ? "required. Send <code>Authorization: Bearer &lt;key&gt;</code>." : "none (keyless, loopback only)."}</p>`,
195
+ )
196
+ if (doc.hint) sections.push(`<p class="hint">${escapeHtml(doc.hint)}</p>`)
197
+ if (doc.apps) sections.push(`<h2>App</h2>${list(doc.apps)}`)
198
+ if (doc.models) {
199
+ const models = doc.models
200
+ sections.push(`<h2>Models</h2>`)
201
+ if (models.hint) sections.push(`<p class="hint">${escapeHtml(models.hint)}</p>`)
202
+ if (models.available !== undefined) sections.push(`<p>${escapeHtml(models.available)} model ids available.</p>`)
203
+ sections.push(`<h3>Allowlist</h3>${models.allowlist.length > 0 ? list(models.allowlist) : "<p>Empty.</p>"}`)
204
+ const aliases = Object.entries(models.aliases)
205
+ if (aliases.length > 0) sections.push(`<h3>Aliases</h3>${list(aliases.map(([alias, target]) => `${alias} -> ${target}`))}`)
206
+ }
207
+ if (doc.suggestedModels) {
208
+ sections.push(`<h2>Suggested <code>models</code> option</h2>${pre(`"models": ${JSON.stringify(doc.suggestedModels)}`)}`)
209
+ }
210
+ if (doc.catalog) {
211
+ const rows = doc.catalog.providers
212
+ .map(
213
+ (provider) =>
214
+ `<tr><td><code>${escapeHtml(provider.id)}</code></td><td>${escapeHtml(provider.models)}</td><td>${provider.examples.map((example) => `<code>${escapeHtml(example)}</code>`).join(" ")}</td></tr>`,
215
+ )
216
+ .join("")
217
+ const more = doc.catalog.moreProviders ? `<p>${escapeHtml(doc.catalog.moreProviders)} more providers not listed.</p>` : ""
218
+ sections.push(`<h2>Catalog</h2><table><tr><th>Provider</th><th>Models</th><th>Examples</th></tr>${rows}</table>${more}`)
219
+ }
220
+ sections.push(`<h2>Try it</h2>${pre(doc.example.curl)}`)
221
+ sections.push(
222
+ `<h2>Endpoints</h2><table><tr><th>Method</th><th>Path</th><th>Key</th><th></th></tr>${doc.endpoints
223
+ .map(
224
+ (endpoint) =>
225
+ `<tr><td>${escapeHtml(endpoint.method)}</td><td><code>${escapeHtml(endpoint.path)}</code></td><td>${endpoint.auth ? "yes" : "no"}</td><td>${escapeHtml(endpoint.description)}</td></tr>`,
226
+ )
227
+ .join("")}</table>`,
228
+ )
229
+ return `<!doctype html>
230
+ <html lang="en">
231
+ <head>
232
+ <meta charset="utf-8">
233
+ <meta name="viewport" content="width=device-width, initial-scale=1">
234
+ <title>${escapeHtml(doc.name)}</title>
235
+ <style>
236
+ body{font:16px/1.5 system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem;color:#1a1a1a}
237
+ code,pre{font-family:ui-monospace,monospace;font-size:.9em}
238
+ pre{background:#f4f4f4;padding:.75rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
239
+ table{border-collapse:collapse}td,th{border:1px solid #ccc;padding:.25rem .5rem;text-align:left;vertical-align:top}
240
+ .hint{border-left:4px solid #c80;background:#fff7e6;padding:.5rem .75rem}
241
+ </style>
242
+ </head>
243
+ <body>
244
+ <h1>${escapeHtml(doc.name)}</h1>
245
+ ${sections.join("\n")}
246
+ <p><a href="/v1">JSON version of this page</a></p>
247
+ </body>
248
+ </html>
249
+ `
250
+ }
251
+
252
+ /** `GET /v1/status`: authenticated runtime facts. `models` is absent when the catalog could not be loaded. */
253
+ export function buildStatus(input: {
254
+ readonly options: PlugboardOptions
255
+ readonly app: string
256
+ readonly runtime: RuntimeSnapshot & {
257
+ readonly runtimeVersion: number
258
+ readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
259
+ readonly servingConfirmed: boolean
260
+ }
261
+ readonly catalog?: ReadonlyArray<CatalogModel>
262
+ readonly now?: number
263
+ }): object {
264
+ const { options, app, runtime, catalog } = input
265
+ const now = input.now ?? Date.now()
266
+ return {
267
+ ok: true,
268
+ app,
269
+ runtimeVersion: runtime.runtimeVersion,
270
+ startedAt: new Date(runtime.startedAt).toISOString(),
271
+ uptimeSeconds: Math.max(0, Math.floor((now - runtime.startedAt) / 1000)),
272
+ listen: { host: options.listen.host, port: options.listen.port, bound: runtime.listener?.bound ?? false },
273
+ generations: runtime.generations,
274
+ servingConfirmed: runtime.servingConfirmed,
275
+ eventPump: { running: runtime.pumpRunning },
276
+ sessionGuards: runtime.guards,
277
+ models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog).length } : { error: "catalog_unavailable" },
278
+ metrics: options.metrics,
279
+ }
280
+ }
package/src/log.ts CHANGED
@@ -1,28 +1,79 @@
1
1
  /**
2
- * Tiny structured logger: one line per event, `[opencode-plugboard] event key=value ...`.
2
+ * Tiny structured logger: one line per event.
3
+ *
4
+ * text (default) `[opencode-plugboard] event key=value ...`
5
+ * json `{"time":"...","level":"info","service":"opencode-plugboard","event":"request","id":"...",...}`
3
6
  *
4
7
  * Callers pass only identifiers and outcomes (event name, request id, app, model, duration, an
5
8
  * error message). Never pass API keys, request or response bodies, URLs or raw provider
6
9
  * objects: nothing here redacts them.
10
+ *
11
+ * The format is process-wide (`setLogFormat`, set by the runtime from the live generation's
12
+ * `logFormat` option). A line logged while a request is being handled also carries that request's
13
+ * id (`id`, the value of the `x-plugboard-request-id` response header): `withRequestId` scopes it
14
+ * to the request's async chain (node:async_hooks), and `outsideRequest` detaches work that merely
15
+ * started from a request but is not about it (the history sweep, the event pump).
16
+ *
17
+ * Both settings sit on `globalThis`, because the module can be evaluated once per Location.
7
18
  */
8
19
 
20
+ import { AsyncLocalStorage } from "node:async_hooks"
21
+
9
22
  export type LogFields = Readonly<Record<string, string | number | boolean | undefined>>
23
+ export type LogFormat = "text" | "json"
10
24
 
11
25
  const PREFIX = "[opencode-plugboard]"
26
+ const SERVICE = "opencode-plugboard"
27
+ const KEY = Symbol.for("opencode-plugboard.log.1")
28
+
29
+ interface LogState {
30
+ format: LogFormat
31
+ readonly requests: AsyncLocalStorage<{ readonly id: string } | undefined>
32
+ }
33
+
34
+ function state(): LogState {
35
+ const slot = globalThis as Record<symbol, unknown>
36
+ return (slot[KEY] ??= { format: "text", requests: new AsyncLocalStorage() } satisfies LogState) as LogState
37
+ }
38
+
39
+ /** Set the line format for the whole process. */
40
+ export function setLogFormat(format: LogFormat): void {
41
+ state().format = format
42
+ }
43
+
44
+ /** Run `fn` so that every log line emitted from its async chain carries `id`. */
45
+ export const withRequestId = <T>(id: string, fn: () => T): T => state().requests.run({ id }, fn)
46
+
47
+ /** Run `fn` detached from the current request: its log lines carry no request id. */
48
+ export const outsideRequest = <T>(fn: () => T): T => state().requests.run(undefined, fn)
12
49
 
13
50
  function formatValue(value: string | number | boolean): string {
14
51
  return typeof value === "string" && !/^[\w.:/#-]+$/.test(value) ? JSON.stringify(value) : String(value)
15
52
  }
16
53
 
17
- function format(event: string, fields: LogFields | undefined): string {
54
+ /** The fields with the ambient request id added (an explicit `id` field wins). */
55
+ function withAmbientId(fields: LogFields | undefined): Array<[string, string | number | boolean | undefined]> {
56
+ const entries = Object.entries(fields ?? {})
57
+ const ambient = state().requests.getStore()?.id
58
+ return ambient !== undefined && !entries.some(([key]) => key === "id") ? [["id", ambient], ...entries] : entries
59
+ }
60
+
61
+ function format(level: "info" | "error", event: string, fields: LogFields | undefined): string {
62
+ const entries = withAmbientId(fields)
63
+ if (state().format === "json") {
64
+ const line: Record<string, unknown> = { time: new Date().toISOString(), level, service: SERVICE, event }
65
+ // The envelope keys cannot be overwritten by a field of the same name.
66
+ for (const [key, value] of entries) if (value !== undefined && !(key in line)) line[key] = value
67
+ return JSON.stringify(line)
68
+ }
18
69
  let line = `${PREFIX} ${event}`
19
- for (const [key, value] of Object.entries(fields ?? {})) if (value !== undefined) line += ` ${key}=${formatValue(value)}`
70
+ for (const [key, value] of entries) if (value !== undefined) line += ` ${key}=${formatValue(value)}`
20
71
  return line
21
72
  }
22
73
 
23
- export const log = (event: string, fields?: LogFields): void => console.log(format(event, fields))
74
+ export const log = (event: string, fields?: LogFields): void => console.log(format("info", event, fields))
24
75
 
25
- export const logError = (event: string, fields?: LogFields): void => console.error(format(event, fields))
76
+ export const logError = (event: string, fields?: LogFields): void => console.error(format("error", event, fields))
26
77
 
27
78
  /** The message of a thrown value, without its stack or attached properties. */
28
79
  export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))
package/src/metrics.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Process metrics in the Prometheus text exposition format (version 0.0.4), hand-rolled: no
3
+ * dependency, no I/O. One registry lives in the process runtime (`src/runtime.ts`), so counts
4
+ * survive a plugin generation being replaced. `GET /metrics` renders it (opt-in, `metrics: true`).
5
+ *
6
+ * Labels are bounded by construction and carry ids only: `app` is a configured key name (or
7
+ * `_none` before authentication succeeded), `route` is one of the known routes or `unknown`,
8
+ * `status` a response code, `outcome` one of a fixed set, `param` a sampling parameter name from
9
+ * `src/openai/sampling.ts`. Never a model string, key, path or client-supplied text.
10
+ */
11
+
12
+ import type { PlugUsage } from "./contract.js"
13
+
14
+ /** `app` label of a request that was not authenticated (app names cannot contain an underscore). */
15
+ export const NO_APP = "_none"
16
+
17
+ /** What a request ended as, for the `outcome` label. */
18
+ export type Outcome = "ok" | "rejected" | "failed" | "timeout" | "disconnected"
19
+
20
+ /** Collapses the request log's outcome string and the response status into the bounded `outcome` label. */
21
+ export function outcomeLabel(logOutcome: string, status: number): Outcome {
22
+ if (logOutcome === "timeout" || logOutcome === "disconnected") return logOutcome
23
+ if (logOutcome === "error") return "failed"
24
+ if (status >= 500) return "failed"
25
+ if (status >= 400) return "rejected"
26
+ return "ok"
27
+ }
28
+
29
+ /** Process-level values the registry does not own; read from the runtime at scrape time. */
30
+ export interface RuntimeSnapshot {
31
+ /** Accepted plugin generations currently serving. */
32
+ readonly generations: number
33
+ /** Whether the event pump is subscribed. */
34
+ readonly pumpRunning: boolean
35
+ /** Request guards outstanding (sessions the runtime still owns, including ones past their response). */
36
+ readonly guards: number
37
+ /** Runtime start, epoch ms. */
38
+ readonly startedAt: number
39
+ }
40
+
41
+ /** Request duration buckets, seconds: model calls run from under a second to many minutes. */
42
+ const BUCKETS = [0.1, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600]
43
+
44
+ const escapeLabel = (value: string): string => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"')
45
+ const escapeHelp = (value: string): string => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n")
46
+ const labelText = (names: ReadonlyArray<string>, values: ReadonlyArray<string>): string =>
47
+ names.length === 0 ? "" : `{${names.map((name, index) => `${name}="${escapeLabel(values[index] ?? "")}"`).join(",")}}`
48
+
49
+ class Counter {
50
+ private readonly series = new Map<string, { values: string[]; total: number }>()
51
+ constructor(
52
+ readonly name: string,
53
+ readonly help: string,
54
+ private readonly labels: ReadonlyArray<string>,
55
+ ) {}
56
+ add(values: ReadonlyArray<string>, amount = 1): void {
57
+ if (!(amount > 0) || !Number.isFinite(amount)) return
58
+ const key = values.join("\u0000")
59
+ const entry = this.series.get(key)
60
+ if (entry) entry.total += amount
61
+ else this.series.set(key, { values: [...values], total: amount })
62
+ }
63
+ render(): string[] {
64
+ const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} counter`]
65
+ for (const { values, total } of this.series.values()) lines.push(`${this.name}${labelText(this.labels, values)} ${total}`)
66
+ return lines
67
+ }
68
+ }
69
+
70
+ class Histogram {
71
+ private readonly series = new Map<string, { values: string[]; counts: number[]; sum: number; count: number }>()
72
+ constructor(
73
+ readonly name: string,
74
+ readonly help: string,
75
+ private readonly labels: ReadonlyArray<string>,
76
+ ) {}
77
+ observe(values: ReadonlyArray<string>, seconds: number): void {
78
+ if (!(seconds >= 0) || !Number.isFinite(seconds)) return
79
+ const key = values.join("\u0000")
80
+ let entry = this.series.get(key)
81
+ if (!entry) this.series.set(key, (entry = { values: [...values], counts: BUCKETS.map(() => 0), sum: 0, count: 0 }))
82
+ BUCKETS.forEach((bound, index) => {
83
+ if (seconds <= bound) entry.counts[index]! += 1
84
+ })
85
+ entry.sum += seconds
86
+ entry.count += 1
87
+ }
88
+ render(): string[] {
89
+ const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} histogram`]
90
+ for (const { values, counts, sum, count } of this.series.values()) {
91
+ const names = [...this.labels, "le"]
92
+ BUCKETS.forEach((bound, index) => lines.push(`${this.name}_bucket${labelText(names, [...values, String(bound)])} ${counts[index]}`))
93
+ lines.push(`${this.name}_bucket${labelText(names, [...values, "+Inf"])} ${count}`)
94
+ lines.push(`${this.name}_sum${labelText(this.labels, values)} ${sum}`)
95
+ lines.push(`${this.name}_count${labelText(this.labels, values)} ${count}`)
96
+ }
97
+ return lines
98
+ }
99
+ }
100
+
101
+ const gauge = (name: string, help: string, value: number): string[] => [
102
+ `# HELP ${name} ${escapeHelp(help)}`,
103
+ `# TYPE ${name} gauge`,
104
+ `${name} ${value}`,
105
+ ]
106
+
107
+ export interface Metrics {
108
+ /** A request entered the handler. Pair with `requestFinished`. */
109
+ requestStarted(): void
110
+ requestFinished(input: { readonly app: string; readonly route: string; readonly status: number; readonly outcome: Outcome; readonly ms: number }): void
111
+ /** Token usage of one model call, as a non-overlapping breakdown (see `plugboard_tokens_total`). */
112
+ tokens(app: string, usage: PlugUsage): void
113
+ /** Sampling parameters dropped for one request. */
114
+ dropped(params: ReadonlyArray<string>): void
115
+ /** One history sweep ended. */
116
+ sweep(result: "ok" | "failed" | "timeout", removed: number): void
117
+ /** The exposition text. Called from a request that is itself in flight, which is not counted. */
118
+ render(snapshot: RuntimeSnapshot): string
119
+ }
120
+
121
+ export function createMetrics(): Metrics {
122
+ let inFlight = 0
123
+ const requests = new Counter("plugboard_requests_total", "Requests handled, by app, route, response status and outcome.", ["app", "route", "status", "outcome"])
124
+ const duration = new Histogram("plugboard_request_duration_seconds", "Time from request start to the end of the response, by app and route.", ["app", "route"])
125
+ const tokens = new Counter(
126
+ "plugboard_tokens_total",
127
+ "Tokens of completed model calls, by app and kind. Kinds do not overlap: input is uncached prompt tokens, cache_read and cache_write are cached prompt tokens, output excludes reasoning.",
128
+ ["app", "kind"],
129
+ )
130
+ const dropped = new Counter("plugboard_dropped_params_total", "Sampling parameters dropped because the target model rejects them, by parameter.", ["param"])
131
+ const sweeps = new Counter("plugboard_history_sweeps_total", "History sweeps that ended, by result.", ["result"])
132
+ const removed = new Counter("plugboard_history_sessions_removed_total", "Request sessions removed by history sweeps.", [])
133
+
134
+ return {
135
+ requestStarted: () => void inFlight++,
136
+ requestFinished({ app, route, status, outcome, ms }) {
137
+ inFlight = Math.max(0, inFlight - 1)
138
+ requests.add([app, route, String(status), outcome])
139
+ duration.observe([app, route], ms / 1000)
140
+ },
141
+ tokens(app, usage) {
142
+ const cacheRead = usage.cacheReadTokens ?? 0
143
+ const cacheWrite = usage.cacheWriteTokens ?? 0
144
+ const reasoning = usage.reasoningTokens ?? 0
145
+ tokens.add([app, "input"], Math.max(0, (usage.inputTokens ?? 0) - cacheRead - cacheWrite))
146
+ tokens.add([app, "cache_read"], cacheRead)
147
+ tokens.add([app, "cache_write"], cacheWrite)
148
+ tokens.add([app, "output"], Math.max(0, (usage.outputTokens ?? 0) - reasoning))
149
+ tokens.add([app, "reasoning"], reasoning)
150
+ },
151
+ dropped(params) {
152
+ for (const param of params) dropped.add([param])
153
+ },
154
+ sweep(result, count) {
155
+ sweeps.add([result])
156
+ removed.add([], count)
157
+ },
158
+ render(snapshot) {
159
+ const lines = [
160
+ ...requests.render(),
161
+ ...duration.render(),
162
+ // The scrape itself is in flight; do not report it.
163
+ ...gauge("plugboard_requests_in_flight", "Requests being handled right now, not counting this scrape.", Math.max(0, inFlight - 1)),
164
+ ...tokens.render(),
165
+ ...dropped.render(),
166
+ ...sweeps.render(),
167
+ ...removed.render(),
168
+ ...gauge("plugboard_generations", "Accepted plugin generations (one per OpenCode Location) serving requests.", snapshot.generations),
169
+ ...gauge("plugboard_event_pump_up", "1 while the event pump is subscribed to the OpenCode event bus, else 0 (it starts with the first request).", snapshot.pumpRunning ? 1 : 0),
170
+ ...gauge("plugboard_session_guards", "Request guards outstanding: request sessions plugboard still owns, including ones whose response has ended.", snapshot.guards),
171
+ ...gauge("plugboard_start_time_seconds", "Start of the plugboard runtime in this process, Unix time.", Math.floor(snapshot.startedAt / 1000)),
172
+ ]
173
+ return lines.join("\n") + "\n"
174
+ },
175
+ }
176
+ }
@@ -63,6 +63,34 @@ export const encodeError = (error: PlugError): object => ({
63
63
  error: { message: error.message, type: ERROR_TYPES[error.kind], param: error.param ?? null, code: error.code ?? null },
64
64
  })
65
65
 
66
+ /** Epoch ms -> ISO 8601, or undefined for a value Date cannot represent. */
67
+ const isoDate = (ms: number | undefined): string | undefined => {
68
+ if (ms === undefined) return undefined
69
+ const date = new Date(ms)
70
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
71
+ }
72
+
73
+ /**
74
+ * The `plugboard` object of a model entry: what the catalog knows beyond the OpenAI fields. Absent
75
+ * facts are omitted. `cost` is USD per million tokens (an entry with `aboveContext` applies when
76
+ * the prompt exceeds that many tokens; the entry without it is the base price); `released` is ISO 8601.
77
+ */
78
+ const encodePlugboard = (model: ListedModel["model"]) => ({
79
+ capabilities: model.capabilities ?? {},
80
+ contextLimit: model.contextLimit,
81
+ limit: model.limits,
82
+ modalities: model.modalities,
83
+ cost: model.cost?.map(({ input, output, cacheRead, cacheWrite, aboveContext }) => ({
84
+ input,
85
+ output,
86
+ cache: { read: cacheRead, write: cacheWrite },
87
+ ...(aboveContext === undefined ? {} : { aboveContext }),
88
+ })),
89
+ status: model.status,
90
+ family: model.family,
91
+ released: isoDate(model.released),
92
+ })
93
+
66
94
  /** `GET /v1/models` body. */
67
95
  export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
68
96
  object: "list",
@@ -71,7 +99,7 @@ export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => (
71
99
  object: "model",
72
100
  created: 0,
73
101
  owned_by: ref.providerID,
74
- plugboard: { capabilities: model.capabilities ?? {}, contextLimit: model.contextLimit },
102
+ plugboard: encodePlugboard(model),
75
103
  })),
76
104
  })
77
105