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.
- package/README.md +458 -39
- package/index.ts +27 -14
- package/package.json +36 -9
- package/rpc.ts +3 -0
- package/src/anthropic/response.ts +8 -25
- package/src/anthropic/route.ts +2 -0
- package/src/budgets.ts +200 -0
- package/src/completions/request.ts +1 -0
- package/src/completions/response.ts +10 -1
- package/src/config.ts +98 -1
- package/src/contract.ts +14 -0
- package/src/dashboard/app.css +209 -0
- package/src/dashboard/app.js +444 -0
- package/src/dashboard/assets.ts +72 -0
- package/src/dashboard/page.ts +87 -0
- package/src/discovery.ts +8 -2
- package/src/gemini/request.ts +1 -0
- package/src/gemini/response.ts +16 -1
- package/src/gemini/route.ts +2 -0
- package/src/log.ts +3 -0
- package/src/metrics.ts +3 -3
- package/src/openai/fanout.ts +4 -0
- package/src/openai/request.ts +1 -0
- package/src/openai/response.ts +21 -3
- package/src/openai/route.ts +35 -4
- package/src/responses/request.ts +28 -14
- package/src/responses/route.ts +9 -4
- package/src/responses/store.ts +51 -0
- package/src/runtime.ts +10 -2
- package/src/stop.ts +54 -0
- package/src/tui-format.ts +217 -0
- package/src/tui.tsx +166 -0
- package/src/usage-header.ts +46 -0
- package/src/usage.ts +43 -3
- package/tui.ts +4 -0
|
@@ -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 =
|
|
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
|
+
}
|