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/README.md +2 -2
- package/index.ts +32 -14
- package/package.json +1 -1
- package/src/admin/route.ts +81 -0
- package/src/anthropic/request.ts +2 -0
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +13 -6
- package/src/budgets.ts +200 -0
- package/src/cache.ts +12 -2
- package/src/completions/request.ts +3 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +183 -10
- package/src/contract.ts +17 -1
- package/src/discovery.ts +8 -0
- package/src/fallback.ts +7 -5
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +5 -1
- package/src/keys.ts +172 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/models.ts +64 -3
- package/src/openai/fanout.ts +14 -2
- package/src/openai/request.ts +3 -0
- package/src/openai/response.ts +23 -5
- package/src/openai/route.ts +46 -7
- package/src/responses/request.ts +30 -14
- package/src/responses/route.ts +12 -5
- package/src/responses/store.ts +51 -0
- package/src/rpc.ts +3 -2
- package/src/runtime.ts +20 -3
- package/src/session/caller.ts +27 -4
- package/src/session/context.ts +3 -1
- package/src/session/history.ts +8 -2
- package/src/session/host.ts +26 -0
- package/src/session/link.ts +33 -0
- package/src/session/state.ts +2 -0
- package/src/stop.ts +54 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +104 -5
- package/src/user.ts +55 -0
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
|
|
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
|
+
}
|