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/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/models.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  import { appPolicy, type AppPolicy, type PlugboardOptions } from "./config.js"
4
4
  import type { CatalogModel, ModelRef, PlugError } from "./contract.js"
5
5
 
6
+ /** Alias target prefix that binds a skill: `skill:<id>@provider/model[#variant]` (see `parseAliasTarget`). */
7
+ export const SKILL_PREFIX = "skill:"
8
+ /** Listed id prefix of an agent (option `agents`): `agent/<name>`. Aliases cannot contain `/`, so the two never collide. */
9
+ export const AGENT_PREFIX = "agent/"
10
+ const SKILL_TARGET = /^skill:([^@\s#*/]+)@([^@/#*\s]+\/[^@#*\s]+(#[^#*\s]+)?)$/
11
+
6
12
  /** `provider/model[#variant]`. The model id may contain slashes (`openrouter/vendor/model`). */
7
13
  export function parseModelRef(s: string): ModelRef | undefined {
8
14
  if (typeof s !== "string") return undefined
@@ -58,6 +64,42 @@ export function allowedBy(models: ReadonlyArray<string>, ref: ModelRef): boolean
58
64
 
59
65
  const allowed = (options: PlugboardOptions, ref: ModelRef): boolean => allowedBy(options.models, ref)
60
66
 
67
+ /** An alias target as written in `aliases`: `provider/model[#variant]`, or `skill:<id>@` in front of one. */
68
+ export interface AliasTarget {
69
+ readonly ref: ModelRef
70
+ /** The skill the alias binds, for a `skill:` target. */
71
+ readonly skill?: string
72
+ }
73
+
74
+ /** The model (and skill) an alias target names, or undefined when it is neither form. */
75
+ export function parseAliasTarget(target: string): AliasTarget | undefined {
76
+ if (!target.startsWith(SKILL_PREFIX)) {
77
+ const ref = parseModelRef(target)
78
+ return ref === undefined ? undefined : { ref }
79
+ }
80
+ const match = SKILL_TARGET.exec(target)
81
+ const ref = match ? parseModelRef(match[2]!) : undefined
82
+ return match && ref ? { ref, skill: match[1]! } : undefined
83
+ }
84
+
85
+ /**
86
+ * What a request name stands for, beyond a plain model: a skill alias (its skill id) or an agent (its name
87
+ * in option `agents`). Undefined for plain model ids and plain aliases. The caller reads it to add the
88
+ * skill's content or the agent's system prompt to the session.
89
+ */
90
+ export type Persona = { readonly kind: "skill"; readonly skill: string } | { readonly kind: "agent"; readonly agent: string }
91
+
92
+ export function personaOf(options: Pick<PlugboardOptions, "aliases" | "agents">, name: string): Persona | undefined {
93
+ const target = own(options.aliases, name)
94
+ if (target !== undefined) {
95
+ const skill = parseAliasTarget(target)?.skill
96
+ return skill === undefined ? undefined : { kind: "skill", skill }
97
+ }
98
+ if (!name.startsWith(AGENT_PREFIX)) return undefined
99
+ const agent = name.slice(AGENT_PREFIX.length)
100
+ return Object.hasOwn(options.agents, agent) ? { kind: "agent", agent } : undefined
101
+ }
102
+
61
103
  /**
62
104
  * Per-app narrowing (`apps.<name>.models`), applied on top of the global allowlist, never instead of it.
63
105
  * `name` is what the caller sent (an alias or a model id). An entry that is an alias name admits that
@@ -108,7 +150,15 @@ export function resolveModel(
108
150
  param: "model",
109
151
  }
110
152
  // Aliases keep priority over slugs; a slug and a canonical id both resolve, whatever `modelIds` lists.
111
- let ref = parseModelRef(own(options.aliases, model) ?? fromSlug(model) ?? model)
153
+ // An agent runs on the model its `agents` entry names (the allowlist still applies below).
154
+ const target = own(options.aliases, model)
155
+ const persona = personaOf(options, model)
156
+ let ref =
157
+ target !== undefined
158
+ ? parseAliasTarget(target)?.ref
159
+ : persona?.kind === "agent"
160
+ ? parseModelRef(options.agents[persona.agent]!)
161
+ : parseModelRef(fromSlug(model) ?? model)
112
162
  if (!ref) return notAvailable
113
163
  if (!ref.variant && reasoningEffort && find(catalog, { ...ref, variant: reasoningEffort }))
114
164
  ref = { ...ref, variant: reasoningEffort }
@@ -120,6 +170,10 @@ export interface ListedModel {
120
170
  readonly id: string
121
171
  readonly ref: ModelRef
122
172
  readonly model: CatalogModel
173
+ /** The skill an alias binds (`skill:` target), listed as `plugboard.skill`. */
174
+ readonly skill?: string
175
+ /** The agent an `agent/<name>` id runs, listed as `plugboard.agent`. */
176
+ readonly agent?: string
123
177
  }
124
178
 
125
179
  /**
@@ -146,9 +200,16 @@ export function listAvailableModels(options: PlugboardOptions, catalog: Readonly
146
200
  }
147
201
  }
148
202
  for (const [alias, target] of Object.entries(options.aliases)) {
149
- const ref = parseModelRef(target)
203
+ const parsed = parseAliasTarget(target)
204
+ const ref = parsed?.ref
150
205
  const model = ref && allowed(options, ref) && inAppScope(policy, alias, ref) ? find(catalog, ref) : undefined
151
- if (ref && model) data.push(entry(alias, ref, model))
206
+ if (ref && model) data.push({ ...entry(alias, ref, model), ...(parsed.skill === undefined ? {} : { skill: parsed.skill }) })
207
+ }
208
+ for (const [name, target] of Object.entries(options.agents)) {
209
+ const id = `${AGENT_PREFIX}${name}`
210
+ const ref = parseModelRef(target)
211
+ const model = ref && allowed(options, ref) && inAppScope(policy, id, ref) ? find(catalog, ref) : undefined
212
+ if (ref && model) data.push({ ...entry(id, ref, model), agent: name })
152
213
  }
153
214
  return data
154
215
  }
@@ -26,6 +26,7 @@ import { reportServed, type ServedInfo } from "../fallback.js"
26
26
  import { reportCache } from "../cache.js"
27
27
  import { reportPolicy } from "../policy.js"
28
28
  import { reportUsage } from "../usage-header.js"
29
+ import { SESSION_URL_HEADER, sessionUrl } from "../session/link.js"
29
30
  import { RequestAborted, boundEvents, sendError, sendJson, sseWriter, type RequestScope } from "../http.js"
30
31
  import { chatUsageChunk, collectCompletion, encodeError, streamCompletion, type OpenAiExchange, type StreamChoice, type StreamResult } from "./response.js"
31
32
  import type { RouteHost } from "./route.js"
@@ -155,7 +156,7 @@ const addUsage = (a: PlugUsage, b: PlugUsage): PlugUsage => {
155
156
  return out
156
157
  }
157
158
 
158
- export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
159
+ export async function serveChoices(host: Pick<RouteHost, "caller" | "options">, { app, calls, response, scope, info, codec }: ChoicesCall): Promise<void> {
159
160
  const fail = (error: PlugError) => sendError(response, error, encodeError(error))
160
161
  // Abandoned requests (client gone, deadline passed) must never reach the model caller.
161
162
  if (scope.signal.aborted) throw scope.signal.reason
@@ -165,6 +166,8 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
165
166
  const gscope = groupScope(scope, AbortSignal.any([scope.signal, group.signal]))
166
167
  /** History record ids as the callers report them, in choice order, for the response header (comma separated). */
167
168
  const records: Array<string | undefined> = []
169
+ /** The session page links of the same records, in choice order (option `historyUrl`, src/session/link.ts). */
170
+ const links: Array<string | undefined> = []
168
171
  // Headers: the first choice to be served wins. Body model: only a single call echoes the served model.
169
172
  const served = reportServed(response, info, (s) => {
170
173
  if (calls.length === 1) codec.setModel(s.model)
@@ -179,10 +182,15 @@ export async function serveChoices(host: Pick<RouteHost, "caller">, { app, calls
179
182
  host.caller.stream(calls[index]!, {
180
183
  app,
181
184
  signal: gscope.signal,
182
- onRecord: (recordID) => {
185
+ onRecord: (recordID, directory) => {
183
186
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
184
187
  records[index] = recordID
185
188
  response.setHeader(SESSION_HEADER, records.filter((id) => id !== undefined).join(","))
189
+ if (host.options.historyUrl !== undefined) {
190
+ links[index] = sessionUrl(host.options.historyUrl, recordID, directory)
191
+ info.sessionUrl = links.filter((link) => link !== undefined).join(",")
192
+ response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
193
+ }
186
194
  },
187
195
  onServed: served,
188
196
  onPolicy: policy,
@@ -6,6 +6,7 @@
6
6
  * the request omits `ref`.
7
7
  */
8
8
  import type { PlugboardOptions } from "../config.js"
9
+ import { bodyUser, userField } from "../user.js"
9
10
  import {
10
11
  isPlugError,
11
12
  type PlugError,
@@ -322,6 +323,7 @@ export function parseChatRequest(body: unknown, options: PlugboardOptions): Pars
322
323
 
323
324
  const forced: PlugToolChoice | undefined = respond ? (withCallerTools ? "required" : { type: "tool", name: RESPOND_TOOL }) : toolChoice
324
325
  const request: Omit<PlugRequest, "ref"> = {
326
+ ...userField(bodyUser(body)),
325
327
  model: body.model,
326
328
  system: format.instruction ? [...parsed.system, format.instruction] : parsed.system,
327
329
  messages: parsed.messages,
@@ -111,12 +111,12 @@ const encodePlugboard = (model: ListedModel["model"]) => ({
111
111
  /** `GET /v1/models` body. */
112
112
  export const encodeModelList = (models: ReadonlyArray<ListedModel>): object => ({
113
113
  object: "list",
114
- data: models.map(({ id, ref, model }) => ({
114
+ data: models.map(({ id, ref, model, skill, agent }) => ({
115
115
  id,
116
116
  object: "model",
117
117
  created: 0,
118
118
  owned_by: ref.providerID,
119
- plugboard: encodePlugboard(model),
119
+ plugboard: { ...encodePlugboard(model), ...(skill === undefined ? {} : { skill }), ...(agent === undefined ? {} : { agent }) },
120
120
  })),
121
121
  })
122
122
 
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { IncomingMessage, ServerResponse } from "node:http"
24
+ import { serveAdmin } from "../admin/route.js"
24
25
  import { DEFAULT_APP, authenticate, authenticateUsage, type PlugboardOptions } from "../config.js"
25
26
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError, type PlugRequest } from "../contract.js"
26
27
  import { buildDiscovery, buildStatus, renderDiscoveryHtml, sendText } from "../discovery.js"
@@ -52,9 +53,12 @@ import { serveCompletions } from "../completions/route.js"
52
53
  import { serveResponses } from "../responses/route.js"
53
54
  import { liveOptions, processMetrics, processStatus, type RuntimeStatus } from "../runtime.js"
54
55
  import { budgetFor, budgetStatus } from "../budgets.js"
56
+ import type { KeyStore } from "../keys.js"
55
57
  import type { UsageRecorder } from "../usage.js"
56
58
  import { DASHBOARD_ROUTES } from "../dashboard/page.js"
57
59
  import { serveDashboard } from "../dashboard/assets.js"
60
+ import { bodyUser, noteEndUser } from "../user.js"
61
+ import { setSessionLink } from "../session/link.js"
58
62
  import { chatCodec, serveChoices } from "./fanout.js"
59
63
  import { parseChatRequest } from "./request.js"
60
64
  import { collectCompletion, encodeError, encodeModelList, streamCompletion } from "./response.js"
@@ -72,6 +76,8 @@ export interface RouteHost {
72
76
  readonly status?: () => RuntimeStatus
73
77
  /** Per-app usage (`src/usage.ts`): fed from the request log point, and served by `GET /v1/usage` when present. */
74
78
  readonly usage?: UsageRecorder
79
+ /** Runtime app keys (src/keys.ts, #55): their digests authenticate, and the admin routes (src/admin/route.ts) read them. */
80
+ readonly keys?: KeyStore
75
81
  }
76
82
 
77
83
  const SESSION_HEADER = "x-plugboard-session"
@@ -116,6 +122,8 @@ interface RequestInfo extends ServedInfo {
116
122
  failed?: string
117
123
  /** Sampling parameters dropped for this model (comma list), when any. */
118
124
  dropped?: string
125
+ /** The end user the body named, when kept (src/user.ts). */
126
+ user?: string
119
127
  }
120
128
 
121
129
  export function createOpenAiRoute(routeHost: RouteHost) {
@@ -123,6 +131,8 @@ export function createOpenAiRoute(routeHost: RouteHost) {
123
131
  const metrics = routeHost.options.metrics ? (routeHost.metrics ?? processMetrics()) : undefined
124
132
  const runtimeStatus = routeHost.status ?? processStatus
125
133
  const host: RouteHost = metrics ? { ...routeHost, caller: metered(routeHost.caller, metrics) } : routeHost
134
+ /** The runtime app keys that authenticate (loaded from storage on first use, #55). */
135
+ const runtimeDigests = async () => (host.keys ? await host.keys.digests() : [])
126
136
  /** Routes with a handler of their own. `GET /metrics` exists only when enabled; otherwise it is an unknown path. */
127
137
  const known = (route: string) =>
128
138
  KNOWN_ROUTES.includes(route) ||
@@ -176,7 +186,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
176
186
  const keyed = Object.keys(host.options.keys).length > 0
177
187
  let app: string | undefined = keyed ? undefined : DEFAULT_APP
178
188
  if (keyed && request.headers.authorization !== undefined) {
179
- const auth = authenticate(liveOptions(host.options), request.headers.authorization)
189
+ const auth = authenticate(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
180
190
  if (isPlugError(auth)) return fail(response, auth)
181
191
  app = auth.app
182
192
  }
@@ -192,9 +202,13 @@ export function createOpenAiRoute(routeHost: RouteHost) {
192
202
  // Usage has its own credentials: the admin key reads every app, any other key only its own app (`authenticateUsage`).
193
203
  if (route === "GET /v1/usage" && host.usage) return serveUsage(request, response, url, host.usage, info)
194
204
 
205
+ // Runtime app keys (#55): the admin routes answer the admin key only, and are a 404 without `adminKey`.
206
+ if (path === "/v1/admin/keys" || path.startsWith("/v1/admin/keys/"))
207
+ return serveAdmin({ options: liveOptions(host.options), keys: host.keys }, { path, request, response, scope, info })
208
+
195
209
  // Authenticate before anything reveals which routes exist: an unauthenticated caller sees 401 for every path but /healthz and the first-run pages.
196
210
  // Gemini clients also send the key as `x-goog-api-key` or `?key=` (see `geminiAuthorization`).
197
- const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization)
211
+ const auth = authenticate(liveOptions(host.options), googleShaped ? geminiAuthorization(request, url) : request.headers.authorization, await runtimeDigests())
198
212
  if (isPlugError(auth)) return fail(response, auth)
199
213
  info.app = auth.app
200
214
 
@@ -253,6 +267,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
253
267
  if (unsupported) return fail(response, unsupported)
254
268
 
255
269
  const body = await readJsonBody(request, scope.signal)
270
+ noteEndUser(response, info, bodyUser(body))
256
271
  if (route === "POST /v1/responses") return serveResponses(host, { app: auth.app, body, response, scope, info })
257
272
  if (route === "POST /v1/completions") return serveCompletions(host, { app: auth.app, body, response, scope, info })
258
273
  const parsed = parseChatRequest(body, host.options)
@@ -280,9 +295,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
280
295
  host.caller.stream(call, {
281
296
  app: auth.app,
282
297
  signal: scope.signal,
283
- onRecord: (recordID) => {
298
+ onRecord: (recordID, directory) => {
284
299
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
285
300
  response.setHeader(SESSION_HEADER, recordID)
301
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
286
302
  },
287
303
  // An alias with fallbacks: report which target answered, and echo it as the response model.
288
304
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
@@ -318,7 +334,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
318
334
 
319
335
  /** `GET /v1/usage?from=&to=&app=`. An app key may name only its own app (another one is a 403); the admin key may name any app or none. */
320
336
  async function serveUsage(request: IncomingMessage, response: ServerResponse, url: URL, usage: UsageRecorder, info: RequestInfo): Promise<void> {
321
- const who = authenticateUsage(liveOptions(host.options), request.headers.authorization)
337
+ const who = authenticateUsage(liveOptions(host.options), request.headers.authorization, await runtimeDigests())
322
338
  if (isPlugError(who)) return fail(response, who)
323
339
  const app = url.searchParams.get("app") ?? undefined
324
340
  if (!who.admin) {
@@ -330,6 +346,7 @@ export function createOpenAiRoute(routeHost: RouteHost) {
330
346
  from: url.searchParams.get("from") ?? undefined,
331
347
  to: url.searchParams.get("to") ?? undefined,
332
348
  app: who.admin ? app : who.app,
349
+ user: url.searchParams.get("user") || undefined,
333
350
  })
334
351
  if (isPlugError(report)) return fail(response, report)
335
352
  // Each app's budget state, read from its current window rather than from the report's range (src/budgets.ts).
@@ -396,10 +413,10 @@ export function createOpenAiRoute(routeHost: RouteHost) {
396
413
  } catch {}
397
414
  const ms = Date.now() - started
398
415
  metrics?.requestFinished({ app: info.app ?? NO_APP, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
399
- if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms })
416
+ if (info.app !== undefined) host.usage?.request({ app: info.app, route: info.route, status: response.statusCode, outcome: outcomeLabel(outcome, response.statusCode), ms, ...(info.user === undefined ? {} : { user: info.user }) })
400
417
  // Probes and scrapes that succeed are not worth a line each.
401
418
  if ((info.route !== "GET /healthz" && info.route !== "GET /metrics") || outcome !== "ok") {
402
- const fields = { id, route: info.route, app: info.app, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
419
+ const fields = { id, route: info.route, app: info.app, user: info.user, session_url: info.sessionUrl, model: info.model, served: info.served, fallbacks: info.fallbacks, dropped: info.dropped, status: response.statusCode, ms, outcome, error }
403
420
  if (outcome === "error" || response.statusCode >= 500) logError("request.failed", fields)
404
421
  else log("request", fields)
405
422
  }
@@ -10,6 +10,7 @@
10
10
  * `ModelRef` afterwards, which is why the request omits `ref`.
11
11
  */
12
12
  import type { PlugboardOptions } from "../config.js"
13
+ import { bodyUser, userField } from "../user.js"
13
14
  import {
14
15
  isPlugError,
15
16
  type PlugError,
@@ -353,6 +354,7 @@ export function parseResponsesRequest(body: unknown, options: PlugboardOptions,
353
354
  const instructions = typeof body.instructions === "string" && body.instructions.length > 0 ? [body.instructions] : []
354
355
  const system = [...instructions, ...parsed.system, ...(format.instruction ? [format.instruction] : [])]
355
356
  const request: Omit<PlugRequest, "ref"> = {
357
+ ...userField(bodyUser(body)),
356
358
  model: body.model,
357
359
  system,
358
360
  messages: parsed.messages,
@@ -16,6 +16,7 @@ import { resolveModel } from "../models.js"
16
16
  import { reportCache } from "../cache.js"
17
17
  import { reportPolicy } from "../policy.js"
18
18
  import { reportUsage } from "../usage-header.js"
19
+ import { setSessionLink } from "../session/link.js"
19
20
  import { encodeError } from "../openai/response.js"
20
21
  import type { RouteHost } from "../openai/route.js"
21
22
  import { parseResponsesRequest } from "./request.js"
@@ -51,9 +52,10 @@ export async function serveResponses(host: RouteHost, { app, body, response, sco
51
52
  host.caller.stream(call, {
52
53
  app,
53
54
  signal: scope.signal,
54
- onRecord: (recordID) => {
55
+ onRecord: (recordID, directory) => {
55
56
  if (response.headersSent || response.destroyed || !/^[\x21-\x7e]+$/.test(recordID)) return
56
57
  response.setHeader(SESSION_HEADER, recordID)
58
+ setSessionLink(response, info, host.options.historyUrl, recordID, directory)
57
59
  },
58
60
  onServed: reportServed(response, info, (served) => void (exchange.model = served.model)),
59
61
  onPolicy: reportPolicy(response),
package/src/rpc.ts CHANGED
@@ -26,6 +26,7 @@ export const UsageRpc = Rpc.define({
26
26
  from: { type: "string", description: "First UTC day, YYYY-MM-DD. Default: 29 days before `to`." },
27
27
  to: { type: "string", description: "Last UTC day, YYYY-MM-DD. Default: today." },
28
28
  app: { type: "string", description: "One app name. Default: every app." },
29
+ user: { type: "string", description: "One end user id. Each app's `users` then lists only that user. Default: every user." },
29
30
  },
30
31
  additionalProperties: false,
31
32
  },
@@ -37,7 +38,7 @@ export const UsageRpc = Rpc.define({
37
38
  errors: {
38
39
  invalid_query: {
39
40
  type: "object",
40
- properties: { param: { type: "string", description: "The query parameter that is wrong: from, to or app." } },
41
+ properties: { param: { type: "string", description: "The query parameter that is wrong: from, to, app or user." } },
41
42
  required: ["param"],
42
43
  additionalProperties: false,
43
44
  },
@@ -51,7 +52,7 @@ export const UsageRpc = Rpc.define({
51
52
  function queryOf(input: unknown): UsageQuery {
52
53
  const record: Record<string, unknown> = typeof input === "object" && input !== null ? (input as Record<string, unknown>) : {}
53
54
  const text = (value: unknown) => (typeof value === "string" ? value : undefined)
54
- return { from: text(record.from), to: text(record.to), app: text(record.app) }
55
+ return { from: text(record.from), to: text(record.to), app: text(record.app), user: text(record.user) }
55
56
  }
56
57
 
57
58
  /** The handlers for `UsageRpc`, reading the given usage query (the generation's recorder). */
package/src/runtime.ts CHANGED
@@ -53,6 +53,7 @@ import { Cause, Effect, Stream } from "effect"
53
53
  import stableStringify from "safe-stable-stringify"
54
54
  import { raceAbort } from "./abort.js"
55
55
  import { cachedCaller, createCacheStore, type CacheStore } from "./cache.js"
56
+ import { personaOf } from "./models.js"
56
57
  import type { PlugboardOptions } from "./config.js"
57
58
  import { isPlugError, type CatalogModel, type ModelCaller, type PlugError } from "./contract.js"
58
59
  import { errorMessage, log, logError, outsideRequest, setLogFormat } from "./log.js"
@@ -63,6 +64,7 @@ import type { HistoryPort } from "./session/host.js"
63
64
  import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
64
65
  import { createSessionStore, type SessionStore } from "./session/state.js"
65
66
  import { createBudgetState, type BudgetState } from "./budgets.js"
67
+ import { createKeyState, type KeyState } from "./keys.js"
66
68
  import { createUsageState, type UsageState } from "./usage.js"
67
69
 
68
70
  export type Handler = (request: IncomingMessage, response: ServerResponse) => void
@@ -116,8 +118,8 @@ const BACKEND_POLL_MS = 25
116
118
  const BIND_RETRY_MS = 15_000
117
119
  const BIND_REMINDER_MS = 5 * 60_000
118
120
 
119
- /** 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
120
- const VERSION = 13
121
+ /** 14: the runtime app keys (src/keys.ts, #55) are cached here, so a key created or revoked through the admin routes holds across plugin generations. 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
122
+ const VERSION = 14
121
123
  const KEY = Symbol.for("opencode-plugboard.runtime")
122
124
 
123
125
  interface Listener {
@@ -183,6 +185,8 @@ interface Runtime {
183
185
  readonly usage: UsageState
184
186
  /** The budget warnings already logged (src/budgets.ts). */
185
187
  readonly budgets: BudgetState
188
+ /** The runtime app keys (src/keys.ts), loaded from storage on the first authentication. */
189
+ readonly runtimeKeys: KeyState
186
190
  listener?: Listener
187
191
  /** Present while a fiber is subscribed to the server event bus. */
188
192
  pump?: Pump
@@ -197,6 +201,7 @@ const detachedStartedAt = Date.now()
197
201
  const detachedLimits = createLimitState()
198
202
  const detachedUsage = createUsageState()
199
203
  const detachedBudgets = createBudgetState()
204
+ const detachedKeys = createKeyState()
200
205
  let foreignVersionLogged = false
201
206
 
202
207
  /** The process runtime, created on first use; undefined when a different version already owns the process. */
@@ -215,6 +220,7 @@ function runtime(): Runtime | undefined {
215
220
  limits: createLimitState(),
216
221
  usage: createUsageState(),
217
222
  budgets: createBudgetState(),
223
+ runtimeKeys: createKeyState(),
218
224
  startedAt: Date.now(),
219
225
  }
220
226
  slot[KEY] = created
@@ -299,6 +305,9 @@ export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
299
305
  /** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
300
306
  export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
301
307
 
308
+ /** The runtime app keys' cache shared by every generation in the process (detached under a foreign-version runtime). */
309
+ export const processKeys = (): KeyState => runtime()?.runtimeKeys ?? detachedKeys
310
+
302
311
  /**
303
312
  * The options authentication reads: `keys` is the live key set, not the serving generation's own
304
313
  * (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
@@ -336,7 +345,7 @@ export function withResponseCache(caller: ModelCaller, options: PlugboardOptions
336
345
  if (rt) rt.cache = held
337
346
  }
338
347
  const count = options.metrics ? (app: string, result: "hit" | "miss") => processMetrics().cache(app, result) : undefined
339
- return cachedCaller(caller, config, held.store, count)
348
+ return cachedCaller(caller, config, held.store, count, (model) => personaOf(options, model) !== undefined)
340
349
  }
341
350
  return { stream: (request, context) => (wrapped ??= build()).stream(request, context) }
342
351
  }