@leo000001/opencode-quota-sidebar 2.0.0 → 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 +14 -12
- package/dist/cost.js +2 -1
- package/dist/format.js +37 -22
- 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 +3 -1
- package/dist/title.js +37 -15
- 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 +25 -1
- package/dist/usage.d.ts +12 -6
- package/dist/usage.js +63 -13
- package/dist/usage_service.js +121 -48
- 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: []`).
|
|
@@ -55,9 +55,10 @@ 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`
|
|
@@ -68,7 +69,8 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
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 refresh 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`)
|
|
@@ -296,7 +298,7 @@ Other defaults:
|
|
|
296
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.
|
|
297
299
|
- `quota.providers` is the extensible per-adapter switch map.
|
|
298
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.
|
|
299
|
-
- 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.
|
|
300
302
|
|
|
301
303
|
### Buzz provider example
|
|
302
304
|
|
|
@@ -319,7 +321,7 @@ The adapter also tolerates `https://buzzai.cc/v1`, but `https://buzzai.cc` is th
|
|
|
319
321
|
With that setup, the sidebar/toast quota line will look like:
|
|
320
322
|
|
|
321
323
|
```text
|
|
322
|
-
Buzz Balance
|
|
324
|
+
Buzz Balance ¥10.17
|
|
323
325
|
```
|
|
324
326
|
|
|
325
327
|
## Rendering examples
|
|
@@ -382,7 +384,7 @@ OpenAI
|
|
|
382
384
|
5h 78% Rst 05:05
|
|
383
385
|
Copilot
|
|
384
386
|
Monthly 78% Rst 04-01
|
|
385
|
-
Buzz Balance
|
|
387
|
+
Buzz Balance ¥10.2
|
|
386
388
|
```
|
|
387
389
|
|
|
388
390
|
Balance-style quota:
|
|
@@ -394,7 +396,7 @@ RC Balance $260
|
|
|
394
396
|
Buzz balance quota:
|
|
395
397
|
|
|
396
398
|
```text
|
|
397
|
-
Buzz Balance
|
|
399
|
+
Buzz Balance ¥10.17
|
|
398
400
|
```
|
|
399
401
|
|
|
400
402
|
Multi-detail quota (window + balance):
|
|
@@ -424,7 +426,7 @@ Quota is rendered inline as part of a single-line title:
|
|
|
424
426
|
Mixed with Buzz balance:
|
|
425
427
|
|
|
426
428
|
```text
|
|
427
|
-
<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
|
|
428
430
|
```
|
|
429
431
|
|
|
430
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.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
|
@@ -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);
|
|
@@ -170,8 +178,11 @@ function alignPairs(pairs, indent = ' ') {
|
|
|
170
178
|
}
|
|
171
179
|
function compactQuotaInline(quota) {
|
|
172
180
|
const label = sanitizeLine(quotaDisplayLabel(quota));
|
|
173
|
-
if (quota.status !== 'ok')
|
|
174
|
-
|
|
181
|
+
if (quota.status !== 'ok') {
|
|
182
|
+
if (quota.status === 'error')
|
|
183
|
+
return `${label} Remaining ?`;
|
|
184
|
+
return `${label} ${sanitizeLine(quota.status)}`;
|
|
185
|
+
}
|
|
175
186
|
if (quota.windows && quota.windows.length > 0) {
|
|
176
187
|
const first = quota.windows[0];
|
|
177
188
|
const showPercent = first.showPercent !== false;
|
|
@@ -238,13 +249,16 @@ function renderSingleLineTitle(baseTitle, usage, quotas, config, width) {
|
|
|
238
249
|
*/
|
|
239
250
|
export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
240
251
|
const width = Math.max(8, Math.floor(config.sidebar.width || 36));
|
|
241
|
-
const safeBaseTitle = stripAnsi(baseTitle || 'Session')
|
|
252
|
+
const safeBaseTitle = stripAnsi(baseTitle || 'Session') || 'Session';
|
|
242
253
|
if (config.sidebar.multilineTitle !== true) {
|
|
243
|
-
|
|
254
|
+
const singleLineBase = safeBaseTitle.split(/\r?\n/, 1)[0] || 'Session';
|
|
255
|
+
return renderSingleLineTitle(singleLineBase, usage, quotas, config, width);
|
|
244
256
|
}
|
|
245
257
|
const cacheMetrics = getCacheCoverageMetrics(usage);
|
|
246
258
|
const lines = [];
|
|
247
|
-
|
|
259
|
+
for (const line of safeBaseTitle.split(/\r?\n/)) {
|
|
260
|
+
lines.push(fitLine(line || 'Session', width));
|
|
261
|
+
}
|
|
248
262
|
lines.push('');
|
|
249
263
|
// Input / Output line
|
|
250
264
|
const io = `Input ${sidebarNumber(usage.input)} Output ${sidebarNumber(usage.output)}`;
|
|
@@ -409,9 +423,6 @@ function compactReset(iso, resetLabel, windowLabel) {
|
|
|
409
423
|
return hhmm;
|
|
410
424
|
return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
|
|
411
425
|
}
|
|
412
|
-
if (sameDay) {
|
|
413
|
-
return `${two(value.getHours())}:${two(value.getMinutes())}`;
|
|
414
|
-
}
|
|
415
426
|
return `${two(value.getMonth() + 1)}-${two(value.getDate())}`;
|
|
416
427
|
}
|
|
417
428
|
function dateLine(iso) {
|
|
@@ -450,6 +461,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
450
461
|
const measuredCostCell = (providerID, cost) => {
|
|
451
462
|
const canonical = canonicalProviderID(providerID);
|
|
452
463
|
const isSubscription = canonical === 'openai' ||
|
|
464
|
+
canonical === 'anthropic' ||
|
|
453
465
|
canonical === 'github-copilot' ||
|
|
454
466
|
rightCodeSubscriptionProviderIDs.has(providerID);
|
|
455
467
|
if (isSubscription)
|
|
@@ -459,6 +471,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
459
471
|
const isSubscriptionMeasuredProvider = (providerID) => {
|
|
460
472
|
const canonical = canonicalProviderID(providerID);
|
|
461
473
|
return (canonical === 'openai' ||
|
|
474
|
+
canonical === 'anthropic' ||
|
|
462
475
|
canonical === 'github-copilot' ||
|
|
463
476
|
rightCodeSubscriptionProviderIDs.has(providerID));
|
|
464
477
|
};
|
|
@@ -495,30 +508,40 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
495
508
|
: `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
|
|
496
509
|
});
|
|
497
510
|
const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
|
|
511
|
+
const displayLabel = quotaDisplayLabel(quota);
|
|
498
512
|
// Multi-window detail
|
|
499
513
|
if (quota.windows && quota.windows.length > 0 && quota.status === 'ok') {
|
|
500
|
-
|
|
514
|
+
const windowLines = quota.windows.map((win) => {
|
|
501
515
|
if (win.showPercent === false) {
|
|
502
516
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
503
|
-
return mdCell(`- ${
|
|
517
|
+
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
504
518
|
}
|
|
505
519
|
const remaining = win.remainingPercent === undefined
|
|
506
520
|
? '-'
|
|
507
521
|
: `${win.remainingPercent.toFixed(1)}%`;
|
|
508
522
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
509
|
-
return mdCell(`- ${
|
|
523
|
+
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
510
524
|
});
|
|
525
|
+
if (quota.balance) {
|
|
526
|
+
windowLines.push(mdCell(`- ${displayLabel}: ${quota.status} | balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`));
|
|
527
|
+
}
|
|
528
|
+
return windowLines;
|
|
511
529
|
}
|
|
512
530
|
if (quota.status === 'ok' && quota.balance) {
|
|
513
531
|
return [
|
|
514
|
-
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}` : ''}`),
|
|
515
538
|
];
|
|
516
539
|
}
|
|
517
540
|
const remaining = quota.remainingPercent === undefined
|
|
518
541
|
? '-'
|
|
519
542
|
: `${quota.remainingPercent.toFixed(1)}%`;
|
|
520
543
|
return [
|
|
521
|
-
mdCell(`- ${
|
|
544
|
+
mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
522
545
|
];
|
|
523
546
|
});
|
|
524
547
|
return [
|
|
@@ -595,14 +618,6 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
595
618
|
value: formatPercent(cacheMetrics.cacheReadCoverage, 1),
|
|
596
619
|
});
|
|
597
620
|
}
|
|
598
|
-
if (showCost) {
|
|
599
|
-
if (usage.apiCost > 0) {
|
|
600
|
-
tokenPairs.push({
|
|
601
|
-
label: 'API Cost',
|
|
602
|
-
value: formatApiCostValue(usage.apiCost),
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
621
|
lines.push(...alignPairs(tokenPairs).map((line) => fitLine(line, width)));
|
|
607
622
|
if (showCost) {
|
|
608
623
|
const costPairs = Object.values(usage.providers)
|
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. */
|
package/dist/storage.js
CHANGED
|
@@ -40,6 +40,7 @@ export function defaultState() {
|
|
|
40
40
|
titleEnabled: true,
|
|
41
41
|
sessionDateMap: {},
|
|
42
42
|
sessions: {},
|
|
43
|
+
deletedSessionDateMap: {},
|
|
43
44
|
quotaCache: {},
|
|
44
45
|
};
|
|
45
46
|
}
|
|
@@ -128,6 +129,17 @@ async function loadVersion2State(raw, statePath) {
|
|
|
128
129
|
acc[sessionID] = value;
|
|
129
130
|
return acc;
|
|
130
131
|
}, {});
|
|
132
|
+
const deletedSessionDateMapRaw = isRecord(raw.deletedSessionDateMap)
|
|
133
|
+
? raw.deletedSessionDateMap
|
|
134
|
+
: {};
|
|
135
|
+
const deletedSessionDateMap = Object.entries(deletedSessionDateMapRaw).reduce((acc, [sessionID, value]) => {
|
|
136
|
+
if (typeof value !== 'string')
|
|
137
|
+
return acc;
|
|
138
|
+
if (!isDateKey(value))
|
|
139
|
+
return acc;
|
|
140
|
+
acc[sessionID] = value;
|
|
141
|
+
return acc;
|
|
142
|
+
}, {});
|
|
131
143
|
const hadRawSessionDateMapEntries = isRecord(raw.sessionDateMap) && Object.keys(raw.sessionDateMap).length > 0;
|
|
132
144
|
const explicitDateKeys = Array.from(new Set(Object.values(sessionDateMap)));
|
|
133
145
|
// Only discover chunks when sessionDateMap is missing from state.
|
|
@@ -150,6 +162,8 @@ async function loadVersion2State(raw, statePath) {
|
|
|
150
162
|
const sessions = {};
|
|
151
163
|
for (const [dateKey, chunkSessions] of chunks) {
|
|
152
164
|
for (const [sessionID, session] of Object.entries(chunkSessions)) {
|
|
165
|
+
if (deletedSessionDateMap[sessionID])
|
|
166
|
+
continue;
|
|
153
167
|
sessions[sessionID] = session;
|
|
154
168
|
if (!sessionDateMap[sessionID])
|
|
155
169
|
sessionDateMap[sessionID] = dateKey;
|
|
@@ -160,6 +174,7 @@ async function loadVersion2State(raw, statePath) {
|
|
|
160
174
|
titleEnabled,
|
|
161
175
|
sessionDateMap,
|
|
162
176
|
sessions,
|
|
177
|
+
deletedSessionDateMap,
|
|
163
178
|
quotaCache,
|
|
164
179
|
};
|
|
165
180
|
}
|
|
@@ -237,22 +252,31 @@ export async function saveState(statePath, state, options) {
|
|
|
237
252
|
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
238
253
|
if (!skipChunks) {
|
|
239
254
|
const keysToWrite = writeAll
|
|
240
|
-
?
|
|
255
|
+
? Array.from(new Set([
|
|
256
|
+
...Object.keys(sessionsByDate),
|
|
257
|
+
...Object.values(state.deletedSessionDateMap),
|
|
258
|
+
]))
|
|
241
259
|
: Array.from(dirtySet ?? []);
|
|
242
260
|
await Promise.all(keysToWrite
|
|
243
261
|
.map((dateKey) => {
|
|
244
|
-
if (!Object.prototype.hasOwnProperty.call(sessionsByDate, dateKey)) {
|
|
245
|
-
return undefined;
|
|
246
|
-
}
|
|
247
262
|
return withDayChunkWriteLock(dateKey, async () => {
|
|
248
263
|
const memorySessions = sessionsByDate[dateKey] || {};
|
|
264
|
+
const tombstonedIDs = Object.entries(state.deletedSessionDateMap)
|
|
265
|
+
.filter(([, tombstoneDateKey]) => tombstoneDateKey === dateKey)
|
|
266
|
+
.map(([sessionID]) => sessionID);
|
|
249
267
|
const next = writeAll
|
|
250
268
|
? memorySessions
|
|
251
269
|
: {
|
|
252
270
|
...(await readDayChunk(rootPath, dateKey)),
|
|
253
271
|
...memorySessions,
|
|
254
272
|
};
|
|
273
|
+
for (const sessionID of tombstonedIDs) {
|
|
274
|
+
delete next[sessionID];
|
|
275
|
+
}
|
|
255
276
|
await writeDayChunk(rootPath, dateKey, next);
|
|
277
|
+
for (const sessionID of tombstonedIDs) {
|
|
278
|
+
delete state.deletedSessionDateMap[sessionID];
|
|
279
|
+
}
|
|
256
280
|
});
|
|
257
281
|
})
|
|
258
282
|
.filter((promise) => Boolean(promise)));
|
|
@@ -262,6 +286,7 @@ export async function saveState(statePath, state, options) {
|
|
|
262
286
|
version: 2,
|
|
263
287
|
titleEnabled: state.titleEnabled,
|
|
264
288
|
sessionDateMap: state.sessionDateMap,
|
|
289
|
+
deletedSessionDateMap: state.deletedSessionDateMap,
|
|
265
290
|
quotaCache: state.quotaCache,
|
|
266
291
|
}, null, 2)}\n`);
|
|
267
292
|
}
|
|
@@ -292,6 +317,7 @@ export function evictOldSessions(state, retentionDays) {
|
|
|
292
317
|
*/
|
|
293
318
|
export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Date.now(), memoryState) {
|
|
294
319
|
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
320
|
+
const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
|
|
295
321
|
const dateKeys = dateKeysInRange(startAt, endAt);
|
|
296
322
|
if (!dateKeys.length) {
|
|
297
323
|
return [];
|
|
@@ -302,6 +328,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
302
328
|
if (memoryState) {
|
|
303
329
|
const dateKeySet = new Set(dateKeys);
|
|
304
330
|
for (const [sessionID, session] of Object.entries(memoryState.sessions)) {
|
|
331
|
+
if (deletedSessionIDs.has(sessionID))
|
|
332
|
+
continue;
|
|
305
333
|
const dk = memoryState.sessionDateMap[sessionID];
|
|
306
334
|
if (!dk || !dateKeySet.has(dk))
|
|
307
335
|
continue;
|
|
@@ -314,11 +342,9 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
314
342
|
}
|
|
315
343
|
}
|
|
316
344
|
}
|
|
317
|
-
// Second pass: read disk chunks for date keys
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
: new Set();
|
|
321
|
-
const diskDateKeys = dateKeys.filter((dk) => !memoryDateKeys.has(dk));
|
|
345
|
+
// Second pass: read disk chunks for all date keys in range, then de-dupe by sessionID.
|
|
346
|
+
// A date can have a mix of in-memory and disk-only sessions.
|
|
347
|
+
const diskDateKeys = [...dateKeys];
|
|
322
348
|
if (diskDateKeys.length > 0) {
|
|
323
349
|
const RANGE_SCAN_CONCURRENCY = 5;
|
|
324
350
|
const chunkEntries = await mapConcurrent(diskDateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
|
|
@@ -332,6 +358,8 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
332
358
|
for (const entry of chunkEntries.flat()) {
|
|
333
359
|
if (seenSessionIDs.has(entry.sessionID))
|
|
334
360
|
continue;
|
|
361
|
+
if (deletedSessionIDs.has(entry.sessionID))
|
|
362
|
+
continue;
|
|
335
363
|
const createdAt = Number.isFinite(entry.state.createdAt) && entry.state.createdAt > 0
|
|
336
364
|
? entry.state.createdAt
|
|
337
365
|
: dateStartFromKey(entry.dateKey);
|
|
@@ -343,6 +371,43 @@ export async function scanSessionsByCreatedRange(statePath, startAt, endAt = Dat
|
|
|
343
371
|
}
|
|
344
372
|
return results;
|
|
345
373
|
}
|
|
374
|
+
export async function scanAllSessions(statePath, memoryState) {
|
|
375
|
+
const rootPath = chunkRootPathFromStateFile(statePath);
|
|
376
|
+
const deletedSessionIDs = new Set(Object.keys(memoryState?.deletedSessionDateMap || {}));
|
|
377
|
+
const results = [];
|
|
378
|
+
const seenSessionIDs = new Set();
|
|
379
|
+
if (memoryState) {
|
|
380
|
+
for (const [sessionID, sessionState] of Object.entries(memoryState.sessions)) {
|
|
381
|
+
if (deletedSessionIDs.has(sessionID))
|
|
382
|
+
continue;
|
|
383
|
+
const dateKey = memoryState.sessionDateMap[sessionID] ||
|
|
384
|
+
dateKeyFromTimestamp(sessionState.createdAt);
|
|
385
|
+
results.push({ sessionID, dateKey, state: sessionState });
|
|
386
|
+
seenSessionIDs.add(sessionID);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const dateKeys = await discoverChunks(rootPath);
|
|
390
|
+
if (dateKeys.length === 0)
|
|
391
|
+
return results;
|
|
392
|
+
const RANGE_SCAN_CONCURRENCY = 5;
|
|
393
|
+
const chunkEntries = await mapConcurrent(dateKeys, RANGE_SCAN_CONCURRENCY, async (dateKey) => {
|
|
394
|
+
const sessions = await readDayChunk(rootPath, dateKey);
|
|
395
|
+
return Object.entries(sessions).map(([sessionID, state]) => ({
|
|
396
|
+
sessionID,
|
|
397
|
+
dateKey,
|
|
398
|
+
state,
|
|
399
|
+
}));
|
|
400
|
+
});
|
|
401
|
+
for (const entry of chunkEntries.flat()) {
|
|
402
|
+
if (seenSessionIDs.has(entry.sessionID))
|
|
403
|
+
continue;
|
|
404
|
+
if (deletedSessionIDs.has(entry.sessionID))
|
|
405
|
+
continue;
|
|
406
|
+
results.push(entry);
|
|
407
|
+
seenSessionIDs.add(entry.sessionID);
|
|
408
|
+
}
|
|
409
|
+
return results;
|
|
410
|
+
}
|
|
346
411
|
/** Best-effort: remove a session entry from its day chunk (if present). */
|
|
347
412
|
export async function deleteSessionFromDayChunk(statePath, sessionID, dateKey) {
|
|
348
413
|
const rootPath = chunkRootPathFromStateFile(statePath);
|