opencode-plugboard 0.16.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/README.md +1 -1
- package/index.ts +40 -15
- package/package.json +4 -2
- package/src/anthropic/request.ts +1 -1
- package/src/anthropic/response.ts +38 -3
- package/src/anthropic/route.ts +13 -6
- package/src/anthropic/tokens.ts +42 -0
- package/src/config.ts +47 -2
- package/src/discovery.ts +8 -2
- package/src/gemini/route.ts +22 -2
- package/src/openai/route.ts +32 -5
- package/src/rpc.ts +65 -0
- package/src/runtime.ts +62 -12
- package/src/session/host.ts +24 -0
- package/src/usage.ts +672 -0
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
|
+
}
|