opencode-plugboard 0.18.0 → 0.19.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,7 +23,7 @@
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"
@@ -115,7 +115,7 @@ export interface UsageCounts<Latency = LatencyHistogram> {
115
115
  cacheMisses: number
116
116
  /** Retries on the next target of an alias after a failure before any output. */
117
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`). */
118
+ /** Calls refused with 429 by a concurrency cap, a requests-per-minute limit or a budget (also counted in `requests` and `errors.client`). */
119
119
  limited: number
120
120
  /** Answered calls that spent tokens but had no price in the catalog (their cost counts as 0). */
121
121
  unpricedRequests: number
@@ -137,6 +137,17 @@ export interface UsageDay extends UsageTotals {
137
137
  perModel: Record<string, ModelUsage>
138
138
  }
139
139
 
140
+ /** One ceiling of an app's budget, with what its current window has used (`GET /v1/usage`, see `src/budgets.ts`). */
141
+ export interface BudgetStatus {
142
+ readonly metric: BudgetMetric
143
+ readonly window: BudgetWindow
144
+ readonly limit: number
145
+ /** Counted so far in the current window; a cost is in USD. */
146
+ readonly used: number
147
+ /** ISO 8601 time the current window ends and its count starts again. */
148
+ readonly resetsAt: string
149
+ }
150
+
140
151
  export interface UsageAppReport {
141
152
  /** Sums over the days of the range. */
142
153
  totals: UsageTotals
@@ -144,6 +155,8 @@ export interface UsageAppReport {
144
155
  models: Record<string, ModelUsage>
145
156
  /** Days with any record in the range, oldest first. */
146
157
  days: UsageDay[]
158
+ /** The app's budget state, when option `budgets` sets one for it. Read from the current window, not from the range. */
159
+ budgets?: BudgetStatus[]
147
160
  }
148
161
 
149
162
  /** The result of `GET /v1/usage` and of the `opencode-plugboard.usage` RPC (`src/rpc.ts`). */
@@ -235,7 +248,7 @@ export function tokenBreakdown(usage: PlugUsage): UsageTokens {
235
248
  }
236
249
  }
237
250
 
238
- const totalTokens = (tokens: UsageTokens): number => tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning
251
+ export const totalTokens = (tokens: UsageTokens): number => tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning
239
252
 
240
253
  /**
241
254
  * The USD cost of one call, or undefined when the model has no applicable price. Mirrors OpenCode's
@@ -399,8 +412,12 @@ export interface UsageRecorder {
399
412
  limited(app: string): void
400
413
  fallback(app: string): void
401
414
  call(input: CallUsage): void
415
+ /** The USD cost of a call's tokens under the serving catalog's price for `ref`, or undefined when unpriced (`x-plugboard-usage`, #52). */
416
+ price(ref: ModelRef, tokens: UsageTokens): Promise<number | undefined>
402
417
  /** A report for the range and app (defaults as `resolveRange`). A bad input is a 400 `PlugError`. */
403
418
  query(input: UsageQuery): Promise<UsageReport | PlugError>
419
+ /** 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. */
420
+ spent(app: string, from: string, to: string): Promise<DayRecord>
404
421
  }
405
422
 
406
423
  export interface UsageRecorderDeps {
@@ -582,6 +599,17 @@ export function createUsageRecorder(deps: UsageRecorderDeps): UsageRecorder {
582
599
  })().catch((error) => logError("usage.record_failed", { app: input.app, error: errorMessage(error) }))
583
600
  },
584
601
 
602
+ price: priceOf,
603
+ async spent(app, from, to) {
604
+ const parts: DayRecord[] = []
605
+ const last = Date.parse(`${to}T00:00:00Z`)
606
+ for (let at = Date.parse(`${from}T00:00:00Z`); at <= last; at += DAY_MS) {
607
+ const entry = await load(app, utcDate(at), false)
608
+ if (entry) parts.push(entry.day)
609
+ }
610
+ return sumDays(parts)
611
+ },
612
+
585
613
  async query(input) {
586
614
  const at = now()
587
615
  if (input.app !== undefined && !APP_NAME_PATTERN.test(input.app))
@@ -659,6 +687,18 @@ export function withUsage(inner: ModelCaller, recorder: UsageRecorder | undefine
659
687
  if (event.type === "finish") {
660
688
  answered = event.reason !== "error"
661
689
  usage = event.usage
690
+ if (answered && context.onUsage) {
691
+ // The header is set before the finish reaches the front-end, so a non-streaming answer carries it.
692
+ const ref = served ? parseModelRef(served.model) : request.ref
693
+ const tokens = tokenBreakdown(usage ?? {})
694
+ const costUsd = ref ? await recorder.price(ref, tokens) : undefined
695
+ context.onUsage({
696
+ inputTokens: usage?.inputTokens ?? 0,
697
+ outputTokens: usage?.outputTokens ?? 0,
698
+ cacheReadTokens: usage?.cacheReadTokens ?? 0,
699
+ ...(costUsd === undefined ? {} : { costUsd }),
700
+ })
701
+ }
662
702
  }
663
703
  yield event
664
704
  }