opencode-plugboard 0.4.1 → 0.6.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/README.md CHANGED
@@ -30,7 +30,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
30
30
  {
31
31
  "plugins": [
32
32
  {
33
- "package": "opencode-plugboard@0.4.1",
33
+ "package": "opencode-plugboard@0.6.0",
34
34
  "options": {
35
35
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
36
36
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -81,10 +81,9 @@ Plugboard is early (0.x) and narrow on purpose.
81
81
  - **No concurrency limit per app** yet ([#54](https://github.com/jjjermiah/opencode-plugboard/issues/54), [#86](https://github.com/jjjermiah/opencode-plugboard/issues/86)).
82
82
  - **Not a gateway with its own provider keys.** It only reuses what OpenCode is logged in to.
83
83
 
84
- How it differs from the alternatives:
84
+ How it differs from a standalone gateway:
85
85
 
86
- - [KochC/opencode-llm-proxy](https://github.com/KochC/opencode-llm-proxy) serves more API formats but targets OpenCode V1 only. Plugboard is V2 only and adds per-app keys and history, an explicit model allowlist, and rules that keep it from freezing the host (see [AGENTS.md](https://github.com/jjjermiah/opencode-plugboard/blob/main/AGENTS.md)).
87
- - [LiteLLM](https://github.com/BerriAI/litellm) is a separate gateway you run and give its own provider keys. It covers many more providers and API shapes. Pick it when you want a standalone service. Pick plugboard when OpenCode already holds the logins you want to reuse, such as a ChatGPT or Copilot subscription.
86
+ - [LiteLLM](https://github.com/BerriAI/litellm) is a separate gateway you run and give its own provider keys. It covers many more providers and API shapes. Pick it when you want a standalone service. Pick plugboard when OpenCode already holds the logins you want to reuse, such as a ChatGPT or Copilot subscription. Plugboard also gives each app its own key and history, an explicit model allowlist, and rules that keep it from ever freezing the host.
88
87
 
89
88
  The most common first failure is an SDK defaulting to `/v1/responses` for newer models. Plugboard serves it, but some clients need `store: false` or a "use chat completions" switch. Check the client's page below.
90
89
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
- "description": "OpenCode V2 plugin that serves an OpenAI-compatible /v1/chat/completions and /v1/models from your OpenCode server, backed by the providers you already connected there.",
7
- "keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions"],
6
+ "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app. An OpenCode V2 plugin serving OpenAI-compatible Chat Completions, Responses and model endpoints.",
7
+ "keywords": ["opencode", "opencode-plugin", "openai", "openai-compatible", "chat-completions", "responses-api", "llm-gateway", "llm-proxy"],
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/jjjermiah/opencode-plugboard.git"
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Plugin options: type, defaults, validation, caller authentication and the loopback check. */
2
2
 
3
3
  import type { PlugError } from "./contract.js"
4
+ import { parseAllowEntry, type RemoteImages } from "./session/media.js"
4
5
  import type { HistoryLimits } from "./session/prune.js"
5
6
 
6
7
  /** Plugin options, after defaults and validation (see `parseOptions`). */
@@ -14,12 +15,21 @@ export interface PlugboardOptions {
14
15
  readonly aliases: Readonly<Record<string, string>>
15
16
  readonly maxTools: number
16
17
  readonly requestTimeoutMs: number
17
- /** Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network. */
18
- readonly remoteImages: boolean
18
+ /**
19
+ * Fetch http(s) image URLs server-side. Off by default: it is an SSRF vector into the host's network.
20
+ * `true` fetches public addresses only (loopback, private, link-local, CGNAT and ULA ranges are refused);
21
+ * `{ allow }` is on too, and lists the hosts (`name`, `*.suffix`), addresses or CIDR ranges that may be
22
+ * fetched even when they are not public.
23
+ */
24
+ readonly remoteImages: RemoteImages
19
25
  /** Prune old request sessions (see `src/session/prune.ts`). A limit that is unset is not applied; with neither, history is kept forever. Applies per app. */
20
26
  readonly history: HistoryLimits
21
27
  /** Drop sampling parameters the target model is known to reject (reported in `x-plugboard-dropped-params`). On by default; off forwards them as sent. */
22
28
  readonly dropUnsupportedParams: boolean
29
+ /** Log line format: `text` (`key=value`, the default) or `json` (one object per line). Process-wide: the live generation's value applies. */
30
+ readonly logFormat: "text" | "json"
31
+ /** Serve `GET /metrics` (Prometheus text). Off by default; same authentication as `/v1`. */
32
+ readonly metrics: boolean
23
33
  }
24
34
 
25
35
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
@@ -31,7 +41,7 @@ const DEFAULTS = {
31
41
  requestTimeoutMs: 600_000,
32
42
  } as const
33
43
 
34
- const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams"])
44
+ const KNOWN = new Set(["listen", "keys", "models", "aliases", "maxTools", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics"])
35
45
  const HISTORY_KNOWN = new Set(["maxSessions", "maxAgeDays"])
36
46
  /** About 100 years: keeps `maxAgeDays * 86_400_000` a safe integer. */
37
47
  const MAX_AGE_DAYS = 36_500
@@ -64,6 +74,23 @@ function positiveInt(value: unknown, name: string, fallback: number, max = Numbe
64
74
  return value
65
75
  }
66
76
 
77
+ const REMOTE_IMAGES_ERROR =
78
+ "plugboard option `remoteImages` must be a boolean or `{ allow: [...] }` with host names, IP addresses or CIDR ranges that may be fetched even when they are not public"
79
+
80
+ function parseRemoteImages(value: unknown): RemoteImages {
81
+ if (value === undefined) return false
82
+ if (typeof value === "boolean") return value
83
+ if (!isRecord(value)) throw new Error(REMOTE_IMAGES_ERROR)
84
+ for (const key of Object.keys(value))
85
+ if (key !== "allow") throw new Error(`Unknown plugboard option \`remoteImages.${key}\` (known: allow)`)
86
+ const allow = value.allow ?? []
87
+ if (!Array.isArray(allow)) throw new Error(REMOTE_IMAGES_ERROR)
88
+ for (const entry of allow)
89
+ if (typeof entry !== "string" || parseAllowEntry(entry) === undefined)
90
+ throw new Error(`plugboard \`remoteImages.allow\` entry \`${String(entry)}\` must be a host name (\`images.internal\`, \`*.internal\`), an IP address or a CIDR range`)
91
+ return { allow: allow as string[] }
92
+ }
93
+
67
94
  /** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
68
95
  export function parseOptions(raw: unknown): PlugboardOptions {
69
96
  if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
@@ -118,11 +145,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
118
145
  }
119
146
  const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
120
147
 
121
- if (input.remoteImages !== undefined && typeof input.remoteImages !== "boolean")
122
- throw new Error("plugboard option `remoteImages` must be a boolean")
148
+ const remoteImages = parseRemoteImages(input.remoteImages)
123
149
  if (input.dropUnsupportedParams !== undefined && typeof input.dropUnsupportedParams !== "boolean")
124
150
  throw new Error("plugboard option `dropUnsupportedParams` must be a boolean")
125
151
 
152
+ if (input.logFormat !== undefined && input.logFormat !== "text" && input.logFormat !== "json")
153
+ throw new Error("plugboard option `logFormat` must be `text` or `json`")
154
+ if (input.metrics !== undefined && typeof input.metrics !== "boolean") throw new Error("plugboard option `metrics` must be a boolean")
155
+
126
156
  const historyInput = input.history
127
157
  if (historyInput !== undefined && !isRecord(historyInput)) throw new Error("plugboard option `history` must be an object")
128
158
  for (const key of Object.keys(historyInput ?? {}))
@@ -137,9 +167,11 @@ export function parseOptions(raw: unknown): PlugboardOptions {
137
167
  aliases,
138
168
  maxTools: positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024),
139
169
  requestTimeoutMs: positiveInt(input.requestTimeoutMs, "requestTimeoutMs", DEFAULTS.requestTimeoutMs, MAX_TIMER_MS),
140
- remoteImages: input.remoteImages === true,
170
+ remoteImages,
141
171
  history: { ...(maxSessions === undefined ? {} : { maxSessions }), ...(maxAgeDays === undefined ? {} : { maxAgeDays }) },
142
172
  dropUnsupportedParams: input.dropUnsupportedParams !== false,
173
+ logFormat: input.logFormat === "json" ? "json" : "text",
174
+ metrics: input.metrics === true,
143
175
  }
144
176
  }
145
177
 
package/src/contract.ts CHANGED
@@ -20,6 +20,18 @@ export interface ModelRef {
20
20
  readonly variant?: string
21
21
  }
22
22
 
23
+ /**
24
+ * One price entry, USD per million tokens (the host's `Model.Cost`). An entry with `aboveContext`
25
+ * applies once the prompt context exceeds that many tokens; the entry without it is the base price.
26
+ */
27
+ export interface CatalogCost {
28
+ readonly input: number
29
+ readonly output: number
30
+ readonly cacheRead: number
31
+ readonly cacheWrite: number
32
+ readonly aboveContext?: number
33
+ }
34
+
23
35
  /** One enabled model as the server sees it: what model listing and the request resolver work from. */
24
36
  export interface CatalogModel {
25
37
  readonly providerID: string
@@ -28,6 +40,15 @@ export interface CatalogModel {
28
40
  readonly variants: ReadonlyArray<string>
29
41
  readonly capabilities?: { readonly tools?: boolean; readonly image?: boolean }
30
42
  readonly contextLimit?: number
43
+ /** Token limits as the host reports them (`limit.{context,input,output}`). */
44
+ readonly limits?: { readonly context?: number; readonly input?: number; readonly output?: number }
45
+ /** Modalities the model accepts and produces (`capabilities.input` / `.output`, for example `text`, `image`). */
46
+ readonly modalities?: { readonly input: ReadonlyArray<string>; readonly output: ReadonlyArray<string> }
47
+ readonly cost?: ReadonlyArray<CatalogCost>
48
+ readonly status?: "alpha" | "beta" | "deprecated" | "active"
49
+ readonly family?: string
50
+ /** Release date, epoch milliseconds. */
51
+ readonly released?: number
31
52
  }
32
53
 
33
54
  // ---------------------------------------------------------------- request
@@ -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))