opencode-plugboard 0.18.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/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
@@ -50,12 +55,16 @@ export interface PlugboardOptions {
50
55
  readonly modelIds: ModelIds
51
56
  /** Concurrency caps and requests per minute per app (see `src/limits.ts`). Absent: unlimited. */
52
57
  readonly limits?: Limits
58
+ /** Per-app budgets (see `src/budgets.ts`): ceilings on requests, tokens and cost per UTC day, week or month. Needs `usage`. Absent: no budgets. */
59
+ readonly budgets?: Budgets
53
60
  /** Opt-in exact-match response cache (see `src/cache.ts`). Absent: off. */
54
61
  readonly cache?: CacheConfig
55
62
  /** Per-app usage statistics (see `src/usage.ts`): recorded and served by `GET /v1/usage` and the RPC. */
56
63
  readonly usage: UsageConfig
57
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. */
58
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
59
68
  }
60
69
 
61
70
  /** Option `usage` after defaults: on, and days of history kept. */
@@ -99,6 +108,34 @@ export interface Limits {
99
108
  readonly apps?: Readonly<Record<string, LimitCaps>>
100
109
  }
101
110
 
111
+ /** What a budget counts: HTTP requests on the model routes (refusals by `limits` or budgets left out), tokens, or cost in USD. */
112
+ export type BudgetMetric = "requests" | "tokens" | "costUsd"
113
+ /** The windows a budget resets on: a UTC calendar day, an ISO week (Monday start) or a calendar month (UTC). */
114
+ export type BudgetWindow = "day" | "week" | "month"
115
+
116
+ /** Ceilings per window. A window that is not set has no ceiling. */
117
+ export interface BudgetWindows {
118
+ readonly day?: number
119
+ readonly week?: number
120
+ readonly month?: number
121
+ }
122
+
123
+ /** One set of ceilings. A metric that is not set is unlimited. `costUsd` is an estimate at list prices. */
124
+ export interface Budget {
125
+ readonly requests?: BudgetWindows
126
+ readonly tokens?: BudgetWindows
127
+ readonly costUsd?: BudgetWindows
128
+ }
129
+
130
+ /**
131
+ * Option `budgets`. `perApp` applies to every app; `apps.<name>` replaces `perApp` metric by metric for
132
+ * that app (its `requests` replaces `perApp.requests`, the other metrics still come from `perApp`).
133
+ */
134
+ export interface Budgets {
135
+ readonly perApp?: Budget
136
+ readonly apps?: Readonly<Record<string, Budget>>
137
+ }
138
+
102
139
  export const APP_NAME_PATTERN = /^[a-z0-9-]+$/
103
140
  export const DEFAULT_APP = "default"
104
141
  const DEFAULTS = {
@@ -109,7 +146,7 @@ const DEFAULTS = {
109
146
  requestTimeoutMs: 600_000,
110
147
  } as const
111
148
 
112
- const KNOWN = new Set(["listen", "keys", "adminKey", "apps", "models", "aliases", "maxTools", "maxN", "requestTimeoutMs", "remoteImages", "history", "dropUnsupportedParams", "logFormat", "metrics", "modelIds", "limits", "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"])
113
150
  const USAGE_KNOWN = new Set(["enabled", "retentionDays"])
114
151
  const USAGE_DEFAULTS = { enabled: true, retentionDays: 90 } as const
115
152
  /** Ten years: keeps a day-keyed store bounded and a range query well inside its limits. */
@@ -304,6 +341,117 @@ function parseCache(value: unknown, keys: Readonly<Record<string, string>>): Cac
304
341
  return { ttlSeconds, maxEntries, maxBytes, apps: [...new Set(value.apps as string[])] }
305
342
  }
306
343
 
344
+ const BUDGET_METRICS: ReadonlyArray<BudgetMetric> = ["requests", "tokens", "costUsd"]
345
+ const BUDGET_WINDOWS: ReadonlyArray<BudgetWindow> = ["day", "week", "month"]
346
+ /** The most days a window can span: a month is up to 31. Retention must keep at least that many, or a budget would count from pruned records. */
347
+ const WINDOW_DAYS: Readonly<Record<BudgetWindow, number>> = { day: 1, week: 7, month: 31 }
348
+
349
+ /** The longest window any ceiling of `budgets` uses, in days (0 without budgets). */
350
+ function widestWindowDays(budgets: Budgets | undefined): number {
351
+ let most = 0
352
+ for (const budget of [budgets?.perApp, ...Object.values(budgets?.apps ?? {})])
353
+ for (const metric of [budget?.requests, budget?.tokens, budget?.costUsd])
354
+ for (const window of Object.keys(metric ?? {}) as BudgetWindow[]) most = Math.max(most, WINDOW_DAYS[window])
355
+ return most
356
+ }
357
+
358
+ /**
359
+ * Validates `budgets`: `{ perApp?, apps? }`, each a budget of `requests`, `tokens` and `costUsd`, each of
360
+ * those a map of window (`day`, `week`, `month`) to a positive limit. Counts are integers, costs any positive
361
+ * number of USD. An `apps` entry must name a configured app, as in `limits`.
362
+ */
363
+ function parseBudgets(raw: unknown, keys: Readonly<Record<string, string>>): Budgets | undefined {
364
+ if (raw === undefined) return undefined
365
+ if (!isRecord(raw)) throw new Error("plugboard option `budgets` must be an object")
366
+ for (const key of Object.keys(raw))
367
+ if (key !== "perApp" && key !== "apps") throw new Error(`Unknown plugboard option \`budgets.${key}\` (known: perApp, apps)`)
368
+
369
+ const budget = (value: unknown, at: string): Budget => {
370
+ if (!isRecord(value)) throw new Error(`plugboard option \`${at}\` must be an object`)
371
+ for (const key of Object.keys(value))
372
+ if (!BUDGET_METRICS.includes(key as BudgetMetric)) throw new Error(`Unknown plugboard option \`${at}.${key}\` (known: ${BUDGET_METRICS.join(", ")})`)
373
+ const parsed: Record<string, Record<string, number>> = {}
374
+ for (const metric of BUDGET_METRICS) {
375
+ const windows = value[metric]
376
+ if (windows === undefined) continue
377
+ const field = `${at}.${metric}`
378
+ if (!isRecord(windows)) throw new Error(`plugboard option \`${field}\` must be an object of window (${BUDGET_WINDOWS.join(", ")}) to limit`)
379
+ const limits: Record<string, number> = {}
380
+ for (const window of Object.keys(windows)) {
381
+ if (!BUDGET_WINDOWS.includes(window as BudgetWindow)) throw new Error(`Unknown plugboard option \`${field}.${window}\` (known: ${BUDGET_WINDOWS.join(", ")})`)
382
+ const limit = windows[window]
383
+ if (limit === undefined) continue
384
+ if (metric === "costUsd") {
385
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) throw new Error(`plugboard option \`${field}.${window}\` must be a positive number of USD`)
386
+ limits[window] = limit
387
+ } else limits[window] = positiveInt(limit, `${field}.${window}`, 0, Number.MAX_SAFE_INTEGER)
388
+ }
389
+ parsed[metric] = limits
390
+ }
391
+ return parsed as Budget
392
+ }
393
+
394
+ const perApp = raw.perApp === undefined ? undefined : budget(raw.perApp, "budgets.perApp")
395
+ if (raw.apps !== undefined && !isRecord(raw.apps)) throw new Error("plugboard option `budgets.apps` must be an object of app name -> budget")
396
+ const apps: Record<string, Budget> = {}
397
+ for (const [app, value] of Object.entries(isRecord(raw.apps) ? raw.apps : {})) {
398
+ const at = `budgets.apps.${app}`
399
+ const configured = app === DEFAULT_APP ? Object.keys(keys).length === 0 : Object.hasOwn(keys, app)
400
+ if (!configured) throw new Error(`plugboard option \`${at}\` must name a configured app (a key in \`keys\`; \`${DEFAULT_APP}\` only without keys)`)
401
+ apps[app] = budget(value, at)
402
+ }
403
+ return { ...(perApp === undefined ? {} : { perApp }), ...(raw.apps === undefined ? {} : { apps }) }
404
+ }
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
+
307
455
  /** Validates raw plugin options and applies defaults. Throws `Error` with an actionable message. */
308
456
  export function parseOptions(raw: unknown): PlugboardOptions {
309
457
  if (raw !== undefined && raw !== null && !isRecord(raw)) throw new Error("plugboard options must be an object")
@@ -360,6 +508,14 @@ export function parseOptions(raw: unknown): PlugboardOptions {
360
508
  const chain = Array.isArray(target) ? target : [target]
361
509
  if (chain.length === 0) throw new Error(`plugboard alias \`${alias}\` must list at least one target`)
362
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
+ }
363
519
  if (typeof entry !== "string" || !ALIAS_TARGET.test(entry))
364
520
  throw new Error(`plugboard alias \`${alias}\` must target \`provider/model\` or \`provider/model#variant\` (or a list of them)`)
365
521
  // A list is new, so it is strict: a target the allowlist would refuse can never serve, so fail at load.
@@ -373,6 +529,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
373
529
  }
374
530
  const aliases: Record<string, string> = Object.fromEntries(aliasEntries)
375
531
  const fallbacks: Record<string, string[]> = Object.fromEntries(fallbackEntries)
532
+ const agents = parseAgents(input.agents, models as string[])
376
533
 
377
534
  const maxTools = positiveInt(input.maxTools, "maxTools", DEFAULTS.maxTools, 1024)
378
535
  const apps = parseApps(input.apps, { keys, aliases, maxTools })
@@ -397,6 +554,11 @@ export function parseOptions(raw: unknown): PlugboardOptions {
397
554
  const limits = parseLimits(input.limits, keys)
398
555
  const cache = parseCache(input.cache, keys)
399
556
  const usage = parseUsage(input.usage)
557
+ const budgets = parseBudgets(input.budgets, keys)
558
+ if (budgets !== undefined && !usage.enabled) throw new Error("plugboard option `budgets` needs `usage` enabled: budgets are counted from the usage records")
559
+ if (budgets !== undefined && usage.retentionDays < widestWindowDays(budgets))
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)
400
562
 
401
563
  return {
402
564
  listen: { host, port: port ?? DEFAULTS.port },
@@ -404,6 +566,7 @@ export function parseOptions(raw: unknown): PlugboardOptions {
404
566
  apps,
405
567
  models: models as string[],
406
568
  aliases,
569
+ agents,
407
570
  fallbacks,
408
571
  maxTools,
409
572
  maxN: positiveInt(input.maxN, "maxN", DEFAULTS.maxN, 64),
@@ -417,7 +580,9 @@ export function parseOptions(raw: unknown): PlugboardOptions {
417
580
  ...(limits === undefined ? {} : { limits }),
418
581
  ...(cache ? { cache } : {}),
419
582
  usage,
583
+ ...(budgets === undefined ? {} : { budgets }),
420
584
  ...(typeof input.adminKey === "string" ? { adminKey: input.adminKey } : {}),
585
+ ...(historyUrl === undefined ? {} : { historyUrl }),
421
586
  }
422
587
  }
423
588
 
@@ -446,24 +611,32 @@ function digestsOf(keys: Readonly<Record<string, string>>): ReadonlyArray<readon
446
611
  /** Who may read `GET /v1/usage`: the admin key (every app), or one app (`default` when keyless). */
447
612
  export type UsageScope = { readonly admin: true } | { readonly admin: false; readonly app: string }
448
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
+
449
617
  /** The admin key is checked first; any other valid key (or keyless mode) is an app, as `authenticate` decides. */
450
- export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined): UsageScope | PlugError {
618
+ export function authenticateUsage(options: PlugboardOptions, authorization: string | undefined, runtime: ReadonlyArray<RuntimeDigest> = []): UsageScope | PlugError {
451
619
  const bearer = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
452
620
  if (options.adminKey !== undefined && bearer !== undefined && timingSafeEqual(sha256(bearer), sha256(options.adminKey))) return { admin: true }
453
- const auth = authenticate(options, authorization)
621
+ const auth = authenticate(options, authorization, runtime)
454
622
  if (isPlugError(auth)) return auth
455
623
  return { admin: false, app: auth.app }
456
624
  }
457
625
 
458
- /** No keys -> app `default`; with keys every request needs `Authorization: Bearer <key>`. */
459
- 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 {
460
633
  const entries = digestsOf(options.keys)
461
- if (entries.length === 0) return { app: DEFAULT_APP }
462
634
  const token = /^Bearer\s+(\S+)\s*$/i.exec(authorization ?? "")?.[1]
463
- if (!token) return UNAUTHORIZED
635
+ if (token === undefined) return entries.length === 0 ? { app: DEFAULT_APP } : UNAUTHORIZED
464
636
  const digest = sha256(token)
465
637
  let app: string | undefined
466
638
  // Every key is compared, no early exit.
467
- for (const [name, expected] of entries) if (timingSafeEqual(digest, expected)) app = name
468
- 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
469
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
@@ -186,6 +188,20 @@ export interface CallContext {
186
188
  readonly onCache?: (result: "hit" | "miss") => void
187
189
  /** Bypass the response cache for this call. Fan-out sets it: each choice of `n` above 1 repeats one request and must be a fresh sample. */
188
190
  readonly noCache?: boolean
191
+ /**
192
+ * Called once per answered call, before its finish event, when usage recording is on (`withUsage`,
193
+ * src/usage.ts): the call's tokens and USD cost. Front-ends set `x-plugboard-usage` from it while the
194
+ * headers are open, so a stream (headers sent first) never gets it.
195
+ */
196
+ readonly onUsage?: (totals: CallTotals) => void
197
+ }
198
+
199
+ /** 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. */
200
+ export interface CallTotals {
201
+ readonly inputTokens: number
202
+ readonly outputTokens: number
203
+ readonly cacheReadTokens: number
204
+ readonly costUsd?: number
189
205
  }
190
206
 
191
207
  /** The target of an alias that answered: `provider/model[#variant]`, and the targets tried before it. */
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
 
@@ -439,6 +439,7 @@ export function parseGeminiRequest(model: string, stream: boolean, body: unknown
439
439
  stream,
440
440
  ...(respond ? { structuredTool: RESPOND_TOOL, ...(format.wrap ? { wrapped: true } : {}), ...(withCallerTools ? { callerTools: true } : {}) } : {}),
441
441
  ...(format.instruction ? { jsonObject: true } : {}),
442
+ ...(generation.stop ? { stop: generation.stop } : {}),
442
443
  },
443
444
  ...(reasoningEffort ? { reasoningEffort } : {}),
444
445
  }
@@ -9,6 +9,7 @@
9
9
  import type { PlugError, PlugErrorKind, PlugEvent, PlugFinish, PlugUsage } from "../contract.js"
10
10
  import type { ListedModel } from "../models.js"
11
11
  import { unfence } from "../openai/response.js"
12
+ import { StopHoldback } from "../stop.js"
12
13
  import { WRAP_KEY } from "./request.js"
13
14
 
14
15
  type ToolCall = Extract<PlugEvent, { type: "tool-call" }>
@@ -30,6 +31,8 @@ export interface GeminiExchange {
30
31
  readonly callerTools?: boolean
31
32
  /** `responseMimeType: application/json` without a schema: a single code fence around JSON text is stripped. */
32
33
  readonly jsonObject?: boolean
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
+ readonly stop?: ReadonlyArray<string>
33
36
  }
34
37
 
35
38
  /** How an exchange ended. Nothing was emitted when `failure` is set and `started` is false: the caller answers with a real HTTP error. */
@@ -160,10 +163,18 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
160
163
  else parts.push(part)
161
164
  emit(envelope([part]))
162
165
  }
166
+ // A stop sequence is held back until the finish decides it (src/stop.ts). Structured output has none.
167
+ const holdback = exchange.structuredTool === undefined ? new StopHoldback(exchange.stop ?? []) : undefined
163
168
  const text = (delta: string) => {
164
- if (delta.length > 0) push({ text: delta })
169
+ const out = holdback ? holdback.push(delta) : delta
170
+ if (out.length > 0) push({ text: out })
171
+ }
172
+ const releaseHeld = () => {
173
+ const rest = holdback?.release() ?? ""
174
+ if (rest.length > 0) push({ text: rest })
165
175
  }
166
176
  const toolCall = (call: ToolCall) => {
177
+ releaseHeld()
167
178
  push({ functionCall: { name: call.name, args: argsOf(call.input) } })
168
179
  calls++
169
180
  }
@@ -221,6 +232,10 @@ export async function runGenerate(events: AsyncIterable<PlugEvent>, exchange: Ge
221
232
  failure ??= finish ? (passthrough ? undefined : structuredFailure(exchange, finish, answered)) : noFinish()
222
233
  if (failure) return { failure, started }
223
234
  flush()
235
+ if (holdback) {
236
+ const rest = holdback.finish(finish === "stop" && calls === 0).text
237
+ if (rest.length > 0) push({ text: rest })
238
+ }
224
239
  if (held && !passthrough) text(structuredText(exchange, held))
225
240
  const candidate = { finishReason: FINISH_REASONS[finish!] }
226
241
  const extra = usage ? { usageMetadata: encodeUsage(usage) } : {}
@@ -21,6 +21,8 @@ import { boundEvents, checkJsonContentType, readJsonBody, sendError, sendJson, s
21
21
  import { listAvailableModels, resolveModel } from "../models.js"
22
22
  import { reportCache } from "../cache.js"
23
23
  import { reportPolicy } from "../policy.js"
24
+ import { reportUsage } from "../usage-header.js"
25
+ import { setSessionLink } from "../session/link.js"
24
26
  import type { RouteHost } from "../openai/route.js"
25
27
  import { dropRejected } from "../openai/sampling.js"
26
28
  import { estimateTokens } from "../anthropic/tokens.js"
@@ -156,12 +158,14 @@ export async function serveGemini(host: RouteHost, { match, request, url, app, r
156
158
  host.caller.stream(call, {
157
159
  app,
158
160
  signal: scope.signal,
159
- onRecord: (recordID) => {
161
+ onRecord: (recordID, directory) => {
160
162
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
161
163
  response.setHeader(SESSION_HEADER, recordID)
164
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
162
165
  },
163
166
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
164
167
  onPolicy: reportPolicy(response),
168
+ onUsage: reportUsage(response, match.stream),
165
169
  onCache: reportCache(response),
166
170
  }),
167
171
  scope,
package/src/keys.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Runtime app keys (#55): keys created through `/v1/admin/keys` (src/admin/route.ts), kept beside
3
+ * the keys of the config.
4
+ *
5
+ * Each runtime key is one record in the plugin's host storage (`ctx.storage`, the table the history
6
+ * index and usage use) under `keys/<id>`, as JSON `{ app, digest, createdAt }`. The digest is the
7
+ * SHA-256 of the key, so the key value is never stored: it is returned once, by the create call.
8
+ * The id is the first 8 hex chars of that digest, the name `DELETE` takes; it is safe to show.
9
+ *
10
+ * The records are read lazily, from request handling, by the first request that authenticates (never
11
+ * from plugin setup, I3), and cached on the process runtime (`KeyState`), so the cache outlives a
12
+ * plugin generation. A failed read leaves the cache unloaded: runtime keys then fail closed (401) and
13
+ * the next request reads again. Create and revoke write storage first and the cache second, so a
14
+ * revoked key stops authenticating on the next request.
15
+ */
16
+
17
+ import { createHash, randomBytes } from "node:crypto"
18
+ import { APP_NAME_PATTERN } from "./config.js"
19
+ import { errorMessage, logError } from "./log.js"
20
+ import type { StoragePort } from "./session/host.js"
21
+
22
+ const PREFIX = "keys/"
23
+ const SCAN_PAGE = 200
24
+ /** A collision of two 32-bit ids is rare; a fresh key is drawn instead of sharing an id. */
25
+ const ID_ATTEMPTS = 5
26
+
27
+ export interface RuntimeKey {
28
+ readonly id: string
29
+ readonly app: string
30
+ readonly digest: Buffer
31
+ readonly createdAt: string
32
+ }
33
+
34
+ /** The live cache of runtime keys on the process runtime (src/runtime.ts). */
35
+ export interface KeyState {
36
+ loaded: boolean
37
+ readonly keys: Map<string, RuntimeKey>
38
+ }
39
+
40
+ export const createKeyState = (): KeyState => ({ loaded: false, keys: new Map() })
41
+
42
+ /** SHA-256 of a key: what is stored and compared. */
43
+ export const keyDigest = (key: string): Buffer => createHash("sha256").update(key).digest()
44
+
45
+ /** The id of a key: the first 8 hex chars of its digest. */
46
+ export const keyIdOf = (digest: Buffer): string => digest.subarray(0, 4).toString("hex")
47
+
48
+ export interface KeyListing {
49
+ readonly id: string
50
+ readonly app: string
51
+ readonly source: "config" | "runtime"
52
+ readonly createdAt?: string
53
+ }
54
+
55
+ export interface CreatedKey {
56
+ readonly id: string
57
+ readonly app: string
58
+ /** The key value. Returned by `create` only; it is never stored or listed. */
59
+ readonly key: string
60
+ readonly createdAt: string
61
+ }
62
+
63
+ export interface KeyStore {
64
+ /** The digests of the loaded runtime keys, as `[app, digest]`. Reads storage once; a failed read answers with what is loaded. */
65
+ digests(): Promise<ReadonlyArray<readonly [string, Buffer]>>
66
+ /** Config keys and runtime keys, without key values. */
67
+ list(config: Readonly<Record<string, string>>): Promise<ReadonlyArray<KeyListing>>
68
+ /** A new random key for `app`. The caller checks the app name and that the config has no key for it. */
69
+ create(app: string, config: Readonly<Record<string, string>>): Promise<CreatedKey>
70
+ /** `config` when the id names a config key (not revocable here), `not_found` when no runtime key has it. */
71
+ revoke(id: string, config: Readonly<Record<string, string>>): Promise<"revoked" | "config" | "not_found">
72
+ }
73
+
74
+ export interface KeyStoreDeps {
75
+ readonly state: KeyState
76
+ readonly storage: StoragePort
77
+ /** Epoch ms; tests inject a clock. */
78
+ readonly now?: () => number
79
+ }
80
+
81
+ const configIds = (config: Readonly<Record<string, string>>): Set<string> => new Set(Object.values(config).map((key) => keyIdOf(keyDigest(key))))
82
+
83
+ /** A stored record, or undefined when the value is not one (logged; the key then never authenticates). */
84
+ function parseRecord(id: string, raw: string | undefined): RuntimeKey | undefined {
85
+ try {
86
+ const value = JSON.parse(raw ?? "") as { app?: unknown; digest?: unknown; createdAt?: unknown }
87
+ const digest = typeof value.digest === "string" && /^[0-9a-f]{64}$/.test(value.digest) ? Buffer.from(value.digest, "hex") : undefined
88
+ if (typeof value.app !== "string" || !APP_NAME_PATTERN.test(value.app) || digest === undefined || typeof value.createdAt !== "string") throw new Error("shape")
89
+ if (keyIdOf(digest) !== id) throw new Error("id")
90
+ return { id, app: value.app, digest, createdAt: value.createdAt }
91
+ } catch {
92
+ logError("keys.unreadable", { id })
93
+ return undefined
94
+ }
95
+ }
96
+
97
+ export function createKeyStore(deps: KeyStoreDeps): KeyStore {
98
+ const { state, storage } = deps
99
+ const now = deps.now ?? Date.now
100
+ let loading: Promise<void> | undefined
101
+
102
+ /** Reads every stored key once. Concurrent callers share one read. */
103
+ const ensure = (): Promise<void> => {
104
+ if (state.loaded) return Promise.resolve()
105
+ loading ??= (async () => {
106
+ const found = new Map<string, RuntimeKey>()
107
+ let after: string | undefined
108
+ for (;;) {
109
+ const page = await storage.scan({ prefix: PREFIX, ...(after === undefined ? {} : { after }), limit: SCAN_PAGE })
110
+ for (const key of page.keys) {
111
+ const record = parseRecord(key.slice(PREFIX.length), await storage.get(key))
112
+ if (record) found.set(record.id, record)
113
+ }
114
+ if (page.next === undefined) break
115
+ after = page.next
116
+ }
117
+ state.keys.clear()
118
+ for (const [id, record] of found) state.keys.set(id, record)
119
+ state.loaded = true
120
+ })().finally(() => {
121
+ loading = undefined
122
+ })
123
+ return loading
124
+ }
125
+
126
+ return {
127
+ async digests() {
128
+ try {
129
+ await ensure()
130
+ } catch (error) {
131
+ logError("keys.load_failed", { error: errorMessage(error) })
132
+ }
133
+ return [...state.keys.values()].map((record) => [record.app, record.digest] as const)
134
+ },
135
+
136
+ async list(config) {
137
+ await ensure()
138
+ const runtime = [...state.keys.values()].sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
139
+ return [
140
+ ...Object.entries(config).map(([app, key]) => ({ id: keyIdOf(keyDigest(key)), app, source: "config" as const })),
141
+ ...runtime.map((record) => ({ id: record.id, app: record.app, source: "runtime" as const, createdAt: record.createdAt })),
142
+ ]
143
+ },
144
+
145
+ async create(app, config) {
146
+ await ensure()
147
+ const taken = configIds(config)
148
+ for (let attempt = 0; attempt < ID_ATTEMPTS; attempt++) {
149
+ const key = randomBytes(32).toString("base64url")
150
+ const digest = keyDigest(key)
151
+ const id = keyIdOf(digest)
152
+ if (taken.has(id) || state.keys.has(id)) continue
153
+ const createdAt = new Date(now()).toISOString()
154
+ // Storage first: a failed write creates no key, and the value is never returned for a key that does not exist.
155
+ await storage.put(PREFIX + id, JSON.stringify({ app, digest: digest.toString("hex"), createdAt }))
156
+ state.keys.set(id, { id, app, digest, createdAt })
157
+ return { id, app, key, createdAt }
158
+ }
159
+ throw new Error("could not draw an unused key id")
160
+ },
161
+
162
+ async revoke(id, config) {
163
+ await ensure()
164
+ if (configIds(config).has(id)) return "config"
165
+ if (!state.keys.has(id)) return "not_found"
166
+ // Storage first: if the delete fails the key keeps working and the caller can retry.
167
+ await storage.drop(PREFIX + id)
168
+ state.keys.delete(id)
169
+ return "revoked"
170
+ },
171
+ }
172
+ }
package/src/log.ts CHANGED
@@ -142,5 +142,8 @@ export const log = (event: string, fields?: LogFields): void => state().logger.i
142
142
 
143
143
  export const logError = (event: string, fields?: LogFields): void => state().logger.error(record(event, fields))
144
144
 
145
+ /** A line for something that needs attention but did not fail a request (a budget at 80%). Info-level destination, `warn` in the level field. */
146
+ export const logWarn = (event: string, fields?: LogFields): void => state().logger.warn(record(event, fields))
147
+
145
148
  /** The message of a thrown value, without its stack or attached properties. */
146
149
  export const errorMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error))