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.
@@ -21,7 +21,7 @@ import { raceAbort } from "../abort.js"
21
21
  import { APP_NAME_PATTERN, DEFAULT_APP, appPolicy, type AppPolicy, type PlugboardOptions } from "../config.js"
22
22
  import { isPlugError, type CallContext, type ModelCaller, type PlugError, type PlugEvent, type PlugRequest } from "../contract.js"
23
23
  import { log, logError } from "../log.js"
24
- import { checkAppModel } from "../models.js"
24
+ import { checkAppModel, type Persona } from "../models.js"
25
25
  import { RequestCollector } from "./events.js"
26
26
  import { ensureAppDir, makeTitle, renderTranscript } from "./history.js"
27
27
  import { describeError, type SessionPort } from "./host.js"
@@ -44,6 +44,11 @@ export interface SessionCallerDeps {
44
44
  * called right before `session.create`. An error ends the request without creating anything.
45
45
  */
46
46
  readonly ensureBackend?: (app: string, signal: AbortSignal) => Promise<PlugError | undefined>
47
+ /**
48
+ * What the requested name stands for beyond a plain model (`personaOf` in src/models.ts): a skill alias adds
49
+ * the skill's content to the system prompt, an agent runs its own system prompt and OpenCode agent.
50
+ */
51
+ readonly persona?: (model: string) => Persona | undefined
47
52
  }
48
53
 
49
54
  /** How long the request guard may outlive the response while the host has not reported the end of the execution. */
@@ -54,6 +59,8 @@ const failure = (status: number, message: string, code: string): PlugEvent => ({
54
59
  error: { status, kind: status >= 500 ? "server" : "invalid_request", message, code },
55
60
  })
56
61
 
62
+ const notFound = (message: string): PlugError => ({ status: 404, kind: "not_found", message, code: "model_not_found", param: "model" })
63
+
57
64
  const countMedia = (request: PlugRequest): number =>
58
65
  request.messages.reduce((sum, message) => sum + message.content.filter((part) => part.type === "media").length, 0)
59
66
 
@@ -123,7 +130,7 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
123
130
  const policy = appPolicy({ apps }, app)
124
131
  const outOfScope = checkAppModel(policy, called.model, called.ref)
125
132
  if (outOfScope) return yield note({ type: "error", error: outOfScope })
126
- const { request, stripped } = applyToolPolicy(called, policy)
133
+ let { request, stripped } = applyToolPolicy(called, policy)
127
134
  const tooMany = checkAppTools(request, app, policy)
128
135
  if (tooMany) return yield note({ type: "error", error: tooMany })
129
136
  if (stripped) {
@@ -166,23 +173,39 @@ export function createSessionCaller(deps: SessionCallerDeps): ModelCaller {
166
173
  if (unavailable) return yield note({ type: "error", error: unavailable })
167
174
  if (context.signal.aborted) return
168
175
 
176
+ // 3b. A persona (see `Persona`). A skill's content leads the system prompt; an agent's prompt replaces the
177
+ // caller's, and the session runs as that agent. Its permissions are still REQUEST_PERMISSIONS (deny all, then seeds).
178
+ const persona = deps.persona?.(called.model)
179
+ let prelude: ReadonlyArray<string> = []
180
+ if (persona?.kind === "skill") {
181
+ const content = await port.skill(directory, persona.skill)
182
+ if (content === undefined) return yield note({ type: "error", error: notFound(`Skill ${persona.skill} is not available`) })
183
+ prelude = [content]
184
+ } else if (persona?.kind === "agent") {
185
+ const agent = await port.agent(directory, persona.agent)
186
+ if (agent === undefined) return yield note({ type: "error", error: notFound(`Agent ${persona.agent} is not available`) })
187
+ request = { ...request, system: agent.system ? [agent.system] : [] }
188
+ }
189
+ if (context.signal.aborted) return
190
+
169
191
  collector = new RequestCollector({ stopTurn: interrupt, requestID })
170
192
  const created = await raceAbort(port.create({
171
193
  directory,
172
194
  model: { providerID: request.ref.providerID, id: request.ref.id, ...(request.ref.variant ? { variant: request.ref.variant } : {}) },
195
+ ...(persona?.kind === "agent" ? { agent: persona.agent } : {}),
173
196
  title: makeTitle(request),
174
197
  metadata: { plugboard: { app, model: request.model, requestID } },
175
198
  permissions: REQUEST_PERMISSIONS,
176
199
  }), context.signal)
177
200
  sessionID = String(created.id)
178
- sessions.register({ sessionID, app, requestID, request, collector })
201
+ sessions.register({ sessionID, app, requestID, request, collector, prelude })
179
202
 
180
203
  context.signal.addEventListener("abort", onAbort, { once: true })
181
204
  if (context.signal.aborted) return onAbort()
182
205
 
183
206
  // The history id is known from here on: tell the front-end before the first event.
184
207
  try {
185
- context.onRecord?.(sessionID)
208
+ context.onRecord?.(sessionID, directory)
186
209
  } catch (error) {
187
210
  logError("request.on_record_failed", { requestID, app, error: describeError(error) })
188
211
  }
@@ -123,7 +123,9 @@ function applyContext(event: unknown, sessions: SessionStore, apps: PlugboardOpt
123
123
  return text("[image unavailable]")
124
124
  }
125
125
 
126
- const system = systemFor(request.system, appPolicy({ apps }, state.app)).map(text)
126
+ // Order: the skill's content (prelude), then the app's system policy over the caller's (or an agent's) prompt,
127
+ // then the tool-choice steering below.
128
+ const system = [...(state.prelude ?? []), ...systemFor(request.system, appPolicy({ apps }, state.app))].map(text)
127
129
  // The OpenCode session API has no tool-choice control, so a forced or required tool is steered by instruction.
128
130
  const choice = request.toolChoice
129
131
  if (choice === "required") system.push(text("You must respond by calling one of the provided tools."))
@@ -61,10 +61,16 @@ const CONTROLS_KEEP_LAYOUT = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u20
61
61
  /** One display line: whitespace (including line breaks) collapsed to single spaces, then control and bidi characters removed. */
62
62
  export const oneLine = (text: string): string => text.replace(/\s+/g, " ").replace(CONTROLS, "").trim()
63
63
 
64
- /** Session title: the first user text, one line, control and bidi characters removed, cut to 60 characters. */
64
+ /**
65
+ * Session title: the end user as `[user] ` when the caller named one (#57), then the first user text, one
66
+ * line, control and bidi characters removed. The whole title is cut to 60 characters.
67
+ */
65
68
  export const makeTitle = (request: PlugRequest): string => {
69
+ const prefix = request.user === undefined ? "" : `[${request.user}] `
66
70
  const flat = oneLine(firstUserText(request))
67
- return flat.length > TITLE_LENGTH ? flat.slice(0, TITLE_LENGTH - 1) + "…" : flat
71
+ const room = TITLE_LENGTH - prefix.length
72
+ const text = flat.length > room ? flat.slice(0, room - 1) + "…" : flat
73
+ return (prefix + text).trimEnd()
68
74
  }
69
75
 
70
76
  /** Readable transcript stored as the session's prompt; images are listed by attachment number. */
@@ -24,12 +24,18 @@ export interface SessionPort {
24
24
  create(input: {
25
25
  readonly directory: string
26
26
  readonly model: { readonly providerID: string; readonly id: string; readonly variant?: string }
27
+ /** The OpenCode agent the session runs as (option `agents`); absent: the host's default agent. */
28
+ readonly agent?: string
27
29
  readonly title: string
28
30
  readonly metadata: { readonly [key: string]: Json }
29
31
  readonly permissions: ReadonlyArray<{ action: string; resource: string; effect: "allow" | "deny" }>
30
32
  }): Promise<{ id: string }>
31
33
  prompt(input: { readonly sessionID: string; readonly text: string; readonly files: ReadonlyArray<{ uri: string; name: string }> }): Promise<unknown>
32
34
  interrupt(sessionID: string): Promise<void>
35
+ /** The content of skill `id` in the Location at `directory`, or undefined when there is none. Briefly cached per directory. */
36
+ skill(directory: string, id: string): Promise<string | undefined>
37
+ /** The agent `id` in the Location at `directory` (its `system` prompt may be absent), or undefined when there is none. */
38
+ agent(directory: string, id: string): Promise<{ readonly system?: string } | undefined>
33
39
  }
34
40
 
35
41
  type HostSession = HostContext["session"]
@@ -45,6 +51,10 @@ const hostSessionID = (id: string): HostSessionID => id as HostSessionID
45
51
  type HostCreate = NonNullable<Parameters<HostSession["create"]>[0]>
46
52
  type HostModelRef = NonNullable<HostCreate["model"]>
47
53
  type HostDirectory = NonNullable<HostCreate["location"]>["directory"]
54
+ type HostAgent = NonNullable<HostCreate["agent"]>
55
+
56
+ /** How long a directory's skill list is reused: a skill edit is seen within this window (per plugin generation). */
57
+ const SKILL_CACHE_MS = 30_000
48
58
 
49
59
  /**
50
60
  * Branded model ref and directory. Same kind of cast as the session ID: the values are the
@@ -65,17 +75,33 @@ const hostDirectory = (directory: string): HostDirectory => directory as HostDir
65
75
  */
66
76
  export function createSessionPort(ctx: HostContext): SessionPort {
67
77
  const run = <A>(effect: Effect.Effect<A, unknown>): Promise<A> => Effect.runPromise(effect)
78
+ const skillsByDirectory = new Map<string, { readonly at: number; readonly skills: ReadonlyMap<string, string> }>()
68
79
  return {
69
80
  create: (input) =>
70
81
  run(
71
82
  ctx.session.create({
72
83
  model: hostModel(input.model),
84
+ ...(input.agent === undefined ? {} : { agent: input.agent as HostAgent }),
73
85
  title: input.title,
74
86
  metadata: input.metadata,
75
87
  permissions: input.permissions,
76
88
  location: { directory: hostDirectory(input.directory) },
77
89
  } satisfies HostCreate),
78
90
  ).then((created) => ({ id: String(created.id) })),
91
+ skill: async (directory, id) => {
92
+ const now = Date.now()
93
+ let held = skillsByDirectory.get(directory)
94
+ if (held === undefined || now - held.at >= SKILL_CACHE_MS) {
95
+ const listed = await run(ctx.skill.list({ location: { directory } }))
96
+ held = { at: now, skills: new Map(listed.data.map((skill): [string, string] => [skill.id, skill.content])) }
97
+ skillsByDirectory.set(directory, held)
98
+ }
99
+ return held.skills.get(id)
100
+ },
101
+ agent: async (directory, id) => {
102
+ const listed = await run(ctx.agent.list({ location: { directory } }))
103
+ return listed.data.find((agent) => agent.id === id)
104
+ },
79
105
  prompt: (input) =>
80
106
  run(
81
107
  ctx.session.prompt({
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Links from a response to the OpenCode session page of its request (#77). The host exposes no UI URL
3
+ * to a plugin, so option `historyUrl` is a template the operator fills in for how they reach OpenCode:
4
+ * `{id}` is the session id, `{directory}` the app directory (URL-encoded). With the option set, a
5
+ * response that carries `x-plugboard-session` also carries `x-plugboard-session-url` with the filled
6
+ * link, and the request log line gains `session_url=`.
7
+ *
8
+ * Pure: no node imports. The routes call `setSessionLink` from their `onRecord` callback, after they
9
+ * set the session header, so a link is only ever sent for a session that was named.
10
+ */
11
+
12
+ export const SESSION_URL_HEADER = "x-plugboard-session-url"
13
+
14
+ /** The template with `{id}` and `{directory}` filled (both URL-encoded). */
15
+ export function sessionUrl(template: string, recordID: string, directory = ""): string {
16
+ return template.replaceAll("{id}", encodeURIComponent(recordID)).replaceAll("{directory}", encodeURIComponent(directory))
17
+ }
18
+
19
+ /**
20
+ * Sets `x-plugboard-session-url` for one history record and the log field, when a template is set.
21
+ * Nothing happens without a template, so a route calls this unconditionally.
22
+ */
23
+ export function setSessionLink(
24
+ response: { setHeader(name: string, value: string): unknown },
25
+ info: { sessionUrl?: string },
26
+ template: string | undefined,
27
+ recordID: string,
28
+ directory?: string,
29
+ ): void {
30
+ if (template === undefined) return
31
+ info.sessionUrl = sessionUrl(template, recordID, directory)
32
+ response.setHeader(SESSION_URL_HEADER, info.sessionUrl)
33
+ }
@@ -32,6 +32,8 @@ export interface RequestState {
32
32
  readonly requestID: string
33
33
  readonly request: PlugRequest
34
34
  readonly collector: EventSink
35
+ /** System parts placed before the app's and the caller's (a skill's content, option `skill:` alias). Empty or absent for a plain model. */
36
+ readonly prelude?: ReadonlyArray<string>
35
37
  }
36
38
 
37
39
  export interface Retirement {
package/src/usage.ts CHANGED
@@ -29,6 +29,7 @@ import { errorMessage, log, logError } from "./log.js"
29
29
  import type { Outcome } from "./metrics.js"
30
30
  import { parseModelRef } from "./models.js"
31
31
  import type { StoragePort } from "./session/host.js"
32
+ import { sanitizeEndUser } from "./user.js"
32
33
 
33
34
  /** Upper bounds of the latency buckets in ms; one more bucket holds everything above the last. */
34
35
  export const LATENCY_BOUNDS_MS: ReadonlyArray<number> = [100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000, 60_000, 120_000, 300_000, 600_000]
@@ -123,9 +124,19 @@ export interface UsageCounts<Latency = LatencyHistogram> {
123
124
  lastRequestAt?: string
124
125
  }
125
126
 
127
+ /** One end user's counts (src/user.ts, #57): HTTP requests on the model routes, tokens and cost of the answered calls. */
128
+ export interface UserUsage {
129
+ requests: number
130
+ /** Total tokens of those calls (the five kinds summed). */
131
+ tokens: number
132
+ costUsd: number
133
+ }
134
+
126
135
  /** One stored day, at `usage/<app>/<date>`. */
127
136
  export interface DayRecord extends UsageCounts {
128
137
  perModel: Record<string, ModelUsage>
138
+ /** Per end user (see `USERS_PER_DAY`). Older stored days have none. */
139
+ users: Record<string, UserUsage>
129
140
  }
130
141
 
131
142
  /** Counters with latency percentiles, as a report carries them. */
@@ -153,6 +164,8 @@ export interface UsageAppReport {
153
164
  totals: UsageTotals
154
165
  /** Per model, summed over the range. The key is `provider/model`; the variant is not part of it. */
155
166
  models: Record<string, ModelUsage>
167
+ /** Per end user, summed over the range (src/user.ts). Past `USERS_PER_DAY` a day's extra users are under `_other`. With `user`, only that user. */
168
+ users?: Record<string, UserUsage>
156
169
  /** Days with any record in the range, oldest first. */
157
170
  days: UsageDay[]
158
171
  /** The app's budget state, when option `budgets` sets one for it. Read from the current window, not from the range. */
@@ -179,6 +192,8 @@ export interface UsageQuery {
179
192
  readonly to?: string
180
193
  /** One app. Absent: every app. */
181
194
  readonly app?: string
195
+ /** One end user (src/user.ts): `users` of each app then lists only that user. Absent: every user. */
196
+ readonly user?: string
182
197
  }
183
198
 
184
199
  // ---------------------------------------------------------------- pure helpers
@@ -213,6 +228,7 @@ export function emptyDay(): DayRecord {
213
228
  limited: 0,
214
229
  unpricedRequests: 0,
215
230
  perModel: {},
231
+ users: {},
216
232
  }
217
233
  }
218
234
 
@@ -296,9 +312,22 @@ export function sumDays(parts: ReadonlyArray<DayRecord>): DayRecord {
296
312
  total.perModel[model] = { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd }
297
313
  }
298
314
  }
315
+ total.users = sumUsers(parts)
299
316
  return total
300
317
  }
301
318
 
319
+ /** Per end user, summed over the day records (`only`: that user alone). */
320
+ function sumUsers(parts: ReadonlyArray<DayRecord>, only?: string): Record<string, UserUsage> {
321
+ const out: Record<string, UserUsage> = {}
322
+ for (const part of parts)
323
+ for (const [user, usage] of Object.entries(part.users ?? {})) {
324
+ if (only !== undefined && user !== only) continue
325
+ const sum = out[user] ?? { requests: 0, tokens: 0, costUsd: 0 }
326
+ out[user] = { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd }
327
+ }
328
+ return out
329
+ }
330
+
302
331
  /** A day's counters with latency percentiles, as a report carries them. */
303
332
  export function summaryOf(day: DayRecord): UsageTotals {
304
333
  return {
@@ -329,6 +358,11 @@ export function fromJson(value: unknown): DayRecord {
329
358
  const usage = object(item)
330
359
  perModel[model] = { requests: nonNegative(usage.requests), tokens: nonNegative(usage.tokens), costUsd: nonNegative(usage.costUsd) }
331
360
  }
361
+ const users: Record<string, UserUsage> = {}
362
+ for (const [user, item] of Object.entries(object(raw.users))) {
363
+ const usage = object(item)
364
+ users[user] = { requests: nonNegative(usage.requests), tokens: nonNegative(usage.tokens), costUsd: nonNegative(usage.costUsd) }
365
+ }
332
366
  return {
333
367
  requests: nonNegative(raw.requests),
334
368
  errors: { client: nonNegative(errors.client), server: nonNegative(errors.server), timeout: nonNegative(errors.timeout), disconnected: nonNegative(errors.disconnected) },
@@ -348,6 +382,7 @@ export function fromJson(value: unknown): DayRecord {
348
382
  unpricedRequests: nonNegative(raw.unpricedRequests),
349
383
  ...(typeof raw.lastRequestAt === "string" ? { lastRequestAt: raw.lastRequestAt } : {}),
350
384
  perModel,
385
+ users,
351
386
  }
352
387
  }
353
388
 
@@ -395,6 +430,8 @@ export interface RequestUsage {
395
430
  readonly status: number
396
431
  readonly outcome: Outcome
397
432
  readonly ms: number
433
+ /** The end user of the request, when kept (src/user.ts). */
434
+ readonly user?: string
398
435
  }
399
436
 
400
437
  /** One model call, when it ended. `answered` when its stream finished with an answer; `cache` when the response cache applied. */
@@ -405,6 +442,8 @@ export interface CallUsage {
405
442
  readonly answered: boolean
406
443
  readonly cache?: "hit" | "miss"
407
444
  readonly usage?: PlugUsage
445
+ /** The end user of the call, when kept (src/user.ts). */
446
+ readonly user?: string
408
447
  }
409
448
 
410
449
  export interface UsageRecorder {
@@ -444,6 +483,18 @@ async function readDay(storage: StoragePort, key: string): Promise<DayRecord | u
444
483
  }
445
484
  }
446
485
 
486
+ /** Distinct end users a day keeps per app. Later users count under `_other` (the key cannot be an end user: no `_`). */
487
+ export const USERS_PER_DAY = 200
488
+ const OTHER_USER = "_other"
489
+
490
+ /** The counts of one end user in a day, created on first use; past the cap the request counts under `_other`. */
491
+ function userSlot(day: DayRecord, user: string): UserUsage {
492
+ if (day.users[user]) return day.users[user]
493
+ const distinct = Object.keys(day.users).length - (day.users[OTHER_USER] ? 1 : 0)
494
+ const key = distinct < USERS_PER_DAY ? user : OTHER_USER
495
+ return (day.users[key] ??= { requests: 0, tokens: 0, costUsd: 0 })
496
+ }
497
+
447
498
  const addTokens = (into: UsageTokens, from: UsageTokens): UsageTokens => ({
448
499
  input: into.input + from.input,
449
500
  cacheRead: into.cacheRead + from.cacheRead,
@@ -558,6 +609,7 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
558
609
  day.latency.sumMs += ms
559
610
  day.latency.buckets[bucketIndex(ms)] = (day.latency.buckets[bucketIndex(ms)] ?? 0) + 1
560
611
  day.lastRequestAt = new Date(at).toISOString()
612
+ if (input.user !== undefined) userSlot(day, input.user).requests++
561
613
  })
562
614
  },
563
615
 
@@ -595,6 +647,11 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
595
647
  day.tokens = addTokens(day.tokens, tokens)
596
648
  day.costUsd += price
597
649
  if (unpriced) day.unpricedRequests++
650
+ if (input.user !== undefined) {
651
+ const slot = userSlot(day, input.user)
652
+ slot.tokens += totalTokens(tokens)
653
+ slot.costUsd += price
654
+ }
598
655
  })
599
656
  })().catch((error) => logError("usage.record_failed", { app: input.app, error: errorMessage(error) }))
600
657
  },
@@ -614,6 +671,8 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
614
671
  const at = now()
615
672
  if (input.app !== undefined && !APP_NAME_PATTERN.test(input.app))
616
673
  return { status: 400, kind: "invalid_request", code: "invalid_app", param: "app", message: "`app` must be an app name from the keys configuration" }
674
+ if (input.user !== undefined && sanitizeEndUser(input.user) === undefined)
675
+ return { status: 400, kind: "invalid_request", code: "invalid_user", param: "user", message: "`user` must be 1 to 64 characters of A-Z, a-z, 0-9, `.`, `_`, `@`, `:` or `-`" }
617
676
  const range = resolveRange(input, at)
618
677
  if (isPlugError(range)) return range
619
678
  const prefix = input.app === undefined ? KEY_PREFIX : keyOf(input.app, "")
@@ -646,7 +705,7 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
646
705
  const total = sumDays(rows.map((row) => row.day))
647
706
  appTotals.push(total)
648
707
  const days: UsageDay[] = rows.map((row) => ({ date: row.date, ...summaryOf(row.day), perModel: { ...row.day.perModel } }))
649
- apps[app] = { totals: summaryOf(total), models: { ...total.perModel }, days }
708
+ apps[app] = { totals: summaryOf(total), models: { ...total.perModel }, users: sumUsers(rows.map((row) => row.day), input.user), days }
650
709
  }
651
710
  return { from: range.from, to: range.to, generatedAt: new Date(at).toISOString(), totals: summaryOf(sumDays(appTotals)), apps }
652
711
  },
@@ -704,7 +763,7 @@ export function withUsage(inner: ModelCaller, recorder: UsageRecorder | undefine
704
763
  }
705
764
  } finally {
706
765
  const ref = served ? parseModelRef(served.model) : request.ref
707
- recorder.call({ app: context.app, ...(ref ? { ref } : {}), answered, ...(cache ? { cache } : {}), ...(usage ? { usage } : {}) })
766
+ recorder.call({ app: context.app, ...(ref ? { ref } : {}), answered, ...(cache ? { cache } : {}), ...(usage ? { usage } : {}), ...(request.user === undefined ? {} : { user: request.user }) })
708
767
  }
709
768
  })()
710
769
  },
package/src/user.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The end user of a request (#57): the caller's own id for one of its users, read from the body
3
+ * (OpenAI `user`, Anthropic `metadata.user_id`; Gemini has none). It labels a session title, the
4
+ * request log line and the per-user usage counts. It never names a path: a value is only ever a
5
+ * string key in a record.
6
+ *
7
+ * A value is kept only when it is trimmed and at most 64 characters of `A-Z a-z 0-9 . _ @ : -`.
8
+ * Anything else is dropped, and the response says so with `x-plugboard-user-dropped: 1`. A blank
9
+ * value is no user at all (neither kept nor reported).
10
+ *
11
+ * Pure: no node imports, so the request decoders (`request.ts` files) can use it (I20).
12
+ */
13
+
14
+ export const USER_DROPPED_HEADER = "x-plugboard-user-dropped"
15
+
16
+ const END_USER = /^[A-Za-z0-9._@:-]{1,64}$/
17
+
18
+ /** The user id to keep, or undefined when the value is absent, blank or not allowed. */
19
+ export function sanitizeEndUser(raw: unknown): string | undefined {
20
+ if (typeof raw !== "string") return undefined
21
+ const trimmed = raw.trim()
22
+ return END_USER.test(trimmed) ? trimmed : undefined
23
+ }
24
+
25
+ /** True when the caller sent a value that is not kept. Absent and blank values are not dropped. */
26
+ export function endUserDropped(raw: unknown): boolean {
27
+ if (raw === undefined || raw === null) return false
28
+ if (typeof raw === "string" && raw.trim() === "") return false
29
+ return sanitizeEndUser(raw) === undefined
30
+ }
31
+
32
+ /** `body.user`, the OpenAI-shaped field. */
33
+ export const bodyUser = (body: unknown): unknown => (typeof body === "object" && body !== null ? (body as { user?: unknown }).user : undefined)
34
+
35
+ /** `body.metadata.user_id`, the Anthropic-shaped field. */
36
+ export function bodyUserId(body: unknown): unknown {
37
+ const metadata = typeof body === "object" && body !== null ? (body as { metadata?: unknown }).metadata : undefined
38
+ return typeof metadata === "object" && metadata !== null ? (metadata as { user_id?: unknown }).user_id : undefined
39
+ }
40
+
41
+ /** The `user` member of a `PlugRequest` (spread into it), empty when no user is kept. */
42
+ export const userField = (raw: unknown): { user?: string } => {
43
+ const user = sanitizeEndUser(raw)
44
+ return user === undefined ? {} : { user }
45
+ }
46
+
47
+ /**
48
+ * For a route, after the body is read: sets `x-plugboard-user-dropped` when the value is not kept (the
49
+ * headers are still open then), and names the kept user in the request log fields.
50
+ */
51
+ export function noteEndUser(response: { setHeader(name: string, value: string): unknown }, info: { user?: string }, raw: unknown): void {
52
+ if (endUserDropped(raw)) response.setHeader(USER_DROPPED_HEADER, "1")
53
+ const user = sanitizeEndUser(raw)
54
+ if (user !== undefined) info.user = user
55
+ }