oc-go-usage-display 1.1.0 → 2.1.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 +246 -93
- package/bin/lib.js +395 -37
- package/bin/oc-go-usage-display-init.js +42 -13
- package/bin/oc-go-usage-display-remove.js +22 -17
- package/bin/oc-go-usage-display-show.js +47 -39
- package/bin/oc-go-usage-display-status.js +31 -52
- package/bin/oc-go-usage-display-update.js +27 -14
- package/dist/helpers.d.ts +117 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +582 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +249 -159
- package/dist/index.js.map +1 -1
- package/dist/plugins/oc-go-usage-display.kilo.ts +699 -0
- package/dist/plugins/oc-go-usage-display.kilo.tsx +1364 -0
- package/dist/plugins/oc-go-usage-display.ts +699 -0
- package/dist/plugins/oc-go-usage-display.tsx +1108 -0
- package/dist/shared.d.ts +87 -8
- package/dist/shared.d.ts.map +1 -1
- package/dist/shared.js +596 -31
- package/dist/shared.js.map +1 -1
- package/dist/tui-shared.d.ts +109 -0
- package/dist/tui-shared.d.ts.map +1 -0
- package/dist/tui-shared.js +408 -0
- package/dist/tui-shared.js.map +1 -0
- package/dist/tui.d.ts +0 -17
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +229 -282
- package/dist/tui.js.map +1 -1
- package/dist/tui.kilo.d.ts +8 -0
- package/dist/tui.kilo.d.ts.map +1 -0
- package/dist/tui.kilo.js +502 -0
- package/dist/tui.kilo.js.map +1 -0
- package/package.json +49 -15
- package/src/helpers.ts +749 -0
- package/src/index.ts +316 -175
- package/src/shared.ts +693 -32
- package/src/tui-shared.tsx +755 -0
- package/src/tui.kilo.tsx +817 -0
- package/src/tui.tsx +352 -366
package/src/shared.ts
CHANGED
|
@@ -14,16 +14,167 @@ import * as path from "node:path";
|
|
|
14
14
|
// Constants
|
|
15
15
|
// ---------------------------------------------------------------------------
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Resolve the user home directory honoring a runtime HOME override.
|
|
18
|
+
// Bun's `os.homedir()` caches the home directory at process startup and does
|
|
19
|
+
// NOT respect runtime `process.env.HOME` changes, which breaks hermetic test
|
|
20
|
+
// overrides that set HOME before dynamic imports. Reading `process.env.HOME`
|
|
21
|
+
// directly (with `os.homedir()` fallback for when HOME is unset) makes path
|
|
22
|
+
// resolution hermetic under both `node --test` and `bun test`.
|
|
23
|
+
function resolveHomedir(): string {
|
|
24
|
+
const envHome = process.env.HOME;
|
|
25
|
+
if (envHome && envHome.length > 0) return envHome;
|
|
26
|
+
return os.homedir();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Path construction is best-effort. `resolveHomedir()` can throw when no home
|
|
30
|
+
// directory is resolvable, and a throwing top-level expression aborts the
|
|
31
|
+
// host's plugin import; degrade to a relative config path instead. `homedir`
|
|
32
|
+
// is injectable so the fallback is directly unit-testable.
|
|
33
|
+
export function resolveConfigDir(homedir: () => string = resolveHomedir): string {
|
|
34
|
+
try {
|
|
35
|
+
return path.join(homedir(), ".config", "opencode");
|
|
36
|
+
} catch {
|
|
37
|
+
return ".config/opencode";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Host roots (opencode vs its Kilo fork): each host keeps its own config and
|
|
43
|
+
// data stores, and each entry module must read the credentials of the host it
|
|
44
|
+
// runs under. opencode: `$XDG_DATA_HOME/opencode/auth.json` (or
|
|
45
|
+
// `~/.local/share/opencode/auth.json`), then its config dir. Kilo:
|
|
46
|
+
// `$XDG_DATA_HOME/kilo/auth.json` (or `~/.local/share/kilo/auth.json`), then
|
|
47
|
+
// `$KILO_CONFIG_DIR` / `$XDG_CONFIG_HOME/kilo` (or `~/.config/kilo`).
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export type UsageHost = "opencode" | "kilo";
|
|
51
|
+
|
|
52
|
+
export type HostRoots = { configDir: string; dataDir: string };
|
|
53
|
+
|
|
54
|
+
// Pure host selection: only the explicit "kilo" marker selects the Kilo
|
|
55
|
+
// stores; every other value (including unset) means opencode.
|
|
56
|
+
export function usageHostFromEnv(env: NodeJS.ProcessEnv | undefined): UsageHost {
|
|
57
|
+
return env?.OC_GO_USAGE_HOST === "kilo" ? "kilo" : "opencode";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Runtime host for the entry module. The Kilo server bundle is built with
|
|
61
|
+
// `process.env.OC_GO_USAGE_HOST` replaced by the literal "kilo"
|
|
62
|
+
// (scripts/build-plugins.mjs), so dist/index.js stays opencode while
|
|
63
|
+
// dist/plugins/oc-go-usage-display.kilo.ts is deterministic. The kilocode TUI
|
|
64
|
+
// entry passes its host explicitly instead.
|
|
65
|
+
export function resolveUsageHost(): UsageHost {
|
|
66
|
+
return process.env.OC_GO_USAGE_HOST === "kilo" ? "kilo" : "opencode";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Host-scoped environment variables
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
//
|
|
73
|
+
// Settings and credentials are namespaced per coding agent, so one shell can
|
|
74
|
+
// drive the two hosts differently:
|
|
75
|
+
//
|
|
76
|
+
// OPENCODE_OC_GO_SIDEBAR=0 opencode
|
|
77
|
+
// KILO_OC_GO_SIDEBAR=1 kilo
|
|
78
|
+
//
|
|
79
|
+
// The prefix comes from the host baked into the running bundle, so a Kilo entry
|
|
80
|
+
// only ever reads `KILO_OC_GO_*` and can never be steered by an opencode-prefixed
|
|
81
|
+
// name. Reading a variable the host does not own is a bug, not a feature: the
|
|
82
|
+
// two hosts keep separate auth stores and separate config dirs for exactly this
|
|
83
|
+
// reason.
|
|
84
|
+
//
|
|
85
|
+
const ENV_PREFIX: Record<UsageHost, string> = {
|
|
86
|
+
opencode: "OPENCODE_OC_GO_",
|
|
87
|
+
kilo: "KILO_OC_GO_",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function hostEnvName(host: UsageHost, suffix: string): string {
|
|
91
|
+
return `${ENV_PREFIX[host]}${suffix}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function hostEnv(
|
|
95
|
+
host: UsageHost,
|
|
96
|
+
suffix: string,
|
|
97
|
+
env: NodeJS.ProcessEnv | undefined = process.env,
|
|
98
|
+
): string | undefined {
|
|
99
|
+
return env?.[hostEnvName(host, suffix)];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// `resolveHomedir` can throw when no home directory is resolvable; plugin
|
|
103
|
+
// entry modules build these paths at import time, so degrade to an empty
|
|
104
|
+
// segment instead of throwing.
|
|
105
|
+
function homedirOrEmpty(homedir: () => string): string {
|
|
106
|
+
try {
|
|
107
|
+
return homedir();
|
|
108
|
+
} catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Host config/data roots. Both hosts are XDG-based: `$XDG_CONFIG_HOME` /
|
|
114
|
+
// `$XDG_DATA_HOME` win with `~/.config` / `~/.local/share` as fallbacks, and
|
|
115
|
+
// Kilo additionally honors `KILO_CONFIG_DIR` (its documented config override).
|
|
116
|
+
// Never throws.
|
|
117
|
+
export function resolveHostRoots(
|
|
118
|
+
host: UsageHost,
|
|
119
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
120
|
+
homedir: () => string = resolveHomedir,
|
|
121
|
+
): HostRoots {
|
|
122
|
+
const home = homedirOrEmpty(homedir);
|
|
123
|
+
const configRoot = toNonEmptyString(env.XDG_CONFIG_HOME) ?? safeJoinPath(home, ".config");
|
|
124
|
+
const dataRoot = toNonEmptyString(env.XDG_DATA_HOME) ?? safeJoinPath(home, ".local", "share");
|
|
125
|
+
if (host === "kilo") {
|
|
126
|
+
return {
|
|
127
|
+
configDir: toNonEmptyString(env.KILO_CONFIG_DIR) ?? safeJoinPath(configRoot, "kilo"),
|
|
128
|
+
dataDir: safeJoinPath(dataRoot, "kilo"),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
configDir: safeJoinPath(configRoot, "opencode"),
|
|
133
|
+
dataDir: safeJoinPath(dataRoot, "opencode"),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Legacy opencode config dir (no XDG): kept as an exported constant because
|
|
138
|
+
// the hermeticity tests assert every runtime path stays inside their tmp root.
|
|
139
|
+
export const CONFIG_DIR = resolveHostRoots("opencode").configDir;
|
|
140
|
+
|
|
141
|
+
// Coerce any runtime value into a path segment deterministically: strings pass
|
|
142
|
+
// through, every other value uses its string form, and only an object with a
|
|
143
|
+
// throwing `toString` degrades to "" (an empty segment, which `path.join`
|
|
144
|
+
// ignores). Never throws.
|
|
145
|
+
function toPathSegment(value: unknown): string {
|
|
146
|
+
if (typeof value === "string") return value;
|
|
147
|
+
try {
|
|
148
|
+
return String(value);
|
|
149
|
+
} catch {
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
}
|
|
18
153
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
154
|
+
// Tolerant `path.join` for module-level path construction. `path.join` throws
|
|
155
|
+
// on a non-string segment; plugin entry modules build cache/config paths at
|
|
156
|
+
// import time, so a throw there would crash startup. Non-string segments are
|
|
157
|
+
// coerced (never silently dropped or thrown away) and the resulting string
|
|
158
|
+
// join cannot throw.
|
|
159
|
+
export function safeJoinPath(base: string, ...segments: unknown[]): string {
|
|
160
|
+
return path.join(toPathSegment(base), ...segments.map(toPathSegment));
|
|
23
161
|
}
|
|
24
162
|
|
|
25
|
-
export function
|
|
26
|
-
|
|
163
|
+
export function dataShareAuthPath(
|
|
164
|
+
host: UsageHost = "opencode",
|
|
165
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
166
|
+
homedir: () => string = resolveHomedir,
|
|
167
|
+
): string {
|
|
168
|
+
return safeJoinPath(resolveHostRoots(host, env, homedir).dataDir, "auth.json");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function authJsonPaths(
|
|
172
|
+
host: UsageHost = "opencode",
|
|
173
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
174
|
+
homedir: () => string = resolveHomedir,
|
|
175
|
+
): string[] {
|
|
176
|
+
const roots = resolveHostRoots(host, env, homedir);
|
|
177
|
+
return [safeJoinPath(roots.dataDir, "auth.json"), safeJoinPath(roots.configDir, "auth.json")];
|
|
27
178
|
}
|
|
28
179
|
|
|
29
180
|
// ---------------------------------------------------------------------------
|
|
@@ -32,8 +183,9 @@ export function authJsonPaths(): string[] {
|
|
|
32
183
|
|
|
33
184
|
export type UsageWindow = {
|
|
34
185
|
percent: number;
|
|
35
|
-
resetInSec: number | null;
|
|
36
186
|
status: string | null;
|
|
187
|
+
limited: boolean;
|
|
188
|
+
resetInSec: number | null;
|
|
37
189
|
resetText: string | null;
|
|
38
190
|
};
|
|
39
191
|
|
|
@@ -60,28 +212,211 @@ export function toFiniteNumber(value: unknown): number | null {
|
|
|
60
212
|
return value;
|
|
61
213
|
}
|
|
62
214
|
|
|
215
|
+
// A host-reported count or cost, coerced into something a formatter can print.
|
|
216
|
+
// Clamping at 0 also folds `-0` into `0`, so no renderer can emit "-0" for a
|
|
217
|
+
// quantity that is zero.
|
|
218
|
+
export function toUsageCount(value: unknown): number {
|
|
219
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
|
|
220
|
+
return Math.max(value, 0);
|
|
221
|
+
}
|
|
222
|
+
|
|
63
223
|
export function toNonEmptyString(value: unknown): string | null {
|
|
64
224
|
if (typeof value !== "string") return null;
|
|
65
225
|
const trimmed = value.trim();
|
|
66
226
|
return trimmed.length > 0 ? trimmed : null;
|
|
67
227
|
}
|
|
68
228
|
|
|
229
|
+
// Stable, human-readable message for any thrown value. Never throws itself, so
|
|
230
|
+
// callers can include it in best-effort logs without a second failure mode.
|
|
231
|
+
export function errorMessage(error: unknown): string {
|
|
232
|
+
if (error === null || error === undefined) return "unknown error";
|
|
233
|
+
if (error instanceof Error) {
|
|
234
|
+
return toNonEmptyString(error.message) ?? toNonEmptyString(error.name) ?? "unknown error";
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
return toNonEmptyString(String(error)) ?? "unknown error";
|
|
238
|
+
} catch {
|
|
239
|
+
return "unknown error";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Kilo sidebar ladder (host-owned; asserted by the e2e contract test)
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
//
|
|
247
|
+
// The `sidebar_content` orders Kilo registers internally, read from the shipped
|
|
248
|
+
// binary. The plugin cannot query them at runtime, so they live here as data: a
|
|
249
|
+
// comment would be unenforceable, and the point of the contract test is to fail
|
|
250
|
+
// when a Kilo release adds a panel inside the band we chose.
|
|
251
|
+
export const KILO_SIDEBAR_ORDERS: Readonly<Record<string, number>> = {
|
|
252
|
+
"internal:kilo-sidebar-pr": 50,
|
|
253
|
+
"internal:sidebar-context": 100,
|
|
254
|
+
"internal:kilo-sidebar-usage": 150,
|
|
255
|
+
"internal:sidebar-mcp": 200,
|
|
256
|
+
"internal:kilo-sidebar-indexing": 225,
|
|
257
|
+
"internal:kilo-sidebar-background-processes": 250,
|
|
258
|
+
"internal:sidebar-lsp": 300,
|
|
259
|
+
"internal:sidebar-todo": 400,
|
|
260
|
+
"internal:sidebar-files": 500,
|
|
261
|
+
"internal:kilo-sidebar-memory": 1000,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// Free band between the context panel and the token-usage panel, so the Go block
|
|
265
|
+
// renders directly between them and never ties with a host panel.
|
|
266
|
+
export const KILO_SLOT_ORDER = 125;
|
|
267
|
+
|
|
268
|
+
// Integrated mode takes over the token-usage panel's own band, so the Go block
|
|
269
|
+
// lands where Kilo already draws usage. It ties with
|
|
270
|
+
// `internal:kilo-sidebar-usage` by construction — which is why that panel is
|
|
271
|
+
// retired rather than extended (see KILO_USAGE_PANEL_PLUGIN_ID): a Kilo
|
|
272
|
+
// release that moves it off 150 leaves us in a free band again, harmlessly.
|
|
273
|
+
export const KILO_INTEGRATED_SLOT_ORDER = 150;
|
|
274
|
+
|
|
275
|
+
// The host panel integrated mode replaces. `api.slots` is a per-plugin facade
|
|
276
|
+
// that exposes only `register`, so this panel cannot be extended or
|
|
277
|
+
// monkey-patched: the only supported way to take its band is to switch it off
|
|
278
|
+
// through the plugin lifecycle (`plugin_enabled` in tui.json at config time,
|
|
279
|
+
// `api.plugins.deactivate` at runtime).
|
|
280
|
+
export const KILO_USAGE_PANEL_PLUGIN_ID = "internal:kilo-sidebar-usage";
|
|
281
|
+
|
|
282
|
+
// Rows the integrated mode mirrors from Kilo's own `Token Usage` panel, in the
|
|
283
|
+
// order it renders them. Kept here so a reword upstream is a test failure rather
|
|
284
|
+
// than a silent divergence between our panel and the host's.
|
|
285
|
+
export const KILO_TOKEN_USAGE_ROWS: readonly string[] = [
|
|
286
|
+
"Input",
|
|
287
|
+
"Output",
|
|
288
|
+
"Reasoning",
|
|
289
|
+
"Cache read",
|
|
290
|
+
"Cache write",
|
|
291
|
+
"Cache rate",
|
|
292
|
+
"Cost",
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// The provider whose plan this plugin exists for, and whose per-model group
|
|
296
|
+
// carries the Go meters.
|
|
297
|
+
export const GO_PROVIDER_ID = "opencode-go";
|
|
298
|
+
|
|
299
|
+
// Layout constants of the Models table Kilo renders, in the same block as the
|
|
300
|
+
// ladder above: a Kilo release that re-lays the table changes these, and a
|
|
301
|
+
// constant is the only thing a drift check can assert against.
|
|
302
|
+
export const KILO_MODEL_NAME_MAX_CHARS = 19;
|
|
303
|
+
export const KILO_STEPS_COLUMN_WIDTH = 5;
|
|
304
|
+
export const KILO_COST_COLUMN_WIDTH = 9;
|
|
305
|
+
|
|
306
|
+
// The sidebar's disclosure glyphs: the section caret and the per-model fold
|
|
307
|
+
// marker. Both hosts draw their own sections with these (Kilo's `Token Usage`
|
|
308
|
+
// and `Models` panels, opencode's `MCP` panel), so the plugin uses the hosts'
|
|
309
|
+
// shapes rather than inventing a third: a caret that does not match the ones
|
|
310
|
+
// around it reads as a different control than the one it is.
|
|
311
|
+
export const SIDEBAR_COLLAPSED_GLYPH = "▶";
|
|
312
|
+
export const SIDEBAR_EXPANDED_GLYPH = "▾";
|
|
313
|
+
|
|
314
|
+
// Cache rate is a share of the three buckets a cache hit can be served from, so
|
|
315
|
+
// it is meaningless when all of them are zero — and a zero denominator is the
|
|
316
|
+
// normal state of a session that never hit a cache. A dash, not "0.0%".
|
|
317
|
+
export const CACHE_RATE_DECIMALS = 1;
|
|
318
|
+
export const CACHE_RATE_EMPTY = "-";
|
|
319
|
+
|
|
320
|
+
// A model name in opencode's narrower sidebar: 12 cells, where Kilo's table
|
|
321
|
+
// gives 19.
|
|
322
|
+
export const OPENCODE_MODEL_NAME_MAX_CHARS = 12;
|
|
323
|
+
// A percent in its own fixed-width cell, so "0%", "42%" and "100%" end on the
|
|
324
|
+
// same column. A text node's `width` reserves cells but does NOT right-align the
|
|
325
|
+
// text inside them, so the padding has to be in the string.
|
|
326
|
+
export const PERCENT_CELL_WIDTH = 5;
|
|
327
|
+
|
|
328
|
+
// The collapsed mix line: how many models it lists, and how many cells it may
|
|
329
|
+
// spend. The budget is why the mix line is built cell by cell (see
|
|
330
|
+
// `buildModelMixSummary`) instead of joined from a fixed list.
|
|
331
|
+
export const TOP_GO_MODELS_LIMIT = 3;
|
|
332
|
+
export const GO_MODEL_MIX_NAME_MAX_CHARS = 6;
|
|
333
|
+
export const GO_MODEL_MIX_BUDGET = 28;
|
|
334
|
+
// No spaces around the separator: the same three entries cost 29 cells with
|
|
335
|
+
// them and 25 without, and 25 is what fits beside the section's indent in a
|
|
336
|
+
// ~30-cell sidebar. A cut-off third entry is a worse summary than tight dots.
|
|
337
|
+
export const GO_MODEL_MIX_SEPARATOR = "·";
|
|
338
|
+
|
|
339
|
+
// The section title says "Go models" on purpose: the weight of a model is its
|
|
340
|
+
// share of the Go tokens, so a model from another provider has no weight to
|
|
341
|
+
// show and is not counted here.
|
|
342
|
+
export const TOP_GO_MODELS_LABEL = "Top Go models";
|
|
343
|
+
|
|
344
|
+
// Section titles of the integrated panel. The token section is deliberately NOT
|
|
345
|
+
// "Token Usage": integrated mode retires the host panel of that name, and
|
|
346
|
+
// re-printing the title would be indistinguishable from the panel that was
|
|
347
|
+
// supposed to go.
|
|
348
|
+
export const INTEGRATED_TOKENS_SECTION_LABEL = "Session Tokens";
|
|
349
|
+
export const INTEGRATED_MODELS_SECTION_LABEL = "Models";
|
|
350
|
+
export const GO_PLAN_HEADING = "Go Plan";
|
|
351
|
+
export const INTEGRATED_GO_SHARE_LABEL = "Go share";
|
|
352
|
+
|
|
353
|
+
// Wording for a model section with nothing to show. Both hosts use it (opencode's
|
|
354
|
+
// mix folds its own message store, Kilo's integrated panel the host endpoint),
|
|
355
|
+
// and Kilo's own load/failure wording is borrowed too, so the section reads the
|
|
356
|
+
// same on either host whether the host panel or ours is on screen.
|
|
357
|
+
export const MODELS_EMPTY_LABEL = "No model usage yet";
|
|
358
|
+
export const INTEGRATED_LOADING_LABEL = "Loading usage...";
|
|
359
|
+
export const INTEGRATED_UNAVAILABLE_LABEL = "Usage unavailable";
|
|
360
|
+
|
|
361
|
+
// A countdown, as every surface renders it.
|
|
362
|
+
//
|
|
363
|
+
// Every unit down to the hour, and no unit that is zero. "1w 0d" says no more
|
|
364
|
+
// than "1w" and reads as though a day were still to come, so a unit earns its
|
|
365
|
+
// place by having something to say -- and dropping the empty one is also what lets
|
|
366
|
+
// the countdown keep a fixed granularity without lying near a boundary: "1w" is
|
|
367
|
+
// not a rounding error, it is what is actually left.
|
|
368
|
+
//
|
|
369
|
+
// Below a day the minutes and seconds are what matter, so they are what is shown,
|
|
370
|
+
// and the hours and minutes run together because that is what has always fitted:
|
|
371
|
+
// `2h5m`, `5m`, `45s`. A 30-day window resets ~720h out, and "720h00m" is a
|
|
372
|
+
// number nobody can read at a glance, so past a day the units are weeks, days and
|
|
373
|
+
// hours: `4w 2d`, `2d 6h`, `1w 1d 22h`.
|
|
374
|
+
//
|
|
375
|
+
// ONE formatter for the sidebar, the statusline and the server line. The sidebar
|
|
376
|
+
// has a column to spend and the statusline does not, so they used to be worth
|
|
377
|
+
// rendering differently -- but the statusline reads its countdown back out of
|
|
378
|
+
// `buildPlanRows` precisely so it cannot drift from the sidebar, so a second
|
|
379
|
+
// formatter would have bought a shorter footer by showing the same fact at two
|
|
380
|
+
// different precisions in two places. The countdowns are short, and they are only
|
|
381
|
+
// printed when a window is capped.
|
|
382
|
+
const SECONDS_PER_MINUTE = 60;
|
|
383
|
+
const SECONDS_PER_HOUR = 3600;
|
|
384
|
+
const SECONDS_PER_DAY = 86400;
|
|
385
|
+
const SECONDS_PER_WEEK = 604800;
|
|
386
|
+
|
|
387
|
+
// The non-zero week/day/hour units of a duration, coarsest first. Minutes are not
|
|
388
|
+
// here: below a day the countdown keeps its own tighter rendering.
|
|
389
|
+
function resetDayUnits(sec: number): string[] {
|
|
390
|
+
const units = [
|
|
391
|
+
{ value: Math.floor(sec / SECONDS_PER_WEEK), suffix: "w" },
|
|
392
|
+
{ value: Math.floor((sec % SECONDS_PER_WEEK) / SECONDS_PER_DAY), suffix: "d" },
|
|
393
|
+
{ value: Math.floor((sec % SECONDS_PER_DAY) / SECONDS_PER_HOUR), suffix: "h" },
|
|
394
|
+
];
|
|
395
|
+
return units.filter((unit) => unit.value > 0).map((unit) => `${unit.value}${unit.suffix}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
69
398
|
export function formatResetDuration(totalSec: number | null): string | null {
|
|
70
399
|
if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
|
|
71
400
|
const sec = Math.floor(totalSec);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
401
|
+
if (sec < SECONDS_PER_DAY) {
|
|
402
|
+
const hours = Math.floor(sec / SECONDS_PER_HOUR);
|
|
403
|
+
const minutes = Math.floor((sec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
|
|
404
|
+
if (hours === 0) return minutes > 0 ? `${minutes}m` : `${sec}s`;
|
|
405
|
+
return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
|
|
406
|
+
}
|
|
407
|
+
return resetDayUnits(sec).join(" ");
|
|
77
408
|
}
|
|
78
409
|
|
|
79
410
|
// ---------------------------------------------------------------------------
|
|
80
411
|
// Credentials boundary (auth.json; secrets never logged)
|
|
81
412
|
// ---------------------------------------------------------------------------
|
|
82
413
|
|
|
83
|
-
export function readAuthJsonApiKey(
|
|
84
|
-
|
|
414
|
+
export function readAuthJsonApiKey(
|
|
415
|
+
host: UsageHost = "opencode",
|
|
416
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
417
|
+
homedir: () => string = resolveHomedir,
|
|
418
|
+
): string | null {
|
|
419
|
+
for (const authPath of authJsonPaths(host, env, homedir)) {
|
|
85
420
|
let raw: string;
|
|
86
421
|
try {
|
|
87
422
|
raw = fs.readFileSync(authPath, "utf8");
|
|
@@ -109,7 +444,44 @@ export function readAuthJsonApiKey(): string | null {
|
|
|
109
444
|
// API payload boundary (tolerant JSON parsing; shape may evolve)
|
|
110
445
|
// ---------------------------------------------------------------------------
|
|
111
446
|
|
|
112
|
-
|
|
447
|
+
// A countdown is only usable when it is a finite, non-negative number of
|
|
448
|
+
// seconds. One guard covers every failure mode, so no consumer re-checks before
|
|
449
|
+
// rendering and no `NaN`, `Invalid Date` or negative span can reach a
|
|
450
|
+
// statusline — an elapsed reset (stale payload, clock skew, a reset that fired
|
|
451
|
+
// mid-flight) is as unusable as an unreadable one and degrades the same way.
|
|
452
|
+
function usableCountdown(seconds: unknown): number | null {
|
|
453
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return null;
|
|
454
|
+
return seconds;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The live usage API reports the reset as an absolute ISO 8601 instant
|
|
458
|
+
// (`resetsAt`), while every consumer wants the RELATIVE count of seconds left
|
|
459
|
+
// (`resetInSec`). Convert here, at the parse boundary, so the instant is read
|
|
460
|
+
// once against a single clock reading.
|
|
461
|
+
function secondsUntilReset(resetsAt: unknown, now: number): number | null {
|
|
462
|
+
const instant = toNonEmptyString(resetsAt);
|
|
463
|
+
if (instant === null) return null;
|
|
464
|
+
return usableCountdown(Math.round((Date.parse(instant) - now) / 1000));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Statuses that mean "this window is capped". Matching is on whole normalized
|
|
468
|
+
// tokens, never substrings: "unlimited" contains "limited" and would flip a
|
|
469
|
+
// healthy window. Every unrecognized value (including the scrape path's
|
|
470
|
+
// historical "active" and the API's "ok") stays false, so a status this build
|
|
471
|
+
// has never seen can never paint a window as limited.
|
|
472
|
+
const LIMITED_STATUSES = new Set(["rate-limited", "limited", "exhausted", "capped"]);
|
|
473
|
+
|
|
474
|
+
// Exported for the cookie-scrape parsers in `src/index.ts`, which build windows
|
|
475
|
+
// from scraped markup instead of the JSON API and must apply the same mapping.
|
|
476
|
+
export function isLimitedStatus(status: string | null): boolean {
|
|
477
|
+
if (status === null) return false;
|
|
478
|
+
const normalized = status.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
|
479
|
+
return LIMITED_STATUSES.has(normalized);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// `now` is injectable so the absolute-to-relative conversion is unit-testable
|
|
483
|
+
// without sleeping; callers always pass the snapshot's single clock reading.
|
|
484
|
+
export function extractWindow(candidate: unknown, now: number = Date.now()): UsageWindow | null {
|
|
113
485
|
if (!isRecord(candidate)) return null;
|
|
114
486
|
const percent = toFiniteNumber(
|
|
115
487
|
candidate.percent ??
|
|
@@ -119,13 +491,20 @@ export function extractWindow(candidate: unknown): UsageWindow | null {
|
|
|
119
491
|
candidate.usage,
|
|
120
492
|
);
|
|
121
493
|
if (percent === null) return null;
|
|
122
|
-
const resetInSec = toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null);
|
|
123
494
|
const status = toNonEmptyString(candidate.status);
|
|
495
|
+
// The relative spellings win when present because they are already a
|
|
496
|
+
// countdown; `resetsAt` is the live field and is derived from the instant.
|
|
497
|
+
const resetInSec =
|
|
498
|
+
usableCountdown(candidate.resetInSec ?? candidate.resetInSeconds) ??
|
|
499
|
+
secondsUntilReset(candidate.resetsAt, now);
|
|
124
500
|
const resetText = toNonEmptyString(candidate.resetText ?? candidate.reset ?? null);
|
|
125
|
-
return { percent: Math.round(percent),
|
|
501
|
+
return { percent: Math.round(percent), status, limited: isLimitedStatus(status), resetInSec, resetText };
|
|
126
502
|
}
|
|
127
503
|
|
|
128
|
-
export function extractSnapshotFromApiPayload(
|
|
504
|
+
export function extractSnapshotFromApiPayload(
|
|
505
|
+
payload: unknown,
|
|
506
|
+
now: number = Date.now(),
|
|
507
|
+
): UsageSnapshot | null {
|
|
129
508
|
if (!isRecord(payload)) return null;
|
|
130
509
|
const containers: unknown[] = [payload];
|
|
131
510
|
for (const key of ["usage", "data", "go"]) {
|
|
@@ -133,16 +512,16 @@ export function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot |
|
|
|
133
512
|
}
|
|
134
513
|
for (const container of containers) {
|
|
135
514
|
if (!isRecord(container)) continue;
|
|
136
|
-
const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
|
|
137
|
-
const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
|
|
138
|
-
const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
|
|
515
|
+
const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"], now);
|
|
516
|
+
const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"], now);
|
|
517
|
+
const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"], now);
|
|
139
518
|
if (rolling !== null || weekly !== null || monthly !== null) {
|
|
140
519
|
return {
|
|
141
520
|
rolling,
|
|
142
521
|
weekly,
|
|
143
522
|
monthly,
|
|
144
523
|
source: "api",
|
|
145
|
-
fetchedAt:
|
|
524
|
+
fetchedAt: now,
|
|
146
525
|
};
|
|
147
526
|
}
|
|
148
527
|
}
|
|
@@ -153,27 +532,309 @@ export function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot |
|
|
|
153
532
|
// Snapshot builders
|
|
154
533
|
// ---------------------------------------------------------------------------
|
|
155
534
|
|
|
156
|
-
|
|
535
|
+
// The mock's plan percents, in the order the windows are read: rolling, weekly,
|
|
536
|
+
// monthly. Lifted out as named values because the display tests drive a ladder
|
|
537
|
+
// over them (`MOCK_PERCENTS`) and a literal in the middle of the snapshot
|
|
538
|
+
// builder is the one number nobody can find when a meter assertion fails.
|
|
539
|
+
export const MOCK_PERCENTS: readonly [number, number, number] = [42, 15, 61];
|
|
540
|
+
|
|
541
|
+
// `MOCK_PERCENTS` override: `rolling,weekly,monthly`, one rung per window, e.g.
|
|
542
|
+
// `"0,50,90"`. Parsed here rather than at the read sites so the shape is pinned
|
|
543
|
+
// once -- a partially parsed ladder (a missing field, a non-numeric cell, `NaN`)
|
|
544
|
+
// falls back to the defaults whole, because a half-applied ladder would render a
|
|
545
|
+
// block whose numbers match no rung the test asked for, which is the worst
|
|
546
|
+
// possible failure mode for a display assertion.
|
|
547
|
+
//
|
|
548
|
+
// Out-of-range values are NOT clamped here: the parser that stands in for the API
|
|
549
|
+
// accepts whatever the wire says, and the meter/percent pair is responsible for
|
|
550
|
+
// rendering a value outside 0-100 without breaking its column.
|
|
551
|
+
export function parseMockPercents(raw: string | undefined): [number, number, number] | null {
|
|
552
|
+
const text = toNonEmptyString(raw);
|
|
553
|
+
if (text === null) return null;
|
|
554
|
+
const parts = text.split(",");
|
|
555
|
+
if (parts.length !== MOCK_PERCENTS.length) return null;
|
|
556
|
+
const values = parts.map((part) => Number(part.trim()));
|
|
557
|
+
const rolling = values[0];
|
|
558
|
+
const weekly = values[1];
|
|
559
|
+
const monthly = values[2];
|
|
560
|
+
if (rolling === undefined || weekly === undefined || monthly === undefined) return null;
|
|
561
|
+
if (!Number.isFinite(rolling) || !Number.isFinite(weekly) || !Number.isFinite(monthly)) return null;
|
|
562
|
+
return [rolling, weekly, monthly];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export type MockSnapshotOverrides = {
|
|
566
|
+
percents?: [number, number, number];
|
|
567
|
+
resets?: [number | null, number | null, number | null];
|
|
568
|
+
limited?: [boolean, boolean, boolean];
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// The mock's default countdowns, in window order: the rolling window counts down
|
|
572
|
+
// (which is what puts a `resets in` on screen at all under the mock), and the
|
|
573
|
+
// longer windows do not. No window is capped, so the plan rows print no per-row
|
|
574
|
+
// countdown and the header line is the only place one appears.
|
|
575
|
+
const MOCK_RESETS: readonly [number | null, number | null, number | null] = [7543, null, null];
|
|
576
|
+
const MOCK_LIMITED: readonly [boolean, boolean, boolean] = [false, false, false];
|
|
577
|
+
|
|
578
|
+
// The Go share override, and the one seam that is NOT a plan number: the share is
|
|
579
|
+
// a fold over the host's own message store, so a display test can only pin its
|
|
580
|
+
// row's layout without a session that holds real assistant messages -- which
|
|
581
|
+
// means a provider call per rung. Same mock-only rule as `MOCK_PERCENTS`, and the
|
|
582
|
+
// real arithmetic stays covered where it belongs: `goSharePercent` in the unit
|
|
583
|
+
// tier and the collapsed model-mix e2e against a local fake provider.
|
|
584
|
+
export function parseMockShare(raw: string | undefined): number | null {
|
|
585
|
+
const text = toNonEmptyString(raw);
|
|
586
|
+
if (text === null) return null;
|
|
587
|
+
const value = Number(text.trim());
|
|
588
|
+
return Number.isFinite(value) ? value : null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// The mock's countdowns, in the same window order as `MOCK_PERCENTS`: one slot per
|
|
592
|
+
// window, in seconds, with `-` or an empty slot for a window that has no countdown
|
|
593
|
+
// at all. A countdown is what a window being capped looks like on the wire, so it
|
|
594
|
+
// carries seconds rather than a formatted string: the formatter is the thing under
|
|
595
|
+
// test, and handing it a pre-rendered value would test nothing.
|
|
596
|
+
export function parseMockResets(raw: string | undefined): [number | null, number | null, number | null] | null {
|
|
597
|
+
const text = toNonEmptyString(raw);
|
|
598
|
+
if (text === null) return null;
|
|
599
|
+
const parts = text.split(",");
|
|
600
|
+
if (parts.length !== MOCK_PERCENTS.length) return null;
|
|
601
|
+
const parsed = parts.map((part) => {
|
|
602
|
+
const slot = part.trim();
|
|
603
|
+
if (slot === "" || slot === "-") return null;
|
|
604
|
+
const value = Number(slot);
|
|
605
|
+
// A negative countdown is what clock skew and a reset that fired mid-flight
|
|
606
|
+
// look like. The parser drops those, so the mock must not be able to
|
|
607
|
+
// manufacture one that the real payload could never carry.
|
|
608
|
+
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
609
|
+
});
|
|
610
|
+
const [rolling, weekly, monthly] = parsed;
|
|
611
|
+
if (rolling === undefined || weekly === undefined || monthly === undefined) return null;
|
|
612
|
+
return [rolling, weekly, monthly];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Which windows the mock reports as capped, same window order. Separate from
|
|
616
|
+
// `MOCK_RESETS` because the two are independent on the wire: `buildPlanRows` only
|
|
617
|
+
// prints a window's countdown when that window is capped, so without this a ladder
|
|
618
|
+
// could vary the seconds but never see a per-row countdown move.
|
|
619
|
+
export function parseMockLimited(raw: string | undefined): [boolean, boolean, boolean] | null {
|
|
620
|
+
const text = toNonEmptyString(raw);
|
|
621
|
+
if (text === null) return null;
|
|
622
|
+
const parts = text.split(",");
|
|
623
|
+
if (parts.length !== MOCK_PERCENTS.length) return null;
|
|
624
|
+
const parsed = parts.map((part) => {
|
|
625
|
+
const slot = part.trim();
|
|
626
|
+
if (slot === "" || slot === "-") return false;
|
|
627
|
+
return MOCK_LIMITED_TRUE.has(slot.toLowerCase());
|
|
628
|
+
});
|
|
629
|
+
const [rolling, weekly, monthly] = parsed;
|
|
630
|
+
if (rolling === undefined || weekly === undefined || monthly === undefined) return null;
|
|
631
|
+
return [rolling, weekly, monthly];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const MOCK_LIMITED_TRUE = new Set(["1", "true", "yes", "on"]);
|
|
635
|
+
|
|
636
|
+
// The Go share a display test asked for, or `null` for the real fold over the
|
|
637
|
+
// host's message store. Gated on the mock FLAG rather than on the override being
|
|
638
|
+
// set, so a stray `*_OC_GO_MOCK_SHARE` in a developer's shell cannot change what a
|
|
639
|
+
// real session renders.
|
|
640
|
+
export function mockGoShare(host: UsageHost, env: NodeJS.ProcessEnv = process.env): number | null {
|
|
641
|
+
if (hostEnv(host, "MOCK", env) !== "1") return null;
|
|
642
|
+
return parseMockShare(hostEnv(host, "MOCK_SHARE", env));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// `"active"` is the status this mock (and the cookie scrape) has always
|
|
646
|
+
// reported, and it must map to `limited: false` exactly like the parser does —
|
|
647
|
+
// the unit tier pins that agreement so the mock cannot drift from live shape.
|
|
648
|
+
// `resetInSec` stays a literal (not derived from `resetsAt`) so the mock reset
|
|
649
|
+
// suffix is deterministic for the TUI display tests.
|
|
650
|
+
export function mockSnapshot(overrides: MockSnapshotOverrides = {}): UsageSnapshot {
|
|
651
|
+
const percents = overrides.percents ?? MOCK_PERCENTS;
|
|
652
|
+
const resets = overrides.resets ?? MOCK_RESETS;
|
|
653
|
+
const limited = overrides.limited ?? MOCK_LIMITED;
|
|
654
|
+
const window = (index: number, percent: number): UsageWindow => {
|
|
655
|
+
const capped = limited[index] === true;
|
|
656
|
+
return {
|
|
657
|
+
percent,
|
|
658
|
+
// A capped window says so, because that is what the real payload says, and
|
|
659
|
+
// because `limited` is what gates the per-row countdown: a window with a
|
|
660
|
+
// countdown and no cap is a shape the API does not produce.
|
|
661
|
+
status: capped ? "rate-limited" : "active",
|
|
662
|
+
limited: capped,
|
|
663
|
+
resetInSec: resets[index] ?? null,
|
|
664
|
+
resetText: null,
|
|
665
|
+
};
|
|
666
|
+
};
|
|
157
667
|
return {
|
|
158
|
-
rolling:
|
|
159
|
-
weekly:
|
|
160
|
-
monthly:
|
|
668
|
+
rolling: window(0, percents[0]),
|
|
669
|
+
weekly: window(1, percents[1]),
|
|
670
|
+
monthly: window(2, percents[2]),
|
|
161
671
|
source: "mock",
|
|
162
672
|
fetchedAt: Date.now(),
|
|
163
673
|
};
|
|
164
674
|
}
|
|
165
675
|
|
|
166
|
-
export function unavailableSnapshot(
|
|
167
|
-
error: string,
|
|
168
|
-
source: UsageSnapshot["source"] = "unavailable",
|
|
169
|
-
): UsageSnapshot {
|
|
676
|
+
export function unavailableSnapshot(error: string): UsageSnapshot {
|
|
170
677
|
return {
|
|
171
678
|
rolling: null,
|
|
172
679
|
weekly: null,
|
|
173
680
|
monthly: null,
|
|
174
|
-
source,
|
|
681
|
+
source: "unavailable",
|
|
175
682
|
fetchedAt: Date.now(),
|
|
176
683
|
apiUnavailable: true,
|
|
177
684
|
apiError: error,
|
|
178
685
|
};
|
|
179
686
|
}
|
|
687
|
+
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
// Session model usage boundary (Kilo's per-session token/cost split)
|
|
690
|
+
// ---------------------------------------------------------------------------
|
|
691
|
+
//
|
|
692
|
+
// `GET /session/{sessionID}/model-usage` on the host API, which reports the
|
|
693
|
+
// whole top-level session tree. `sessionCost` also exists in 7.8.x and is absent
|
|
694
|
+
// in 7.7.5, so it is deliberately not part of the trusted shape: nothing here may
|
|
695
|
+
// depend on it. The path is a PATH param and is pinned against the real host by
|
|
696
|
+
// test/e2e/kilo-contract.test.js — the generated SDK types are the only place a
|
|
697
|
+
// route typo could otherwise hide.
|
|
698
|
+
|
|
699
|
+
export type UsageTokens = {
|
|
700
|
+
input: number;
|
|
701
|
+
output: number;
|
|
702
|
+
reasoning: number;
|
|
703
|
+
cache: { read: number; write: number };
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
export type UsageTotals = {
|
|
707
|
+
steps: number;
|
|
708
|
+
cost: number;
|
|
709
|
+
tokens: UsageTokens;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
export type ModelUsage = UsageTotals & {
|
|
713
|
+
providerID: string;
|
|
714
|
+
modelID: string;
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
export type SessionModelUsage = {
|
|
718
|
+
sessionIDs: string[];
|
|
719
|
+
totals: UsageTotals;
|
|
720
|
+
models: ModelUsage[];
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const EMPTY_TOKENS: UsageTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
|
|
724
|
+
|
|
725
|
+
// Tolerant because the payload is host-owned and can be renamed without breaking
|
|
726
|
+
// our build: a missing bucket becomes 0 rather than `undefined`, and a missing
|
|
727
|
+
// envelope is a null so the caller can show the unavailable state instead of
|
|
728
|
+
// rendering a column of zeros it cannot vouch for.
|
|
729
|
+
function parseUsageTokens(candidate: unknown): UsageTokens {
|
|
730
|
+
if (!isRecord(candidate)) return { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } };
|
|
731
|
+
const cache = isRecord(candidate.cache) ? candidate.cache : {};
|
|
732
|
+
return {
|
|
733
|
+
input: toUsageCount(candidate.input),
|
|
734
|
+
output: toUsageCount(candidate.output),
|
|
735
|
+
reasoning: toUsageCount(candidate.reasoning),
|
|
736
|
+
cache: { read: toUsageCount(cache.read), write: toUsageCount(cache.write) },
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function parseUsageTotals(candidate: unknown): UsageTotals {
|
|
741
|
+
if (!isRecord(candidate)) return { steps: 0, cost: 0, tokens: { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } } };
|
|
742
|
+
return {
|
|
743
|
+
steps: toUsageCount(candidate.steps),
|
|
744
|
+
cost: toUsageCount(candidate.cost),
|
|
745
|
+
tokens: parseUsageTokens(candidate.tokens),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function parseSessionModelUsage(payload: unknown): SessionModelUsage | null {
|
|
750
|
+
if (!isRecord(payload)) return null;
|
|
751
|
+
const models: ModelUsage[] = [];
|
|
752
|
+
if (Array.isArray(payload.models)) {
|
|
753
|
+
for (const entry of payload.models) {
|
|
754
|
+
if (!isRecord(entry)) continue;
|
|
755
|
+
const providerID = toNonEmptyString(entry.providerID);
|
|
756
|
+
const modelID = toNonEmptyString(entry.modelID);
|
|
757
|
+
// A row without both keys cannot be grouped or labelled, so it is dropped
|
|
758
|
+
// rather than rendered as an unlabelled model.
|
|
759
|
+
if (providerID === null || modelID === null) continue;
|
|
760
|
+
models.push({ providerID, modelID, ...parseUsageTotals(entry) });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
return {
|
|
764
|
+
sessionIDs: Array.isArray(payload.sessionIDs) ? payload.sessionIDs.filter((id): id is string => typeof id === "string") : [],
|
|
765
|
+
totals: parseUsageTotals(payload.totals),
|
|
766
|
+
models,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ---------------------------------------------------------------------------
|
|
771
|
+
// The same split, derived from opencode's own message store
|
|
772
|
+
// ---------------------------------------------------------------------------
|
|
773
|
+
//
|
|
774
|
+
// opencode has no `model-usage` endpoint to call, and it does not need one: every
|
|
775
|
+
// assistant message already carries the provider, the model, the cost and the
|
|
776
|
+
// five token buckets, so the per-model table is a fold over
|
|
777
|
+
// `api.state.session.messages(sessionID)` instead of a request. The result is
|
|
778
|
+
// the SAME `SessionModelUsage` shape Kilo's endpoint returns, which is what lets
|
|
779
|
+
// both hosts share one renderer.
|
|
780
|
+
//
|
|
781
|
+
// SCOPE: the rendered session only, not the session tree. That is the scope
|
|
782
|
+
// opencode's own Context panel reports (`Session.tokens` / `Session.cost`), so
|
|
783
|
+
// the mix is measured exactly like the number the user already sees next to it,
|
|
784
|
+
// and this plugin measures nothing the host does not. Kilo differs here only
|
|
785
|
+
// because its endpoint sums the tree and a client cannot ask for less.
|
|
786
|
+
//
|
|
787
|
+
// Tolerant for the same reason `parseSessionModelUsage` is: a message without a
|
|
788
|
+
// provider or a model cannot be weighted, so it is skipped rather than folded
|
|
789
|
+
// into an unnamed bucket that would silently take share from real models.
|
|
790
|
+
|
|
791
|
+
function addUsageTokens(left: UsageTokens, right: UsageTokens): UsageTokens {
|
|
792
|
+
return {
|
|
793
|
+
input: left.input + right.input,
|
|
794
|
+
output: left.output + right.output,
|
|
795
|
+
reasoning: left.reasoning + right.reasoning,
|
|
796
|
+
cache: { read: left.cache.read + right.cache.read, write: left.cache.write + right.cache.write },
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export function aggregateModelUsageFromMessages(messages: readonly unknown[]): SessionModelUsage {
|
|
801
|
+
const byModel = new Map<string, ModelUsage>();
|
|
802
|
+
const sessionIDs: string[] = [];
|
|
803
|
+
for (const entry of messages) {
|
|
804
|
+
if (!isRecord(entry) || entry.role !== "assistant") continue;
|
|
805
|
+
const providerID = toNonEmptyString(entry.providerID);
|
|
806
|
+
const modelID = toNonEmptyString(entry.modelID);
|
|
807
|
+
if (providerID === null || modelID === null) continue;
|
|
808
|
+
const parsed = parseUsageTotals(entry);
|
|
809
|
+
const key = `${providerID}/${modelID}`;
|
|
810
|
+
const existing = byModel.get(key);
|
|
811
|
+
if (existing === undefined) {
|
|
812
|
+
byModel.set(key, {
|
|
813
|
+
providerID,
|
|
814
|
+
modelID,
|
|
815
|
+
steps: 1,
|
|
816
|
+
cost: parsed.cost,
|
|
817
|
+
tokens: { ...parsed.tokens, cache: { ...parsed.tokens.cache } },
|
|
818
|
+
});
|
|
819
|
+
} else {
|
|
820
|
+
// In place: the map owns the row, and a fresh object per message would
|
|
821
|
+
// make a long session allocate one row per step.
|
|
822
|
+
existing.steps += 1;
|
|
823
|
+
existing.cost += parsed.cost;
|
|
824
|
+
existing.tokens = addUsageTokens(existing.tokens, parsed.tokens);
|
|
825
|
+
}
|
|
826
|
+
const sessionID = toNonEmptyString(entry.sessionID);
|
|
827
|
+
if (sessionID !== null && !sessionIDs.includes(sessionID)) sessionIDs.push(sessionID);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const models = [...byModel.values()];
|
|
831
|
+
const totals = models.reduce<UsageTotals>(
|
|
832
|
+
(sum, model) => ({
|
|
833
|
+
steps: sum.steps + model.steps,
|
|
834
|
+
cost: sum.cost + model.cost,
|
|
835
|
+
tokens: addUsageTokens(sum.tokens, model.tokens),
|
|
836
|
+
}),
|
|
837
|
+
{ steps: 0, cost: 0, tokens: { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } } },
|
|
838
|
+
);
|
|
839
|
+
return { sessionIDs, totals, models };
|
|
840
|
+
}
|