@leo000001/opencode-quota-sidebar 2.0.1 → 2.0.4
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/CHANGELOG.md +3 -0
- package/CONTRIBUTING.md +6 -1
- package/README.md +50 -33
- package/SECURITY.md +1 -1
- package/dist/format.js +118 -30
- package/dist/index.js +55 -4
- package/dist/persistence.js +15 -1
- package/dist/providers/core/kimi_for_coding.d.ts +2 -0
- package/dist/providers/core/kimi_for_coding.js +219 -0
- package/dist/providers/index.d.ts +2 -1
- package/dist/providers/index.js +3 -1
- package/dist/quota.js +1 -1
- package/dist/quota_render.js +1 -0
- package/dist/quota_service.js +153 -13
- package/dist/storage_parse.js +1 -0
- package/dist/title_apply.d.ts +21 -5
- package/dist/title_apply.js +77 -21
- package/dist/title_refresh.d.ts +3 -1
- package/dist/title_refresh.js +25 -2
- package/dist/tools.d.ts +19 -3
- package/dist/tools.js +58 -16
- package/dist/types.d.ts +2 -0
- package/dist/usage.d.ts +2 -0
- package/dist/usage.js +15 -0
- package/dist/usage_service.js +38 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Add built-in `kimi-for-coding` subscription quota support via `GET https://api.kimi.com/coding/v1/usages`.
|
|
6
|
+
- Parse Kimi's `5h` and `Weekly` windows, including reset timestamps, and render them like other subscription providers.
|
|
7
|
+
- Accept OpenCode provider discovery responses that expose Kimi API keys through provider `key` fields.
|
|
5
8
|
- Add Buzz API balance support for OpenAI-compatible providers that use a Buzz `baseURL`.
|
|
6
9
|
- Document Buzz configuration, rendering, and outbound billing endpoints.
|
|
7
10
|
- Keep session measured cost aligned with OpenCode root-session `message.cost` while still including descendant subagent usage in API-equivalent cost.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -19,6 +19,7 @@ The plugin now uses a provider adapter registry, so adding a new provider does n
|
|
|
19
19
|
- `baseURL` match: best for OpenAI-compatible relays such as RightCode or Buzz
|
|
20
20
|
- Prefix/variant normalization: best when one provider has multiple runtime IDs
|
|
21
21
|
- Balance-only providers should prefer `balance` over inventing fake percent windows
|
|
22
|
+
- Built-in API-key providers such as `kimi-for-coding` may need both: direct ID matching for the canonical provider and support for OpenCode's discovered `key -> options.apiKey` bridge
|
|
22
23
|
|
|
23
24
|
## Add a new provider
|
|
24
25
|
|
|
@@ -70,6 +71,10 @@ If your provider is an OpenAI-compatible relay, prefer matching on
|
|
|
70
71
|
`providerOptions.baseURL` instead of the runtime `providerID`; that keeps custom
|
|
71
72
|
aliases working without extra user config.
|
|
72
73
|
|
|
74
|
+
If your provider is built into OpenCode and already has a stable runtime ID
|
|
75
|
+
(for example `kimi-for-coding`), prefer a direct provider-ID match first, then
|
|
76
|
+
add a `baseURL` fallback only when it helps older/custom runtime shapes.
|
|
77
|
+
|
|
73
78
|
If the new provider should appear in default `quota_summary` reports even when
|
|
74
79
|
it has not yet been used in the current session, also update
|
|
75
80
|
`listDefaultQuotaProviderIDs()` in `src/quota.ts`.
|
|
@@ -98,7 +103,7 @@ At minimum:
|
|
|
98
103
|
- format output if using special fields (e.g. `balance`)
|
|
99
104
|
- cache compatibility if the change replaces an older snapshot shape
|
|
100
105
|
- mixed-provider rendering if the new provider will commonly appear next to
|
|
101
|
-
OpenAI/Copilot/RightCode in sidebar or toast output
|
|
106
|
+
OpenAI/Copilot/Kimi/RightCode in sidebar or toast output
|
|
102
107
|
|
|
103
108
|
If the provider introduces new rendering rules or multi-window behavior, add
|
|
104
109
|
coverage in both `src/__tests__/quota.test.ts` and `src/__tests__/format.test.ts`.
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
|
|
|
13
13
|
|
|
14
14
|
```json
|
|
15
15
|
{
|
|
16
|
-
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.
|
|
16
|
+
"plugin": ["@leo000001/opencode-quota-sidebar@2.0.1"]
|
|
17
17
|
}
|
|
18
18
|
```
|
|
19
19
|
|
|
@@ -41,13 +41,14 @@ On Windows, use forward slashes: `"file:///D:/Lab/opencode-quota-sidebar/dist/in
|
|
|
41
41
|
|
|
42
42
|
## Supported quota providers
|
|
43
43
|
|
|
44
|
-
| Provider | Endpoint | Auth | Status |
|
|
45
|
-
| -------------- | -------------------------------------- | --------------- | --------------------------------------- |
|
|
46
|
-
| OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
|
|
47
|
-
| GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
44
|
+
| Provider | Endpoint | Auth | Status |
|
|
45
|
+
| -------------- | -------------------------------------- | --------------- | --------------------------------------- |
|
|
46
|
+
| OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
|
|
47
|
+
| GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
|
|
48
|
+
| Kimi For Coding | `api.kimi.com/coding/v1/usages` | API key | Multi-window subscription (5h + weekly) |
|
|
49
|
+
| RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
|
|
50
|
+
| Buzz | `buzzai.cc/v1/dashboard/billing/*` | API key | Balance only (computed from total-used) |
|
|
51
|
+
| Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
|
|
51
52
|
|
|
52
53
|
Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware AI, etc.)? See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
53
54
|
|
|
@@ -64,23 +65,32 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
64
65
|
- 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`
|
|
65
66
|
- 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
|
|
66
67
|
- 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.
|
|
67
|
-
- Toast message
|
|
68
|
+
- Toast message can include four sections: `Token Usage`, `Cost as API` (per provider), `Provider Cache` (when provider-level cache coverage is available), and `Quota`
|
|
68
69
|
- `quota_summary` markdown / toast also include `Cache Coverage` and `Cache Read Coverage` summary lines when available
|
|
69
70
|
- Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
|
|
70
71
|
- Custom tools:
|
|
71
72
|
- `quota_summary` — generate usage report for session/day/week/month (markdown + toast)
|
|
72
73
|
- `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
|
|
74
|
-
- Quota connectors:
|
|
75
|
-
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
76
|
-
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
-
|
|
74
|
+
- 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
|
|
75
|
+
- Quota connectors:
|
|
76
|
+
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
77
|
+
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
78
|
+
- Kimi For Coding API key (`/usages`, built-in `kimi-for-coding` provider)
|
|
79
|
+
- RightCode API key (`/account/summary`)
|
|
80
|
+
- Buzz API key (`/v1/dashboard/billing/subscription` + `/v1/dashboard/billing/usage`)
|
|
81
|
+
- Anthropic Claude OAuth (`/api/oauth/usage`, with beta header)
|
|
80
82
|
- OpenAI OAuth quota checks auto-refresh expired access token (using refresh token)
|
|
81
|
-
- API key providers still show usage aggregation (quota only applies to subscription providers)
|
|
82
|
-
- Incremental usage aggregation — only processes new messages since last cursor
|
|
83
|
-
- Sidebar token units are adaptive (`k`/`m` with one decimal where applicable)
|
|
83
|
+
- API key providers still show usage aggregation (quota only applies to subscription providers)
|
|
84
|
+
- Incremental usage aggregation — only processes new messages since last cursor
|
|
85
|
+
- Sidebar token units are adaptive (`k`/`m` with one decimal where applicable)
|
|
86
|
+
|
|
87
|
+
### Kimi For Coding notes
|
|
88
|
+
|
|
89
|
+
- OpenCode's built-in provider ID is `kimi-for-coding` and its runtime base URL is `https://api.kimi.com/coding/v1`.
|
|
90
|
+
- The plugin treats Kimi as a subscription quota source, not a balance source.
|
|
91
|
+
- Quota data is read from `GET https://api.kimi.com/coding/v1/usages`.
|
|
92
|
+
- The current implementation maps the short rolling window in `limits[]` to `5h` and the top-level `usage` block to `Weekly`.
|
|
93
|
+
- Rendering follows the same compact reset formatting as OpenAI: short windows show `Rst MM-DD HH:MM` when they cross days, and longer windows show `Rst MM-DD`.
|
|
84
94
|
|
|
85
95
|
## Storage layout
|
|
86
96
|
|
|
@@ -90,12 +100,17 @@ The plugin stores lightweight global state and date-partitioned session chunks.
|
|
|
90
100
|
- `titleEnabled`
|
|
91
101
|
- `sessionDateMap` (sessionID -> `YYYY-MM-DD`)
|
|
92
102
|
- `quotaCache`
|
|
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`
|
|
98
|
-
- incremental aggregation cursor
|
|
103
|
+
- Session chunks: `<opencode-data>/quota-sidebar-sessions/YYYY/MM/DD.json`
|
|
104
|
+
- per-session title state (`baseTitle`, `lastAppliedTitle`)
|
|
105
|
+
- `createdAt`
|
|
106
|
+
- `parentID` (when the session is a subagent child session)
|
|
107
|
+
- cached usage summary used by `quota_summary`, including session-level and provider-level `cacheBuckets` for cache coverage reporting
|
|
108
|
+
- incremental aggregation cursor
|
|
109
|
+
|
|
110
|
+
Notes on cache coverage persistence:
|
|
111
|
+
|
|
112
|
+
- Older cached usage written before `cacheBuckets` existed can only be approximated from top-level `cache_read` / `cache_write` totals.
|
|
113
|
+
- 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.
|
|
99
114
|
|
|
100
115
|
Example tree:
|
|
101
116
|
|
|
@@ -450,12 +465,13 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
|
|
|
450
465
|
## Security & privacy notes
|
|
451
466
|
|
|
452
467
|
- The plugin reads OpenCode credentials from `<opencode-data>/auth.json`.
|
|
453
|
-
- If enabled, quota checks call external endpoints:
|
|
454
|
-
- OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
|
|
455
|
-
- GitHub Copilot: `https://api.github.com/copilot_internal/user`
|
|
456
|
-
-
|
|
457
|
-
-
|
|
458
|
-
-
|
|
468
|
+
- If enabled, quota checks call external endpoints:
|
|
469
|
+
- OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
|
|
470
|
+
- GitHub Copilot: `https://api.github.com/copilot_internal/user`
|
|
471
|
+
- Kimi For Coding: `https://api.kimi.com/coding/v1/usages`
|
|
472
|
+
- RightCode: `https://www.right.codes/account/summary`
|
|
473
|
+
- Buzz: `https://buzzai.cc/v1/dashboard/billing/subscription` and `https://buzzai.cc/v1/dashboard/billing/usage`
|
|
474
|
+
- Anthropic: `https://api.anthropic.com/api/oauth/usage`
|
|
459
475
|
- **Screen-sharing warning**: Session titles and toasts surface usage/quota
|
|
460
476
|
information. If you are screen-sharing or recording, consider toggling the
|
|
461
477
|
sidebar display off (`/qtoggle` or `quota_show` tool) to avoid leaking
|
|
@@ -465,8 +481,9 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
|
|
|
465
481
|
- OpenAI OAuth token refresh is disabled by default; set
|
|
466
482
|
`quota.refreshAccessToken=true` if you want the plugin to refresh access
|
|
467
483
|
tokens when expired.
|
|
468
|
-
- Anthropic quota currently uses a beta/internal-style OAuth usage endpoint and
|
|
469
|
-
request header; response fields may change without notice.
|
|
484
|
+
- Anthropic quota currently uses a beta/internal-style OAuth usage endpoint and
|
|
485
|
+
request header; response fields may change without notice.
|
|
486
|
+
- Kimi For Coding quota uses the current `/usages` response shape exposed by the Kimi coding service; if Kimi changes that payload, window parsing may need to be updated.
|
|
470
487
|
- State/chunk file writes refuse to write through symlinked targets (best-effort defense-in-depth).
|
|
471
488
|
- The `OPENCODE_QUOTA_DATA_HOME` env var overrides the OpenCode data directory
|
|
472
489
|
path (for testing); do not set this in production.
|
package/SECURITY.md
CHANGED
|
@@ -25,7 +25,7 @@ We will acknowledge reports as quickly as possible and provide a remediation tim
|
|
|
25
25
|
- Keep debug logs free of secrets.
|
|
26
26
|
- Prefer fail-closed behavior for writes (already enforced via symlink checks and atomic writes).
|
|
27
27
|
- Quota fetching may contact provider-operated endpoints such as OpenAI, GitHub,
|
|
28
|
-
RightCode, Buzz, and Anthropic; review any new provider integration for
|
|
28
|
+
Kimi, RightCode, Buzz, and Anthropic; review any new provider integration for
|
|
29
29
|
outbound data exposure and header/token handling.
|
|
30
30
|
- Some quota integrations rely on beta or internal-style endpoints; document
|
|
31
31
|
instability risks clearly and avoid assuming long-term API compatibility.
|
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. */
|
|
@@ -161,6 +161,16 @@ function formatPercent(value, decimals = 1) {
|
|
|
161
161
|
const pct = (safe * 100).toFixed(decimals);
|
|
162
162
|
return `${pct.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')}%`;
|
|
163
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
|
+
}
|
|
164
174
|
function alignPairs(pairs, indent = ' ') {
|
|
165
175
|
if (pairs.length === 0)
|
|
166
176
|
return [];
|
|
@@ -187,9 +197,10 @@ function compactQuotaInline(quota) {
|
|
|
187
197
|
const first = quota.windows[0];
|
|
188
198
|
const showPercent = first.showPercent !== false;
|
|
189
199
|
const firstLabel = sanitizeLine(first.label || '');
|
|
190
|
-
const pct = first.remainingPercent
|
|
191
|
-
|
|
192
|
-
:
|
|
200
|
+
const pct = formatQuotaPercent(first.remainingPercent, {
|
|
201
|
+
rounded: true,
|
|
202
|
+
missing: '',
|
|
203
|
+
});
|
|
193
204
|
const summary = showPercent
|
|
194
205
|
? [firstLabel, pct].filter(Boolean).join(' ')
|
|
195
206
|
: firstLabel.replace(/^Daily\s+/i, '') || firstLabel;
|
|
@@ -200,8 +211,12 @@ function compactQuotaInline(quota) {
|
|
|
200
211
|
if (quota.balance) {
|
|
201
212
|
return `${label} Balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
|
|
202
213
|
}
|
|
203
|
-
|
|
204
|
-
|
|
214
|
+
const singlePercent = formatQuotaPercent(quota.remainingPercent, {
|
|
215
|
+
rounded: true,
|
|
216
|
+
missing: '',
|
|
217
|
+
});
|
|
218
|
+
if (singlePercent) {
|
|
219
|
+
return `${label} ${singlePercent}`;
|
|
205
220
|
}
|
|
206
221
|
return label;
|
|
207
222
|
}
|
|
@@ -351,9 +366,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
351
366
|
: undefined;
|
|
352
367
|
const renderWindow = (win) => {
|
|
353
368
|
const showPercent = win.showPercent !== false;
|
|
354
|
-
const pct = win.remainingPercent
|
|
355
|
-
? '?'
|
|
356
|
-
: `${Math.round(win.remainingPercent)}%`;
|
|
369
|
+
const pct = formatQuotaPercent(win.remainingPercent, { rounded: true });
|
|
357
370
|
const parts = win.label
|
|
358
371
|
? showPercent
|
|
359
372
|
? [sanitizeLine(win.label), pct]
|
|
@@ -387,9 +400,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
387
400
|
return maybeBreak(balanceText, [balanceText]);
|
|
388
401
|
}
|
|
389
402
|
// Fallback: single value from top-level remainingPercent
|
|
390
|
-
const percent = quota.remainingPercent
|
|
391
|
-
? '?'
|
|
392
|
-
: `${Math.round(quota.remainingPercent)}%`;
|
|
403
|
+
const percent = formatQuotaPercent(quota.remainingPercent, { rounded: true });
|
|
393
404
|
const reset = compactReset(quota.resetAt, 'Rst');
|
|
394
405
|
const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
|
|
395
406
|
return maybeBreak(fallbackText, [fallbackText]);
|
|
@@ -499,12 +510,71 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
499
510
|
return '-';
|
|
500
511
|
return formatApiCostValue(usage.apiCost);
|
|
501
512
|
};
|
|
502
|
-
const
|
|
503
|
-
|
|
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
|
|
504
574
|
.map((provider) => {
|
|
505
575
|
const providerID = mdCell(provider.providerID);
|
|
506
576
|
return showCost
|
|
507
|
-
? `| ${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)} |`
|
|
508
578
|
: `| ${providerID} | ${shortNumber(provider.input)} | ${shortNumber(provider.output)} | ${shortNumber(provider.cacheRead + provider.cacheWrite)} | ${shortNumber(provider.total)} |`;
|
|
509
579
|
});
|
|
510
580
|
const quotaLines = collapseQuotaSnapshots(quotas).flatMap((quota) => {
|
|
@@ -516,9 +586,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
516
586
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
517
587
|
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
518
588
|
}
|
|
519
|
-
const remaining = win.remainingPercent
|
|
520
|
-
? '-'
|
|
521
|
-
: `${win.remainingPercent.toFixed(1)}%`;
|
|
589
|
+
const remaining = formatQuotaPercent(win.remainingPercent);
|
|
522
590
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
523
591
|
return mdCell(`- ${displayLabel}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
524
592
|
});
|
|
@@ -537,9 +605,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
537
605
|
mdCell(`- ${displayLabel}: ${quota.status}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
538
606
|
];
|
|
539
607
|
}
|
|
540
|
-
const remaining = quota.remainingPercent
|
|
541
|
-
? '-'
|
|
542
|
-
: `${quota.remainingPercent.toFixed(1)}%`;
|
|
608
|
+
const remaining = formatQuotaPercent(quota.remainingPercent);
|
|
543
609
|
return [
|
|
544
610
|
mdCell(`- ${displayLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
545
611
|
];
|
|
@@ -564,17 +630,20 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
564
630
|
`- API cost: ${apiCostSummaryValue()}`,
|
|
565
631
|
]
|
|
566
632
|
: []),
|
|
633
|
+
...(highlightLines().length > 0
|
|
634
|
+
? ['', '### Highlights', ...highlightLines()]
|
|
635
|
+
: []),
|
|
567
636
|
'',
|
|
568
637
|
'### Usage by Provider',
|
|
569
638
|
showCost
|
|
570
|
-
? '| Provider | Input | Output | Cache | Total | Measured Cost | API Cost |'
|
|
639
|
+
? '| Provider | Input | Output | Cache | Total | Cache Coverage | Cache Read Coverage | Measured Cost | API Cost |'
|
|
571
640
|
: '| Provider | Input | Output | Cache | Total |',
|
|
572
641
|
showCost
|
|
573
|
-
? '
|
|
642
|
+
? '|---|---:|---:|---:|---:|---:|---:|---:|---:|'
|
|
574
643
|
: '|---|---:|---:|---:|---:|',
|
|
575
644
|
...(providerRows.length
|
|
576
645
|
? providerRows
|
|
577
|
-
: [showCost ? '| - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
|
|
646
|
+
: [showCost ? '| - | - | - | - | - | - | - | - | - |' : '| - | - | - | - | - |']),
|
|
578
647
|
'',
|
|
579
648
|
'### Subscription Quota',
|
|
580
649
|
...(quotaLines.length
|
|
@@ -638,14 +707,35 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
638
707
|
lines.push(fitLine(hasAnyUsage ? ' N/A (Copilot)' : ' -', width));
|
|
639
708
|
}
|
|
640
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
|
+
}
|
|
641
733
|
const quotaPairs = collapseQuotaSnapshots(quotas).flatMap((item) => {
|
|
642
734
|
if (item.status === 'ok') {
|
|
643
735
|
if (item.windows && item.windows.length > 0) {
|
|
644
736
|
const pairs = item.windows.map((win, idx) => {
|
|
645
737
|
const showPercent = win.showPercent !== false;
|
|
646
|
-
const pct = win.remainingPercent
|
|
647
|
-
? '-'
|
|
648
|
-
: `${win.remainingPercent.toFixed(1)}%`;
|
|
738
|
+
const pct = formatQuotaPercent(win.remainingPercent);
|
|
649
739
|
const reset = compactReset(win.resetAt, win.resetLabel, win.label);
|
|
650
740
|
const parts = [win.label];
|
|
651
741
|
if (showPercent)
|
|
@@ -673,9 +763,7 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
673
763
|
},
|
|
674
764
|
];
|
|
675
765
|
}
|
|
676
|
-
const percent = item.remainingPercent
|
|
677
|
-
? '-'
|
|
678
|
-
: `${item.remainingPercent.toFixed(1)}%`;
|
|
766
|
+
const percent = formatQuotaPercent(item.remainingPercent);
|
|
679
767
|
const reset = compactReset(item.resetAt, 'Rst');
|
|
680
768
|
return [
|
|
681
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();
|
|
@@ -164,18 +166,61 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
164
166
|
restoreConcurrency: RESTORE_TITLE_CONCURRENCY,
|
|
165
167
|
});
|
|
166
168
|
const titleRefresh = createTitleRefreshScheduler({
|
|
167
|
-
apply:
|
|
169
|
+
apply: async (sessionID) => {
|
|
170
|
+
await titleApplicator.applyTitle(sessionID);
|
|
171
|
+
},
|
|
168
172
|
onError: swallow('titleRefresh'),
|
|
169
173
|
});
|
|
170
174
|
scheduleTitleRefresh = titleRefresh.schedule;
|
|
171
175
|
const restoreAllVisibleTitles = titleApplicator.restoreAllVisibleTitles;
|
|
172
176
|
const refreshAllTouchedTitles = titleApplicator.refreshAllTouchedTitles;
|
|
173
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
|
+
};
|
|
174
193
|
if (!state.titleEnabled || !config.sidebar.enabled) {
|
|
175
|
-
|
|
194
|
+
startupTitleWork = runStartupRestore().catch(swallow('startup:restoreAllVisibleTitles'));
|
|
176
195
|
}
|
|
177
196
|
else {
|
|
178
|
-
|
|
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
|
+
}
|
|
179
224
|
}
|
|
180
225
|
const showToast = async (period, message) => {
|
|
181
226
|
await input.client.tui
|
|
@@ -268,9 +313,12 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
268
313
|
},
|
|
269
314
|
scheduleSave,
|
|
270
315
|
flushSave,
|
|
316
|
+
waitForStartupTitleWork: () => startupTitleWork,
|
|
271
317
|
refreshSessionTitle: (sessionID, delay) => titleRefresh.schedule(sessionID, delay ?? 250),
|
|
272
318
|
cancelAllTitleRefreshes: () => titleRefresh.cancelAll(),
|
|
319
|
+
flushScheduledTitleRefreshes: () => titleRefresh.flushScheduled(),
|
|
273
320
|
waitForTitleRefreshIdle: () => titleRefresh.waitForIdle(),
|
|
321
|
+
waitForTitleRefreshQuiescence: () => titleRefresh.waitForQuiescence(),
|
|
274
322
|
restoreAllVisibleTitles,
|
|
275
323
|
refreshAllTouchedTitles,
|
|
276
324
|
refreshAllVisibleTitles,
|
|
@@ -279,7 +327,10 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
279
327
|
getQuotaSnapshots,
|
|
280
328
|
renderMarkdownReport,
|
|
281
329
|
renderToastMessage,
|
|
282
|
-
config
|
|
330
|
+
config: {
|
|
331
|
+
sidebar: config.sidebar,
|
|
332
|
+
sidebarEnabled: config.sidebar.enabled,
|
|
333
|
+
},
|
|
283
334
|
}),
|
|
284
335
|
};
|
|
285
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 () => {
|