opencode-plugboard 0.15.0 → 0.17.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 ADDED
@@ -0,0 +1,672 @@
1
+ /**
2
+ * Per-app usage statistics: one record per app per UTC day.
3
+ *
4
+ * What a day holds (`DayRecord`): HTTP requests on the model routes with their error classes and a
5
+ * latency histogram, the tokens of answered model calls by kind (the same non-overlapping split as
6
+ * `plugboard_tokens_total`), the cost in USD from the catalog's prices, cache hits and misses,
7
+ * fallbacks, 429 refusals, the requests and cost per model, and when the last request ended.
8
+ *
9
+ * Where it lives: the live aggregate is in memory on the process runtime (`src/runtime.ts`), so it
10
+ * survives a plugin generation, and each day is persisted to the plugin's host storage as JSON under
11
+ * `usage/<app>/<YYYY-MM-DD>`. Every write comes from request handling (`withUsage` and the route's
12
+ * request log point), never from plugin setup or a finalizer (docs/architecture.md, I23). A day is
13
+ * written at most every `FLUSH_MS`: a change is persisted by the first request that finds the last
14
+ * write that old, so the stored copy can lag a quiet period behind memory. Queries read memory first,
15
+ * so a report is never stale.
16
+ *
17
+ * Retention: days older than `usage.retentionDays` are deleted from request handling, one bounded
18
+ * page of keys at a time and at most once per `PRUNE_EVERY_MS`.
19
+ *
20
+ * Cost follows OpenCode's own accounting (`calculateCost` in packages/core/src/session/usage.ts): the
21
+ * price entry is the one with the largest `aboveContext` that the prompt (uncached input plus cache
22
+ * reads and writes) exceeds, else the entry without one. A model with no applicable entry is priced
23
+ * at 0 and its call counted in `unpricedRequests`.
24
+ */
25
+
26
+ import { APP_NAME_PATTERN } from "./config.js"
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
+ import { errorMessage, log, logError } from "./log.js"
29
+ import type { Outcome } from "./metrics.js"
30
+ import { parseModelRef } from "./models.js"
31
+ import type { StoragePort } from "./session/host.js"
32
+
33
+ /** Upper bounds of the latency buckets in ms; one more bucket holds everything above the last. */
34
+ 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]
35
+
36
+ /** Routes whose requests and model calls are usage: the model endpoints, not discovery, status or the store itself. */
37
+ export const GENERATION_ROUTES: ReadonlySet<string> = new Set([
38
+ "POST /v1/chat/completions",
39
+ "POST /v1/completions",
40
+ "POST /v1/responses",
41
+ "POST /v1/messages",
42
+ "POST /v1/models/{model}:generateContent",
43
+ "POST /v1/models/{model}:streamGenerateContent",
44
+ "POST /v1beta/models/{model}:generateContent",
45
+ "POST /v1beta/models/{model}:streamGenerateContent",
46
+ ])
47
+
48
+ const DAY_MS = 86_400_000
49
+ const KEY_PREFIX = "usage/"
50
+ /** A changed day is written at most this often. */
51
+ const FLUSH_MS = 10_000
52
+ /** Retention runs at most this often, and each run reads one page of keys. */
53
+ const PRUNE_EVERY_MS = 10 * 60_000
54
+ const PRUNE_PAGE = 200
55
+ const QUERY_PAGE = 500
56
+ const DEFAULT_RANGE_DAYS = 30
57
+ const MAX_RANGE_DAYS = 366
58
+ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
59
+
60
+ // ---------------------------------------------------------------- shapes
61
+
62
+ /** Non-overlapping token counts, the same split as `plugboard_tokens_total`. */
63
+ export interface UsageTokens {
64
+ /** Uncached prompt tokens. */
65
+ input: number
66
+ cacheRead: number
67
+ cacheWrite: number
68
+ /** Output tokens excluding reasoning. */
69
+ output: number
70
+ reasoning: number
71
+ }
72
+
73
+ export interface UsageErrors {
74
+ /** 4xx answers, including 429 refusals. */
75
+ client: number
76
+ /** 5xx answers, and errors after the response had started. */
77
+ server: number
78
+ /** The request deadline passed. */
79
+ timeout: number
80
+ /** The client went away before the answer ended. */
81
+ disconnected: number
82
+ }
83
+
84
+ /** Latency as counts per bucket: `buckets[i]` counts requests up to `LATENCY_BOUNDS_MS[i]` ms (and above the bound before it); the last counts those above the last bound. */
85
+ export interface LatencyHistogram {
86
+ count: number
87
+ sumMs: number
88
+ buckets: number[]
89
+ }
90
+
91
+ export interface LatencySummary extends LatencyHistogram {
92
+ /** Estimate: the upper bound of the bucket holding the 50th percentile; null without requests. The overflow bucket reports the largest bound. */
93
+ p50Ms: number | null
94
+ /** As `p50Ms`, for the 95th percentile. */
95
+ p95Ms: number | null
96
+ }
97
+
98
+ export interface ModelUsage {
99
+ /** Answered calls of this model (a cache hit counts; its tokens are 0). */
100
+ requests: number
101
+ /** Total tokens of those calls (the five kinds summed). */
102
+ tokens: number
103
+ costUsd: number
104
+ }
105
+
106
+ /** The counters of one day, of several days, or of one app. */
107
+ export interface UsageCounts<Latency = LatencyHistogram> {
108
+ /** HTTP requests on the model routes that reached an authenticated app. */
109
+ requests: number
110
+ errors: UsageErrors
111
+ latency: Latency
112
+ tokens: UsageTokens
113
+ costUsd: number
114
+ cacheHits: number
115
+ cacheMisses: number
116
+ /** Retries on the next target of an alias after a failure before any output. */
117
+ 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
+ limited: number
120
+ /** Answered calls that spent tokens but had no price in the catalog (their cost counts as 0). */
121
+ unpricedRequests: number
122
+ /** ISO 8601 time the last counted request ended. Absent when none did. */
123
+ lastRequestAt?: string
124
+ }
125
+
126
+ /** One stored day, at `usage/<app>/<date>`. */
127
+ export interface DayRecord extends UsageCounts {
128
+ perModel: Record<string, ModelUsage>
129
+ }
130
+
131
+ /** Counters with latency percentiles, as a report carries them. */
132
+ export type UsageTotals = UsageCounts<LatencySummary>
133
+
134
+ export interface UsageDay extends UsageTotals {
135
+ /** UTC day, `YYYY-MM-DD`. */
136
+ date: string
137
+ perModel: Record<string, ModelUsage>
138
+ }
139
+
140
+ export interface UsageAppReport {
141
+ /** Sums over the days of the range. */
142
+ totals: UsageTotals
143
+ /** Per model, summed over the range. The key is `provider/model`; the variant is not part of it. */
144
+ models: Record<string, ModelUsage>
145
+ /** Days with any record in the range, oldest first. */
146
+ days: UsageDay[]
147
+ }
148
+
149
+ /** The result of `GET /v1/usage` and of the `opencode-plugboard.usage` RPC (`src/rpc.ts`). */
150
+ export interface UsageReport {
151
+ /** First and last UTC day of the range, inclusive. */
152
+ from: string
153
+ to: string
154
+ /** ISO 8601 time the report was built. */
155
+ generatedAt: string
156
+ /** Sums over the apps in `apps`. */
157
+ totals: UsageTotals
158
+ /** Apps with a record in the range. With an `app` filter, that app is always present (empty when it has no records). */
159
+ apps: Record<string, UsageAppReport>
160
+ }
161
+
162
+ export interface UsageQuery {
163
+ /** `YYYY-MM-DD`, UTC. Default: 29 days before `to`. */
164
+ readonly from?: string
165
+ /** `YYYY-MM-DD`, UTC. Default: today. */
166
+ readonly to?: string
167
+ /** One app. Absent: every app. */
168
+ readonly app?: string
169
+ }
170
+
171
+ // ---------------------------------------------------------------- pure helpers
172
+
173
+ const nonNegative = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0)
174
+
175
+ /** The UTC calendar day of an epoch-ms instant, `YYYY-MM-DD`. */
176
+ export const utcDate = (at: number): string => new Date(at).toISOString().slice(0, 10)
177
+
178
+ const isDate = (value: string): boolean => {
179
+ if (!DATE_PATTERN.test(value)) return false
180
+ const ms = Date.parse(`${value}T00:00:00Z`)
181
+ return Number.isFinite(ms) && utcDate(ms) === value
182
+ }
183
+
184
+ /** The UTC day a storage key belongs to, or undefined for a key that is not a usage day. */
185
+ const dateOf = (key: string): string | undefined => {
186
+ const parts = key.split("/")
187
+ return parts.length === 3 && parts[0] === "usage" && DATE_PATTERN.test(parts[2] ?? "") ? parts[2] : undefined
188
+ }
189
+
190
+ export function emptyDay(): DayRecord {
191
+ return {
192
+ requests: 0,
193
+ errors: { client: 0, server: 0, timeout: 0, disconnected: 0 },
194
+ latency: { count: 0, sumMs: 0, buckets: Array.from({ length: LATENCY_BOUNDS_MS.length + 1 }, () => 0) },
195
+ tokens: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 },
196
+ costUsd: 0,
197
+ cacheHits: 0,
198
+ cacheMisses: 0,
199
+ fallbacks: 0,
200
+ limited: 0,
201
+ unpricedRequests: 0,
202
+ perModel: {},
203
+ }
204
+ }
205
+
206
+ /** The bucket a latency falls in (see `LatencyHistogram`). */
207
+ export function bucketIndex(ms: number): number {
208
+ const index = LATENCY_BOUNDS_MS.findIndex((bound) => ms <= bound)
209
+ return index === -1 ? LATENCY_BOUNDS_MS.length : index
210
+ }
211
+
212
+ /** The upper bound of the bucket holding the `p` quantile; null without requests. */
213
+ export function percentileMs(latency: LatencyHistogram, p: number): number | null {
214
+ if (latency.count === 0) return null
215
+ const target = Math.ceil(p * latency.count)
216
+ let seen = 0
217
+ for (let index = 0; index < latency.buckets.length; index++) {
218
+ seen += latency.buckets[index] ?? 0
219
+ if (seen >= target) return LATENCY_BOUNDS_MS[Math.min(index, LATENCY_BOUNDS_MS.length - 1)] ?? null
220
+ }
221
+ return LATENCY_BOUNDS_MS.at(-1) ?? null
222
+ }
223
+
224
+ /** A call's usage as the five non-overlapping kinds (`PlugUsage` totals are inclusive). */
225
+ export function tokenBreakdown(usage: PlugUsage): UsageTokens {
226
+ const cacheRead = nonNegative(usage.cacheReadTokens)
227
+ const cacheWrite = nonNegative(usage.cacheWriteTokens)
228
+ const reasoning = nonNegative(usage.reasoningTokens)
229
+ return {
230
+ input: Math.max(0, nonNegative(usage.inputTokens) - cacheRead - cacheWrite),
231
+ cacheRead,
232
+ cacheWrite,
233
+ output: Math.max(0, nonNegative(usage.outputTokens) - reasoning),
234
+ reasoning,
235
+ }
236
+ }
237
+
238
+ const totalTokens = (tokens: UsageTokens): number => tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning
239
+
240
+ /**
241
+ * The USD cost of one call, or undefined when the model has no applicable price. Mirrors OpenCode's
242
+ * `calculateCost`: the prompt is uncached input plus cache reads and writes; the price entry is the
243
+ * one with the largest `aboveContext` below that prompt size (`>`, as the host compares), else the
244
+ * entry without one. Output and reasoning are both priced at the output rate.
245
+ */
246
+ export function priceCall(costs: ReadonlyArray<CatalogCost> | undefined, tokens: UsageTokens): number | undefined {
247
+ if (!costs) return undefined
248
+ const prompt = tokens.input + tokens.cacheRead + tokens.cacheWrite
249
+ const tier = costs
250
+ .filter((cost): cost is CatalogCost & { readonly aboveContext: number } => cost.aboveContext !== undefined && prompt > cost.aboveContext)
251
+ .sort((a, b) => b.aboveContext - a.aboveContext)[0]
252
+ const price = tier ?? costs.find((cost) => cost.aboveContext === undefined)
253
+ if (!price) return undefined
254
+ return (tokens.input * price.input + (tokens.output + tokens.reasoning) * price.output + tokens.cacheRead * price.cacheRead + tokens.cacheWrite * price.cacheWrite) / 1_000_000
255
+ }
256
+
257
+ /** The sum of day records (a new record; the inputs are not changed). */
258
+ export function sumDays(parts: ReadonlyArray<DayRecord>): DayRecord {
259
+ const total = emptyDay()
260
+ for (const part of parts) {
261
+ total.requests += part.requests
262
+ total.errors.client += part.errors.client
263
+ total.errors.server += part.errors.server
264
+ total.errors.timeout += part.errors.timeout
265
+ total.errors.disconnected += part.errors.disconnected
266
+ total.latency.count += part.latency.count
267
+ total.latency.sumMs += part.latency.sumMs
268
+ total.latency.buckets = total.latency.buckets.map((value, index) => value + (part.latency.buckets[index] ?? 0))
269
+ total.tokens.input += part.tokens.input
270
+ total.tokens.cacheRead += part.tokens.cacheRead
271
+ total.tokens.cacheWrite += part.tokens.cacheWrite
272
+ total.tokens.output += part.tokens.output
273
+ total.tokens.reasoning += part.tokens.reasoning
274
+ total.costUsd += part.costUsd
275
+ total.cacheHits += part.cacheHits
276
+ total.cacheMisses += part.cacheMisses
277
+ total.fallbacks += part.fallbacks
278
+ total.limited += part.limited
279
+ total.unpricedRequests += part.unpricedRequests
280
+ if (part.lastRequestAt !== undefined && (total.lastRequestAt === undefined || part.lastRequestAt > total.lastRequestAt)) total.lastRequestAt = part.lastRequestAt
281
+ for (const [model, usage] of Object.entries(part.perModel)) {
282
+ const sum = total.perModel[model] ?? { requests: 0, tokens: 0, costUsd: 0 }
283
+ total.perModel[model] = { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd }
284
+ }
285
+ }
286
+ return total
287
+ }
288
+
289
+ /** A day's counters with latency percentiles, as a report carries them. */
290
+ export function summaryOf(day: DayRecord): UsageTotals {
291
+ return {
292
+ requests: day.requests,
293
+ errors: { ...day.errors },
294
+ latency: { count: day.latency.count, sumMs: day.latency.sumMs, buckets: [...day.latency.buckets], p50Ms: percentileMs(day.latency, 0.5), p95Ms: percentileMs(day.latency, 0.95) },
295
+ tokens: { ...day.tokens },
296
+ costUsd: day.costUsd,
297
+ cacheHits: day.cacheHits,
298
+ cacheMisses: day.cacheMisses,
299
+ fallbacks: day.fallbacks,
300
+ limited: day.limited,
301
+ unpricedRequests: day.unpricedRequests,
302
+ ...(day.lastRequestAt === undefined ? {} : { lastRequestAt: day.lastRequestAt }),
303
+ }
304
+ }
305
+
306
+ /** Reads a stored day, tolerating missing or mistyped fields (they count as zero), so an older record still reads. */
307
+ export function fromJson(value: unknown): DayRecord {
308
+ const object = (item: unknown): Record<string, unknown> => (typeof item === "object" && item !== null ? (item as Record<string, unknown>) : {})
309
+ const raw = object(value)
310
+ const errors = object(raw.errors)
311
+ const latency = object(raw.latency)
312
+ const tokens = object(raw.tokens)
313
+ const buckets: unknown[] = Array.isArray(latency.buckets) ? latency.buckets : []
314
+ const perModel: Record<string, ModelUsage> = {}
315
+ for (const [model, item] of Object.entries(object(raw.perModel))) {
316
+ const usage = object(item)
317
+ perModel[model] = { requests: nonNegative(usage.requests), tokens: nonNegative(usage.tokens), costUsd: nonNegative(usage.costUsd) }
318
+ }
319
+ return {
320
+ requests: nonNegative(raw.requests),
321
+ errors: { client: nonNegative(errors.client), server: nonNegative(errors.server), timeout: nonNegative(errors.timeout), disconnected: nonNegative(errors.disconnected) },
322
+ latency: { count: nonNegative(latency.count), sumMs: nonNegative(latency.sumMs), buckets: emptyDay().latency.buckets.map((_, index) => nonNegative(buckets[index])) },
323
+ tokens: {
324
+ input: nonNegative(tokens.input),
325
+ cacheRead: nonNegative(tokens.cacheRead),
326
+ cacheWrite: nonNegative(tokens.cacheWrite),
327
+ output: nonNegative(tokens.output),
328
+ reasoning: nonNegative(tokens.reasoning),
329
+ },
330
+ costUsd: nonNegative(raw.costUsd),
331
+ cacheHits: nonNegative(raw.cacheHits),
332
+ cacheMisses: nonNegative(raw.cacheMisses),
333
+ fallbacks: nonNegative(raw.fallbacks),
334
+ limited: nonNegative(raw.limited),
335
+ unpricedRequests: nonNegative(raw.unpricedRequests),
336
+ ...(typeof raw.lastRequestAt === "string" ? { lastRequestAt: raw.lastRequestAt } : {}),
337
+ perModel,
338
+ }
339
+ }
340
+
341
+ const dateError = (param: "from" | "to", message: string, code = "invalid_date"): PlugError => ({ status: 400, kind: "invalid_request", code, param, message })
342
+
343
+ /** Validates `from` and `to` (defaults: the 30 days ending today, UTC) and the size of the range. */
344
+ export function resolveRange(query: UsageQuery, at: number): { readonly from: string; readonly to: string } | PlugError {
345
+ const to = query.to ?? utcDate(at)
346
+ if (!isDate(to)) return dateError("to", "`to` must be a UTC date, YYYY-MM-DD")
347
+ const from = query.from ?? utcDate(Date.parse(`${to}T00:00:00Z`) - (DEFAULT_RANGE_DAYS - 1) * DAY_MS)
348
+ if (!isDate(from)) return dateError("from", "`from` must be a UTC date, YYYY-MM-DD")
349
+ if (from > to) return dateError("from", "`from` must not be after `to`", "invalid_range")
350
+ const days = (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS + 1
351
+ if (days > MAX_RANGE_DAYS) return dateError("from", `The range may span at most ${MAX_RANGE_DAYS} days`, "range_too_long")
352
+ return { from, to }
353
+ }
354
+
355
+ // ---------------------------------------------------------------- the recorder
356
+
357
+ /** The live state, held on the process runtime (src/runtime.ts) so it outlives a plugin generation. */
358
+ export interface UsageState {
359
+ /** Days in memory, keyed by `usage/<app>/<date>`. A day is read from storage on its first access. */
360
+ readonly entries: Map<string, Entry>
361
+ readonly prune: { at: number; running: boolean; after?: string }
362
+ }
363
+
364
+ interface Entry {
365
+ readonly key: string
366
+ readonly app: string
367
+ readonly date: string
368
+ day: DayRecord
369
+ /** Bumped by every change; `flushedVersion` is the version last persisted. */
370
+ version: number
371
+ flushedVersion: number
372
+ flushedAt: number
373
+ flushing?: Promise<void>
374
+ }
375
+
376
+ export const createUsageState = (): UsageState => ({ entries: new Map(), prune: { at: 0, running: false } })
377
+
378
+ /** One finished HTTP request, from the route's request log point. */
379
+ export interface RequestUsage {
380
+ readonly app: string
381
+ readonly route: string
382
+ readonly status: number
383
+ readonly outcome: Outcome
384
+ readonly ms: number
385
+ }
386
+
387
+ /** One model call, when it ended. `answered` when its stream finished with an answer; `cache` when the response cache applied. */
388
+ export interface CallUsage {
389
+ readonly app: string
390
+ /** The model that answered, or the requested one when there was no fallback chain. */
391
+ readonly ref?: ModelRef
392
+ readonly answered: boolean
393
+ readonly cache?: "hit" | "miss"
394
+ readonly usage?: PlugUsage
395
+ }
396
+
397
+ export interface UsageRecorder {
398
+ request(input: RequestUsage): void
399
+ limited(app: string): void
400
+ fallback(app: string): void
401
+ call(input: CallUsage): void
402
+ /** A report for the range and app (defaults as `resolveRange`). A bad input is a 400 `PlugError`. */
403
+ query(input: UsageQuery): Promise<UsageReport | PlugError>
404
+ }
405
+
406
+ export interface UsageRecorderDeps {
407
+ readonly state: UsageState
408
+ /** The plugin's host storage (`createStoragePort`). Server-wide, so any live generation's works. */
409
+ readonly storage: StoragePort
410
+ /** The serving Location's catalog, for prices. A failure leaves the call unpriced. */
411
+ readonly loadCatalog: () => Promise<ReadonlyArray<CatalogModel>>
412
+ readonly retentionDays: number
413
+ /** Epoch ms. Tests inject a clock; the default is `Date.now`. */
414
+ readonly now?: () => number
415
+ }
416
+
417
+ const keyOf = (app: string, date: string): string => `${KEY_PREFIX}${app}/${date}`
418
+
419
+ async function readDay(storage: StoragePort, key: string): Promise<DayRecord | undefined> {
420
+ const raw = await storage.get(key)
421
+ if (raw === undefined) return undefined
422
+ try {
423
+ return fromJson(JSON.parse(raw))
424
+ } catch {
425
+ logError("usage.unreadable", { key })
426
+ return undefined
427
+ }
428
+ }
429
+
430
+ const addTokens = (into: UsageTokens, from: UsageTokens): UsageTokens => ({
431
+ input: into.input + from.input,
432
+ cacheRead: into.cacheRead + from.cacheRead,
433
+ cacheWrite: into.cacheWrite + from.cacheWrite,
434
+ output: into.output + from.output,
435
+ reasoning: into.reasoning + from.reasoning,
436
+ })
437
+
438
+ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
439
+ const { state, storage } = deps
440
+ const now = deps.now ?? Date.now
441
+
442
+ /** The entry for one day: held in memory, else read from storage (once), else new when `create`. */
443
+ async function load(app: string, date: string, create: boolean): Promise<Entry | undefined> {
444
+ const key = keyOf(app, date)
445
+ const held = state.entries.get(key)
446
+ if (held) return held
447
+ const stored = await readDay(storage, key)
448
+ if (stored === undefined && !create) return undefined
449
+ // Another request may have loaded the day while this one read storage: keep the first entry.
450
+ const current = state.entries.get(key)
451
+ if (current) return current
452
+ const entry: Entry = { key, app, date, day: stored ?? emptyDay(), version: 0, flushedVersion: 0, flushedAt: 0 }
453
+ state.entries.set(key, entry)
454
+ return entry
455
+ }
456
+
457
+ /** Persists a changed day when the last write is older than `FLUSH_MS`. The JSON is taken now; a later change waits for the next write. */
458
+ function flushIfDue(entry: Entry, at: number): void {
459
+ if (entry.version === entry.flushedVersion || entry.flushing !== undefined || at - entry.flushedAt < FLUSH_MS) return
460
+ const version = entry.version
461
+ const json = JSON.stringify(entry.day)
462
+ entry.flushedAt = at
463
+ entry.flushing = storage
464
+ .put(entry.key, json)
465
+ .then(
466
+ () => {
467
+ entry.flushedVersion = version
468
+ },
469
+ (error) => logError("usage.flush_failed", { app: entry.app, date: entry.date, error: errorMessage(error) }),
470
+ )
471
+ .finally(() => {
472
+ entry.flushing = undefined
473
+ })
474
+ }
475
+
476
+ /** Deletes the days older than the retention window: one page of keys per run, at most once per `PRUNE_EVERY_MS`. */
477
+ function maybePrune(at: number): void {
478
+ const prune = state.prune
479
+ if (prune.running || at - prune.at < PRUNE_EVERY_MS) return
480
+ prune.running = true
481
+ prune.at = at
482
+ const cutoff = utcDate(at - (deps.retentionDays - 1) * DAY_MS)
483
+ void (async () => {
484
+ try {
485
+ const page = await storage.scan({ prefix: KEY_PREFIX, ...(prune.after === undefined ? {} : { after: prune.after }), limit: PRUNE_PAGE })
486
+ let dropped = 0
487
+ for (const key of page.keys) {
488
+ const date = dateOf(key)
489
+ if (date === undefined || date >= cutoff) continue
490
+ await storage.drop(key)
491
+ state.entries.delete(key)
492
+ dropped++
493
+ }
494
+ prune.after = page.next
495
+ if (dropped > 0) log("usage.pruned", { dropped, before: cutoff })
496
+ } catch (error) {
497
+ logError("usage.prune_failed", { error: errorMessage(error) })
498
+ } finally {
499
+ prune.running = false
500
+ }
501
+ })()
502
+ }
503
+
504
+ /** Changes one day and persists it when due. Never throws into the request. */
505
+ function record(app: string, at: number, update: (day: DayRecord) => void): void {
506
+ load(app, utcDate(at), true)
507
+ .then((entry) => {
508
+ if (!entry) return
509
+ update(entry.day)
510
+ entry.version++
511
+ flushIfDue(entry, at)
512
+ maybePrune(at)
513
+ })
514
+ .catch((error) => logError("usage.record_failed", { app, error: errorMessage(error) }))
515
+ }
516
+
517
+ /** The USD cost of a call's tokens under the serving catalog's price for the model, or undefined when unpriced. */
518
+ async function priceOf(ref: ModelRef, tokens: UsageTokens): Promise<number | undefined> {
519
+ let catalog: ReadonlyArray<CatalogModel>
520
+ try {
521
+ catalog = await deps.loadCatalog()
522
+ } catch {
523
+ return undefined
524
+ }
525
+ const model = catalog.find((item) => item.providerID === ref.providerID && item.id === ref.id)
526
+ return priceCall(model?.cost, tokens)
527
+ }
528
+
529
+ return {
530
+ request(input) {
531
+ if (!GENERATION_ROUTES.has(input.route)) return
532
+ const at = now()
533
+ const ms = nonNegative(input.ms)
534
+ record(input.app, at, (day) => {
535
+ day.requests++
536
+ if (input.outcome === "rejected") day.errors.client++
537
+ else if (input.outcome === "failed") day.errors.server++
538
+ else if (input.outcome === "timeout") day.errors.timeout++
539
+ else if (input.outcome === "disconnected") day.errors.disconnected++
540
+ day.latency.count++
541
+ day.latency.sumMs += ms
542
+ day.latency.buckets[bucketIndex(ms)] = (day.latency.buckets[bucketIndex(ms)] ?? 0) + 1
543
+ day.lastRequestAt = new Date(at).toISOString()
544
+ })
545
+ },
546
+
547
+ limited(app) {
548
+ record(app, now(), (day) => {
549
+ day.limited++
550
+ })
551
+ },
552
+
553
+ fallback(app) {
554
+ record(app, now(), (day) => {
555
+ day.fallbacks++
556
+ })
557
+ },
558
+
559
+ call(input) {
560
+ if (!input.answered && input.cache === undefined) return
561
+ const at = now()
562
+ const tokens = input.usage ? tokenBreakdown(input.usage) : { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 }
563
+ void (async () => {
564
+ // A cache hit spent no tokens and is not priced; an answer that spent tokens is priced from the catalog.
565
+ const spent = input.answered && input.cache !== "hit" && totalTokens(tokens) > 0
566
+ const cost = spent && input.ref ? await priceOf(input.ref, tokens) : undefined
567
+ const unpriced = spent && cost === undefined
568
+ record(input.app, at, (day) => {
569
+ if (input.cache === "hit") day.cacheHits++
570
+ else if (input.cache === "miss") day.cacheMisses++
571
+ if (!input.answered) return
572
+ const price = cost ?? 0
573
+ if (input.ref) {
574
+ const model = `${input.ref.providerID}/${input.ref.id}`
575
+ const sum = day.perModel[model] ?? { requests: 0, tokens: 0, costUsd: 0 }
576
+ day.perModel[model] = { requests: sum.requests + 1, tokens: sum.tokens + totalTokens(tokens), costUsd: sum.costUsd + price }
577
+ }
578
+ day.tokens = addTokens(day.tokens, tokens)
579
+ day.costUsd += price
580
+ if (unpriced) day.unpricedRequests++
581
+ })
582
+ })().catch((error) => logError("usage.record_failed", { app: input.app, error: errorMessage(error) }))
583
+ },
584
+
585
+ async query(input) {
586
+ const at = now()
587
+ if (input.app !== undefined && !APP_NAME_PATTERN.test(input.app))
588
+ return { status: 400, kind: "invalid_request", code: "invalid_app", param: "app", message: "`app` must be an app name from the keys configuration" }
589
+ const range = resolveRange(input, at)
590
+ if (isPlugError(range)) return range
591
+ const prefix = input.app === undefined ? KEY_PREFIX : keyOf(input.app, "")
592
+ const inRange = (date: string | undefined) => date !== undefined && date >= range.from && date <= range.to
593
+
594
+ // The days in range: from storage (page by page), plus memory for days not in storage yet.
595
+ const keys = new Set<string>()
596
+ let after: string | undefined
597
+ for (;;) {
598
+ const page = await storage.scan({ prefix, ...(after === undefined ? {} : { after }), limit: QUERY_PAGE })
599
+ for (const key of page.keys) if (inRange(dateOf(key))) keys.add(key)
600
+ if (page.next === undefined) break
601
+ after = page.next
602
+ }
603
+ for (const entry of state.entries.values()) if (entry.key.startsWith(prefix) && inRange(entry.date)) keys.add(entry.key)
604
+
605
+ const byApp = new Map<string, Array<{ readonly date: string; readonly day: DayRecord }>>()
606
+ for (const key of [...keys].sort()) {
607
+ const [, app = "", date = ""] = key.split("/")
608
+ const entry = await load(app, date, false)
609
+ if (!entry) continue
610
+ byApp.set(app, [...(byApp.get(app) ?? []), { date, day: entry.day }])
611
+ }
612
+
613
+ const names = [...new Set([...byApp.keys(), ...(input.app === undefined ? [] : [input.app])])].sort()
614
+ const apps: Record<string, UsageAppReport> = {}
615
+ const appTotals: DayRecord[] = []
616
+ for (const app of names) {
617
+ const rows = (byApp.get(app) ?? []).sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
618
+ const total = sumDays(rows.map((row) => row.day))
619
+ appTotals.push(total)
620
+ 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 }
622
+ }
623
+ return { from: range.from, to: range.to, generatedAt: new Date(at).toISOString(), totals: summaryOf(sumDays(appTotals)), apps }
624
+ },
625
+ }
626
+ }
627
+
628
+ // ---------------------------------------------------------------- the model caller seam
629
+
630
+ /**
631
+ * Records every model call of one request path: the finish usage, the model that answered (the
632
+ * fallback chain's `onServed`, else the requested ref) and the response cache's answer. Events pass
633
+ * through untouched. Outermost in `index.ts`, so it sees the answer the client gets. With no recorder
634
+ * (option `usage` off) the caller is returned unchanged.
635
+ */
636
+ export function withUsage(inner: ModelCaller, recorder: UsageRecorder | undefined): ModelCaller {
637
+ if (!recorder) return inner
638
+ return {
639
+ stream(request: PlugRequest, context: CallContext): AsyncIterable<PlugEvent> {
640
+ let served: ServedModel | undefined
641
+ let cache: "hit" | "miss" | undefined
642
+ const forward: CallContext = {
643
+ ...context,
644
+ onServed: (value) => {
645
+ served ??= value
646
+ context.onServed?.(value)
647
+ },
648
+ onCache: (result) => {
649
+ cache = result
650
+ context.onCache?.(result)
651
+ },
652
+ }
653
+ const events = inner.stream(request, forward)
654
+ return (async function* () {
655
+ let answered = false
656
+ let usage: PlugUsage | undefined
657
+ try {
658
+ for await (const event of events) {
659
+ if (event.type === "finish") {
660
+ answered = event.reason !== "error"
661
+ usage = event.usage
662
+ }
663
+ yield event
664
+ }
665
+ } finally {
666
+ const ref = served ? parseModelRef(served.model) : request.ref
667
+ recorder.call({ app: context.app, ...(ref ? { ref } : {}), answered, ...(cache ? { cache } : {}), ...(usage ? { usage } : {}) })
668
+ }
669
+ })()
670
+ },
671
+ }
672
+ }