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/usage.ts CHANGED
@@ -23,12 +23,13 @@
23
23
  * at 0 and its call counted in `unpricedRequests`.
24
24
  */
25
25
 
26
- import { APP_NAME_PATTERN } from "./config.js"
26
+ import { APP_NAME_PATTERN, type BudgetMetric, type BudgetWindow } from "./config.js"
27
27
  import { isPlugError, type CallContext, type CatalogCost, type CatalogModel, type ModelCaller, type ModelRef, type PlugError, type PlugEvent, type PlugRequest, type PlugUsage, type ServedModel } from "./contract.js"
28
28
  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]
@@ -115,7 +116,7 @@ export interface UsageCounts<Latency = LatencyHistogram> {
115
116
  cacheMisses: number
116
117
  /** Retries on the next target of an alias after a failure before any output. */
117
118
  fallbacks: number
118
- /** Calls refused with 429 by a concurrency cap or a requests-per-minute limit (also counted in `requests` and `errors.client`). */
119
+ /** Calls refused with 429 by a concurrency cap, a requests-per-minute limit or a budget (also counted in `requests` and `errors.client`). */
119
120
  limited: number
120
121
  /** Answered calls that spent tokens but had no price in the catalog (their cost counts as 0). */
121
122
  unpricedRequests: number
@@ -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. */
@@ -137,13 +148,28 @@ export interface UsageDay extends UsageTotals {
137
148
  perModel: Record<string, ModelUsage>
138
149
  }
139
150
 
151
+ /** One ceiling of an app's budget, with what its current window has used (`GET /v1/usage`, see `src/budgets.ts`). */
152
+ export interface BudgetStatus {
153
+ readonly metric: BudgetMetric
154
+ readonly window: BudgetWindow
155
+ readonly limit: number
156
+ /** Counted so far in the current window; a cost is in USD. */
157
+ readonly used: number
158
+ /** ISO 8601 time the current window ends and its count starts again. */
159
+ readonly resetsAt: string
160
+ }
161
+
140
162
  export interface UsageAppReport {
141
163
  /** Sums over the days of the range. */
142
164
  totals: UsageTotals
143
165
  /** Per model, summed over the range. The key is `provider/model`; the variant is not part of it. */
144
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>
145
169
  /** Days with any record in the range, oldest first. */
146
170
  days: UsageDay[]
171
+ /** The app's budget state, when option `budgets` sets one for it. Read from the current window, not from the range. */
172
+ budgets?: BudgetStatus[]
147
173
  }
148
174
 
149
175
  /** The result of `GET /v1/usage` and of the `opencode-plugboard.usage` RPC (`src/rpc.ts`). */
@@ -166,6 +192,8 @@ export interface UsageQuery {
166
192
  readonly to?: string
167
193
  /** One app. Absent: every app. */
168
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
169
197
  }
170
198
 
171
199
  // ---------------------------------------------------------------- pure helpers
@@ -200,6 +228,7 @@ export function emptyDay(): DayRecord {
200
228
  limited: 0,
201
229
  unpricedRequests: 0,
202
230
  perModel: {},
231
+ users: {},
203
232
  }
204
233
  }
205
234
 
@@ -235,7 +264,7 @@ export function tokenBreakdown(usage: PlugUsage): UsageTokens {
235
264
  }
236
265
  }
237
266
 
238
- const totalTokens = (tokens: UsageTokens): number => tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning
267
+ export const totalTokens = (tokens: UsageTokens): number => tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning
239
268
 
240
269
  /**
241
270
  * The USD cost of one call, or undefined when the model has no applicable price. Mirrors OpenCode's
@@ -283,9 +312,22 @@ export function sumDays(parts: ReadonlyArray<DayRecord>): DayRecord {
283
312
  total.perModel[model] = { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd }
284
313
  }
285
314
  }
315
+ total.users = sumUsers(parts)
286
316
  return total
287
317
  }
288
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
+
289
331
  /** A day's counters with latency percentiles, as a report carries them. */
290
332
  export function summaryOf(day: DayRecord): UsageTotals {
291
333
  return {
@@ -316,6 +358,11 @@ export function fromJson(value: unknown): DayRecord {
316
358
  const usage = object(item)
317
359
  perModel[model] = { requests: nonNegative(usage.requests), tokens: nonNegative(usage.tokens), costUsd: nonNegative(usage.costUsd) }
318
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
+ }
319
366
  return {
320
367
  requests: nonNegative(raw.requests),
321
368
  errors: { client: nonNegative(errors.client), server: nonNegative(errors.server), timeout: nonNegative(errors.timeout), disconnected: nonNegative(errors.disconnected) },
@@ -335,6 +382,7 @@ export function fromJson(value: unknown): DayRecord {
335
382
  unpricedRequests: nonNegative(raw.unpricedRequests),
336
383
  ...(typeof raw.lastRequestAt === "string" ? { lastRequestAt: raw.lastRequestAt } : {}),
337
384
  perModel,
385
+ users,
338
386
  }
339
387
  }
340
388
 
@@ -382,6 +430,8 @@ export interface RequestUsage {
382
430
  readonly status: number
383
431
  readonly outcome: Outcome
384
432
  readonly ms: number
433
+ /** The end user of the request, when kept (src/user.ts). */
434
+ readonly user?: string
385
435
  }
386
436
 
387
437
  /** One model call, when it ended. `answered` when its stream finished with an answer; `cache` when the response cache applied. */
@@ -392,6 +442,8 @@ export interface CallUsage {
392
442
  readonly answered: boolean
393
443
  readonly cache?: "hit" | "miss"
394
444
  readonly usage?: PlugUsage
445
+ /** The end user of the call, when kept (src/user.ts). */
446
+ readonly user?: string
395
447
  }
396
448
 
397
449
  export interface UsageRecorder {
@@ -399,8 +451,12 @@ export interface UsageRecorder {
399
451
  limited(app: string): void
400
452
  fallback(app: string): void
401
453
  call(input: CallUsage): void
454
+ /** The USD cost of a call's tokens under the serving catalog's price for `ref`, or undefined when unpriced (`x-plugboard-usage`, #52). */
455
+ price(ref: ModelRef, tokens: UsageTokens): Promise<number | undefined>
402
456
  /** A report for the range and app (defaults as `resolveRange`). A bad input is a 400 `PlugError`. */
403
457
  query(input: UsageQuery): Promise<UsageReport | PlugError>
458
+ /** The sum of one app's days from `from` to `to` (UTC, inclusive, `YYYY-MM-DD`), memory first. Read by the budget check; a storage failure rejects. */
459
+ spent(app: string, from: string, to: string): Promise<DayRecord>
404
460
  }
405
461
 
406
462
  export interface UsageRecorderDeps {
@@ -427,6 +483,18 @@ async function readDay(storage: StoragePort, key: string): Promise<DayRecord | u
427
483
  }
428
484
  }
429
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
+
430
498
  const addTokens = (into: UsageTokens, from: UsageTokens): UsageTokens => ({
431
499
  input: into.input + from.input,
432
500
  cacheRead: into.cacheRead + from.cacheRead,
@@ -541,6 +609,7 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
541
609
  day.latency.sumMs += ms
542
610
  day.latency.buckets[bucketIndex(ms)] = (day.latency.buckets[bucketIndex(ms)] ?? 0) + 1
543
611
  day.lastRequestAt = new Date(at).toISOString()
612
+ if (input.user !== undefined) userSlot(day, input.user).requests++
544
613
  })
545
614
  },
546
615
 
@@ -578,14 +647,32 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
578
647
  day.tokens = addTokens(day.tokens, tokens)
579
648
  day.costUsd += price
580
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
+ }
581
655
  })
582
656
  })().catch((error) => logError("usage.record_failed", { app: input.app, error: errorMessage(error) }))
583
657
  },
584
658
 
659
+ price: priceOf,
660
+ async spent(app, from, to) {
661
+ const parts: DayRecord[] = []
662
+ const last = Date.parse(`${to}T00:00:00Z`)
663
+ for (let at = Date.parse(`${from}T00:00:00Z`); at <= last; at += DAY_MS) {
664
+ const entry = await load(app, utcDate(at), false)
665
+ if (entry) parts.push(entry.day)
666
+ }
667
+ return sumDays(parts)
668
+ },
669
+
585
670
  async query(input) {
586
671
  const at = now()
587
672
  if (input.app !== undefined && !APP_NAME_PATTERN.test(input.app))
588
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 `-`" }
589
676
  const range = resolveRange(input, at)
590
677
  if (isPlugError(range)) return range
591
678
  const prefix = input.app === undefined ? KEY_PREFIX : keyOf(input.app, "")
@@ -618,7 +705,7 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
618
705
  const total = sumDays(rows.map((row) => row.day))
619
706
  appTotals.push(total)
620
707
  const days: UsageDay[] = rows.map((row) => ({ date: row.date, ...summaryOf(row.day), perModel: { ...row.day.perModel } }))
621
- 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 }
622
709
  }
623
710
  return { from: range.from, to: range.to, generatedAt: new Date(at).toISOString(), totals: summaryOf(sumDays(appTotals)), apps }
624
711
  },
@@ -659,12 +746,24 @@ export function withUsage(inner: ModelCaller, recorder: UsageRecorder | undefine
659
746
  if (event.type === "finish") {
660
747
  answered = event.reason !== "error"
661
748
  usage = event.usage
749
+ if (answered && context.onUsage) {
750
+ // The header is set before the finish reaches the front-end, so a non-streaming answer carries it.
751
+ const ref = served ? parseModelRef(served.model) : request.ref
752
+ const tokens = tokenBreakdown(usage ?? {})
753
+ const costUsd = ref ? await recorder.price(ref, tokens) : undefined
754
+ context.onUsage({
755
+ inputTokens: usage?.inputTokens ?? 0,
756
+ outputTokens: usage?.outputTokens ?? 0,
757
+ cacheReadTokens: usage?.cacheReadTokens ?? 0,
758
+ ...(costUsd === undefined ? {} : { costUsd }),
759
+ })
760
+ }
662
761
  }
663
762
  yield event
664
763
  }
665
764
  } finally {
666
765
  const ref = served ? parseModelRef(served.model) : request.ref
667
- 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 }) })
668
767
  }
669
768
  })()
670
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
+ }