@leo000001/opencode-quota-sidebar 2.0.0 → 2.0.2
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 +26 -19
- package/dist/cost.js +2 -1
- package/dist/format.js +155 -52
- package/dist/index.js +77 -4
- package/dist/persistence.js +15 -1
- package/dist/quota.js +3 -5
- package/dist/quota_service.js +194 -29
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +74 -9
- package/dist/storage_chunks.js +20 -9
- package/dist/storage_parse.js +4 -1
- package/dist/title.js +37 -15
- package/dist/title_apply.d.ts +21 -3
- package/dist/title_apply.js +109 -23
- package/dist/title_refresh.d.ts +4 -0
- package/dist/title_refresh.js +35 -1
- package/dist/tools.d.ts +22 -1
- package/dist/tools.js +60 -14
- package/dist/types.d.ts +27 -1
- package/dist/usage.d.ts +14 -6
- package/dist/usage.js +78 -13
- package/dist/usage_service.js +159 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ OpenCode plugin: show token usage and subscription quota in the session sidebar
|
|
|
12
12
|
Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to install it automatically on startup:
|
|
13
13
|
|
|
14
14
|
```json
|
|
15
|
-
{
|
|
16
|
-
"plugin": ["@leo000001/opencode-quota-sidebar@
|
|
17
|
-
}
|
|
15
|
+
{
|
|
16
|
+
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.1"]
|
|
17
|
+
}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Note for OpenCode `>=1.2.15`: TUI settings (`theme`/`keybinds`/`tui`) moved to `tui.json`, but plugin loading still stays in `opencode.json` (`plugin: []`).
|
|
@@ -55,20 +55,22 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
55
55
|
|
|
56
56
|
- Session title becomes multiline in sidebar:
|
|
57
57
|
- line 1: original session title
|
|
58
|
-
- line 2:
|
|
59
|
-
- line 3:
|
|
60
|
-
- line 4: Cache
|
|
58
|
+
- line 2: blank separator
|
|
59
|
+
- line 3: Input/Output tokens
|
|
60
|
+
- line 4: Cache Read tokens (only if non-zero)
|
|
61
|
+
- line 5: Cache Write tokens (only if non-zero)
|
|
61
62
|
- next lines: `Cache Coverage` (read/write cache models) and `Cache Read Coverage` (read-only cache models) when enough cache telemetry is available; mixed sessions can show both
|
|
62
63
|
- next line: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
|
|
63
64
|
- quota lines: quota text like `OpenAI 5h 80% Rst 16:20`; short windows (`5h`, `1d`, `Daily`) show `HH:MM` on same-day resets and `MM-DD HH:MM` when crossing days, while longer windows continue to show `MM-DD`
|
|
64
65
|
- RightCode daily quota shows `$remaining/$dailyTotal` + expiry (e.g. `RC Daily $105/$60 Exp 02-27`, without trailing percent) and also shows balance on the next indented line when available; `Exp` remains date-only
|
|
65
66
|
- Session-scoped usage/quota can include descendant subagent sessions (enabled by default via `sidebar.includeChildren=true`). Traversal is bounded by `childrenMaxDepth` (default 6), `childrenMaxSessions` (default 128), and `childrenConcurrency` (default 5); truncation is logged when `OPENCODE_QUOTA_DEBUG=1`. Day/week/month ranges never merge children — only session scope does.
|
|
66
|
-
- Toast message
|
|
67
|
+
- Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cache coverage is available), and `Quota`
|
|
67
68
|
- `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
|
|
68
69
|
- Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
|
|
69
70
|
- Custom tools:
|
|
70
71
|
- `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
|
|
71
|
-
|
|
72
|
+
- `quota_show` — toggle sidebar title display on/off (state persists across sessions)
|
|
73
|
+
- After startup, titles are restored immediately when persisted display mode is OFF; when persisted display mode is ON, touched titles refresh on startup and the rest update on the next relevant session/message event or when `quota_show` is toggled
|
|
72
74
|
- Quota connectors:
|
|
73
75
|
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
74
76
|
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
@@ -88,12 +90,17 @@ The plugin stores lightweight global state and date-partitioned session chunks.
|
|
|
88
90
|
- `titleEnabled`
|
|
89
91
|
- `sessionDateMap` (sessionID -> `YYYY-MM-DD`)
|
|
90
92
|
- `quotaCache`
|
|
91
|
-
- Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
|
|
92
|
-
- per-session title state (`baseTitle`, `lastAppliedTitle`)
|
|
93
|
-
- `createdAt`
|
|
94
|
-
- `parentID` (when the session is a subagent child session)
|
|
95
|
-
- cached usage summary used by `quota_summary`
|
|
96
|
-
- incremental aggregation cursor
|
|
93
|
+
- Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
|
|
94
|
+
- per-session title state (`baseTitle`, `lastAppliedTitle`)
|
|
95
|
+
- `createdAt`
|
|
96
|
+
- `parentID` (when the session is a subagent child session)
|
|
97
|
+
- cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
|
|
98
|
+
- incremental aggregation cursor
|
|
99
|
+
|
|
100
|
+
Notes on cache coverage persistence:
|
|
101
|
+
|
|
102
|
+
- Older cached usage written before `cacheBuckets` existed can only be approximated from top-level `cache_read` / `cache_write` totals.
|
|
103
|
+
- In those legacy cases, mixed read-only + read-write cache traffic may be attributed to a single fallback bucket until the session is recomputed from messages.
|
|
97
104
|
|
|
98
105
|
Example tree:
|
|
99
106
|
|
|
@@ -296,7 +303,7 @@ Other defaults:
|
|
|
296
303
|
- When OpenCode exposes a long-context tier like `context_over_200k`, the plugin uses that premium rate for the whole request once `input > 200000`, matching OpenCode's current pricing schema.
|
|
297
304
|
- `quota.providers` is the extensible per-adapter switch map.
|
|
298
305
|
- If API Cost is `$0.00`, it usually means the model/provider has no pricing mapping in OpenCode at the moment, so equivalent API cost cannot be estimated.
|
|
299
|
-
- Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`)
|
|
306
|
+
- Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`) recomputes range totals from session messages so period filtering follows message completion time; refreshed full-session usage may then be persisted back into day chunks when billing-cache refresh is needed.
|
|
300
307
|
|
|
301
308
|
### Buzz provider example
|
|
302
309
|
|
|
@@ -319,7 +326,7 @@ The adapter also tolerates `https://buzzai.cc/v1`, but `https://buzzai.cc` is th
|
|
|
319
326
|
With that setup, the sidebar/toast quota line will look like:
|
|
320
327
|
|
|
321
328
|
```text
|
|
322
|
-
Buzz Balance
|
|
329
|
+
Buzz Balance ¥10.17
|
|
323
330
|
```
|
|
324
331
|
|
|
325
332
|
## Rendering examples
|
|
@@ -382,7 +389,7 @@ OpenAI
|
|
|
382
389
|
5h 78% Rst 05:05
|
|
383
390
|
Copilot
|
|
384
391
|
Monthly 78% Rst 04-01
|
|
385
|
-
Buzz Balance
|
|
392
|
+
Buzz Balance ¥10.2
|
|
386
393
|
```
|
|
387
394
|
|
|
388
395
|
Balance-style quota:
|
|
@@ -394,7 +401,7 @@ RC Balance $260
|
|
|
394
401
|
Buzz balance quota:
|
|
395
402
|
|
|
396
403
|
```text
|
|
397
|
-
Buzz Balance
|
|
404
|
+
Buzz Balance ¥10.17
|
|
398
405
|
```
|
|
399
406
|
|
|
400
407
|
Multi-detail quota (window + balance):
|
|
@@ -424,7 +431,7 @@ Quota is rendered inline as part of a single-line title:
|
|
|
424
431
|
Mixed with Buzz balance:
|
|
425
432
|
|
|
426
433
|
```text
|
|
427
|
-
<base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance
|
|
434
|
+
<base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance ¥10.2
|
|
428
435
|
```
|
|
429
436
|
|
|
430
437
|
`quota_summary` also supports an optional `includeChildren` flag (only effective for `period=session`) to override the config per call. For `day`/`week`/`month` periods, children are never merged — each session is counted independently.
|
package/dist/cost.js
CHANGED
|
@@ -88,7 +88,8 @@ export function cacheCoverageModeFromRates(rates) {
|
|
|
88
88
|
return 'none';
|
|
89
89
|
}
|
|
90
90
|
export function calcEquivalentApiCostForMessage(message, rates) {
|
|
91
|
-
const effectiveRates = message.tokens.input > 200_000 &&
|
|
91
|
+
const effectiveRates = message.tokens.input + message.tokens.cache.read > 200_000 &&
|
|
92
|
+
rates.contextOver200k
|
|
92
93
|
? rates.contextOver200k
|
|
93
94
|
: rates;
|
|
94
95
|
// For providers that expose reasoning tokens separately, they are still
|
package/dist/format.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getCacheCoverageMetrics } from './usage.js';
|
|
1
|
+
import { getCacheCoverageMetrics, getProviderCacheCoverageMetrics, } from './usage.js';
|
|
2
2
|
import { canonicalProviderID, collapseQuotaSnapshots, displayShortLabel, quotaDisplayLabel, } from './quota_render.js';
|
|
3
3
|
import { stripAnsi } from './title.js';
|
|
4
4
|
/** M6 fix: handle negative, NaN, Infinity gracefully. */
|
|
@@ -129,10 +129,18 @@ function fitLine(value, width) {
|
|
|
129
129
|
return `${head}~`;
|
|
130
130
|
}
|
|
131
131
|
function formatCurrency(value, currency) {
|
|
132
|
-
const safe = Number.isFinite(value)
|
|
132
|
+
const safe = Number.isFinite(value) ? value : 0;
|
|
133
133
|
const prefix = typeof currency === 'string' && currency ? currency : '$';
|
|
134
134
|
if (safe === 0)
|
|
135
135
|
return `${prefix}0.00`;
|
|
136
|
+
if (safe < 0) {
|
|
137
|
+
const abs = Math.abs(safe);
|
|
138
|
+
if (abs < 10)
|
|
139
|
+
return `-${prefix}${abs.toFixed(2)}`;
|
|
140
|
+
const one = abs.toFixed(1);
|
|
141
|
+
const trimmed = one.endsWith('.0') ? one.slice(0, -2) : one;
|
|
142
|
+
return `-${prefix}${trimmed}`;
|
|
143
|
+
}
|
|
136
144
|
if (safe < 10)
|
|
137
145
|
return `${prefix}${safe.toFixed(2)}`;
|
|
138
146
|
const one = safe.toFixed(1);
|
|
@@ -153,6 +161,16 @@ function formatPercent(value, decimals = 1) {
|
|
|
153
161
|
const pct = (safe * 100).toFixed(decimals);
|
|
154
162
|
return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
|
|
155
163
|
}
|
|
164
|
+
function formatQuotaPercent(value, options) {
|
|
165
|
+
const missing = options?.missing ?? '-';
|
|
166
|
+
if (value === undefined)
|
|
167
|
+
return missing;
|
|
168
|
+
if (!Number.isFinite(value) || value < 0)
|
|
169
|
+
return missing;
|
|
170
|
+
if (options?.rounded)
|
|
171
|
+
return `${Math.round(value)}%`;
|
|
172
|
+
return `${value.toFixed(options?.decimals ?? 1)}%`;
|
|
173
|
+
}
|
|
156
174
|
function alignPairs(pairs, indent = ' ') {
|
|
157
175
|
if (pairs.length === 0)
|
|
158
176
|
return [];
|
|
@@ -170,15 +188,19 @@ function alignPairs(pairs, indent = ' ') {
|
|
|
170
188
|
}
|
|
171
189
|
function compactQuotaInline(quota) {
|
|
172
190
|
const label = sanitizeLine(quotaDisplayLabel(quota));
|
|
173
|
-
if (quota.status !== 'ok')
|
|
174
|
-
|
|
191
|
+
if (quota.status !== 'ok') {
|
|
192
|
+
if (quota.status === 'error')
|
|
193
|
+
return `${label} Remaining ?`;
|
|
194
|
+
return `${label} ${sanitizeLine(quota.status)}`;
|
|
195
|
+
}
|
|
175
196
|
if (quota.windows && quota.windows.length > 0) {
|
|
176
197
|
const first = quota.windows[0];
|
|
177
198
|
const showPercent = first.showPercent !== false;
|
|
178
199
|
const firstLabel = sanitizeLine(first.label || '');
|
|
179
|
-
const pct = first.remainingPercent
|
|
180
|
-
|
|
181
|
-
:
|
|
200
|
+
const pct = formatQuotaPercent(first.remainingPercent, {
|
|
201
|
+
rounded: true,
|
|
202
|
+
missing: '',
|
|
203
|
+
});
|
|
182
204
|
const summary = showPercent
|
|
183
205
|
? [firstLabel, pct].filter(Boolean).join(' ')
|
|
184
206
|
: firstLabel.replace(/^Daily\s+/i, '') || firstLabel;
|
|
@@ -189,8 +211,12 @@ function compactQuotaInline(quota) {
|
|
|
189
211
|
if (quota.balance) {
|
|
190
212
|
return `${label} Balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
|
|
191
213
|
}
|
|
192
|
-
|
|
193
|
-
|
|
214
|
+
const singlePercent = formatQuotaPercent(quota.remainingPercent, {
|
|
215
|
+
rounded: true,
|
|
216
|
+
missing: '',
|
|
217
|
+
});
|
|
218
|
+
if (singlePercent) {
|
|
219
|
+
return `${label} ${singlePercent}`;
|
|
194
220
|
}
|
|
195
221
|
return label;
|
|
196
222
|
}
|
|
@@ -238,13 +264,16 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
|
|
|
238
264
|
*/
|
|
239
265
|
export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
240
266
|
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
241
|
-
const safeBaseTitle = stripAnsi(baseTitle || 'Session')
|
|
267
|
+
const safeBaseTitle = stripAnsi(baseTitle || 'Session') || 'Session';
|
|
242
268
|
if (config.sidebar.multilineTitle !== true) {
|
|
243
|
-
|
|
269
|
+
const singleLineBase = safeBaseTitle.split(/\r?\n/, 1)[0] || 'Session';
|
|
270
|
+
return renderSingleLineTitle(singleLineBase, usage, quotas, config, width);
|
|
244
271
|
}
|
|
245
272
|
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
246
273
|
const lines = [];
|
|
247
|
-
|
|
274
|
+
for (const line of safeBaseTitle.split(/\r?\n/)) {
|
|
275
|
+
lines.push(fitLine(line || 'Session', width));
|
|
276
|
+
}
|
|
248
277
|
lines.push('');
|
|
249
278
|
// Input / Output line
|
|
250
279
|
const io = `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`;
|
|
@@ -337,9 +366,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
337
366
|
: undefined;
|
|
338
367
|
const renderWindow = (win) => {
|
|
339
368
|
const showPercent = win.showPercent !== false;
|
|
340
|
-
const pct = win.remainingPercent
|
|
341
|
-
? '?'
|
|
342
|
-
: `${Math.round(win.remainingPercent)}%`;
|
|
369
|
+
const pct = formatQuotaPercent(win.remainingPercent, { rounded: true });
|
|
343
370
|
const parts = win.label
|
|
344
371
|
? showPercent
|
|
345
372
|
? [sanitizeLine(win.label), pct]
|
|
@@ -373,9 +400,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
373
400
|
return maybeBreak(balanceText, [balanceText]);
|
|
374
401
|
}
|
|
375
402
|
// Fallback: single value from top-level remainingPercent
|
|
376
|
-
const percent = quota.remainingPercent
|
|
377
|
-
? '?'
|
|
378
|
-
: `${Math.round(quota.remainingPercent)}%`;
|
|
403
|
+
const percent = formatQuotaPercent(quota.remainingPercent, { rounded: true });
|
|
379
404
|
const reset = compactReset(quota.resetAt, 'Rst');
|
|
380
405
|
const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
|
|
381
406
|
return maybeBreak(fallbackText, [fallbackText]);
|
|
@@ -409,9 +434,6 @@ function compactReset(iso, resetLabel, windowLabel) {
|
|
|
409
434
|
return hhmm;
|
|
410
435
|
return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
|
|
411
436
|
}
|
|
412
|
-
if (sameDay) {
|
|
413
|
-
return `${two(value.getHours())}:${two(value.getMinutes())}`;
|
|
414
|
-
}
|
|
415
437
|
return `${two(value.getMonth() + 1)}-${two(value.getDate())}`;
|
|
416
438
|
}
|
|
417
439
|
function dateLine(iso) {
|
|
@@ -450,6 +472,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
450
472
|
const measuredCostCell = (providerID, cost) => {
|
|
451
473
|
const canonical = canonicalProviderID(providerID);
|
|
452
474
|
const isSubscription = canonical === 'openai' ||
|
|
475
|
+
canonical === 'anthropic' ||
|
|
453
476
|
canonical === 'github-copilot' ||
|
|
454
477
|
rightCodeSubscriptionProviderIDs.has(providerID);
|
|
455
478
|
if (isSubscription)
|
|
@@ -459,6 +482,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
459
482
|
const isSubscriptionMeasuredProvider = (providerID) => {
|
|
460
483
|
const canonical = canonicalProviderID(providerID);
|
|
461
484
|
return (canonical === 'openai' ||
|
|
485
|
+
canonical === 'anthropic' ||
|
|
462
486
|
canonical === 'github-copilot' ||
|
|
463
487
|
rightCodeSubscriptionProviderIDs.has(providerID));
|
|
464
488
|
};
|
|
@@ -486,39 +510,104 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
486
510
|
return '-';
|
|
487
511
|
return formatApiCostValue(usage.apiCost);
|
|
488
512
|
};
|
|
489
|
-
const
|
|
490
|
-
|
|
513
|
+
const cacheCoverageCell = (provider) => {
|
|
514
|
+
const metrics = getProviderCacheCoverageMetrics(provider);
|
|
515
|
+
return metrics.cacheCoverage !== undefined
|
|
516
|
+
? formatPercent(metrics.cacheCoverage, 1)
|
|
517
|
+
: '-';
|
|
518
|
+
};
|
|
519
|
+
const cacheReadCoverageCell = (provider) => {
|
|
520
|
+
const metrics = getProviderCacheCoverageMetrics(provider);
|
|
521
|
+
return metrics.cacheReadCoverage !== undefined
|
|
522
|
+
? formatPercent(metrics.cacheReadCoverage, 1)
|
|
523
|
+
: '-';
|
|
524
|
+
};
|
|
525
|
+
const providerEntries = Object.values(usage.providers).sort((a, b) => b.total - a.total);
|
|
526
|
+
const highlightLines = () => {
|
|
527
|
+
const lines = [];
|
|
528
|
+
const providerLabel = (providerID) => quotaDisplayLabel({
|
|
529
|
+
providerID,
|
|
530
|
+
label: providerID,
|
|
531
|
+
status: 'ok',
|
|
532
|
+
checkedAt: 0,
|
|
533
|
+
});
|
|
534
|
+
const topApiCost = providerEntries
|
|
535
|
+
.filter((provider) => provider.apiCost > 0)
|
|
536
|
+
.sort((a, b) => b.apiCost - a.apiCost)[0];
|
|
537
|
+
if (topApiCost) {
|
|
538
|
+
lines.push(`- Top API cost: ${quotaDisplayLabel({
|
|
539
|
+
providerID: topApiCost.providerID,
|
|
540
|
+
label: topApiCost.providerID,
|
|
541
|
+
status: 'ok',
|
|
542
|
+
checkedAt: 0,
|
|
543
|
+
})} (${formatUsd(topApiCost.apiCost)})`);
|
|
544
|
+
}
|
|
545
|
+
const bestCacheCoverage = providerEntries
|
|
546
|
+
.map((provider) => ({
|
|
547
|
+
provider,
|
|
548
|
+
value: getProviderCacheCoverageMetrics(provider).cacheCoverage,
|
|
549
|
+
}))
|
|
550
|
+
.filter((entry) => entry.value !== undefined)
|
|
551
|
+
.sort((a, b) => b.value - a.value)[0];
|
|
552
|
+
if (bestCacheCoverage) {
|
|
553
|
+
lines.push(`- Best Cache Coverage: ${providerLabel(bestCacheCoverage.provider.providerID)} (${formatPercent(bestCacheCoverage.value, 1)})`);
|
|
554
|
+
}
|
|
555
|
+
const bestCacheReadCoverage = providerEntries
|
|
556
|
+
.map((provider) => ({
|
|
557
|
+
provider,
|
|
558
|
+
value: getProviderCacheCoverageMetrics(provider).cacheReadCoverage,
|
|
559
|
+
}))
|
|
560
|
+
.filter((entry) => entry.value !== undefined)
|
|
561
|
+
.sort((a, b) => b.value - a.value)[0];
|
|
562
|
+
if (bestCacheReadCoverage) {
|
|
563
|
+
lines.push(`- Best Cache Read Coverage: ${providerLabel(bestCacheReadCoverage.provider.providerID)} (${formatPercent(bestCacheReadCoverage.value, 1)})`);
|
|
564
|
+
}
|
|
565
|
+
const highestMeasured = providerEntries
|
|
566
|
+
.filter((provider) => measuredCostCell(provider.providerID, provider.cost) !== '-')
|
|
567
|
+
.sort((a, b) => b.cost - a.cost)[0];
|
|
568
|
+
if (highestMeasured && highestMeasured.cost > 0) {
|
|
569
|
+
lines.push(`- Highest measured cost: ${providerLabel(highestMeasured.providerID)} (${formatUsd(highestMeasured.cost)})`);
|
|
570
|
+
}
|
|
571
|
+
return lines;
|
|
572
|
+
};
|
|
573
|
+
const providerRows = providerEntries
|
|
491
574
|
.map((provider) => {
|
|
492
575
|
const providerID = mdCell(provider.providerID);
|
|
493
576
|
return showCost
|
|
494
|
-
? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
|
|
577
|
+
? `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} | ${cacheCoverageCell(provider)} | ${cacheReadCoverageCell(provider)} | ${measuredCostCell(provider.providerID, provider.cost)} | ${apiCostCell(provider.providerID, provider.apiCost)} |`
|
|
495
578
|
: `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
|
|
496
579
|
});
|
|
497
580
|
const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
|
|
581
|
+
const displayLabel = quotaDisplayLabel(quota);
|
|
498
582
|
// Multi-window detail
|
|
499
583
|
if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
|
|
500
|
-
|
|
584
|
+
const windowLines = quota.windows.map((win) => {
|
|
501
585
|
if (win.showPercent === false) {
|
|
502
586
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
503
|
-
return mdCell(`- ${
|
|
587
|
+
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
504
588
|
}
|
|
505
|
-
const remaining = win.remainingPercent
|
|
506
|
-
? '-'
|
|
507
|
-
: `${win.remainingPercent.toFixed(1)}%`;
|
|
589
|
+
const remaining = formatQuotaPercent(win.remainingPercent);
|
|
508
590
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
509
|
-
return mdCell(`- ${
|
|
591
|
+
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
510
592
|
});
|
|
593
|
+
if (quota.balance) {
|
|
594
|
+
windowLines.push(mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`));
|
|
595
|
+
}
|
|
596
|
+
return windowLines;
|
|
511
597
|
}
|
|
512
598
|
if (quota.status === 'ok' && quota.balance) {
|
|
513
599
|
return [
|
|
514
|
-
mdCell(`- ${
|
|
600
|
+
mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
|
|
515
601
|
];
|
|
516
602
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
603
|
+
if (quota.status !== 'ok') {
|
|
604
|
+
return [
|
|
605
|
+
mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
606
|
+
];
|
|
607
|
+
}
|
|
608
|
+
const remaining = formatQuotaPercent(quota.remainingPercent);
|
|
520
609
|
return [
|
|
521
|
-
mdCell(`- ${
|
|
610
|
+
mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
522
611
|
];
|
|
523
612
|
});
|
|
524
613
|
return [
|
|
@@ -541,17 +630,20 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
541
630
|
`- API cost: ${apiCostSummaryValue()}`,
|
|
542
631
|
]
|
|
543
632
|
: []),
|
|
633
|
+
...(highlightLines().length > 0
|
|
634
|
+
? ['', '### Highlights', ...highlightLines()]
|
|
635
|
+
: []),
|
|
544
636
|
'',
|
|
545
637
|
'### Usage by Provider',
|
|
546
638
|
showCost
|
|
547
|
-
? '| Provider | Input | Output | Cache | Total | Measured Cost | API Cost |'
|
|
639
|
+
? '| Provider | Input | Output | Cache | Total | Cache Coverage | Cache Read Coverage | Measured Cost | API Cost |'
|
|
548
640
|
: '| Provider | Input | Output | Cache | Total |',
|
|
549
641
|
showCost
|
|
550
|
-
? '
|
|
642
|
+
? '|---|---:|---:|---:|---:|---:|---:|---:|---:|'
|
|
551
643
|
: '|---|---:|---:|---:|---:|',
|
|
552
644
|
...(providerRows.length
|
|
553
645
|
? providerRows
|
|
554
|
-
: [showCost ? '| - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
|
|
646
|
+
: [showCost ? '| - | - | - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
|
|
555
647
|
'',
|
|
556
648
|
'### Subscription Quota',
|
|
557
649
|
...(quotaLines.length
|
|
@@ -595,14 +687,6 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
595
687
|
value: formatPercent(cacheMetrics.cacheReadCoverage, 1),
|
|
596
688
|
});
|
|
597
689
|
}
|
|
598
|
-
if (showCost) {
|
|
599
|
-
if (usage.apiCost > 0) {
|
|
600
|
-
tokenPairs.push({
|
|
601
|
-
label: 'API Cost',
|
|
602
|
-
value: formatApiCostValue(usage.apiCost),
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
690
|
lines.push(...alignPairs(tokenPairs).map((line) => fitLine(line, width)));
|
|
607
691
|
if (showCost) {
|
|
608
692
|
const costPairs = Object.values(usage.providers)
|
|
@@ -623,14 +707,35 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
623
707
|
lines.push(fitLine(hasAnyUsage ? ' N/A (Copilot)' : ' -', width));
|
|
624
708
|
}
|
|
625
709
|
}
|
|
710
|
+
const providerCachePairs = Object.values(usage.providers)
|
|
711
|
+
.map((provider) => {
|
|
712
|
+
const metrics = getProviderCacheCoverageMetrics(provider);
|
|
713
|
+
const parts = [];
|
|
714
|
+
if (metrics.cacheCoverage !== undefined) {
|
|
715
|
+
parts.push(`Cov ${formatPercent(metrics.cacheCoverage, 1)}`);
|
|
716
|
+
}
|
|
717
|
+
if (metrics.cacheReadCoverage !== undefined) {
|
|
718
|
+
parts.push(`Read ${formatPercent(metrics.cacheReadCoverage, 1)}`);
|
|
719
|
+
}
|
|
720
|
+
if (parts.length === 0)
|
|
721
|
+
return undefined;
|
|
722
|
+
return {
|
|
723
|
+
label: displayShortLabel(provider.providerID),
|
|
724
|
+
value: parts.join(' '),
|
|
725
|
+
};
|
|
726
|
+
})
|
|
727
|
+
.filter((item) => Boolean(item));
|
|
728
|
+
if (providerCachePairs.length > 0) {
|
|
729
|
+
lines.push('');
|
|
730
|
+
lines.push(fitLine('Provider Cache', width));
|
|
731
|
+
lines.push(...alignPairs(providerCachePairs).map((line) => fitLine(line, width)));
|
|
732
|
+
}
|
|
626
733
|
const quotaPairs = collapseQuotaSnapshots(quotas).flatMap((item) => {
|
|
627
734
|
if (item.status === 'ok') {
|
|
628
735
|
if (item.windows && item.windows.length > 0) {
|
|
629
736
|
const pairs = item.windows.map((win, idx) => {
|
|
630
737
|
const showPercent = win.showPercent !== false;
|
|
631
|
-
const pct = win.remainingPercent
|
|
632
|
-
? '-'
|
|
633
|
-
: `${win.remainingPercent.toFixed(1)}%`;
|
|
738
|
+
const pct = formatQuotaPercent(win.remainingPercent);
|
|
634
739
|
const reset = compactReset(win.resetAt, win.resetLabel, win.label);
|
|
635
740
|
const parts = [win.label];
|
|
636
741
|
if (showPercent)
|
|
@@ -658,9 +763,7 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
658
763
|
},
|
|
659
764
|
];
|
|
660
765
|
}
|
|
661
|
-
const percent = item.remainingPercent
|
|
662
|
-
? '-'
|
|
663
|
-
: `${item.remainingPercent.toFixed(1)}%`;
|
|
766
|
+
const percent = formatQuotaPercent(item.remainingPercent);
|
|
664
767
|
const reset = compactReset(item.resetAt, 'Rst');
|
|
665
768
|
return [
|
|
666
769
|
{
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { createPersistenceScheduler } from './persistence.js';
|
|
|
12
12
|
import { createQuotaService } from './quota_service.js';
|
|
13
13
|
import { createUsageService } from './usage_service.js';
|
|
14
14
|
import { createTitleApplicator } from './title_apply.js';
|
|
15
|
+
const SHUTDOWN_HOOK_KEY = Symbol.for('opencode-quota-sidebar.shutdown-hook');
|
|
16
|
+
const SHUTDOWN_CALLBACKS_KEY = Symbol.for('opencode-quota-sidebar.shutdown-callbacks');
|
|
15
17
|
export async function QuotaSidebarPlugin(input) {
|
|
16
18
|
const quotaRuntime = createQuotaRuntime();
|
|
17
19
|
const configDir = resolveOpencodeConfigDir();
|
|
@@ -29,7 +31,7 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
29
31
|
const authPath = authFilePath(dataDir);
|
|
30
32
|
const state = await loadState(statePath);
|
|
31
33
|
// M2: evict old sessions on startup
|
|
32
|
-
evictOldSessions(state, config.retentionDays);
|
|
34
|
+
const evictedOnStartup = evictOldSessions(state, config.retentionDays);
|
|
33
35
|
const persistence = createPersistenceScheduler({
|
|
34
36
|
statePath,
|
|
35
37
|
state,
|
|
@@ -38,6 +40,9 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
38
40
|
const markDirty = persistence.markDirty;
|
|
39
41
|
const scheduleSave = persistence.scheduleSave;
|
|
40
42
|
const flushSave = persistence.flushSave;
|
|
43
|
+
if (evictedOnStartup > 0) {
|
|
44
|
+
scheduleSave();
|
|
45
|
+
}
|
|
41
46
|
const RESTORE_TITLE_CONCURRENCY = 5;
|
|
42
47
|
const quotaService = createQuotaService({
|
|
43
48
|
quotaRuntime,
|
|
@@ -161,11 +166,62 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
161
166
|
restoreConcurrency: RESTORE_TITLE_CONCURRENCY,
|
|
162
167
|
});
|
|
163
168
|
const titleRefresh = createTitleRefreshScheduler({
|
|
164
|
-
apply:
|
|
169
|
+
apply: async (sessionID) => {
|
|
170
|
+
await titleApplicator.applyTitle(sessionID);
|
|
171
|
+
},
|
|
165
172
|
onError: swallow('titleRefresh'),
|
|
166
173
|
});
|
|
167
174
|
scheduleTitleRefresh = titleRefresh.schedule;
|
|
168
175
|
const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
|
|
176
|
+
const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
|
|
177
|
+
const refreshAllVisibleTitles = titleApplicator.refreshAllVisibleTitles;
|
|
178
|
+
let startupTitleWork = Promise.resolve();
|
|
179
|
+
const runStartupRestore = async (attempt = 0) => {
|
|
180
|
+
const result = await restoreAllVisibleTitles({
|
|
181
|
+
abortIfEnabled: config.sidebar.enabled,
|
|
182
|
+
});
|
|
183
|
+
if (result.restored === result.attempted)
|
|
184
|
+
return;
|
|
185
|
+
debug(`startup restore incomplete: restored ${result.restored}/${result.attempted} touched titles while display mode remains OFF`);
|
|
186
|
+
if (state.titleEnabled || config.sidebar.enabled === false)
|
|
187
|
+
return;
|
|
188
|
+
if (attempt >= 2)
|
|
189
|
+
return;
|
|
190
|
+
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
|
191
|
+
await runStartupRestore(attempt + 1);
|
|
192
|
+
};
|
|
193
|
+
if (!state.titleEnabled || !config.sidebar.enabled) {
|
|
194
|
+
startupTitleWork = runStartupRestore().catch(swallow('startup:restoreAllVisibleTitles'));
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
startupTitleWork = refreshAllTouchedTitles()
|
|
198
|
+
.then(() => undefined)
|
|
199
|
+
.catch(swallow('startup:refreshAllTouchedTitles'));
|
|
200
|
+
}
|
|
201
|
+
const shutdown = async () => {
|
|
202
|
+
await Promise.race([
|
|
203
|
+
startupTitleWork,
|
|
204
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
205
|
+
]).catch(swallow('shutdown:startupTitleWork'));
|
|
206
|
+
await titleRefresh.waitForQuiescence().catch(swallow('shutdown:titleQuiescence'));
|
|
207
|
+
await flushSave().catch(swallow('shutdown:flushSave'));
|
|
208
|
+
};
|
|
209
|
+
const processWithHook = process;
|
|
210
|
+
const shutdownCallbacks = (processWithHook[SHUTDOWN_CALLBACKS_KEY] ||= new Set());
|
|
211
|
+
shutdownCallbacks.add(shutdown);
|
|
212
|
+
if (!processWithHook[SHUTDOWN_HOOK_KEY]) {
|
|
213
|
+
processWithHook[SHUTDOWN_HOOK_KEY] = true;
|
|
214
|
+
process.once('beforeExit', () => {
|
|
215
|
+
void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback()));
|
|
216
|
+
});
|
|
217
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
218
|
+
process.once(signal, () => {
|
|
219
|
+
void Promise.allSettled(Array.from(shutdownCallbacks).map((callback) => callback())).finally(() => {
|
|
220
|
+
process.kill(process.pid, signal);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
169
225
|
const showToast = async (period, message) => {
|
|
170
226
|
await input.client.tui
|
|
171
227
|
.showToast({
|
|
@@ -217,10 +273,16 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
217
273
|
titleRefresh.cancel(session.id);
|
|
218
274
|
const dateKey = state.sessionDateMap[session.id] ||
|
|
219
275
|
dateKeyFromTimestamp(session.time.created);
|
|
276
|
+
state.deletedSessionDateMap[session.id] = dateKey;
|
|
220
277
|
delete state.sessions[session.id];
|
|
221
278
|
delete state.sessionDateMap[session.id];
|
|
279
|
+
markDirty(dateKey);
|
|
222
280
|
scheduleSave();
|
|
223
|
-
await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
|
|
281
|
+
const deletedFromChunk = await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
|
|
282
|
+
if (deletedFromChunk) {
|
|
283
|
+
delete state.deletedSessionDateMap[session.id];
|
|
284
|
+
scheduleSave();
|
|
285
|
+
}
|
|
224
286
|
if (config.sidebar.includeChildren && session.parentID) {
|
|
225
287
|
titleRefresh.schedule(session.parentID, 0);
|
|
226
288
|
}
|
|
@@ -250,14 +312,25 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
250
312
|
state.titleEnabled = enabled;
|
|
251
313
|
},
|
|
252
314
|
scheduleSave,
|
|
315
|
+
flushSave,
|
|
316
|
+
waitForStartupTitleWork: () => startupTitleWork,
|
|
253
317
|
refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
|
|
318
|
+
cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
|
|
319
|
+
flushScheduledTitleRefreshes: () => titleRefresh.flushScheduled(),
|
|
320
|
+
waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
|
|
321
|
+
waitForTitleRefreshQuiescence: () => titleRefresh.waitForQuiescence(),
|
|
254
322
|
restoreAllVisibleTitles,
|
|
323
|
+
refreshAllTouchedTitles,
|
|
324
|
+
refreshAllVisibleTitles,
|
|
255
325
|
showToast,
|
|
256
326
|
summarizeForTool,
|
|
257
327
|
getQuotaSnapshots,
|
|
258
328
|
renderMarkdownReport,
|
|
259
329
|
renderToastMessage,
|
|
260
|
-
config
|
|
330
|
+
config: {
|
|
331
|
+
sidebar: config.sidebar,
|
|
332
|
+
sidebarEnabled: config.sidebar.enabled,
|
|
333
|
+
},
|
|
261
334
|
}),
|
|
262
335
|
};
|
|
263
336
|
}
|
package/dist/persistence.js
CHANGED
|
@@ -4,6 +4,17 @@ export function createPersistenceScheduler(deps) {
|
|
|
4
4
|
let stateDirty = false;
|
|
5
5
|
let saveTimer;
|
|
6
6
|
let saveInFlight = Promise.resolve();
|
|
7
|
+
const scheduleRetry = (delayMs = 1_000) => {
|
|
8
|
+
if (saveTimer)
|
|
9
|
+
return;
|
|
10
|
+
saveTimer = setTimeout(() => {
|
|
11
|
+
saveTimer = undefined;
|
|
12
|
+
void persist().catch((error) => {
|
|
13
|
+
debug(`persistState retry failed: ${String(error)}`);
|
|
14
|
+
scheduleRetry(Math.min(delayMs * 2, 10_000));
|
|
15
|
+
});
|
|
16
|
+
}, delayMs);
|
|
17
|
+
};
|
|
7
18
|
/**
|
|
8
19
|
* Capture and delete specific dirty keys instead of clearing the whole set.
|
|
9
20
|
* Keys added between capture and write completion are preserved.
|
|
@@ -34,7 +45,10 @@ export function createPersistenceScheduler(deps) {
|
|
|
34
45
|
clearTimeout(saveTimer);
|
|
35
46
|
saveTimer = setTimeout(() => {
|
|
36
47
|
saveTimer = undefined;
|
|
37
|
-
void persist().catch(
|
|
48
|
+
void persist().catch((error) => {
|
|
49
|
+
swallow('persistState:save')(error);
|
|
50
|
+
scheduleRetry();
|
|
51
|
+
});
|
|
38
52
|
}, 200);
|
|
39
53
|
};
|
|
40
54
|
const flushSave = async () => {
|
package/dist/quota.js
CHANGED
|
@@ -6,14 +6,12 @@ function resolveContext(providerID, providerOptions) {
|
|
|
6
6
|
return { providerID, providerOptions };
|
|
7
7
|
}
|
|
8
8
|
function authCandidates(providerID, normalizedProviderID, adapterID) {
|
|
9
|
-
const candidates = new Set([
|
|
10
|
-
providerID,
|
|
11
|
-
normalizedProviderID,
|
|
12
|
-
adapterID,
|
|
13
|
-
]);
|
|
9
|
+
const candidates = new Set([providerID]);
|
|
14
10
|
if (adapterID === 'github-copilot') {
|
|
15
11
|
candidates.add('github-copilot-enterprise');
|
|
16
12
|
}
|
|
13
|
+
candidates.add(normalizedProviderID);
|
|
14
|
+
candidates.add(adapterID);
|
|
17
15
|
return [...candidates];
|
|
18
16
|
}
|
|
19
17
|
function pickAuth(providerID, normalizedProviderID, adapterID, authMap) {
|