opencode-plugboard 0.19.0 → 0.21.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/src/config.ts CHANGED
@@ -3,9 +3,10 @@
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
+ import { SEED_PREFIX } from "./session/seeds.js"
9
10
 
10
11
  /** How `/v1/models` spells a model id: the canonical `provider/model#variant`, a UI-safe slug, or both. Resolution accepts either. */
11
12
  export type ModelIds = "canonical" | "slugs" | "both"
@@ -21,6 +22,11 @@ export interface PlugboardOptions {
21
22
  readonly models: ReadonlyArray<string>
22
23
  /** Alias -> `provider/model[#variant]`. Targets must also pass the allowlist. */
23
24
  readonly aliases: Readonly<Record<string, string>>
25
+ /**
26
+ * Agent name -> `provider/model[#variant]` (option `agents`): each is listed as `agent/<name>` and runs its
27
+ * OpenCode agent's system prompt on that model. The model must pass the allowlist.
28
+ */
29
+ readonly agents: Readonly<Record<string, string>>
24
30
  /**
25
31
  * Alias -> the targets after the first, in order, for an alias written as an array. `aliases` holds the
26
32
  * first target, so everything that resolves an alias keeps working; the model caller retries the rest
@@ -58,6 +64,8 @@ export interface PlugboardOptions {
58
64
  readonly usage: UsageConfig
59
65
  /** 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
66
  readonly adminKey?: string
67
+ /** A template for the OpenCode session page of each request: `{id}`, and optional `{directory}` (see src/session/link.ts, #77). */
68
+ readonly historyUrl?: string
61
69
  }
62
70
 
63
71
  /** Option `usage` after defaults: on, and days of history kept. */
@@ -81,6 +89,13 @@ export interface AppPolicy {
81
89
  readonly tools: boolean
82
90
  /** Lowers the global `maxTools` for this app. */
83
91
  readonly maxTools?: number
92
+ /**
93
+ * Exact OpenCode tool ids this app's sessions may run themselves (option `apps.<name>.serverTools`).
94
+ * Absent: none, the session denies every real tool. Trusted apps only, see docs/configuration.md.
95
+ */
96
+ readonly serverTools?: ReadonlyArray<string>
97
+ /** Server-tool steps one request may run before the turn is stopped (option `maxServerToolSteps`). */
98
+ readonly maxServerToolSteps?: number
84
99
  }
85
100
 
86
101
  /** One set of caps. A field that is not set is unlimited. */
@@ -99,8 +114,24 @@ export interface Limits {
99
114
  readonly maxConcurrent?: number
100
115
  readonly perApp?: LimitCaps
101
116
  readonly apps?: Readonly<Record<string, LimitCaps>>
117
+ /**
118
+ * Bytes of request bodies buffered at once, across every request in the process (`readJsonBody` in
119
+ * `src/http.ts`). A body that would go over it is refused with 503 `server_busy` before it is read. Not per app.
120
+ */
121
+ readonly maxBodyBytesInFlight?: number
122
+ /** Milliseconds a request body may take to arrive in full; a slower one is refused with 408. */
123
+ readonly bodyReadTimeoutMs?: number
124
+ }
125
+
126
+ /** The bounds `readJsonBody` applies before it buffers a request body (see `bodyLimits`). */
127
+ export interface BodyLimits {
128
+ readonly maxBytesInFlight: number
129
+ readonly readTimeoutMs: number
102
130
  }
103
131
 
132
+ /** Defaults of `limits.maxBodyBytesInFlight` (128 MiB) and `limits.bodyReadTimeoutMs` (30 s). */
133
+ export const BODY_LIMIT_DEFAULTS: BodyLimits = { maxBytesInFlight: 128 * 1024 * 1024, readTimeoutMs: 30_000 }
134
+
104
135
  /** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
105
136
  export type BudgetMetric = "requests" | "tokens" | "costUsd"
106
137
  /** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
@@ -131,6 +162,8 @@ export interface Budgets {
131
162
 
132
163
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
133
164
  export const DEFAULT_APP = "default"
165
+ /** Server-tool steps per request when `maxServerToolSteps` is not set. */
166
+ export const DEFAULT_SERVER_TOOL_STEPS = 8
134
167
  const DEFAULTS = {
135
168
  host: "127.0.0.1",
136
169
  port: 4010,
@@ -139,12 +172,12 @@ const DEFAULTS = {
139
172
  requestTimeoutMs: 600_000,
140
173
  } as const
141
174
 
142
- const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "budgets", "cache", "usage"])
175
+ 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
176
  const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
144
177
  const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
145
178
  /** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
146
179
  const MAX_RETENTION_DAYS = 3650
147
- const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps"])
180
+ const LIMITS_KNOWN = new Set(["maxConcurrent", "perApp", "apps", "maxBodyBytesInFlight", "bodyReadTimeoutMs"])
148
181
  const LIMIT_CAP_KNOWN = new Set(["maxConcurrent", "requestsPerMinute"])
149
182
  /** Largest cap accepted: far above any provider's limit, and keeps the error messages short. */
150
183
  const MAX_LIMIT = 1_000_000
@@ -217,7 +250,26 @@ function mergedKeys(keys: unknown, apps: unknown): Array<[string, unknown]> {
217
250
  return [...merged]
218
251
  }
219
252
 
220
- const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools"])
253
+ const APP_KNOWN = new Set(["key", "models", "system", "systemMode", "tools", "maxTools", "serverTools", "maxServerToolSteps"])
254
+ const SERVER_TOOL_PATTERN = /^[A-Za-z0-9_.:-]+$/
255
+ const MAX_SERVER_TOOLS = 32
256
+ const MAX_SERVER_TOOL_STEPS = 100
257
+
258
+ /** `apps.<name>.serverTools`: exact OpenCode tool ids. No wildcards, no duplicates, and not the seeds or CodeMode's `execute`. */
259
+ function parseServerTools(value: unknown, at: string): string[] {
260
+ if (!Array.isArray(value) || value.length === 0 || value.length > MAX_SERVER_TOOLS)
261
+ throw new Error(`${at}.serverTools must be an array of 1 to ${MAX_SERVER_TOOLS} OpenCode tool ids`)
262
+ const tools: string[] = []
263
+ for (const tool of value) {
264
+ if (typeof tool !== "string" || !SERVER_TOOL_PATTERN.test(tool))
265
+ throw new Error(`${at}.serverTools entry \`${String(tool)}\` must be an exact OpenCode tool id (letters, digits, \`_\`, \`.\`, \`:\`, \`-\`; no wildcards)`)
266
+ if (tool === "execute") throw new Error(`${at}.serverTools cannot name \`execute\`: it is OpenCode's CodeMode tool and would run every tool it wraps`)
267
+ if (tool.startsWith(SEED_PREFIX)) throw new Error(`${at}.serverTools cannot name \`${tool}\`: plugboard's own seed tools are reserved`)
268
+ if (tools.includes(tool)) throw new Error(`${at}.serverTools lists \`${tool}\` twice`)
269
+ tools.push(tool)
270
+ }
271
+ return tools
272
+ }
221
273
  const SYSTEM_MODES: ReadonlySet<string> = new Set(["prepend", "append", "replace"])
222
274
  const MODEL_IDS: ReadonlySet<string> = new Set(["canonical", "slugs", "both"])
223
275
 
@@ -252,6 +304,12 @@ function parseApps(
252
304
  if (value.systemMode !== undefined && value.system === undefined) throw new Error(`${at}.systemMode needs \`system\``)
253
305
  if (value.tools !== undefined && typeof value.tools !== "boolean") throw new Error(`${at}.tools must be a boolean`)
254
306
  const maxTools = value.maxTools === undefined ? undefined : positiveInt(value.maxTools, `apps.${app}.maxTools`, 0, context.maxTools)
307
+ if (value.serverTools !== undefined && app === DEFAULT_APP)
308
+ throw new Error(`${at}.serverTools needs a key: the keyless app cannot run OpenCode tools; name the app and give it a key`)
309
+ const serverTools = value.serverTools === undefined ? undefined : parseServerTools(value.serverTools, at)
310
+ if (value.maxServerToolSteps !== undefined && serverTools === undefined) throw new Error(`${at}.maxServerToolSteps needs \`serverTools\``)
311
+ const maxServerToolSteps =
312
+ value.maxServerToolSteps === undefined ? undefined : positiveInt(value.maxServerToolSteps, `apps.${app}.maxServerToolSteps`, DEFAULT_SERVER_TOOL_STEPS, MAX_SERVER_TOOL_STEPS)
255
313
 
256
314
  const policy: AppPolicy = {
257
315
  ...(value.models === undefined ? {} : { models: value.models as string[] }),
@@ -259,9 +317,12 @@ function parseApps(
259
317
  systemMode: (value.systemMode as SystemMode | undefined) ?? "prepend",
260
318
  tools: value.tools !== false,
261
319
  ...(maxTools === undefined ? {} : { maxTools }),
320
+ ...(serverTools === undefined ? {} : { serverTools }),
321
+ ...(maxServerToolSteps === undefined ? {} : { maxServerToolSteps }),
262
322
  }
263
323
  // An app that only carries a key has no policy to look up.
264
- if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined) entries.push([app, policy])
324
+ if (value.models !== undefined || value.system !== undefined || value.tools !== undefined || maxTools !== undefined || serverTools !== undefined)
325
+ entries.push([app, policy])
265
326
  }
266
327
  return Object.fromEntries(entries)
267
328
  }
@@ -296,6 +357,18 @@ function parseLimits(raw: unknown, keys: Readonly<Record<string, string>>): Limi
296
357
  ...(raw.maxConcurrent === undefined ? {} : { maxConcurrent: positiveInt(raw.maxConcurrent, "limits.maxConcurrent", 0, MAX_LIMIT) }),
297
358
  ...(raw.perApp === undefined ? {} : { perApp: parseCaps(raw.perApp, "limits.perApp") }),
298
359
  ...(appsRaw === undefined ? {} : { apps }),
360
+ ...(raw.maxBodyBytesInFlight === undefined
361
+ ? {}
362
+ : { maxBodyBytesInFlight: positiveInt(raw.maxBodyBytesInFlight, "limits.maxBodyBytesInFlight", 0, Number.MAX_SAFE_INTEGER) }),
363
+ ...(raw.bodyReadTimeoutMs === undefined ? {} : { bodyReadTimeoutMs: positiveInt(raw.bodyReadTimeoutMs, "limits.bodyReadTimeoutMs", 0, MAX_TIMER_MS) }),
364
+ }
365
+ }
366
+
367
+ /** The body limits in force for these options (`limits.maxBodyBytesInFlight`, `limits.bodyReadTimeoutMs`), or the defaults. */
368
+ export function bodyLimits(options: Pick<PlugboardOptions, "limits">): BodyLimits {
369
+ return {
370
+ maxBytesInFlight: options.limits?.maxBodyBytesInFlight ?? BODY_LIMIT_DEFAULTS.maxBytesInFlight,
371
+ readTimeoutMs: options.limits?.bodyReadTimeoutMs ?? BODY_LIMIT_DEFAULTS.readTimeoutMs,
299
372
  }
300
373
  }
301
374
 
@@ -396,6 +469,55 @@ function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Bud
396
469
  return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
397
470
  }
398
471
 
472
+ const AGENT_KNOWN = new Set(["name", "model"])
473
+ /** An OpenCode agent name: letters, digits, `_` and `-`, starting with a letter or digit. */
474
+ const AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
475
+
476
+ /**
477
+ * Option `agents`: `{ name, model }` per OpenCode agent to expose. `model` is required and must pass the
478
+ * allowlist, so an agent's own configured model never runs outside `models` (the agent's model setting is
479
+ * not read). Names must be unique.
480
+ */
481
+ function parseAgents(raw: unknown, models: ReadonlyArray<string>): Record<string, string> {
482
+ if (raw === undefined) return {}
483
+ if (!Array.isArray(raw)) throw new Error("plugboard option `agents` must be an array of { name, model } objects")
484
+ const entries: Array<[string, string]> = []
485
+ for (const item of raw) {
486
+ if (!isRecord(item)) throw new Error("plugboard option `agents` entries must be objects of the form { name, model }")
487
+ for (const key of Object.keys(item))
488
+ if (!AGENT_KNOWN.has(key)) throw new Error(`Unknown plugboard option \`agents.${key}\` (known: ${[...AGENT_KNOWN].join(", ")})`)
489
+ const { name, model } = item
490
+ if (typeof name !== "string" || !AGENT_NAME.test(name)) throw new Error(`plugboard agent name \`${String(name)}\` must match ${AGENT_NAME} (an OpenCode agent name)`)
491
+ if (entries.some(([known]) => known === name)) throw new Error(`plugboard agent \`${name}\` is listed more than once in \`agents\``)
492
+ if (typeof model !== "string" || !ALIAS_TARGET.test(model))
493
+ throw new Error(`plugboard agent \`${name}\` must set \`model\` to \`provider/model\` or \`provider/model#variant\``)
494
+ if (!allowedBy(models as string[], parseModelRef(model)!))
495
+ throw new Error(`plugboard agent \`${name}\` model \`${model}\` is not allowed by the \`models\` option`)
496
+ entries.push([name, model])
497
+ }
498
+ return Object.fromEntries(entries)
499
+ }
500
+
501
+ /**
502
+ * Option `historyUrl` (#77): an http(s) URL template that must contain `{id}`. Its only other placeholder is
503
+ * `{directory}`, so a typo such as `{sesion}` fails at load instead of producing a link that is wrong.
504
+ */
505
+ function parseHistoryUrl(value: unknown): string | undefined {
506
+ if (value === undefined) return undefined
507
+ const at = "plugboard option `historyUrl`"
508
+ 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})`)
509
+ const unknown = (value.match(/\{[^{}]*\}/g) ?? []).find((placeholder) => placeholder !== "{id}" && placeholder !== "{directory}")
510
+ if (unknown !== undefined) throw new Error(`${at} has the unknown placeholder ${unknown} (known: {id}, {directory})`)
511
+ let url: URL
512
+ try {
513
+ url = new URL(value.replaceAll("{id}", "x").replaceAll("{directory}", "x"))
514
+ } catch {
515
+ throw new Error(`${at} must be an http(s) URL template`)
516
+ }
517
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${at} must be http or https`)
518
+ return value
519
+ }
520
+
399
521
  /** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
400
522
  export function parseOptions(raw: unknown): PlugboardOptions {
401
523
  if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
@@ -452,6 +574,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
452
574
  const chain = Array.isArray(target) ? target : [target]
453
575
  if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
454
576
  for (const entry of chain) {
577
+ // A skill target binds one model and takes no fallbacks; like a list entry it must pass the allowlist at load.
578
+ if (typeof entry === "string" && entry.startsWith(SKILL_PREFIX)) {
579
+ const parsed = parseAliasTarget(entry)
580
+ 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\``)
581
+ if (Array.isArray(target)) throw new Error(`plugboard alias \`${alias}\` binds a skill, so it takes one target and no list of fallbacks`)
582
+ if (!allowedBy(models as string[], parsed.ref)) throw new Error(`plugboard alias \`${alias}\` target \`${entry}\` is not allowed by the \`models\` option`)
583
+ continue
584
+ }
455
585
  if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
456
586
  throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
457
587
  // A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
@@ -465,6 +595,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
465
595
  }
466
596
  const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
467
597
  const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
598
+ const agents = parseAgents(input.agents, models as string[])
468
599
 
469
600
  const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
470
601
  const apps = parseApps(input.apps, { keys, aliases, maxTools })
@@ -493,6 +624,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
493
624
  if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
494
625
  if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
495
626
  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`)
627
+ const historyUrl = parseHistoryUrl(input.historyUrl)
496
628
 
497
629
  return {
498
630
  listen: { host, port: port ?? DEFAULTS.port },
@@ -500,6 +632,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
500
632
  apps,
501
633
  models: models as string[],
502
634
  aliases,
635
+ agents,
503
636
  fallbacks,
504
637
  maxTools,
505
638
  maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
@@ -515,6 +648,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
515
648
  usage,
516
649
  ...(budgets === undefined ? {} : { budgets }),
517
650
  ...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
651
+ ...(historyUrl === undefined ? {} : { historyUrl }),
518
652
  }
519
653
  }
520
654
 
@@ -543,24 +677,32 @@ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readon
543
677
  /** Who may read `GET /v1/usage`: the admin key (every app), or one app (`default` when keyless). */
544
678
  export type UsageScope = { readonly admin: true } | { readonly admin: false; readonly app: string }
545
679
 
680
+ /** The digest of a runtime key (src/keys.ts), with its app: checked like a config key (#55). */
681
+ export type RuntimeDigest = readonly [app: string, digest: Buffer]
682
+
546
683
  /** 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 {
684
+ export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): UsageScope | PlugError {
548
685
  const bearer = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
549
686
  if (options.adminKey !== undefined && bearer !== undefined && timingSafeEqual(sha256(bearer), sha256(options.adminKey))) return { admin: true }
550
- const auth = authenticate(options, authorization)
687
+ const auth = authenticate(options, authorization, runtime)
551
688
  if (isPlugError(auth)) return auth
552
689
  return { admin: false, app: auth.app }
553
690
  }
554
691
 
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 {
692
+ /**
693
+ * No config keys -> app `default` (keyless), as before. With config keys every request needs
694
+ * `Authorization: Bearer <key>`. A runtime key (#55) matches like a config key, so it names its app
695
+ * whenever it is presented. It never makes a keyless server require a key, so deleting the last
696
+ * runtime key cannot open or close access by itself.
697
+ */
698
+ export function authenticate(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): { app: string } | PlugError {
557
699
  const entries = digestsOf(options.keys)
558
- if (entries.length === 0) return { app: DEFAULT_APP }
559
700
  const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
560
- if (!token) return UNAUTHORIZED
701
+ if (token === undefined) return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
561
702
  const digest = sha256(token)
562
703
  let app: string | undefined
563
704
  // 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 }
705
+ for (const [name, expected] of [...entries, ...runtime]) if (timingSafeEqual(digest, expected)) app = name
706
+ if (app !== undefined) return { app }
707
+ return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
566
708
  }
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
@@ -192,6 +194,12 @@ export interface CallContext {
192
194
  * headers are open, so a stream (headers sent first) never gets it.
193
195
  */
194
196
  readonly onUsage?: (totals: CallTotals) => void
197
+ /**
198
+ * Called once when the call ends, only if OpenCode ran server tools for it (option `apps.<name>.serverTools`),
199
+ * with how many. Front-ends set `x-plugboard-server-tools` from it while the headers are open, so a stream
200
+ * (headers sent first) never gets it, and a choice of `n` adds to the count of the others.
201
+ */
202
+ readonly onServerTools?: (count: number) => void
195
203
  }
196
204
 
197
205
  /** One answered call's totals for the caller: `inputTokens` and `outputTokens` as the response body reports them (cache reads inside input, reasoning inside output), and `costUsd` when the model has a price. */
package/src/discovery.ts CHANGED
@@ -67,6 +67,7 @@ export const ENDPOINTS: ReadonlyArray<Endpoint> = [
67
67
  { method: "GET", path: "/", auth: false, description: "This page." },
68
68
  { method: "GET", path: "/v1", auth: false, description: "This page as JSON." },
69
69
  { method: "GET", path: "/healthz", auth: false, description: "Liveness: {\"ok\":true}." },
70
+ { method: "GET", path: "/readyz", auth: false, description: "Readiness: {\"ready\":true}, or 503 {\"ready\":false,\"reason\"} until the backend can serve." },
70
71
  { method: "GET", path: "/v1/models", auth: true, description: "Models this app may call (OpenAI shape)." },
71
72
  { method: "GET", path: "/v1/models/{id}", auth: true, description: "One model." },
72
73
  { method: "POST", path: "/v1/chat/completions", auth: true, description: "OpenAI Chat Completions." },
@@ -92,6 +93,14 @@ function endpoints(options: PlugboardOptions): ReadonlyArray<Endpoint> {
92
93
  ]
93
94
  : []),
94
95
  ...(options.metrics ? [{ method: "GET", path: "/metrics", auth: true, description: "Prometheus metrics." }] : []),
96
+ // The admin key only (option `adminKey`); without it these paths are a 404 (src/admin/route.ts).
97
+ ...(options.adminKey === undefined
98
+ ? []
99
+ : [
100
+ { method: "GET", path: "/v1/admin/keys", auth: true, description: "Admin: the config and runtime app keys, without key values." },
101
+ { method: "POST", path: "/v1/admin/keys", auth: true, description: "Admin: a new app key for `{ app }`, returned once." },
102
+ { method: "DELETE", path: "/v1/admin/keys/{keyId}", auth: true, description: "Admin: revoke a runtime app key." },
103
+ ]),
95
104
  ]
96
105
  }
97
106
 
@@ -276,6 +285,29 @@ ${sections.join("\n")}
276
285
  `
277
286
  }
278
287
 
288
+ /** The facts `/readyz` decides from (`RuntimeStatus` in src/runtime.ts). */
289
+ export interface ReadinessFacts {
290
+ readonly generations: number
291
+ readonly servingConfirmed: boolean
292
+ readonly pumpRunning: boolean
293
+ readonly catalogLoaded: boolean
294
+ readonly configError?: { readonly message: string }
295
+ }
296
+
297
+ /**
298
+ * `GET /readyz` (#160): whether the backend can serve a request. The reason names the first piece
299
+ * that is missing, in the order a person fixes them. A recorded options error comes first and is
300
+ * its own message (#159); nothing else from the config is shown.
301
+ */
302
+ export function readiness(facts: ReadinessFacts): { ready: true } | { ready: false; reason: string } {
303
+ if (facts.configError) return { ready: false, reason: facts.configError.message }
304
+ if (facts.generations === 0) return { ready: false, reason: "no plugboard generation is live; the server is reloading or the plugin is not loaded" }
305
+ if (!facts.servingConfirmed) return { ready: false, reason: "no app directory has been used since start; one model request to an app confirms it" }
306
+ if (!facts.pumpRunning) return { ready: false, reason: "the event pump is not running; the next request to an app starts it" }
307
+ if (!facts.catalogLoaded) return { ready: false, reason: "the model catalog has not loaded yet" }
308
+ return { ready: true }
309
+ }
310
+
279
311
  /** `GET /v1/status`: authenticated runtime facts. `models` is absent when the catalog could not be loaded. */
280
312
  export function buildStatus(input: {
281
313
  readonly options: PlugboardOptions
@@ -284,6 +316,7 @@ export function buildStatus(input: {
284
316
  readonly runtimeVersion: number
285
317
  readonly listener?: { readonly host: string; readonly port: number; readonly bound: boolean }
286
318
  readonly servingConfirmed: boolean
319
+ readonly configError?: { readonly message: string; readonly at: number }
287
320
  }
288
321
  readonly catalog?: ReadonlyArray<CatalogModel>
289
322
  readonly now?: number
@@ -303,5 +336,7 @@ export function buildStatus(input: {
303
336
  sessionGuards: runtime.guards,
304
337
  models: catalog ? { catalog: catalog.length, available: listAvailableModels(options, catalog, app).length } : { error: "catalog_unavailable" },
305
338
  metrics: options.metrics,
339
+ // The plugin options error the last Location to load the plugin hit (#159); absent when options parse.
340
+ ...(runtime.configError ? { configError: { message: runtime.configError.message, at: new Date(runtime.configError.at).toISOString() } } : {}),
306
341
  }
307
342
  }
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
 
@@ -351,7 +351,7 @@ const parseGeneration = (config: JsonObject): PlugGeneration | PlugError => {
351
351
  }
352
352
 
353
353
  /** `responseMimeType` / `responseSchema` / `responseJsonSchema`: same outcomes as `response_format` in the other front-ends. */
354
- const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; wrap?: boolean; instruction?: string } | PlugError => {
354
+ const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; schema?: JsonObject; wrap?: boolean; instruction?: string } | PlugError => {
355
355
  const mime = field(config, "responseMimeType")
356
356
  const schema = field(config, "responseJsonSchema") ?? field(config, "responseSchema")
357
357
  if (mime != null && typeof mime !== "string") return bad("responseMimeType must be a string", "generationConfig.responseMimeType")
@@ -364,6 +364,7 @@ const parseResponseFormat = (config: JsonObject): { structured?: PlugTool; wrap?
364
364
  const wrap = converted.type !== undefined && converted.type !== "object"
365
365
  return {
366
366
  wrap,
367
+ schema: converted,
367
368
  structured: {
368
369
  name: RESPOND_TOOL,
369
370
  description: "Give your final answer by calling this tool with JSON matching its schema.",
@@ -437,7 +438,7 @@ export function parseGeminiRequest(model: string, stream: boolean, body: unknown
437
438
  exchange: {
438
439
  model,
439
440
  stream,
440
- ...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
441
+ ...(respond ? { structuredTool: RESPOND_TOOL, ...(format.schema ? { structuredSchema: format.schema } : {}), ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
441
442
  ...(format.instruction ? { jsonObject: true } : {}),
442
443
  ...(generation.stop ? { stop: generation.stop } : {}),
443
444
  },
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
10
  import type { ListedModel } from "../models.js"
11
- import { unfence } from "../openai/response.js"
11
+ import { structuredMissing, textFallback, unfence } from "../openai/response.js"
12
12
  import { StopHoldback } from "../stop.js"
13
13
  import { WRAP_KEY } from "./request.js"
14
14
 
@@ -33,6 +33,8 @@ export interface GeminiExchange {
33
33
  readonly jsonObject?: boolean
34
34
  /** The request's `generationConfig.stopSequences`. A plain stop whose text ends with one of these leaves the sequence out (src/stop.ts). Not applied to structured output. */
35
35
  readonly stop?: ReadonlyArray<string>
36
+ /** The caller's schema as sent (converted): a text answer is taken when the model skipped the call (`textFallback`, #173). */
37
+ readonly structuredSchema?: Readonly<Record<string, unknown>>
36
38
  }
37
39
 
38
40
  /** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
@@ -42,6 +44,8 @@ export interface GenerateRun {
42
44
  readonly started: boolean
43
45
  /** The aggregated response (non-streaming body). Absent on failure. */
44
46
  readonly response?: object
47
+ /** The structured answer came from the final text, not the respond call (`textFallback`). */
48
+ readonly structuredFallback?: boolean
45
49
  }
46
50
 
47
51
  type Part = Record<string, unknown>
@@ -121,20 +125,6 @@ const structuredText = (exchange: GeminiExchange, call: ToolCall): string => {
121
125
  return typeof input === "string" && !exchange.wrapped ? input : JSON.stringify(input ?? {})
122
126
  }
123
127
 
124
- /**
125
- * Structured-output projection, as in the other front-ends: the text is exactly the first
126
- * `respond` call; prose and other calls are suppressed. Without an answer the exchange fails,
127
- * except when the model stopped for `length` or `content-filter`.
128
- */
129
- const structuredFailure = (exchange: GeminiExchange, finish: Exclude<PlugFinish, "error">, answered: boolean): PlugError | undefined =>
130
- exchange.structuredTool === undefined || answered || finish === "length" || finish === "content-filter"
131
- ? undefined
132
- : {
133
- status: 502,
134
- kind: "server",
135
- code: "structured_output_missing",
136
- message: `The model finished without calling the \`${exchange.structuredTool}\` tool, so there is no structured output`,
137
- }
138
128
 
139
129
  export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: GeminiExchange, emit: (chunk: object) => void): Promise<GenerateRun> {
140
130
  const responseId = crypto.randomUUID().replaceAll("-", "").slice(0, 22)
@@ -145,11 +135,15 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
145
135
  responseId,
146
136
  })
147
137
 
138
+ // Only a non-streamed body needs the aggregated parts: a stream is already on the wire, so nothing grows (#153).
139
+ const aggregate = !exchange.stream
148
140
  /** The aggregated parts: adjacent text merges into one part. */
149
141
  const parts: Part[] = []
150
142
  let started = false
151
143
  let calls = 0
152
144
  let answered = false
145
+ /** The text of a structured turn (not emitted): it is the answer only if the respond call never comes (#173). */
146
+ let collected = ""
153
147
  /** With caller tools offered the structured answer waits for the finish: a caller tool call in the same step replaces it. */
154
148
  let held: ToolCall | undefined
155
149
  let finish: Exclude<PlugFinish, "error"> | undefined
@@ -158,9 +152,11 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
158
152
 
159
153
  const push = (part: Part) => {
160
154
  started = true
161
- const last = parts.at(-1)
162
- if (typeof part.text === "string" && last && typeof last.text === "string") last.text += part.text
163
- else parts.push(part)
155
+ if (aggregate) {
156
+ const last = parts.at(-1)
157
+ if (typeof part.text === "string" && last && typeof last.text === "string") last.text += part.text
158
+ else parts.push(part)
159
+ }
164
160
  emit(envelope([part]))
165
161
  }
166
162
  // A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
@@ -212,6 +208,7 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
212
208
  }
213
209
  continue
214
210
  }
211
+ if (event.type === "text-delta") collected += event.text
215
212
  if (!(exchange.callerTools && event.type === "tool-call")) continue
216
213
  }
217
214
  if (exchange.jsonObject && event.type === "text-delta" && !passing) {
@@ -229,7 +226,9 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
229
226
  }
230
227
 
231
228
  const passthrough = exchange.callerTools === true && calls > 0
232
- failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
229
+ const fallback = exchange.structuredTool !== undefined && !passthrough && !answered && exchange.structuredSchema !== undefined ? textFallback(collected, exchange.structuredSchema) : undefined
230
+ const missing = passthrough || answered || fallback !== undefined || exchange.structuredTool === undefined ? undefined : structuredMissing(exchange.structuredTool)
231
+ failure ??= finish ? missing : noFinish()
233
232
  if (failure) return { failure, started }
234
233
  flush()
235
234
  if (holdback) {
@@ -237,12 +236,13 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
237
236
  if (rest.length > 0) push({ text: rest })
238
237
  }
239
238
  if (held && !passthrough) text(structuredText(exchange, held))
239
+ if (fallback !== undefined) text(fallback)
240
240
  const candidate = { finishReason: FINISH_REASONS[finish!] }
241
241
  const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
242
242
  // The last chunk carries the finish reason and usage, with an empty text part as Google's does.
243
243
  emit(envelope([{ text: "" }], extra, candidate))
244
244
  started = true
245
- return { started, response: envelope(parts.length > 0 ? parts : [{ text: "" }], extra, candidate) }
245
+ return { started, response: envelope(parts.length > 0 ? parts : [{ text: "" }], extra, candidate), ...(fallback !== undefined ? { structuredFallback: true } : {}) }
246
246
  }
247
247
 
248
248
  /** SSE frame of one stream chunk (`alt=sse`): `data: <json>`, no event name and no `[DONE]`. */
@@ -17,12 +17,15 @@
17
17
  import type { IncomingMessage, ServerResponse } from "node:http"
18
18
  import { isPlugError, type PlugError, type PlugRequest } from "../contract.js"
19
19
  import { reportServed, type ServedInfo } from "../fallback.js"
20
+ import { bodyLimits } from "../config.js"
20
21
  import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
21
22
  import { listAvailableModels, resolveModel } from "../models.js"
22
23
  import { reportCache } from "../cache.js"
23
- import { reportPolicy } from "../policy.js"
24
+ import { reportPolicy, reportServerTools } from "../policy.js"
24
25
  import { reportUsage } from "../usage-header.js"
26
+ import { setSessionLink } from "../session/link.js"
25
27
  import type { RouteHost } from "../openai/route.js"
28
+ import { STRUCTURED_FALLBACK_HEADER } from "../openai/response.js"
26
29
  import { dropRejected } from "../openai/sampling.js"
27
30
  import { estimateTokens } from "../anthropic/tokens.js"
28
31
  import { parseGeminiRequest } from "./request.js"
@@ -111,7 +114,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
111
114
  if (match.op === "count") {
112
115
  const unsupported = checkJsonContentType(request)
113
116
  if (unsupported) return fail(unsupported)
114
- const parsed = parseGeminiRequest(match.model, false, countBody(await readJsonBody(request, scope.signal)), host.options)
117
+ const parsed = parseGeminiRequest(match.model, false, countBody(await readJsonBody(request, scope.signal, bodyLimits(host.options))), host.options)
115
118
  if (isPlugError(parsed)) return fail(parsed)
116
119
  const catalog = await scope.race(host.loadCatalog())
117
120
  const ref = resolveModel(host.options, match.model, catalog, parsed.reasoningEffort)
@@ -134,7 +137,7 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
134
137
  return fail({ status: 400, kind: "invalid_request", code: "unsupported_parameter", param: "alt", message: "streamGenerateContent needs the query parameter alt=sse" })
135
138
  const unsupported = checkJsonContentType(request)
136
139
  if (unsupported) return fail(unsupported)
137
- const body = await readJsonBody(request, scope.signal)
140
+ const body = await readJsonBody(request, scope.signal, bodyLimits(host.options))
138
141
 
139
142
  const parsed = parseGeminiRequest(match.model, match.stream, body, host.options)
140
143
  if (isPlugError(parsed)) return fail(parsed)
@@ -157,13 +160,15 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
157
160
  host.caller.stream(call, {
158
161
  app,
159
162
  signal: scope.signal,
160
- onRecord: (recordID) => {
163
+ onRecord: (recordID, directory) => {
161
164
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
162
165
  response.setHeader(SESSION_HEADER, recordID)
166
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
163
167
  },
164
168
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
165
169
  onPolicy: reportPolicy(response),
166
170
  onUsage: reportUsage(response, match.stream),
171
+ onServerTools: reportServerTools(response, match.stream),
167
172
  onCache: reportCache(response),
168
173
  }),
169
174
  scope,
@@ -187,5 +192,6 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
187
192
 
188
193
  const run = await runGenerate(events, exchange, () => {})
189
194
  if (run.failure || !run.response) return fail(run.failure ?? { status: 502, kind: "server", message: "No response from the model" })
195
+ if (run.structuredFallback) response.setHeader(STRUCTURED_FALLBACK_HEADER, "text")
190
196
  sendJson(response, 200, run.response)
191
197
  }