opencode-plugboard 0.19.0 → 0.20.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
@@ -43,7 +43,7 @@ export PLUGBOARD_KEY_MY_APP=choose-a-long-random-string
43
43
  {
44
44
  "plugins": [
45
45
  {
46
- "package": "opencode-plugboard@0.19.0",
46
+ "package": "opencode-plugboard@0.20.0",
47
47
  "options": {
48
48
  "keys": { "my-app": "{env:PLUGBOARD_KEY_MY_APP}" },
49
49
  "models": ["anthropic/claude-sonnet-4-6", "github-copilot/*", "opencode-go/glm-5.3-flash"],
@@ -390,7 +390,7 @@ A server shared by a Discord bot, n8n, Open WebUI and a coding agent, with most
390
390
  {
391
391
  "plugins": [
392
392
  {
393
- "package": "opencode-plugboard@0.19.0",
393
+ "package": "opencode-plugboard@0.20.0",
394
394
  "options": {
395
395
  // Reachable from the LAN. Non-loopback hosts refuse to start without keys.
396
396
  "listen": { "host": "10.0.1.50", "port": 4010 },
package/index.ts CHANGED
@@ -21,15 +21,17 @@
21
21
  import { Plugin } from "@opencode/plugin/effect"
22
22
  import { Effect } from "effect"
23
23
  import { parseOptions } from "./src/config.js"
24
+ import { personaOf } from "./src/models.js"
24
25
  import { errorMessage, logError } from "./src/log.js"
25
26
  import { withFallbacks } from "./src/fallback.js"
26
27
  import { withBudgets } from "./src/budgets.js"
27
28
  import { withLimits } from "./src/limits.js"
28
29
  import { createOpenAiRoute } from "./src/openai/route.js"
29
- import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
30
+ import { ensureBackend, ensurePump, loadServingCatalog, processBudgets, processKeys, processLimits, processMetrics, processUsage, registerGeneration, sessionStore, withResponseCache } from "./src/runtime.js"
30
31
  import { createSessionCaller } from "./src/session/caller.js"
31
32
  import { installSessionHooks } from "./src/session/context.js"
32
33
  import { createHistoryPort, createSessionPort, createStoragePort, loadCatalog } from "./src/session/host.js"
34
+ import { createKeyStore } from "./src/keys.js"
33
35
  import { usageHandlers, UsageRpc } from "./src/rpc.js"
34
36
  import { createUsageRecorder, withUsage } from "./src/usage.js"
35
37
 
@@ -59,6 +61,7 @@ export default Plugin.define({
59
61
  sessions,
60
62
  maxTools: options.maxTools,
61
63
  apps: options.apps,
64
+ persona: (model: string) => personaOf(options, model),
62
65
  remoteImages: options.remoteImages,
63
66
  // The readiness gate the caller awaits before creating a session (fails closed: 503 `backend_unavailable`).
64
67
  ensureBackend: (app: string, signal: AbortSignal) => ensureBackend(app, signal, bootLocation),
@@ -118,6 +121,8 @@ export default Plugin.define({
118
121
  ),
119
122
  loadCatalog: servingCatalog,
120
123
  usage,
124
+ // Runtime app keys (#55): read from storage on the first authentication, cached on the runtime.
125
+ keys: createKeyStore({ state: processKeys(), storage: createStoragePort(ctx) }),
121
126
  })
122
127
  const registration = registerGeneration({
123
128
  listen: options.listen,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-plugboard",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Use the ChatGPT, Claude, Copilot and OpenCode Go logins in your OpenCode server from any app: an OpenCode V2 plugin serving OpenAI, Anthropic and Gemini compatible APIs.",
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Admin routes for runtime app keys (#55): `GET /v1/admin/keys` (keys without values),
3
+ * `POST /v1/admin/keys` with body `{ "app": "<name>" }` (a new random key, returned once) and
4
+ * `DELETE /v1/admin/keys/{keyId}` (revokes a runtime key; a config key is a 409).
5
+ *
6
+ * The routes exist only when option `adminKey` is set: without it every path here is a 404. Only the
7
+ * admin key passes; an app key, a wrong key or no key is a 401. src/openai/route.ts dispatches here
8
+ * after the origin check and before app authentication, so an app key never reaches them.
9
+ */
10
+
11
+ import type { IncomingMessage, ServerResponse } from "node:http"
12
+ import { APP_NAME_PATTERN, DEFAULT_APP, authenticateUsage, type PlugboardOptions } from "../config.js"
13
+ import { isPlugError, type PlugError } from "../contract.js"
14
+ import { checkJsonContentType, readJsonBody, sendError, sendJson, type RequestScope } from "../http.js"
15
+ import type { KeyStore } from "../keys.js"
16
+ import { log } from "../log.js"
17
+ import { encodeError } from "../openai/response.js"
18
+
19
+ export interface AdminHost {
20
+ /** The live options (`liveOptions`): the config keys, and `adminKey`. */
21
+ readonly options: PlugboardOptions
22
+ /** The runtime key store; absent means the admin routes are not served. */
23
+ readonly keys?: KeyStore
24
+ }
25
+
26
+ export interface AdminCall {
27
+ readonly path: string
28
+ readonly request: IncomingMessage
29
+ readonly response: ServerResponse
30
+ readonly scope: RequestScope
31
+ /** The route's log fields: the route name is set here, so the request log names the admin route. */
32
+ readonly info: { route: string; app?: string }
33
+ }
34
+
35
+ const KEYS = "/v1/admin/keys"
36
+ const ONE_KEY = /^\/v1\/admin\/keys\/([0-9a-f]{8})$/
37
+
38
+ export async function serveAdmin(host: AdminHost, call: AdminCall): Promise<void> {
39
+ const { path, request, response, scope, info } = call
40
+ const fail = (error: PlugError) => sendError(response, error, encodeError(error))
41
+ const { keys } = host
42
+ if (host.options.adminKey === undefined || keys === undefined)
43
+ return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
44
+
45
+ const who = authenticateUsage(host.options, request.headers.authorization, await keys.digests())
46
+ if (isPlugError(who) || !who.admin) return fail({ status: 401, kind: "authentication", code: "invalid_api_key", message: "Missing or invalid admin key" })
47
+
48
+ const id = ONE_KEY.exec(path)?.[1]
49
+ if (path === KEYS && request.method === "GET") {
50
+ info.route = "GET /v1/admin/keys"
51
+ return sendJson(response, 200, { keys: await keys.list(host.options.keys) })
52
+ }
53
+ if (path === KEYS && request.method === "POST") {
54
+ info.route = "POST /v1/admin/keys"
55
+ const unsupported = checkJsonContentType(request)
56
+ if (unsupported) return fail(unsupported)
57
+ const body = await readJsonBody(request, scope.signal)
58
+ const app = typeof body === "object" && body !== null && !Array.isArray(body) ? (body as { app?: unknown }).app : undefined
59
+ if (typeof app !== "string" || app === DEFAULT_APP || !APP_NAME_PATTERN.test(app))
60
+ return fail({ status: 400, kind: "invalid_request", code: "invalid_app", param: "app", message: `\`app\` must match ${APP_NAME_PATTERN} and must not be \`${DEFAULT_APP}\`` })
61
+ if (Object.hasOwn(host.options.keys, app))
62
+ return fail({ status: 409, kind: "invalid_request", code: "app_has_config_key", param: "app", message: `App ${app} already has a key in the plugboard config; edit \`keys\` to change it` })
63
+ const created = await keys.create(app, host.options.keys)
64
+ log("admin.key_created", { id: created.id, app })
65
+ // The key value is shown once: a cache or a log of this response must not keep it.
66
+ response.setHeader("cache-control", "no-store")
67
+ return sendJson(response, 201, created)
68
+ }
69
+ if (id !== undefined && request.method === "DELETE") {
70
+ info.route = "DELETE /v1/admin/keys/{keyId}"
71
+ const result = await keys.revoke(id, host.options.keys)
72
+ if (result === "config")
73
+ return fail({ status: 409, kind: "invalid_request", code: "config_key", message: "This key is set in the plugboard config (`keys`). Remove it there and reload OpenCode." })
74
+ if (result === "not_found") return fail({ status: 404, kind: "not_found", code: "key_not_found", message: `No runtime key ${id}` })
75
+ log("admin.key_revoked", { id })
76
+ return sendJson(response, 200, { id, revoked: true })
77
+ }
78
+ if (path === KEYS || id !== undefined)
79
+ return fail({ status: 405, kind: "invalid_request", code: "method_not_allowed", message: `Method ${request.method} is not allowed for ${path}` })
80
+ return fail({ status: 404, kind: "not_found", code: "not_found", message: `No route for ${request.method} ${path}` })
81
+ }
@@ -9,6 +9,7 @@
9
9
  * afterwards, which is why the request omits `ref`.
10
10
  */
11
11
  import type { PlugboardOptions } from "../config.js"
12
+ import { bodyUserId, userField } from "../user.js"
12
13
  import {
13
14
  isPlugError,
14
15
  type PlugError,
@@ -327,6 +328,7 @@ export function parseMessagesRequest(body: unknown, options: PlugboardOptions):
327
328
  return bad(`tool_choice.name: "${toolChoice.name}" is not in tools`)
328
329
 
329
330
  const request: Omit<PlugRequest, "ref"> = {
331
+ ...userField(bodyUserId(body)),
330
332
  model: body.model,
331
333
  system,
332
334
  messages,
@@ -14,6 +14,7 @@
14
14
  import type { IncomingMessage, ServerResponse } from "node:http"
15
15
  import { authenticate } from "../config.js"
16
16
  import { liveOptions } from "../runtime.js"
17
+ import { bodyUserId, noteEndUser } from "../user.js"
17
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
18
19
  import {
19
20
  BodyError,
@@ -32,6 +33,7 @@ import { resolveModel } from "../models.js"
32
33
  import { reportCache } from "../cache.js"
33
34
  import { reportPolicy } from "../policy.js"
34
35
  import { reportUsage } from "../usage-header.js"
36
+ import { setSessionLink } from "../session/link.js"
35
37
  import { dropRejected } from "../openai/sampling.js"
36
38
  import type { RouteHost } from "../openai/route.js"
37
39
  import { parseMessagesRequest } from "./request.js"
@@ -54,7 +56,7 @@ export interface MessagesCall {
54
56
  readonly response: ServerResponse
55
57
  readonly scope: RequestScope
56
58
  /** The route's log fields: the route name, the app, the resolved model, dropped parameters, and the outcome of a stream that failed after it committed. */
57
- readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string }
59
+ readonly info: ServedInfo & { route: string; app?: string; model?: string; failed?: string; dropped?: string; user?: string }
58
60
  }
59
61
 
60
62
  const header = (request: IncomingMessage, name: string): string | undefined => {
@@ -63,11 +65,12 @@ const header = (request: IncomingMessage, name: string): string | undefined => {
63
65
  }
64
66
 
65
67
  /** `Authorization: Bearer` first (Claude Code with ANTHROPIC_AUTH_TOKEN), then `x-api-key` (the Anthropic SDKs). One key store. */
66
- const authenticateRequest = (host: RouteHost, request: IncomingMessage) => {
68
+ const authenticateRequest = async (host: RouteHost, request: IncomingMessage) => {
67
69
  const options = liveOptions(host.options)
68
- const bearer = authenticate(options, request.headers.authorization)
70
+ const runtime = host.keys ? await host.keys.digests() : []
71
+ const bearer = authenticate(options, request.headers.authorization, runtime)
69
72
  const apiKey = header(request, "x-api-key")
70
- return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`) : bearer
73
+ return isPlugError(bearer) && apiKey ? authenticate(options, `Bearer ${apiKey}`, runtime) : bearer
71
74
  }
72
75
 
73
76
  export async function serveMessages(host: RouteHost, call: MessagesCall): Promise<void> {
@@ -94,7 +97,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
94
97
  const rejected = checkOrigin(request, host.options)
95
98
  if (rejected) return fail(rejected)
96
99
  // Authenticate before the method check, so an unauthenticated caller cannot map this path (#137).
97
- const auth = authenticateRequest(host, request)
100
+ const auth = await authenticateRequest(host, request)
98
101
  if (isPlugError(auth)) return fail(auth)
99
102
  info.app = auth.app
100
103
  if (request.method !== "POST")
@@ -103,6 +106,7 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
103
106
  if (unsupported) return fail(unsupported)
104
107
 
105
108
  const body = await readJsonBody(request, scope.signal)
109
+ noteEndUser(response, info, bodyUserId(body))
106
110
  // count_tokens takes the message body without max_tokens; the parser needs one, so a placeholder stands in
107
111
  // (the count never uses it). A max_tokens the client did send is still validated.
108
112
  const sized = path === COUNT_TOKENS && isRecord(body) && body.max_tokens === undefined ? { ...body, max_tokens: Number.MAX_SAFE_INTEGER } : body
@@ -130,9 +134,10 @@ export async function serveMessages(host: RouteHost, call: MessagesCall): Promis
130
134
  host.caller.stream(model, {
131
135
  app: auth.app,
132
136
  signal: scope.signal,
133
- onRecord: (recordID) => {
137
+ onRecord: (recordID, directory) => {
134
138
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
135
139
  response.setHeader(SESSION_HEADER, recordID)
140
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
136
141
  },
137
142
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
138
143
  onPolicy: reportPolicy(response),
package/src/cache.ts CHANGED
@@ -25,6 +25,10 @@
25
25
  *
26
26
  * Fan-out (`n` above 1, prompt arrays) calls the caller once per choice with the same request, so
27
27
  * the fan-out sets `noCache`: every choice must be a fresh sample.
28
+ *
29
+ * A persona (a skill alias or an `agent/<name>` id, `bypass`) is never cached: its answer depends on
30
+ * the skill's content or the agent's prompt, which the key does not see, and a skill edit must not be
31
+ * masked by an old entry.
28
32
  */
29
33
 
30
34
  import { createHash } from "node:crypto"
@@ -115,11 +119,17 @@ function append(events: PlugEvent[], event: { readonly type: "text-delta" | "rea
115
119
  }
116
120
 
117
121
  /** Wraps `inner`: hits are replayed, misses stream through and are stored when they qualify. */
118
- export function cachedCaller(inner: ModelCaller, config: CacheConfig, store: CacheStore, count?: CacheCounter): ModelCaller {
122
+ export function cachedCaller(
123
+ inner: ModelCaller,
124
+ config: CacheConfig,
125
+ store: CacheStore,
126
+ count?: CacheCounter,
127
+ bypass?: (model: string) => boolean,
128
+ ): ModelCaller {
119
129
  const apps = config.apps ? new Set(config.apps) : undefined
120
130
  return {
121
131
  async *stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
122
- if (context.noCache || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
132
+ if (context.noCache || bypass?.(request.model) || (apps && !apps.has(context.app))) return yield* inner.stream(request, context)
123
133
  const key = cacheKey(context.app, request)
124
134
  const hit = store.get(key)
125
135
  if (hit) {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import type { PlugboardOptions } from "../config.js"
12
12
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
13
+ import { bodyUser, userField } from "../user.js"
13
14
  import { parseChoiceCount, parseGeneration } from "../openai/request.js"
14
15
  import type { CompletionsExchange } from "./response.js"
15
16
 
@@ -85,6 +86,7 @@ export function parseCompletionsRequest(body: unknown, options: PlugboardOptions
85
86
  return {
86
87
  requests: prompts.map((prompt) => ({
87
88
  model: body.model as string,
89
+ ...userField(bodyUser(body)),
88
90
  system: suffix ? [SUFFIX_INSTRUCTION(suffix)] : [],
89
91
  messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
90
92
  tools: [],
package/src/config.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { createHash, timingSafeEqual } from "node:crypto"
4
4
  import type { CacheConfig } from "./cache.js"
5
5
  import { isPlugError, type PlugError } from "./contract.js"
6
- import { allowedBy, parseModelRef } from "./models.js"
6
+ import { allowedBy, parseAliasTarget, parseModelRef, SKILL_PREFIX } from "./models.js"
7
7
  import { parseAllowEntry, type RemoteImages } from "./session/media.js"
8
8
  import type { HistoryLimits } from "./session/prune.js"
9
9
 
@@ -21,6 +21,11 @@ export interface PlugboardOptions {
21
21
  readonly models: ReadonlyArray<string>
22
22
  /** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
23
23
  readonly aliases: Readonly<Record<string, string>>
24
+ /**
25
+ * Agent name -> `provider/model[#variant]` (option `agents`): each is listed as `agent/<name>` and runs its
26
+ * OpenCode agent's system prompt on that model. The model must pass the allowlist.
27
+ */
28
+ readonly agents: Readonly<Record<string, string>>
24
29
  /**
25
30
  * Alias -> the targets after the first, in order, for an alias written as an array. `aliases` holds the
26
31
  * first target, so everything that resolves an alias keeps working; the model caller retries the rest
@@ -58,6 +63,8 @@ export interface PlugboardOptions {
58
63
  readonly usage: UsageConfig
59
64
  /** A key that may read every app's usage on `GET /v1/usage`. Compared like app keys; must differ from every one of them. */
60
65
  readonly adminKey?: string
66
+ /** A template for the OpenCode session page of each request: `{id}`, and optional `{directory}` (see src/session/link.ts, #77). */
67
+ readonly historyUrl?: string
61
68
  }
62
69
 
63
70
  /** Option `usage` after defaults: on, and days of history kept. */
@@ -139,7 +146,7 @@ const DEFAULTS = {
139
146
  requestTimeoutMs: 600_000,
140
147
  } as const
141
148
 
142
- const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage"])
149
+ const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "agents", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage", "historyUrl"])
143
150
  const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
144
151
  const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
145
152
  /** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
@@ -396,6 +403,55 @@ function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Bud
396
403
  return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
397
404
  }
398
405
 
406
+ const AGENT_KNOWN = new Set(["name", "model"])
407
+ /** An OpenCode agent name: letters, digits, `_` and `-`, starting with a letter or digit. */
408
+ const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
409
+
410
+ /**
411
+ * Option `agents`: `{ name, model }` per OpenCode agent to expose. `model` is required and must pass the
412
+ * allowlist, so an agent's own configured model never runs outside `models` (the agent's model setting is
413
+ * not read). Names must be unique.
414
+ */
415
+ function parseAgents(raw: unknown, models: ReadonlyArray<string>): Record<string, string> {
416
+ if (raw === undefined) return {}
417
+ if (!Array.isArray(raw)) throw new Error("plugboard option `agents` must be an array of { name, model } objects")
418
+ const entries: Array<[string, string]> = []
419
+ for (const item of raw) {
420
+ if (!isRecord(item)) throw new Error("plugboard option `agents` entries must be objects of the form { name, model }")
421
+ for (const key of Object.keys(item))
422
+ if (!AGENT_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`agents.${key}\` (known: ${[...AGENT_KNOWN].join(", ")})`)
423
+ const { name, model } = item
424
+ if (typeof name !== "string" || !AGENT_NAME.test(name)) throw new Error(`plugboard agent name \`${String(name)}\` must match ${AGENT_NAME} (an OpenCode agent name)`)
425
+ if (entries.some(([known]) => known === name)) throw new Error(`plugboard agent \`${name}\` is listed more than once in \`agents\``)
426
+ if (typeof model !== "string" || !ALIAS_TARGET.test(model))
427
+ throw new Error(`plugboard agent \`${name}\` must set \`model\` to \`provider/model\` or \`provider/model#variant\``)
428
+ if (!allowedBy(models as string[], parseModelRef(model)!))
429
+ throw new Error(`plugboard agent \`${name}\` model \`${model}\` is not allowed by the \`models\` option`)
430
+ entries.push([name, model])
431
+ }
432
+ return Object.fromEntries(entries)
433
+ }
434
+
435
+ /**
436
+ * Option `historyUrl` (#77): an http(s) URL template that must contain `{id}`. Its only other placeholder is
437
+ * `{directory}`, so a typo such as `{sesion}` fails at load instead of producing a link that is wrong.
438
+ */
439
+ function parseHistoryUrl(value: unknown): string | undefined {
440
+ if (value === undefined) return undefined
441
+ const at = "plugboard option `historyUrl`"
442
+ if (typeof value !== "string" || !value.includes("{id}")) throw new Error(`${at} must be a URL template with {id} (for example http://host:4096/session/{id})`)
443
+ const unknown = (value.match(/\{[^{}]*\}/g) ?? []).find((placeholder) => placeholder !== "{id}" && placeholder !== "{directory}")
444
+ if (unknown !== undefined) throw new Error(`${at} has the unknown placeholder ${unknown} (known: {id}, {directory})`)
445
+ let url: URL
446
+ try {
447
+ url = new URL(value.replaceAll("{id}", "x").replaceAll("{directory}", "x"))
448
+ } catch {
449
+ throw new Error(`${at} must be an http(s) URL template`)
450
+ }
451
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${at} must be http or https`)
452
+ return value
453
+ }
454
+
399
455
  /** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
400
456
  export function parseOptions(raw: unknown): PlugboardOptions {
401
457
  if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
@@ -452,6 +508,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
452
508
  const chain = Array.isArray(target) ? target : [target]
453
509
  if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
454
510
  for (const entry of chain) {
511
+ // A skill target binds one model and takes no fallbacks; like a list entry it must pass the allowlist at load.
512
+ if (typeof entry === "string" && entry.startsWith(SKILL_PREFIX)) {
513
+ const parsed = parseAliasTarget(entry)
514
+ if (parsed?.skill === undefined) throw new Error(`plugboard alias \`${alias}\` skill target \`${entry}\` must be \`skill:<id>@provider/model\` or \`skill:<id>@provider/model#variant\``)
515
+ if (Array.isArray(target)) throw new Error(`plugboard alias \`${alias}\` binds a skill, so it takes one target and no list of fallbacks`)
516
+ if (!allowedBy(models as string[], parsed.ref)) throw new Error(`plugboard alias \`${alias}\` target \`${entry}\` is not allowed by the \`models\` option`)
517
+ continue
518
+ }
455
519
  if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
456
520
  throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
457
521
  // A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
@@ -465,6 +529,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
465
529
  }
466
530
  const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
467
531
  const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
532
+ const agents = parseAgents(input.agents, models as string[])
468
533
 
469
534
  const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
470
535
  const apps = parseApps(input.apps, { keys, aliases, maxTools })
@@ -493,6 +558,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
493
558
  if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
494
559
  if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
495
560
  throw new Error(`plugboard option \`usage.retentionDays\` must be at least ${widestWindowDays(budgets)} for the budget windows in use (a month is 31 days, a week 7): older days are deleted and would stop counting`)
561
+ const historyUrl = parseHistoryUrl(input.historyUrl)
496
562
 
497
563
  return {
498
564
  listen: { host, port: port ?? DEFAULTS.port },
@@ -500,6 +566,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
500
566
  apps,
501
567
  models: models as string[],
502
568
  aliases,
569
+ agents,
503
570
  fallbacks,
504
571
  maxTools,
505
572
  maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
@@ -515,6 +582,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
515
582
  usage,
516
583
  ...(budgets === undefined ? {} : { budgets }),
517
584
  ...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
585
+ ...(historyUrl === undefined ? {} : { historyUrl }),
518
586
  }
519
587
  }
520
588
 
@@ -543,24 +611,32 @@ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readon
543
611
  /** Who may read `GET /v1/usage`: the admin key (every app), or one app (`default` when keyless). */
544
612
  export type UsageScope = { readonly admin: true } | { readonly admin: false; readonly app: string }
545
613
 
614
+ /** The digest of a runtime key (src/keys.ts), with its app: checked like a config key (#55). */
615
+ export type RuntimeDigest = readonly [app: string, digest: Buffer]
616
+
546
617
  /** The admin key is checked first; any other valid key (or keyless mode) is an app, as `authenticate` decides. */
547
- export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined): UsageScope | PlugError {
618
+ export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): UsageScope | PlugError {
548
619
  const bearer = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
549
620
  if (options.adminKey !== undefined && bearer !== undefined && timingSafeEqual(sha256(bearer), sha256(options.adminKey))) return { admin: true }
550
- const auth = authenticate(options, authorization)
621
+ const auth = authenticate(options, authorization, runtime)
551
622
  if (isPlugError(auth)) return auth
552
623
  return { admin: false, app: auth.app }
553
624
  }
554
625
 
555
- /** No keys -> app `default`; with keys every request needs `Authorization: Bearer <key>`. */
556
- export function authenticate(options: PlugboardOptions, authorization: string | undefined): { app: string } | PlugError {
626
+ /**
627
+ * No config keys -> app `default` (keyless), as before. With config keys every request needs
628
+ * `Authorization: Bearer <key>`. A runtime key (#55) matches like a config key, so it names its app
629
+ * whenever it is presented. It never makes a keyless server require a key, so deleting the last
630
+ * runtime key cannot open or close access by itself.
631
+ */
632
+ export function authenticate(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): { app: string } | PlugError {
557
633
  const entries = digestsOf(options.keys)
558
- if (entries.length === 0) return { app: DEFAULT_APP }
559
634
  const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
560
- if (!token) return UNAUTHORIZED
635
+ if (token === undefined) return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
561
636
  const digest = sha256(token)
562
637
  let app: string | undefined
563
638
  // Every key is compared, no early exit.
564
- for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
565
- return app === undefined ? UNAUTHORIZED : { app }
639
+ for (const [name, expected] of [...entries, ...runtime]) if (timingSafeEqual(digest, expected)) app = name
640
+ if (app !== undefined) return { app }
641
+ return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
566
642
  }
package/src/contract.ts CHANGED
@@ -114,6 +114,8 @@ export interface PlugRequest {
114
114
  readonly tools: ReadonlyArray<PlugTool>
115
115
  readonly toolChoice?: PlugToolChoice
116
116
  readonly generation: PlugGeneration
117
+ /** The end user the caller named, when kept (src/user.ts, #57). Labels the session title and the usage counts. */
118
+ readonly user?: string
117
119
  }
118
120
 
119
121
  // ---------------------------------------------------------------- events
@@ -172,7 +174,7 @@ export interface CallContext {
172
174
  /** Aborted on client disconnect or timeout. */
173
175
  readonly signal: AbortSignal
174
176
  /** Called once, before the first event, with the id of the history record this call writes (if any). */
175
- readonly onRecord?: (recordID: string) => void
177
+ readonly onRecord?: (recordID: string, directory?: string) => void
176
178
  /**
177
179
  * Called once, before the first event, only for a request whose model is an alias with fallbacks
178
180
  * (`src/fallback.ts`): which target answered and how many earlier targets failed first. A front-end
package/src/discovery.ts CHANGED
@@ -92,6 +92,14 @@ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
92
92
  ]
93
93
  : []),
94
94
  ...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
95
+ // The admin key only (option `adminKey`); without it these paths are a 404 (src/admin/route.ts).
96
+ ...(options.adminKey === undefined
97
+ ? []
98
+ : [
99
+ { method: "GET", path: "/v1/admin/keys", auth: true, description: "Admin: the config and runtime app keys, without key values." },
100
+ { method: "POST", path: "/v1/admin/keys", auth: true, description: "Admin: a new app key for `{ app }`, returned once." },
101
+ { method: "DELETE", path: "/v1/admin/keys/{keyId}", auth: true, description: "Admin: revoke a runtime app key." },
102
+ ]),
95
103
  ]
96
104
  }
97
105
 
package/src/fallback.ts CHANGED
@@ -51,6 +51,8 @@ export const FALLBACKS_HEADER = "x-plugboard-fallbacks"
51
51
  export interface ServedInfo {
52
52
  served?: string
53
53
  fallbacks?: number
54
+ /** The session page link of the request (option `historyUrl`, src/session/link.ts), for the log line. */
55
+ sessionUrl?: string
54
56
  }
55
57
 
56
58
  /**
@@ -90,9 +92,9 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
90
92
  let attempt = 0
91
93
  for (;;) {
92
94
  // The history id of this attempt: handed on only if the attempt is the one that answers (or the last).
93
- let recordID: string | undefined
95
+ let recorded: { id: string; directory?: string } | undefined
94
96
  const next = attempt === 0 ? request : retarget(deps.options, request, ref)
95
- const iterator = inner.stream(next, { ...context, onRecord: (id) => void (recordID = id) })[Symbol.asyncIterator]()
97
+ const iterator = inner.stream(next, { ...context, onRecord: (id, directory) => void (recorded = { id, directory }) })[Symbol.asyncIterator]()
96
98
 
97
99
  let first: IteratorResult<PlugEvent> | undefined
98
100
  let thrown: { readonly error: unknown } | undefined
@@ -124,7 +126,7 @@ async function* chain(inner: ModelCaller, deps: FallbackDeps, request: PlugReque
124
126
  }
125
127
 
126
128
  // Answered, ran out of targets, or the request ended: this attempt's outcome is the response.
127
- if (recordID !== undefined) report(context, recordID)
129
+ if (recorded !== undefined) report(context, recorded)
128
130
  if (thrown) throw thrown.error
129
131
  if (first!.done) return
130
132
  // An error is not an answer: a call that fails (non-retryable, or on its last target) reports no served model.
@@ -153,9 +155,9 @@ async function resolveRest(deps: FallbackDeps, request: PlugRequest, app: string
153
155
  }
154
156
  }
155
157
 
156
- const report = (context: CallContext, recordID: string): void => {
158
+ const report = (context: CallContext, recorded: { readonly id: string; readonly directory?: string }): void => {
157
159
  try {
158
- context.onRecord?.(recordID)
160
+ context.onRecord?.(recorded.id, recorded.directory)
159
161
  } catch {}
160
162
  }
161
163
 
@@ -22,6 +22,7 @@ import { listAvailableModels, resolveModel } from "../models.js"
22
22
  import { reportCache } from "../cache.js"
23
23
  import { reportPolicy } from "../policy.js"
24
24
  import { reportUsage } from "../usage-header.js"
25
+ import { setSessionLink } from "../session/link.js"
25
26
  import type { RouteHost } from "../openai/route.js"
26
27
  import { dropRejected } from "../openai/sampling.js"
27
28
  import { estimateTokens } from "../anthropic/tokens.js"
@@ -157,9 +158,10 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
157
158
  host.caller.stream(call, {
158
159
  app,
159
160
  signal: scope.signal,
160
- onRecord: (recordID) => {
161
+ onRecord: (recordID, directory) => {
161
162
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
162
163
  response.setHeader(SESSION_HEADER, recordID)
164
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
163
165
  },
164
166
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
165
167
  onPolicy: reportPolicy(response),