opencode-plugboard 0.17.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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The output items of recent Responses answers, so a later request can refer to them with an
3
+ * `item_reference` (#147). The Vercel AI SDK keeps `store: true` by default and sends the earlier
4
+ * assistant turn as references to the items plugboard returned, so without this store its
5
+ * multi-turn chat is refused.
6
+ *
7
+ * Bounded and per app: an app's store holds up to 1000 answers for an hour, and an app reads only
8
+ * its own answers. Nothing is written to disk. The store lives on `globalThis` under a versioned
9
+ * key, as the other process-wide state does (docs/architecture.md, I6), so it outlives a plugin
10
+ * generation that is replaced. A reference to an item that was evicted, expired or never returned
11
+ * is a 400 that suggests `store: false`; it is never a guess.
12
+ */
13
+ import { LRUCache } from "lru-cache"
14
+
15
+ type Item = Readonly<Record<string, unknown>>
16
+
17
+ const TTL_MS = 60 * 60 * 1000
18
+ const MAX_ANSWERS = 1000
19
+ const SLOT = Symbol.for("opencode-plugboard.responses-items/1")
20
+
21
+ const holder = globalThis as unknown as Record<symbol, Map<string, LRUCache<string, ReadonlyArray<Item>>> | undefined>
22
+
23
+ /** The store of one app: answer id -> its output items. */
24
+ function storeOf(app: string): LRUCache<string, ReadonlyArray<Item>> {
25
+ const apps = (holder[SLOT] ??= new Map())
26
+ let store = apps.get(app)
27
+ if (store === undefined) {
28
+ store = new LRUCache<string, ReadonlyArray<Item>>({ max: MAX_ANSWERS, ttl: TTL_MS })
29
+ apps.set(app, store)
30
+ }
31
+ return store
32
+ }
33
+
34
+ const isItem = (value: unknown): value is Item => typeof value === "object" && value !== null && typeof (value as Item).id === "string"
35
+
36
+ /** Keeps the output items of a completed or incomplete answer (a failed one has no items to replay). */
37
+ export function rememberAnswer(app: string, response: object): void {
38
+ const { id, output } = response as { id?: unknown; output?: unknown }
39
+ if (typeof id !== "string" || !Array.isArray(output)) return
40
+ const items = output.filter(isItem)
41
+ if (items.length > 0) storeOf(app).set(id, items)
42
+ }
43
+
44
+ /** The output item with this id that the app's recent answers returned, if still held. */
45
+ export function findItem(app: string, itemId: string): Item | undefined {
46
+ for (const items of storeOf(app).values()) {
47
+ const found = items.find((item) => item.id === itemId)
48
+ if (found !== undefined) return found
49
+ }
50
+ return undefined
51
+ }
package/src/runtime.ts CHANGED
@@ -62,6 +62,7 @@ import { ensureAppDir } from "./session/history.js"
62
62
  import type { HistoryPort } from "./session/host.js"
63
63
  import { recordSession, sweepHistory, type HistoryLimits } from "./session/prune.js"
64
64
  import { createSessionStore, type SessionStore } from "./session/state.js"
65
+ import { createBudgetState, type BudgetState } from "./budgets.js"
65
66
  import { createUsageState, type UsageState } from "./usage.js"
66
67
 
67
68
  export type Handler = (request: IncomingMessage, response: ServerResponse) => void
@@ -115,8 +116,8 @@ const BACKEND_POLL_MS = 25
115
116
  const BIND_RETRY_MS = 15_000
116
117
  const BIND_REMINDER_MS = 5 * 60_000
117
118
 
118
- /** 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
119
- const VERSION = 12
119
+ /** 13: the budget warning state (`src/budgets.ts`) lives here too. 12: the live key set (`keys`, `Runtime.keys`) that a stricter newer generation replaces at once (#90). 11: the usage store's live days (`src/usage.ts`) live here too. 10: the response cache store (`src/cache.ts`) lives here too. 9: the request limiter state (`src/limits.ts`) lives here, so the concurrency caps and rate windows survive a generation. 8: `Metrics.render` is async and the registry is @prometheus-io/client's. 7: the options fingerprint comes from safe-stable-stringify, so it must not be compared with an older one. 6: the listener retries a failed bind (new fields on `Listener`). 5: adds the metrics registry and the start time. 4: the history sweep state and the session store's register hook. 3: confirmed directories and rejected generations. */
120
+ const VERSION = 13
120
121
  const KEY = Symbol.for("opencode-plugboard.runtime")
121
122
 
122
123
  interface Listener {
@@ -180,6 +181,8 @@ interface Runtime {
180
181
  cache?: { readonly fingerprint: string; readonly store: CacheStore }
181
182
  /** The usage store's live days and retention clock (src/usage.ts). */
182
183
  readonly usage: UsageState
184
+ /** The budget warnings already logged (src/budgets.ts). */
185
+ readonly budgets: BudgetState
183
186
  listener?: Listener
184
187
  /** Present while a fiber is subscribed to the server event bus. */
185
188
  pump?: Pump
@@ -193,6 +196,7 @@ const detachedMetrics = createMetrics()
193
196
  const detachedStartedAt = Date.now()
194
197
  const detachedLimits = createLimitState()
195
198
  const detachedUsage = createUsageState()
199
+ const detachedBudgets = createBudgetState()
196
200
  let foreignVersionLogged = false
197
201
 
198
202
  /** The process runtime, created on first use; undefined when a different version already owns the process. */
@@ -210,6 +214,7 @@ function runtime(): Runtime | undefined {
210
214
  metrics: createMetrics(),
211
215
  limits: createLimitState(),
212
216
  usage: createUsageState(),
217
+ budgets: createBudgetState(),
213
218
  startedAt: Date.now(),
214
219
  }
215
220
  slot[KEY] = created
@@ -291,6 +296,9 @@ export const processLimits = (): LimitState => runtime()?.limits ?? detachedLimi
291
296
  /** The usage store's live days shared by every generation in the process (detached under a foreign-version runtime). */
292
297
  export const processUsage = (): UsageState => runtime()?.usage ?? detachedUsage
293
298
 
299
+ /** The budget warning state shared by every generation in the process (detached under a foreign-version runtime). */
300
+ export const processBudgets = (): BudgetState => runtime()?.budgets ?? detachedBudgets
301
+
294
302
  /**
295
303
  * The options authentication reads: `keys` is the live key set, not the serving generation's own
296
304
  * (a generation that is still live after a key revocation keeps its old keys otherwise, #90).
package/src/stop.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Caller stop sequences on the answer text (#145, #149). A provider ends the answer at a sequence
3
+ * but does not always return it, and Anthropic, OpenAI and Gemini all leave it out of the answer
4
+ * when the stop is the one that ended it. The host reports only `stop`, so the end of a plain
5
+ * stop's text is checked for a sequence, which is removed and reported. A streamed text keeps its
6
+ * last characters back (`StopHoldback`) until the finish decides whether they end in a sequence.
7
+ */
8
+
9
+ /** The longest of `stops` that `tail` ends with, if any. */
10
+ export const stopTail = (tail: string, stops: ReadonlyArray<string>): string | undefined =>
11
+ stops.filter((stop) => tail.endsWith(stop)).sort((a, b) => b.length - a.length)[0]
12
+
13
+ /** Streamed text with its last `size` characters (the longest sequence) held back. Without sequences nothing is held. */
14
+ export class StopHoldback {
15
+ private held = ""
16
+ private readonly size: number
17
+
18
+ constructor(private readonly stops: ReadonlyArray<string>) {
19
+ this.size = Math.max(0, ...stops.map((stop) => stop.length))
20
+ }
21
+
22
+ /** Adds a chunk and returns the text that can be sent now: everything except the last `size` characters. */
23
+ push(chunk: string): string {
24
+ if (this.size === 0) return chunk
25
+ this.held += chunk
26
+ const out = this.held.slice(0, Math.max(0, this.held.length - this.size))
27
+ this.held = this.held.slice(out.length)
28
+ return out
29
+ }
30
+
31
+ /** Everything still held, unchanged. Used before a tool call or reasoning, which cannot be a stop. */
32
+ release(): string {
33
+ const out = this.held
34
+ this.held = ""
35
+ return out
36
+ }
37
+
38
+ /**
39
+ * The end of the text. `plainStop` is true when the model stopped for `stop` with no tool call:
40
+ * then a sequence the held text ends with is removed and returned as `matched`.
41
+ */
42
+ finish(plainStop: boolean): { readonly text: string; readonly matched?: string } {
43
+ const matched = plainStop && this.size > 0 ? stopTail(this.held, this.stops) : undefined
44
+ const text = matched === undefined ? this.held : this.held.slice(0, this.held.length - matched.length)
45
+ this.held = ""
46
+ return matched === undefined ? { text } : { text, matched }
47
+ }
48
+ }
49
+
50
+ /** A complete text (no streaming): the text without the sequence it ends with, if any. */
51
+ export const stripStopTail = (text: string, stops: ReadonlyArray<string>): { readonly text: string; readonly matched?: string } => {
52
+ const matched = stopTail(text, stops)
53
+ return matched === undefined ? { text } : { text: text.slice(0, text.length - matched.length), matched }
54
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Pure helpers for the `/plugboard` usage page (src/tui.tsx): the range presets, the rows and totals
3
+ * of a `UsageReport`, and the number formats. No OpenTUI, Solid or server imports, so `bun test`
4
+ * covers them directly. Types only from src/usage.ts (its runtime pulls in the server's logger).
5
+ */
6
+
7
+ import type { UsageErrors, UsageReport, UsageTokens, UsageTotals } from "./usage.js"
8
+
9
+ export type RangePreset = "today" | "week" | "month" | "quarter"
10
+
11
+ /** The page's range keys, in the order the header lists them. `days` counts the UTC days ending today. */
12
+ export const RANGE_PRESETS: ReadonlyArray<{ readonly key: string; readonly preset: RangePreset; readonly label: string; readonly days: number }> = [
13
+ { key: "t", preset: "today", label: "today", days: 1 },
14
+ { key: "w", preset: "week", label: "7 days", days: 7 },
15
+ { key: "m", preset: "month", label: "30 days", days: 30 },
16
+ { key: "q", preset: "quarter", label: "90 days", days: 90 },
17
+ ]
18
+
19
+ const DAY_MS = 86_400_000
20
+
21
+ /** The UTC calendar day of an epoch-ms instant, `YYYY-MM-DD`. */
22
+ export function utcDay(at: number): string {
23
+ return new Date(at).toISOString().slice(0, 10)
24
+ }
25
+
26
+ /** The inclusive UTC range of a preset ending today, the same window the server's default uses. */
27
+ export function presetRange(preset: RangePreset, at: number): { readonly from: string; readonly to: string } {
28
+ const days = RANGE_PRESETS.find((item) => item.preset === preset)?.days ?? 30
29
+ const to = utcDay(at)
30
+ return { from: utcDay(Date.parse(`${to}T00:00:00Z`) - (days - 1) * DAY_MS), to }
31
+ }
32
+
33
+ /** Counts in thousands and millions: 999, 1.2k, 45k, 3.4M. Inputs are whole numbers. */
34
+ export function formatCount(value: number): string {
35
+ for (const [size, suffix] of [
36
+ [1_000_000_000, "B"],
37
+ [1_000_000, "M"],
38
+ [1_000, "k"],
39
+ ] as const) {
40
+ if (value >= size) return `${trimmed(value / size)}${suffix}`
41
+ }
42
+ return String(value)
43
+ }
44
+
45
+ function trimmed(value: number): string {
46
+ return value >= 100 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, "")
47
+ }
48
+
49
+ /** USD: `$0.00`, `<$0.01` for a cost below a cent, `$12.34`, and `$1.2k` from a thousand up. */
50
+ export function formatCost(usd: number): string {
51
+ if (usd === 0) return "$0.00"
52
+ if (usd < 0.01) return "<$0.01"
53
+ if (usd < 1_000) return `$${usd.toFixed(2)}`
54
+ return `$${formatCount(usd)}`
55
+ }
56
+
57
+ /** A latency in ms, or `-` without requests. The percentiles are bucket bounds, so they read as estimates. */
58
+ export function formatMs(ms: number | null): string {
59
+ if (ms === null) return "-"
60
+ if (ms < 1_000) return `${ms} ms`
61
+ return `${Number((ms / 1_000).toFixed(1))} s`
62
+ }
63
+
64
+ /** `part` of `whole` as a whole percent, or `-` when `whole` is zero. */
65
+ export function formatPercent(part: number, whole: number): string {
66
+ return whole === 0 ? "-" : `${Math.round((part / whole) * 100)}%`
67
+ }
68
+
69
+ /** An ISO instant as `2026-10-11 14:02 UTC`, or `-` when absent. */
70
+ export function formatInstant(iso: string | undefined): string {
71
+ return iso === undefined ? "-" : `${iso.slice(0, 16).replace("T", " ")} UTC`
72
+ }
73
+
74
+ /** A text cell of exactly `width` columns; longer text is cut with an ellipsis. */
75
+ export function cell(text: string, width: number, align: "left" | "right"): string {
76
+ const clipped = text.length > width ? `${text.slice(0, width - 1)}…` : text
77
+ return align === "right" ? clipped.padStart(width) : clipped.padEnd(width)
78
+ }
79
+
80
+ export interface Column {
81
+ readonly title: string
82
+ readonly width: number
83
+ readonly align: "left" | "right"
84
+ }
85
+
86
+ export const APP_COLUMNS: ReadonlyArray<Column> = [
87
+ { title: "app", width: 18, align: "left" },
88
+ { title: "requests", width: 9, align: "right" },
89
+ { title: "tokens", width: 8, align: "right" },
90
+ { title: "cost", width: 9, align: "right" },
91
+ { title: "errors", width: 7, align: "right" },
92
+ { title: "p95", width: 8, align: "right" },
93
+ { title: "last request", width: 17, align: "left" },
94
+ ]
95
+
96
+ export const MODEL_COLUMNS: ReadonlyArray<Column> = [
97
+ { title: "model", width: 36, align: "left" },
98
+ { title: "requests", width: 9, align: "right" },
99
+ { title: "tokens", width: 8, align: "right" },
100
+ { title: "cost", width: 9, align: "right" },
101
+ ]
102
+
103
+ /** One line of a table: each value in its column, separated by one space. */
104
+ export function formatLine(values: ReadonlyArray<string>, columns: ReadonlyArray<Column>): string {
105
+ return columns.map((column, index) => cell(values[index] ?? "", column.width, column.align)).join(" ")
106
+ }
107
+
108
+ /** A bar of up to `width` blocks for a share in 0..1; any positive share shows at least one block. */
109
+ export function barCells(share: number, width: number): string {
110
+ if (!(share > 0)) return ""
111
+ return "█".repeat(Math.min(width, Math.max(1, Math.round(share * width))))
112
+ }
113
+
114
+ export function tokenTotal(tokens: UsageTokens): number {
115
+ return tokens.input + tokens.cacheRead + tokens.cacheWrite + tokens.output + tokens.reasoning
116
+ }
117
+
118
+ export function errorTotal(errors: UsageErrors): number {
119
+ return errors.client + errors.server + errors.timeout + errors.disconnected
120
+ }
121
+
122
+ export interface AppRow {
123
+ readonly app: string
124
+ readonly requests: number
125
+ readonly tokens: number
126
+ readonly costUsd: number
127
+ readonly errors: number
128
+ readonly p95Ms: number | null
129
+ readonly lastRequestAt?: string
130
+ /** Share of the range's cost, or of its requests when nothing was priced: 0..1, for the bar. */
131
+ readonly share: number
132
+ }
133
+
134
+ /** One row per app, most expensive first (then most requests, then name). */
135
+ export function appRows(report: UsageReport): AppRow[] {
136
+ const { totals } = report
137
+ return Object.entries(report.apps)
138
+ .map(([app, entry]): AppRow => {
139
+ const share =
140
+ totals.costUsd > 0 ? entry.totals.costUsd / totals.costUsd : totals.requests > 0 ? entry.totals.requests / totals.requests : 0
141
+ return {
142
+ app,
143
+ requests: entry.totals.requests,
144
+ tokens: tokenTotal(entry.totals.tokens),
145
+ costUsd: entry.totals.costUsd,
146
+ errors: errorTotal(entry.totals.errors),
147
+ p95Ms: entry.totals.latency.p95Ms,
148
+ ...(entry.totals.lastRequestAt === undefined ? {} : { lastRequestAt: entry.totals.lastRequestAt }),
149
+ share,
150
+ }
151
+ })
152
+ .sort((a, b) => b.costUsd - a.costUsd || b.requests - a.requests || a.app.localeCompare(b.app))
153
+ }
154
+
155
+ export function appValues(row: AppRow): string[] {
156
+ return [
157
+ row.app,
158
+ formatCount(row.requests),
159
+ formatCount(row.tokens),
160
+ formatCost(row.costUsd),
161
+ formatCount(row.errors),
162
+ formatMs(row.p95Ms),
163
+ formatInstant(row.lastRequestAt),
164
+ ]
165
+ }
166
+
167
+ export interface ModelRow {
168
+ readonly model: string
169
+ readonly requests: number
170
+ readonly tokens: number
171
+ readonly costUsd: number
172
+ }
173
+
174
+ /** The models of every app, summed over the range: most expensive first, at most `limit`. */
175
+ export function modelRows(report: UsageReport, limit: number): ModelRow[] {
176
+ const sums = new Map<string, { requests: number; tokens: number; costUsd: number }>()
177
+ for (const app of Object.values(report.apps)) {
178
+ for (const [model, usage] of Object.entries(app.models)) {
179
+ const sum = sums.get(model) ?? { requests: 0, tokens: 0, costUsd: 0 }
180
+ sums.set(model, { requests: sum.requests + usage.requests, tokens: sum.tokens + usage.tokens, costUsd: sum.costUsd + usage.costUsd })
181
+ }
182
+ }
183
+ return [...sums]
184
+ .map(([model, sum]): ModelRow => ({ model, ...sum }))
185
+ .sort((a, b) => b.costUsd - a.costUsd || b.requests - a.requests || a.model.localeCompare(b.model))
186
+ .slice(0, limit)
187
+ }
188
+
189
+ export function modelValues(row: ModelRow): string[] {
190
+ return [row.model, formatCount(row.requests), formatCount(row.tokens), formatCost(row.costUsd)]
191
+ }
192
+
193
+ export interface TotalItem {
194
+ readonly label: string
195
+ readonly value: string
196
+ }
197
+
198
+ /** The totals line, in the order the page shows it. */
199
+ export function totalItems(totals: UsageTotals): TotalItem[] {
200
+ const lookups = totals.cacheHits + totals.cacheMisses
201
+ return [
202
+ { label: "requests", value: formatCount(totals.requests) },
203
+ { label: "tokens", value: formatCount(tokenTotal(totals.tokens)) },
204
+ { label: "est. cost", value: formatCost(totals.costUsd) },
205
+ { label: "errors", value: formatCount(errorTotal(totals.errors)) },
206
+ { label: "cache hit", value: formatPercent(totals.cacheHits, lookups) },
207
+ { label: "fallbacks", value: formatCount(totals.fallbacks) },
208
+ { label: "429s", value: formatCount(totals.limited) },
209
+ { label: "p95", value: formatMs(totals.latency.p95Ms) },
210
+ ]
211
+ }
212
+
213
+ /** The message the page shows when the usage RPC cannot answer (plugboard not loaded there, or usage off). */
214
+ export function unavailableMessage(error: unknown): string {
215
+ const reason = error instanceof Error ? error.message : String(error)
216
+ return `Usage is unavailable on this server. Plugboard must be loaded there with usage enabled. (${reason})`
217
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,166 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /**
3
+ * The `/plugboard` usage page in OpenCode's terminal UI (package entry `./tui`). OpenCode loads it
4
+ * for every server that runs plugboard with the TUI feature: it reads the usage report over the
5
+ * server's own RPC (`context.client.rpc(UsageRpc).get`, src/rpc.ts), so it sees every app there.
6
+ * Nothing is served over HTTP, and nothing here runs on the server.
7
+ *
8
+ * Page keys: t, w, m, q pick the range (today, 7, 30, 90 UTC days); r reloads; esc goes back.
9
+ * Setup only registers the page and the command (no awaits). Shaping and formats: src/tui-format.ts.
10
+ */
11
+
12
+ import { Plugin } from "@opencode/plugin/tui"
13
+ import { TextAttributes } from "@opentui/core"
14
+ import { createResource, createSignal, For, Show } from "solid-js"
15
+ import { UsageRpc } from "./rpc.js"
16
+ import type { UsageReport } from "./usage.js"
17
+ import {
18
+ APP_COLUMNS,
19
+ appRows,
20
+ appValues,
21
+ barCells,
22
+ formatLine,
23
+ MODEL_COLUMNS,
24
+ modelRows,
25
+ modelValues,
26
+ presetRange,
27
+ RANGE_PRESETS,
28
+ totalItems,
29
+ unavailableMessage,
30
+ type RangePreset,
31
+ } from "./tui-format.js"
32
+
33
+ type Theme = Plugin.Context["theme"]
34
+
35
+ interface Query {
36
+ readonly preset: RangePreset
37
+ /** Bumped by `r`; only here so that a reload is a new query. */
38
+ readonly reload: number
39
+ }
40
+
41
+ const PRESET_LABEL = (preset: RangePreset) => RANGE_PRESETS.find((item) => item.preset === preset)?.label ?? preset
42
+
43
+ function UsagePage(props: { context: Plugin.Context; onClose: () => void }) {
44
+ const theme = props.context.theme
45
+ const [query, setQuery] = createSignal<Query>({ preset: "month", reload: 0 })
46
+ const [report] = createResource(query, async (current) => {
47
+ const range = presetRange(current.preset, Date.now())
48
+ try {
49
+ return (await props.context.client.rpc(UsageRpc).get({ from: range.from, to: range.to })) as UsageReport
50
+ } catch (error) {
51
+ props.context.ui.toast.show({ variant: "error", title: "Plugboard", message: unavailableMessage(error) })
52
+ throw error
53
+ }
54
+ })
55
+
56
+ props.context.keymap.layer(() => ({
57
+ commands: [
58
+ { bind: "escape", title: "back", run: props.onClose },
59
+ ...RANGE_PRESETS.map((item) => ({
60
+ bind: item.key,
61
+ title: `range ${item.label}`,
62
+ run: () => setQuery((current) => ({ preset: item.preset, reload: current.reload })),
63
+ })),
64
+ { bind: "r", title: "refresh", run: () => setQuery((current) => ({ ...current, reload: current.reload + 1 })) },
65
+ ],
66
+ }))
67
+
68
+ return (
69
+ <box width="100%" height="100%" backgroundColor={theme.background.base}>
70
+ <scrollbox flexGrow={1}>
71
+ <box flexDirection="column" gap={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
72
+ <text fg={theme.text.base} attributes={TextAttributes.BOLD}>
73
+ Plugboard usage
74
+ </text>
75
+ <text fg={theme.text.muted}>
76
+ {report() ? `${report()!.from} to ${report()!.to} (UTC)` : PRESET_LABEL(query().preset)}
77
+ {" · "}t today · w 7 days · m 30 days · q 90 days · r refresh · esc back
78
+ </text>
79
+ <Show
80
+ when={!report.error}
81
+ fallback={<text fg={theme.text.feedback.error.base}>{unavailableMessage(report.error)}</text>}
82
+ >
83
+ <Show when={report()} fallback={<text fg={theme.text.muted}>Loading usage…</text>}>
84
+ <UsageBody report={report()!} theme={theme} />
85
+ </Show>
86
+ </Show>
87
+ </box>
88
+ </scrollbox>
89
+ </box>
90
+ )
91
+ }
92
+
93
+ function UsageBody(props: { report: UsageReport; theme: Theme }) {
94
+ const apps = () => appRows(props.report)
95
+ const models = () => modelRows(props.report, 10)
96
+ return (
97
+ <box flexDirection="column" gap={1}>
98
+ <box flexDirection="row" flexWrap="wrap" gap={2}>
99
+ <For each={totalItems(props.report.totals)}>
100
+ {(item) => (
101
+ <box flexDirection="row" gap={1}>
102
+ <text fg={props.theme.text.muted}>{item.label}</text>
103
+ <text fg={props.theme.text.base} attributes={TextAttributes.BOLD}>
104
+ {item.value}
105
+ </text>
106
+ </box>
107
+ )}
108
+ </For>
109
+ </box>
110
+ <text fg={props.theme.text.muted} attributes={TextAttributes.BOLD}>
111
+ Apps
112
+ </text>
113
+ <Show when={apps().length > 0} fallback={<text fg={props.theme.text.muted}>No requests in this range.</text>}>
114
+ <text fg={props.theme.text.muted}>{formatLine(APP_COLUMNS.map((column) => column.title), APP_COLUMNS)}</text>
115
+ <For each={apps()}>
116
+ {(row) => (
117
+ <box flexDirection="row">
118
+ <text fg={props.theme.text.base}>{formatLine(appValues(row), APP_COLUMNS)}</text>
119
+ <text fg={props.theme.categorical[0][200]}>{` ${barCells(row.share, 24)}`}</text>
120
+ </box>
121
+ )}
122
+ </For>
123
+ </Show>
124
+ <text fg={props.theme.text.muted} attributes={TextAttributes.BOLD}>
125
+ Models
126
+ </text>
127
+ <Show when={models().length > 0} fallback={<text fg={props.theme.text.muted}>No model calls in this range.</text>}>
128
+ <text fg={props.theme.text.muted}>{formatLine(MODEL_COLUMNS.map((column) => column.title), MODEL_COLUMNS)}</text>
129
+ <For each={models()}>
130
+ {(row) => <text fg={props.theme.text.base}>{formatLine(modelValues(row), MODEL_COLUMNS)}</text>}
131
+ </For>
132
+ </Show>
133
+ </box>
134
+ )
135
+ }
136
+
137
+ export default Plugin.define({
138
+ id: "opencode-plugboard.tui",
139
+ setup(context) {
140
+ const [previous, setPrevious] = createSignal({ ...context.ui.router.current() })
141
+ context.ui.router.register({
142
+ name: "plugboard.usage",
143
+ render: () => <UsagePage context={context} onClose={() => context.ui.router.navigate(previous())} />,
144
+ })
145
+ context.keymap.layer(() => ({
146
+ mode: "global",
147
+ commands: [
148
+ {
149
+ id: "plugboard.usage.open",
150
+ title: "Plugboard usage",
151
+ group: "Plugboard",
152
+ slash: { name: "plugboard" },
153
+ palette: true,
154
+ run() {
155
+ const current = context.ui.router.current()
156
+ if (current.type === "plugin" && current.name === "plugboard.usage") return
157
+ // The router exposes a mutable store; keep the route before navigating.
158
+ setPrevious({ ...current })
159
+ context.ui.dialog.clear()
160
+ context.ui.router.navigate({ type: "plugin", name: "plugboard.usage" })
161
+ },
162
+ },
163
+ ],
164
+ }))
165
+ },
166
+ })
@@ -0,0 +1,46 @@
1
+ /**
2
+ * `x-plugboard-usage` (#52): the tokens and USD cost of the model calls a response answered, so a
3
+ * caller sees what one request cost without asking `GET /v1/usage`. It is set only while usage
4
+ * recording is on (`withUsage`, src/usage.ts), and only on a non-streaming response: a stream
5
+ * sends its headers with its first event, before the usage is known, so it has no such header.
6
+ */
7
+ import type { ServerResponse } from "node:http"
8
+ import type { CallTotals } from "./contract.js"
9
+
10
+ export const USAGE_HEADER = "x-plugboard-usage"
11
+
12
+ /** The totals of one response, summed over its calls. `costUsd` is undefined when any call had no price. */
13
+ interface Totals {
14
+ readonly inputTokens: number
15
+ readonly outputTokens: number
16
+ readonly cacheReadTokens: number
17
+ readonly costUsd?: number
18
+ }
19
+
20
+ /** `input=<n>; output=<n>; cache_read=<n>; cost_usd=<x.xxxxxx>`; the cost part is left out for an unpriced model. */
21
+ export function formatUsage(totals: Totals): string {
22
+ const parts = [`input=${totals.inputTokens}`, `output=${totals.outputTokens}`, `cache_read=${totals.cacheReadTokens}`]
23
+ if (totals.costUsd !== undefined) parts.push(`cost_usd=${totals.costUsd.toFixed(6)}`)
24
+ return parts.join("; ")
25
+ }
26
+
27
+ /**
28
+ * The `CallContext.onUsage` of one response: each answered call adds its totals and the header
29
+ * is rewritten while the headers are still open, so the fan-out's `n` choices sum into one value.
30
+ * `undefined` for a stream (see the module comment).
31
+ */
32
+ export function reportUsage(response: ServerResponse, stream: boolean): ((call: CallTotals) => void) | undefined {
33
+ if (stream) return undefined
34
+ let totals: Totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, costUsd: 0 }
35
+ return (call) => {
36
+ if (response.headersSent || response.destroyed) return
37
+ const costUsd = totals.costUsd === undefined || call.costUsd === undefined ? undefined : totals.costUsd + call.costUsd
38
+ totals = {
39
+ inputTokens: totals.inputTokens + call.inputTokens,
40
+ outputTokens: totals.outputTokens + call.outputTokens,
41
+ cacheReadTokens: totals.cacheReadTokens + call.cacheReadTokens,
42
+ ...(costUsd === undefined ? {} : { costUsd }),
43
+ }
44
+ response.setHeader(USAGE_HEADER, formatUsage(totals))
45
+ }
46
+ }