@leo000001/opencode-quota-sidebar 1.13.10 → 2.0.1
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 +21 -17
- package/dist/cost.d.ts +2 -0
- package/dist/cost.js +11 -1
- package/dist/format.d.ts +1 -1
- package/dist/format.js +78 -21
- package/dist/index.js +24 -2
- package/dist/quota.js +3 -5
- package/dist/quota_service.js +65 -18
- 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 +36 -1
- package/dist/title.js +37 -13
- package/dist/title_apply.d.ts +2 -0
- package/dist/title_apply.js +43 -13
- package/dist/title_refresh.d.ts +2 -0
- package/dist/title_refresh.js +12 -1
- package/dist/tools.d.ts +5 -0
- package/dist/tools.js +8 -4
- package/dist/types.d.ts +37 -0
- package/dist/usage.d.ts +15 -2
- package/dist/usage.js +166 -7
- package/dist/usage_service.js +158 -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.0"]
|
|
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: []`).
|
|
@@ -53,20 +53,24 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
53
53
|
|
|
54
54
|
## Features
|
|
55
55
|
|
|
56
|
-
- Session title becomes multiline in sidebar:
|
|
57
|
-
- line 1: original session title
|
|
58
|
-
- line 2:
|
|
59
|
-
- line 3:
|
|
60
|
-
- line 4: Cache
|
|
61
|
-
- line 5:
|
|
62
|
-
-
|
|
56
|
+
- Session title becomes multiline in sidebar:
|
|
57
|
+
- line 1: original session title
|
|
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)
|
|
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
|
|
63
|
+
- next line: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
|
|
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`
|
|
63
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
|
|
64
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.
|
|
65
|
-
- Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
|
|
67
|
+
- Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
|
|
68
|
+
- `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
|
|
66
69
|
- Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
|
|
67
70
|
- Custom tools:
|
|
68
71
|
- `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
|
|
69
|
-
|
|
72
|
+
- `quota_show` — toggle sidebar title display on/off (state persists across sessions)
|
|
73
|
+
- After startup, titles refresh on the next relevant session/message event or when `quota_show` is toggled
|
|
70
74
|
- Quota connectors:
|
|
71
75
|
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
72
76
|
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
@@ -294,7 +298,7 @@ Other defaults:
|
|
|
294
298
|
- 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.
|
|
295
299
|
- `quota.providers` is the extensible per-adapter switch map.
|
|
296
300
|
- 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.
|
|
297
|
-
- Usage chunks cache both measured `cost` and computed `apiCost`. `quota_summary` (`/qday`, `/qweek`, `/qmonth`)
|
|
301
|
+
- 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.
|
|
298
302
|
|
|
299
303
|
### Buzz provider example
|
|
300
304
|
|
|
@@ -317,7 +321,7 @@ The adapter also tolerates `https://buzzai.cc/v1`, but `https://buzzai.cc` is th
|
|
|
317
321
|
With that setup, the sidebar/toast quota line will look like:
|
|
318
322
|
|
|
319
323
|
```text
|
|
320
|
-
Buzz Balance
|
|
324
|
+
Buzz Balance ¥10.17
|
|
321
325
|
```
|
|
322
326
|
|
|
323
327
|
## Rendering examples
|
|
@@ -380,7 +384,7 @@ OpenAI
|
|
|
380
384
|
5h 78% Rst 05:05
|
|
381
385
|
Copilot
|
|
382
386
|
Monthly 78% Rst 04-01
|
|
383
|
-
Buzz Balance
|
|
387
|
+
Buzz Balance ¥10.2
|
|
384
388
|
```
|
|
385
389
|
|
|
386
390
|
Balance-style quota:
|
|
@@ -392,7 +396,7 @@ RC Balance $260
|
|
|
392
396
|
Buzz balance quota:
|
|
393
397
|
|
|
394
398
|
```text
|
|
395
|
-
Buzz Balance
|
|
399
|
+
Buzz Balance ¥10.17
|
|
396
400
|
```
|
|
397
401
|
|
|
398
402
|
Multi-detail quota (window + balance):
|
|
@@ -422,7 +426,7 @@ Quota is rendered inline as part of a single-line title:
|
|
|
422
426
|
Mixed with Buzz balance:
|
|
423
427
|
|
|
424
428
|
```text
|
|
425
|
-
<base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance
|
|
429
|
+
<base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance ¥10.2
|
|
426
430
|
```
|
|
427
431
|
|
|
428
432
|
`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.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AssistantMessage } from '@opencode-ai/sdk';
|
|
2
|
+
import type { CacheCoverageMode } from './types.js';
|
|
2
3
|
export declare const SUBSCRIPTION_API_COST_PROVIDERS: Set<string>;
|
|
3
4
|
export declare function canonicalApiCostProviderID(providerID: string): string;
|
|
4
5
|
export type ModelCostRates = {
|
|
@@ -16,4 +17,5 @@ export type ModelCostRates = {
|
|
|
16
17
|
export declare function modelCostKey(providerID: string, modelID: string): string;
|
|
17
18
|
export declare function parseModelCostRates(value: unknown): ModelCostRates | undefined;
|
|
18
19
|
export declare function guessModelCostDivisor(rates: ModelCostRates): 1 | 1000000;
|
|
20
|
+
export declare function cacheCoverageModeFromRates(rates: ModelCostRates | undefined): CacheCoverageMode;
|
|
19
21
|
export declare function calcEquivalentApiCostForMessage(message: AssistantMessage, rates: ModelCostRates): number;
|
package/dist/cost.js
CHANGED
|
@@ -78,8 +78,18 @@ export function guessModelCostDivisor(rates) {
|
|
|
78
78
|
? MODEL_COST_DIVISOR_PER_MILLION
|
|
79
79
|
: MODEL_COST_DIVISOR_PER_TOKEN;
|
|
80
80
|
}
|
|
81
|
+
export function cacheCoverageModeFromRates(rates) {
|
|
82
|
+
if (!rates)
|
|
83
|
+
return 'none';
|
|
84
|
+
if (rates.cacheWrite > 0)
|
|
85
|
+
return 'read-write';
|
|
86
|
+
if (rates.cacheRead > 0)
|
|
87
|
+
return 'read-only';
|
|
88
|
+
return 'none';
|
|
89
|
+
}
|
|
81
90
|
export function calcEquivalentApiCostForMessage(message, rates) {
|
|
82
|
-
const effectiveRates = message.tokens.input > 200_000 &&
|
|
91
|
+
const effectiveRates = message.tokens.input + message.tokens.cache.read > 200_000 &&
|
|
92
|
+
rates.contextOver200k
|
|
83
93
|
? rates.contextOver200k
|
|
84
94
|
: rates;
|
|
85
95
|
// For providers that expose reasoning tokens separately, they are still
|
package/dist/format.d.ts
CHANGED
package/dist/format.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getCacheCoverageMetrics } from './usage.js';
|
|
1
2
|
import { canonicalProviderID, collapseQuotaSnapshots, displayShortLabel, quotaDisplayLabel, } from './quota_render.js';
|
|
2
3
|
import { stripAnsi } from './title.js';
|
|
3
4
|
/** M6 fix: handle negative, NaN, Infinity gracefully. */
|
|
@@ -128,10 +129,18 @@ function fitLine(value, width) {
|
|
|
128
129
|
return `${head}~`;
|
|
129
130
|
}
|
|
130
131
|
function formatCurrency(value, currency) {
|
|
131
|
-
const safe = Number.isFinite(value)
|
|
132
|
+
const safe = Number.isFinite(value) ? value : 0;
|
|
132
133
|
const prefix = typeof currency === 'string' && currency ? currency : '$';
|
|
133
134
|
if (safe === 0)
|
|
134
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
|
+
}
|
|
135
144
|
if (safe < 10)
|
|
136
145
|
return `${prefix}${safe.toFixed(2)}`;
|
|
137
146
|
const one = safe.toFixed(1);
|
|
@@ -147,6 +156,11 @@ function formatApiCostValue(value) {
|
|
|
147
156
|
function formatApiCostLine(value) {
|
|
148
157
|
return `${formatApiCostValue(value)} as API cost`;
|
|
149
158
|
}
|
|
159
|
+
function formatPercent(value, decimals = 1) {
|
|
160
|
+
const safe = Number.isFinite(value) && value >= 0 ? value : 0;
|
|
161
|
+
const pct = (safe * 100).toFixed(decimals);
|
|
162
|
+
return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
|
|
163
|
+
}
|
|
150
164
|
function alignPairs(pairs, indent = ' ') {
|
|
151
165
|
if (pairs.length === 0)
|
|
152
166
|
return [];
|
|
@@ -164,8 +178,11 @@ function alignPairs(pairs, indent = ' ') {
|
|
|
164
178
|
}
|
|
165
179
|
function compactQuotaInline(quota) {
|
|
166
180
|
const label = sanitizeLine(quotaDisplayLabel(quota));
|
|
167
|
-
if (quota.status !== 'ok')
|
|
168
|
-
|
|
181
|
+
if (quota.status !== 'ok') {
|
|
182
|
+
if (quota.status === 'error')
|
|
183
|
+
return `${label} Remaining ?`;
|
|
184
|
+
return `${label} ${sanitizeLine(quota.status)}`;
|
|
185
|
+
}
|
|
169
186
|
if (quota.windows && quota.windows.length > 0) {
|
|
170
187
|
const first = quota.windows[0];
|
|
171
188
|
const showPercent = first.showPercent !== false;
|
|
@@ -191,6 +208,7 @@ function compactQuotaInline(quota) {
|
|
|
191
208
|
function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
|
|
192
209
|
const baseBudget = Math.min(16, Math.max(8, Math.floor(width * 0.35)));
|
|
193
210
|
const base = fitLine(baseTitle, baseBudget);
|
|
211
|
+
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
194
212
|
const segments = [
|
|
195
213
|
`Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`,
|
|
196
214
|
];
|
|
@@ -200,6 +218,12 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
|
|
|
200
218
|
if (usage.cacheWrite > 0) {
|
|
201
219
|
segments.push(`Cache Write ${sidebarNumber(usage.cacheWrite)}`);
|
|
202
220
|
}
|
|
221
|
+
if (cacheMetrics.cacheCoverage !== undefined) {
|
|
222
|
+
segments.push(`Cache Coverage ${formatPercent(cacheMetrics.cacheCoverage, 0)}`);
|
|
223
|
+
}
|
|
224
|
+
if (cacheMetrics.cacheReadCoverage !== undefined) {
|
|
225
|
+
segments.push(`Cache Read Coverage ${formatPercent(cacheMetrics.cacheReadCoverage, 0)}`);
|
|
226
|
+
}
|
|
203
227
|
if (config.sidebar.showCost && usage.apiCost > 0) {
|
|
204
228
|
segments.push(formatApiCostLine(usage.apiCost));
|
|
205
229
|
}
|
|
@@ -225,12 +249,16 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
|
|
|
225
249
|
*/
|
|
226
250
|
export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
227
251
|
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
228
|
-
const safeBaseTitle = stripAnsi(baseTitle || 'Session')
|
|
252
|
+
const safeBaseTitle = stripAnsi(baseTitle || 'Session') || 'Session';
|
|
229
253
|
if (config.sidebar.multilineTitle !== true) {
|
|
230
|
-
|
|
254
|
+
const singleLineBase = safeBaseTitle.split(/\r?\n/, 1)[0] || 'Session';
|
|
255
|
+
return renderSingleLineTitle(singleLineBase, usage, quotas, config, width);
|
|
231
256
|
}
|
|
257
|
+
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
232
258
|
const lines = [];
|
|
233
|
-
|
|
259
|
+
for (const line of safeBaseTitle.split(/\r?\n/)) {
|
|
260
|
+
lines.push(fitLine(line || 'Session', width));
|
|
261
|
+
}
|
|
234
262
|
lines.push('');
|
|
235
263
|
// Input / Output line
|
|
236
264
|
const io = `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`;
|
|
@@ -242,6 +270,12 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
|
242
270
|
if (usage.cacheWrite > 0) {
|
|
243
271
|
lines.push(fitLine(`Cache Write ${sidebarNumber(usage.cacheWrite)}`, width));
|
|
244
272
|
}
|
|
273
|
+
if (cacheMetrics.cacheCoverage !== undefined) {
|
|
274
|
+
lines.push(fitLine(`Cache Coverage ${formatPercent(cacheMetrics.cacheCoverage, 0)}`, width));
|
|
275
|
+
}
|
|
276
|
+
if (cacheMetrics.cacheReadCoverage !== undefined) {
|
|
277
|
+
lines.push(fitLine(`Cache Read Coverage ${formatPercent(cacheMetrics.cacheReadCoverage, 0)}`, width));
|
|
278
|
+
}
|
|
245
279
|
if (config.sidebar.showCost && usage.apiCost > 0) {
|
|
246
280
|
lines.push(fitLine(formatApiCostLine(usage.apiCost), width));
|
|
247
281
|
}
|
|
@@ -389,9 +423,6 @@ function compactReset(iso, resetLabel, windowLabel) {
|
|
|
389
423
|
return hhmm;
|
|
390
424
|
return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
|
|
391
425
|
}
|
|
392
|
-
if (sameDay) {
|
|
393
|
-
return `${two(value.getHours())}:${two(value.getMinutes())}`;
|
|
394
|
-
}
|
|
395
426
|
return `${two(value.getMonth() + 1)}-${two(value.getDate())}`;
|
|
396
427
|
}
|
|
397
428
|
function dateLine(iso) {
|
|
@@ -419,6 +450,7 @@ function periodLabel(period) {
|
|
|
419
450
|
}
|
|
420
451
|
export function renderMarkdownReport(period, usage, quotas, options) {
|
|
421
452
|
const showCost = options?.showCost !== false;
|
|
453
|
+
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
422
454
|
const mdCell = (value) => sanitizeLine(value).replace(/\|/g, '\\|');
|
|
423
455
|
const rightCodeSubscriptionProviderIDs = new Set(collapseQuotaSnapshots(quotas)
|
|
424
456
|
.filter((quota) => quota.adapterID === 'rightcode')
|
|
@@ -429,6 +461,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
429
461
|
const measuredCostCell = (providerID, cost) => {
|
|
430
462
|
const canonical = canonicalProviderID(providerID);
|
|
431
463
|
const isSubscription = canonical === 'openai' ||
|
|
464
|
+
canonical === 'anthropic' ||
|
|
432
465
|
canonical === 'github-copilot' ||
|
|
433
466
|
rightCodeSubscriptionProviderIDs.has(providerID);
|
|
434
467
|
if (isSubscription)
|
|
@@ -438,6 +471,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
438
471
|
const isSubscriptionMeasuredProvider = (providerID) => {
|
|
439
472
|
const canonical = canonicalProviderID(providerID);
|
|
440
473
|
return (canonical === 'openai' ||
|
|
474
|
+
canonical === 'anthropic' ||
|
|
441
475
|
canonical === 'github-copilot' ||
|
|
442
476
|
rightCodeSubscriptionProviderIDs.has(providerID));
|
|
443
477
|
};
|
|
@@ -474,30 +508,40 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
474
508
|
: `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
|
|
475
509
|
});
|
|
476
510
|
const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
|
|
511
|
+
const displayLabel = quotaDisplayLabel(quota);
|
|
477
512
|
// Multi-window detail
|
|
478
513
|
if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
|
|
479
|
-
|
|
514
|
+
const windowLines = quota.windows.map((win) => {
|
|
480
515
|
if (win.showPercent === false) {
|
|
481
516
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
482
|
-
return mdCell(`- ${
|
|
517
|
+
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
483
518
|
}
|
|
484
519
|
const remaining = win.remainingPercent === undefined
|
|
485
520
|
? '-'
|
|
486
521
|
: `${win.remainingPercent.toFixed(1)}%`;
|
|
487
522
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
488
|
-
return mdCell(`- ${
|
|
523
|
+
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
489
524
|
});
|
|
525
|
+
if (quota.balance) {
|
|
526
|
+
windowLines.push(mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`));
|
|
527
|
+
}
|
|
528
|
+
return windowLines;
|
|
490
529
|
}
|
|
491
530
|
if (quota.status === 'ok' && quota.balance) {
|
|
492
531
|
return [
|
|
493
|
-
mdCell(`- ${
|
|
532
|
+
mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`),
|
|
533
|
+
];
|
|
534
|
+
}
|
|
535
|
+
if (quota.status !== 'ok') {
|
|
536
|
+
return [
|
|
537
|
+
mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
494
538
|
];
|
|
495
539
|
}
|
|
496
540
|
const remaining = quota.remainingPercent === undefined
|
|
497
541
|
? '-'
|
|
498
542
|
: `${quota.remainingPercent.toFixed(1)}%`;
|
|
499
543
|
return [
|
|
500
|
-
mdCell(`- ${
|
|
544
|
+
mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
501
545
|
];
|
|
502
546
|
});
|
|
503
547
|
return [
|
|
@@ -506,6 +550,14 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
506
550
|
`- Sessions: ${usage.sessionCount}`,
|
|
507
551
|
`- Assistant messages: ${usage.assistantMessages}`,
|
|
508
552
|
`- Tokens: input ${usage.input}, output ${usage.output}, cache_read ${usage.cacheRead}, cache_write ${usage.cacheWrite}, total ${usage.total}`,
|
|
553
|
+
...(cacheMetrics.cacheCoverage !== undefined
|
|
554
|
+
? [`- Cache Coverage: ${formatPercent(cacheMetrics.cacheCoverage, 1)}`]
|
|
555
|
+
: []),
|
|
556
|
+
...(cacheMetrics.cacheReadCoverage !== undefined
|
|
557
|
+
? [
|
|
558
|
+
`- Cache Read Coverage: ${formatPercent(cacheMetrics.cacheReadCoverage, 1)}`,
|
|
559
|
+
]
|
|
560
|
+
: []),
|
|
509
561
|
...(showCost
|
|
510
562
|
? [
|
|
511
563
|
`- Measured cost: ${measuredCostSummaryValue()}`,
|
|
@@ -533,6 +585,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
533
585
|
export function renderToastMessage(period, usage, quotas, options) {
|
|
534
586
|
const width = Math.max(24, Math.floor(options?.width || 56));
|
|
535
587
|
const showCost = options?.showCost !== false;
|
|
588
|
+
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
536
589
|
const lines = [];
|
|
537
590
|
lines.push(fitLine(`${periodLabel(period)} - Total ${shortNumber(usage.total)}`, width));
|
|
538
591
|
lines.push('');
|
|
@@ -553,13 +606,17 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
553
606
|
value: shortNumber(usage.cacheWrite),
|
|
554
607
|
});
|
|
555
608
|
}
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
609
|
+
if (cacheMetrics.cacheCoverage !== undefined) {
|
|
610
|
+
tokenPairs.push({
|
|
611
|
+
label: 'Cache Coverage',
|
|
612
|
+
value: formatPercent(cacheMetrics.cacheCoverage, 1),
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (cacheMetrics.cacheReadCoverage !== undefined) {
|
|
616
|
+
tokenPairs.push({
|
|
617
|
+
label: 'Cache Read Coverage',
|
|
618
|
+
value: formatPercent(cacheMetrics.cacheReadCoverage, 1),
|
|
619
|
+
});
|
|
563
620
|
}
|
|
564
621
|
lines.push(...alignPairs(tokenPairs).map((line) => fitLine(line, width)));
|
|
565
622
|
if (showCost) {
|
package/dist/index.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
29
29
|
const authPath = authFilePath(dataDir);
|
|
30
30
|
const state = await loadState(statePath);
|
|
31
31
|
// M2: evict old sessions on startup
|
|
32
|
-
evictOldSessions(state, config.retentionDays);
|
|
32
|
+
const evictedOnStartup = evictOldSessions(state, config.retentionDays);
|
|
33
33
|
const persistence = createPersistenceScheduler({
|
|
34
34
|
statePath,
|
|
35
35
|
state,
|
|
@@ -38,6 +38,9 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
38
38
|
const markDirty = persistence.markDirty;
|
|
39
39
|
const scheduleSave = persistence.scheduleSave;
|
|
40
40
|
const flushSave = persistence.flushSave;
|
|
41
|
+
if (evictedOnStartup > 0) {
|
|
42
|
+
scheduleSave();
|
|
43
|
+
}
|
|
41
44
|
const RESTORE_TITLE_CONCURRENCY = 5;
|
|
42
45
|
const quotaService = createQuotaService({
|
|
43
46
|
quotaRuntime,
|
|
@@ -166,6 +169,14 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
166
169
|
});
|
|
167
170
|
scheduleTitleRefresh = titleRefresh.schedule;
|
|
168
171
|
const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
|
|
172
|
+
const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
|
|
173
|
+
const refreshAllVisibleTitles = titleApplicator.refreshAllVisibleTitles;
|
|
174
|
+
if (!state.titleEnabled || !config.sidebar.enabled) {
|
|
175
|
+
void restoreAllVisibleTitles().catch(swallow('startup:restoreAllVisibleTitles'));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
void refreshAllTouchedTitles().catch(swallow('startup:refreshAllTouchedTitles'));
|
|
179
|
+
}
|
|
169
180
|
const showToast = async (period, message) => {
|
|
170
181
|
await input.client.tui
|
|
171
182
|
.showToast({
|
|
@@ -217,10 +228,16 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
217
228
|
titleRefresh.cancel(session.id);
|
|
218
229
|
const dateKey = state.sessionDateMap[session.id] ||
|
|
219
230
|
dateKeyFromTimestamp(session.time.created);
|
|
231
|
+
state.deletedSessionDateMap[session.id] = dateKey;
|
|
220
232
|
delete state.sessions[session.id];
|
|
221
233
|
delete state.sessionDateMap[session.id];
|
|
234
|
+
markDirty(dateKey);
|
|
222
235
|
scheduleSave();
|
|
223
|
-
await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
|
|
236
|
+
const deletedFromChunk = await deleteSessionFromDayChunk(statePath, session.id, dateKey).catch(swallow('deleteSessionFromDayChunk'));
|
|
237
|
+
if (deletedFromChunk) {
|
|
238
|
+
delete state.deletedSessionDateMap[session.id];
|
|
239
|
+
scheduleSave();
|
|
240
|
+
}
|
|
224
241
|
if (config.sidebar.includeChildren && session.parentID) {
|
|
225
242
|
titleRefresh.schedule(session.parentID, 0);
|
|
226
243
|
}
|
|
@@ -250,8 +267,13 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
250
267
|
state.titleEnabled = enabled;
|
|
251
268
|
},
|
|
252
269
|
scheduleSave,
|
|
270
|
+
flushSave,
|
|
253
271
|
refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
|
|
272
|
+
cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
|
|
273
|
+
waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
|
|
254
274
|
restoreAllVisibleTitles,
|
|
275
|
+
refreshAllTouchedTitles,
|
|
276
|
+
refreshAllVisibleTitles,
|
|
255
277
|
showToast,
|
|
256
278
|
summarizeForTool,
|
|
257
279
|
getQuotaSnapshots,
|
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) {
|
package/dist/quota_service.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { TtlValueCache } from './cache.js';
|
|
2
3
|
import { isRecord, swallow } from './helpers.js';
|
|
3
4
|
import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
|
|
@@ -10,6 +11,36 @@ export function createQuotaService(deps) {
|
|
|
10
11
|
const authCache = new TtlValueCache();
|
|
11
12
|
const providerOptionsCache = new TtlValueCache();
|
|
12
13
|
const inFlight = new Map();
|
|
14
|
+
const authFingerprint = (auth) => {
|
|
15
|
+
if (!auth || typeof auth !== 'object')
|
|
16
|
+
return undefined;
|
|
17
|
+
const stable = JSON.stringify(Object.keys(auth)
|
|
18
|
+
.sort()
|
|
19
|
+
.reduce((acc, key) => {
|
|
20
|
+
const value = auth[key];
|
|
21
|
+
if (value !== undefined)
|
|
22
|
+
acc[key] = value;
|
|
23
|
+
return acc;
|
|
24
|
+
}, {}));
|
|
25
|
+
return createHash('sha256').update(stable).digest('hex').slice(0, 12);
|
|
26
|
+
};
|
|
27
|
+
const providerOptionsFingerprint = (providerOptions) => {
|
|
28
|
+
if (!providerOptions)
|
|
29
|
+
return undefined;
|
|
30
|
+
const stable = JSON.stringify(Object.keys(providerOptions)
|
|
31
|
+
.sort()
|
|
32
|
+
.reduce((acc, key) => {
|
|
33
|
+
if (key === 'baseURL')
|
|
34
|
+
return acc;
|
|
35
|
+
const value = providerOptions[key];
|
|
36
|
+
if (value !== undefined)
|
|
37
|
+
acc[key] = value;
|
|
38
|
+
return acc;
|
|
39
|
+
}, {}));
|
|
40
|
+
if (stable === '{}')
|
|
41
|
+
return undefined;
|
|
42
|
+
return createHash('sha256').update(stable).digest('hex').slice(0, 12);
|
|
43
|
+
};
|
|
13
44
|
const getAuthMap = async () => {
|
|
14
45
|
const cached = authCache.get();
|
|
15
46
|
if (cached)
|
|
@@ -23,7 +54,7 @@ export function createQuotaService(deps) {
|
|
|
23
54
|
return cached;
|
|
24
55
|
const client = deps.client;
|
|
25
56
|
if (!client.config?.providers && !client.provider?.list) {
|
|
26
|
-
return providerOptionsCache.set({},
|
|
57
|
+
return providerOptionsCache.set({}, 5_000);
|
|
27
58
|
}
|
|
28
59
|
// Newer runtimes expose config.providers; older clients may only expose
|
|
29
60
|
// provider.list with a slightly different response shape.
|
|
@@ -63,7 +94,7 @@ export function createQuotaService(deps) {
|
|
|
63
94
|
return acc;
|
|
64
95
|
}, {})
|
|
65
96
|
: {};
|
|
66
|
-
return providerOptionsCache.set(map,
|
|
97
|
+
return providerOptionsCache.set(map, 5_000);
|
|
67
98
|
};
|
|
68
99
|
const isValidQuotaCache = (snapshot) => {
|
|
69
100
|
// Guard against stale RightCode cache entries from pre-daily format.
|
|
@@ -166,14 +197,6 @@ export function createQuotaService(deps) {
|
|
|
166
197
|
? directCandidates
|
|
167
198
|
: defaultCandidates;
|
|
168
199
|
const matchedCandidates = rawCandidates.filter((candidate) => Boolean(deps.quotaRuntime.resolveQuotaAdapter(candidate.providerID, candidate.providerOptions)));
|
|
169
|
-
const dedupedCandidates = Array.from(matchedCandidates
|
|
170
|
-
.reduce((acc, candidate) => {
|
|
171
|
-
const key = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
|
|
172
|
-
if (!acc.has(key))
|
|
173
|
-
acc.set(key, candidate);
|
|
174
|
-
return acc;
|
|
175
|
-
}, new Map())
|
|
176
|
-
.values());
|
|
177
200
|
function authScopeFor(providerID, providerOptions) {
|
|
178
201
|
const adapter = deps.quotaRuntime.resolveQuotaAdapter(providerID, providerOptions);
|
|
179
202
|
const normalized = deps.quotaRuntime.normalizeProviderID(providerID);
|
|
@@ -186,24 +209,48 @@ export function createQuotaService(deps) {
|
|
|
186
209
|
candidates.push(value);
|
|
187
210
|
};
|
|
188
211
|
push(providerID);
|
|
189
|
-
push(normalized);
|
|
190
|
-
push(adapterID);
|
|
191
212
|
if (adapterID === 'github-copilot')
|
|
192
213
|
push('github-copilot-enterprise');
|
|
214
|
+
push(normalized);
|
|
215
|
+
push(adapterID);
|
|
216
|
+
const optionsFingerprint = providerOptionsFingerprint(providerOptions);
|
|
193
217
|
for (const key of candidates) {
|
|
194
218
|
const auth = authMap[key];
|
|
195
219
|
if (!auth)
|
|
196
220
|
continue;
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
typeof auth.accountId === 'string' &&
|
|
200
|
-
|
|
201
|
-
|
|
221
|
+
if (auth.type === 'oauth') {
|
|
222
|
+
const authRecord = auth;
|
|
223
|
+
const identity = (typeof auth.accountId === 'string' && auth.accountId) ||
|
|
224
|
+
(typeof authRecord.login === 'string' && authRecord.login) ||
|
|
225
|
+
(typeof authRecord.userId === 'string' && authRecord.userId);
|
|
226
|
+
if (identity) {
|
|
227
|
+
return optionsFingerprint
|
|
228
|
+
? `${key}@${identity}|options@${optionsFingerprint}`
|
|
229
|
+
: `${key}@${identity}`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const fingerprint = authFingerprint(auth);
|
|
233
|
+
if (fingerprint) {
|
|
234
|
+
return optionsFingerprint
|
|
235
|
+
? `${key}@${fingerprint}|options@${optionsFingerprint}`
|
|
236
|
+
: `${key}@${fingerprint}`;
|
|
202
237
|
}
|
|
203
|
-
return key;
|
|
238
|
+
return optionsFingerprint ? `${key}|options@${optionsFingerprint}` : key;
|
|
239
|
+
}
|
|
240
|
+
if (optionsFingerprint) {
|
|
241
|
+
return `options@${optionsFingerprint}`;
|
|
204
242
|
}
|
|
205
243
|
return 'none';
|
|
206
244
|
}
|
|
245
|
+
const dedupedCandidates = Array.from(matchedCandidates
|
|
246
|
+
.reduce((acc, candidate) => {
|
|
247
|
+
const baseKey = deps.quotaRuntime.quotaCacheKey(candidate.providerID, candidate.providerOptions);
|
|
248
|
+
const key = `${baseKey}#${authScopeFor(candidate.providerID, candidate.providerOptions)}`;
|
|
249
|
+
if (!acc.has(key))
|
|
250
|
+
acc.set(key, candidate);
|
|
251
|
+
return acc;
|
|
252
|
+
}, new Map())
|
|
253
|
+
.values());
|
|
207
254
|
let cacheChanged = false;
|
|
208
255
|
const fetchSnapshot = (providerID, providerOptions) => {
|
|
209
256
|
const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
|
package/dist/storage.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export declare function scanSessionsByCreatedRange(statePath: string, startAt: n
|
|
|
30
30
|
dateKey: string;
|
|
31
31
|
state: SessionState;
|
|
32
32
|
}[]>;
|
|
33
|
+
export declare function scanAllSessions(statePath: string, memoryState?: QuotaSidebarState): Promise<{
|
|
34
|
+
sessionID: string;
|
|
35
|
+
dateKey: string;
|
|
36
|
+
state: SessionState;
|
|
37
|
+
}[]>;
|
|
33
38
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
34
39
|
export declare function deleteSessionFromDayChunk(statePath: string, sessionID: string, dateKey: string): Promise<boolean>;
|
|
35
40
|
/** Best-effort: persist recomputed usage/cursor for sessions loaded from disk-only chunks. */
|