@leo000001/opencode-quota-sidebar 1.11.0 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -12
- package/dist/events.d.ts +4 -1
- package/dist/events.js +5 -1
- package/dist/format.js +33 -9
- package/dist/index.js +4 -3
- package/dist/providers/core/anthropic.js +107 -38
- package/dist/quota_service.js +25 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,6 +18,7 @@ Add the package name to `plugin` in your `opencode.json`. OpenCode uses Bun to i
|
|
|
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: []`).
|
|
21
|
+
This plugin also accepts both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
|
|
21
22
|
|
|
22
23
|
## Development (build from source)
|
|
23
24
|
|
|
@@ -38,12 +39,12 @@ On Windows, use forward slashes: `"file:///D:/Lab/opencode-quota-sidebar/dist/in
|
|
|
38
39
|
|
|
39
40
|
## Supported quota providers
|
|
40
41
|
|
|
41
|
-
| Provider | Endpoint | Auth | Status
|
|
42
|
-
| -------------- | -------------------------------------- | --------------- |
|
|
43
|
-
| OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly)
|
|
44
|
-
| GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota
|
|
45
|
-
| RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix)
|
|
46
|
-
| Anthropic |
|
|
42
|
+
| Provider | Endpoint | Auth | Status |
|
|
43
|
+
| -------------- | -------------------------------------- | --------------- | --------------------------------------- |
|
|
44
|
+
| OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
|
|
45
|
+
| GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
|
|
46
|
+
| RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
|
|
47
|
+
| Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
|
|
47
48
|
|
|
48
49
|
Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware AI, etc.)? See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
49
50
|
|
|
@@ -55,8 +56,8 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
55
56
|
- line 3: Cache Read tokens (only if non-zero)
|
|
56
57
|
- line 4: Cache Write tokens (only if non-zero)
|
|
57
58
|
- line 5: `$X.XX as API cost` (equivalent API billing for subscription-auth providers)
|
|
58
|
-
- quota lines: quota text like `OpenAI 5h 80% Rst 16:20`,
|
|
59
|
-
- 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
|
|
59
|
+
- 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`
|
|
60
|
+
- 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
|
|
60
61
|
- 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.
|
|
61
62
|
- Toast message includes three sections: `Token Usage`, `Cost as API` (per provider), and `Quota`
|
|
62
63
|
- Quota snapshots are de-duplicated before rendering to avoid repeated provider lines
|
|
@@ -67,7 +68,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
|
|
|
67
68
|
- OpenAI Codex OAuth (`/backend-api/wham/usage`)
|
|
68
69
|
- GitHub Copilot OAuth (`/copilot_internal/user`)
|
|
69
70
|
- RightCode API key (`/account/summary`)
|
|
70
|
-
- Anthropic
|
|
71
|
+
- Anthropic Claude OAuth (`/api/oauth/usage`, with beta header)
|
|
71
72
|
- OpenAI OAuth quota checks auto-refresh expired access token (using refresh token)
|
|
72
73
|
- API key providers still show usage aggregation (quota only applies to subscription providers)
|
|
73
74
|
- Incremental usage aggregation — only processes new messages since last cursor
|
|
@@ -145,7 +146,9 @@ Recommended global config:
|
|
|
145
146
|
Optional project overrides:
|
|
146
147
|
|
|
147
148
|
- `<worktree>/quota-sidebar.config.json`
|
|
149
|
+
- `<directory>/quota-sidebar.config.json` (when different from `worktree`)
|
|
148
150
|
- `<worktree>/.opencode/quota-sidebar.config.json`
|
|
151
|
+
- `<directory>/.opencode/quota-sidebar.config.json` (when different from `worktree`)
|
|
149
152
|
|
|
150
153
|
Optional explicit override:
|
|
151
154
|
|
|
@@ -158,12 +161,46 @@ Optional config-home override:
|
|
|
158
161
|
Resolution order (low -> high):
|
|
159
162
|
|
|
160
163
|
1. Global config (`~/.config/opencode/...`)
|
|
161
|
-
2.
|
|
162
|
-
3.
|
|
163
|
-
4. `
|
|
164
|
+
2. `<worktree>/quota-sidebar.config.json`
|
|
165
|
+
3. `<directory>/quota-sidebar.config.json`
|
|
166
|
+
4. `<worktree>/.opencode/quota-sidebar.config.json`
|
|
167
|
+
5. `<directory>/.opencode/quota-sidebar.config.json`
|
|
168
|
+
6. `OPENCODE_QUOTA_CONFIG`
|
|
164
169
|
|
|
165
170
|
Values are layered; later sources override earlier ones.
|
|
166
171
|
|
|
172
|
+
## Defaults
|
|
173
|
+
|
|
174
|
+
If you do not provide any config file, the plugin uses the built-in defaults below.
|
|
175
|
+
|
|
176
|
+
Sidebar defaults:
|
|
177
|
+
|
|
178
|
+
- `sidebar.enabled`: `true`
|
|
179
|
+
- `sidebar.width`: `36` (clamped to `20`-`60`)
|
|
180
|
+
- `sidebar.multilineTitle`: `true`
|
|
181
|
+
- `sidebar.showCost`: `true`
|
|
182
|
+
- `sidebar.showQuota`: `true`
|
|
183
|
+
- `sidebar.wrapQuotaLines`: `true`
|
|
184
|
+
- `sidebar.includeChildren`: `true`
|
|
185
|
+
- `sidebar.childrenMaxDepth`: `6` (clamped to `1`-`32`)
|
|
186
|
+
- `sidebar.childrenMaxSessions`: `128` (clamped to `0`-`2000`)
|
|
187
|
+
- `sidebar.childrenConcurrency`: `5` (clamped to `1`-`10`)
|
|
188
|
+
|
|
189
|
+
Quota defaults:
|
|
190
|
+
|
|
191
|
+
- `quota.refreshMs`: `300000` (clamped to `>=30000`)
|
|
192
|
+
- `quota.includeOpenAI`: `true`
|
|
193
|
+
- `quota.includeCopilot`: `true`
|
|
194
|
+
- `quota.includeAnthropic`: `true`
|
|
195
|
+
- `quota.providers`: `{}` (per-adapter switches, for example `rightcode.enabled`)
|
|
196
|
+
- `quota.refreshAccessToken`: `false`
|
|
197
|
+
- `quota.requestTimeoutMs`: `8000` (clamped to `>=1000`)
|
|
198
|
+
|
|
199
|
+
Other defaults:
|
|
200
|
+
|
|
201
|
+
- `toast.durationMs`: `12000` (clamped to `>=1000`)
|
|
202
|
+
- `retentionDays`: `730`
|
|
203
|
+
|
|
167
204
|
Example config:
|
|
168
205
|
|
|
169
206
|
```json
|
|
@@ -203,6 +240,7 @@ Example config:
|
|
|
203
240
|
Notes:
|
|
204
241
|
|
|
205
242
|
- `sidebar.showCost` controls API-cost visibility in sidebar title, `quota_summary` markdown report, and toast message.
|
|
243
|
+
- `quota_summary` follows the same reset compaction rules for short windows in its subscription section (`5h` / `1d` / `Daily` show time, long windows show date, RightCode `Exp` stays date-only).
|
|
206
244
|
- `sidebar.width` is measured in terminal cells. CJK/emoji truncation is best-effort to avoid sidebar overflow.
|
|
207
245
|
- `sidebar.multilineTitle` controls multi-line sidebar layout (default: `true`). Set `false` for compact single-line title.
|
|
208
246
|
- `sidebar.wrapQuotaLines` controls quota line wrapping and continuation indentation (default: `true`).
|
|
@@ -215,6 +253,89 @@ Notes:
|
|
|
215
253
|
- `quota.providers` is the extensible per-adapter switch map.
|
|
216
254
|
- 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.
|
|
217
255
|
|
|
256
|
+
## Rendering examples
|
|
257
|
+
|
|
258
|
+
These examples show the quota block portion of the sidebar title.
|
|
259
|
+
|
|
260
|
+
### `sidebar.multilineTitle=true`
|
|
261
|
+
|
|
262
|
+
0 providers (no quota data):
|
|
263
|
+
|
|
264
|
+
```text
|
|
265
|
+
(no quota block)
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
1 provider, 1 window (fits):
|
|
269
|
+
|
|
270
|
+
```text
|
|
271
|
+
Copilot Monthly 78% Rst 04-01
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
1 provider, multi-window (for example OpenAI 5h + Weekly):
|
|
275
|
+
|
|
276
|
+
```text
|
|
277
|
+
OpenAI
|
|
278
|
+
5h 78% Rst 05:05
|
|
279
|
+
Weekly 73% Rst 03-12
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
1 provider, short window crossing into the next day:
|
|
283
|
+
|
|
284
|
+
```text
|
|
285
|
+
Anthropic
|
|
286
|
+
5h 0% Rst 03-10 01:00
|
|
287
|
+
Weekly 46% Rst 03-15
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
2+ providers (even if each provider is single-window):
|
|
291
|
+
|
|
292
|
+
```text
|
|
293
|
+
OpenAI
|
|
294
|
+
5h 78% Rst 05:05
|
|
295
|
+
Copilot
|
|
296
|
+
Monthly 78% Rst 04-01
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
2+ providers mixed (multi-window + single-window):
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
OpenAI
|
|
303
|
+
5h 78% Rst 05:05
|
|
304
|
+
Weekly 73% Rst 03-12
|
|
305
|
+
Copilot
|
|
306
|
+
Monthly 78% Rst 04-01
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Balance-style quota:
|
|
310
|
+
|
|
311
|
+
```text
|
|
312
|
+
RC Balance $260
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Multi-detail quota (window + balance):
|
|
316
|
+
|
|
317
|
+
```text
|
|
318
|
+
RC
|
|
319
|
+
Daily $88.9/$60 Exp 02-27
|
|
320
|
+
Balance $260
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Provider status / quota (examples):
|
|
324
|
+
|
|
325
|
+
```text
|
|
326
|
+
Anthropic 5h 80%+
|
|
327
|
+
Copilot unavailable
|
|
328
|
+
OpenAI Remaining ?
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### `sidebar.multilineTitle=false`
|
|
332
|
+
|
|
333
|
+
Quota is rendered inline as part of a single-line title:
|
|
334
|
+
|
|
335
|
+
```text
|
|
336
|
+
<base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | ...
|
|
337
|
+
```
|
|
338
|
+
|
|
218
339
|
`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.
|
|
219
340
|
|
|
220
341
|
## Debug logging
|
package/dist/events.d.ts
CHANGED
|
@@ -3,6 +3,9 @@ export declare function createEventDispatcher(handlers: {
|
|
|
3
3
|
onSessionCreated: (session: Session) => Promise<void>;
|
|
4
4
|
onSessionUpdated: (session: Session) => Promise<void>;
|
|
5
5
|
onSessionDeleted: (session: Session) => Promise<void>;
|
|
6
|
-
onMessageRemoved: (
|
|
6
|
+
onMessageRemoved: (info: {
|
|
7
|
+
sessionID: string;
|
|
8
|
+
messageID?: string;
|
|
9
|
+
}) => Promise<void>;
|
|
7
10
|
onAssistantMessageCompleted: (message: AssistantMessage) => Promise<void>;
|
|
8
11
|
}): (event: Event) => Promise<void>;
|
package/dist/events.js
CHANGED
|
@@ -16,7 +16,11 @@ export function createEventDispatcher(handlers) {
|
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
if (event.type === 'message.removed') {
|
|
19
|
-
|
|
19
|
+
const props = event.properties;
|
|
20
|
+
await handlers.onMessageRemoved({
|
|
21
|
+
sessionID: props.sessionID,
|
|
22
|
+
messageID: typeof props.messageID === 'string' ? props.messageID : undefined,
|
|
23
|
+
});
|
|
20
24
|
return;
|
|
21
25
|
}
|
|
22
26
|
if (event.type !== 'message.updated')
|
package/dist/format.js
CHANGED
|
@@ -248,6 +248,9 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
|
248
248
|
// Quota lines (one provider per line for stable wrapping)
|
|
249
249
|
if (config.sidebar.showQuota) {
|
|
250
250
|
const visibleQuotas = collapseQuotaSnapshots(quotas).filter((q) => ['ok', 'error', 'unsupported', 'unavailable'].includes(q.status));
|
|
251
|
+
// When multiple providers are visible, keep a consistent visual rhythm by
|
|
252
|
+
// always rendering each provider as a header line + indented detail line(s).
|
|
253
|
+
const forceWrappedProviders = visibleQuotas.length > 1;
|
|
251
254
|
const labelWidth = visibleQuotas.reduce((max, item) => {
|
|
252
255
|
const label = sanitizeLine(quotaDisplayLabel(item));
|
|
253
256
|
return Math.max(max, stringCellWidth(label));
|
|
@@ -256,6 +259,7 @@ export function renderSidebarTitle(baseTitle, usage, quotas, config) {
|
|
|
256
259
|
.flatMap((item) => compactQuotaWide(item, labelWidth, {
|
|
257
260
|
width,
|
|
258
261
|
wrapLines: config.sidebar.wrapQuotaLines,
|
|
262
|
+
forceWrapped: forceWrappedProviders,
|
|
259
263
|
}))
|
|
260
264
|
.filter((s) => Boolean(s));
|
|
261
265
|
if (quotaItems.length > 0) {
|
|
@@ -290,19 +294,22 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
290
294
|
const withLabel = (content) => `${labelPadded} ${content}`;
|
|
291
295
|
const wrap = options?.wrapLines === true && (options?.width || 0) > 0;
|
|
292
296
|
const width = options?.width || 0;
|
|
297
|
+
const forceWrapped = options?.forceWrapped === true;
|
|
293
298
|
/** If inline version overflows, break into label-line + indented detail lines. */
|
|
294
299
|
const maybeBreak = (inlineText, detailLines) => {
|
|
295
300
|
const inline = withLabel(inlineText);
|
|
301
|
+
if (forceWrapped)
|
|
302
|
+
return [label, ...detailLines.map((d) => `${detailIndent}${d}`)];
|
|
296
303
|
if (!wrap || stringCellWidth(inline) <= width)
|
|
297
304
|
return [inline];
|
|
298
305
|
return [label, ...detailLines.map((d) => `${detailIndent}${d}`)];
|
|
299
306
|
};
|
|
300
307
|
if (quota.status === 'error')
|
|
301
|
-
return
|
|
308
|
+
return maybeBreak('Remaining ?', ['Remaining ?']);
|
|
302
309
|
if (quota.status === 'unsupported')
|
|
303
|
-
return
|
|
310
|
+
return maybeBreak('unsupported', ['unsupported']);
|
|
304
311
|
if (quota.status === 'unavailable')
|
|
305
|
-
return
|
|
312
|
+
return maybeBreak('unavailable', ['unavailable']);
|
|
306
313
|
if (quota.status !== 'ok')
|
|
307
314
|
return [];
|
|
308
315
|
const balanceText = quota.balance
|
|
@@ -318,7 +325,7 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
318
325
|
? [sanitizeLine(win.label), pct]
|
|
319
326
|
: [sanitizeLine(win.label)]
|
|
320
327
|
: [pct];
|
|
321
|
-
const reset = compactReset(win.resetAt, win.resetLabel);
|
|
328
|
+
const reset = compactReset(win.resetAt, win.resetLabel, win.label);
|
|
322
329
|
if (reset) {
|
|
323
330
|
parts.push(`${sanitizeLine(win.resetLabel || 'Rst')} ${reset}`);
|
|
324
331
|
}
|
|
@@ -353,7 +360,12 @@ function compactQuotaWide(quota, labelWidth = 0, options) {
|
|
|
353
360
|
const fallbackText = `Remaining ${percent}${reset ? ` Rst ${reset}` : ''}`;
|
|
354
361
|
return maybeBreak(fallbackText, [fallbackText]);
|
|
355
362
|
}
|
|
356
|
-
function
|
|
363
|
+
function isShortResetWindow(label) {
|
|
364
|
+
if (typeof label !== 'string')
|
|
365
|
+
return false;
|
|
366
|
+
return /^\s*(?:\d+\s*[hd]|daily)\b/i.test(label);
|
|
367
|
+
}
|
|
368
|
+
function compactReset(iso, resetLabel, windowLabel) {
|
|
357
369
|
if (!iso)
|
|
358
370
|
return undefined;
|
|
359
371
|
const timestamp = Date.parse(iso);
|
|
@@ -371,6 +383,12 @@ function compactReset(iso, resetLabel) {
|
|
|
371
383
|
value.getMonth() === now.getMonth() &&
|
|
372
384
|
value.getDate() === now.getDate();
|
|
373
385
|
const two = (num) => `${num}`.padStart(2, '0');
|
|
386
|
+
if (isShortResetWindow(windowLabel)) {
|
|
387
|
+
const hhmm = `${two(value.getHours())}:${two(value.getMinutes())}`;
|
|
388
|
+
if (sameDay)
|
|
389
|
+
return hhmm;
|
|
390
|
+
return `${two(value.getMonth() + 1)}-${two(value.getDate())} ${hhmm}`;
|
|
391
|
+
}
|
|
374
392
|
if (sameDay) {
|
|
375
393
|
return `${two(value.getHours())}:${two(value.getMinutes())}`;
|
|
376
394
|
}
|
|
@@ -384,6 +402,12 @@ function dateLine(iso) {
|
|
|
384
402
|
return iso;
|
|
385
403
|
return new Date(time).toLocaleString();
|
|
386
404
|
}
|
|
405
|
+
function reportResetLine(iso, resetLabel, windowLabel) {
|
|
406
|
+
const compact = compactReset(iso, resetLabel, windowLabel);
|
|
407
|
+
if (compact)
|
|
408
|
+
return compact;
|
|
409
|
+
return dateLine(iso);
|
|
410
|
+
}
|
|
387
411
|
function periodLabel(period) {
|
|
388
412
|
if (period === 'day')
|
|
389
413
|
return 'Today';
|
|
@@ -455,13 +479,13 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
455
479
|
return quota.windows.map((win) => {
|
|
456
480
|
if (win.showPercent === false) {
|
|
457
481
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
458
|
-
return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${
|
|
482
|
+
return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
459
483
|
}
|
|
460
484
|
const remaining = win.remainingPercent === undefined
|
|
461
485
|
? '-'
|
|
462
486
|
: `${win.remainingPercent.toFixed(1)}%`;
|
|
463
487
|
const winLabel = win.label ? ` (${win.label})` : '';
|
|
464
|
-
return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${
|
|
488
|
+
return mdCell(`- ${quota.label}${winLabel}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(win.resetAt, win.resetLabel, win.label)}`);
|
|
465
489
|
});
|
|
466
490
|
}
|
|
467
491
|
if (quota.status === 'ok' && quota.balance) {
|
|
@@ -473,7 +497,7 @@ export function renderMarkdownReport(period, usage, quotas, options) {
|
|
|
473
497
|
? '-'
|
|
474
498
|
: `${quota.remainingPercent.toFixed(1)}%`;
|
|
475
499
|
return [
|
|
476
|
-
mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${
|
|
500
|
+
mdCell(`- ${quota.label}: ${quota.status} | remaining ${remaining} | reset ${reportResetLine(quota.resetAt)}${quota.note ? ` | ${quota.note}` : ''}`),
|
|
477
501
|
];
|
|
478
502
|
});
|
|
479
503
|
return [
|
|
@@ -565,7 +589,7 @@ export function renderToastMessage(period, usage, quotas, options) {
|
|
|
565
589
|
const pct = win.remainingPercent === undefined
|
|
566
590
|
? '-'
|
|
567
591
|
: `${win.remainingPercent.toFixed(1)}%`;
|
|
568
|
-
const reset = compactReset(win.resetAt, win.resetLabel);
|
|
592
|
+
const reset = compactReset(win.resetAt, win.resetLabel, win.label);
|
|
569
593
|
const parts = [win.label];
|
|
570
594
|
if (showPercent)
|
|
571
595
|
parts.push(pct);
|
package/dist/index.js
CHANGED
|
@@ -225,9 +225,10 @@ export async function QuotaSidebarPlugin(input) {
|
|
|
225
225
|
titleRefresh.schedule(session.parentID, 0);
|
|
226
226
|
}
|
|
227
227
|
},
|
|
228
|
-
onMessageRemoved: async (
|
|
229
|
-
usageService.markForceRescan(sessionID);
|
|
230
|
-
titleRefresh.schedule(sessionID);
|
|
228
|
+
onMessageRemoved: async (info) => {
|
|
229
|
+
usageService.markForceRescan(info.sessionID);
|
|
230
|
+
titleRefresh.schedule(info.sessionID, 0);
|
|
231
|
+
scheduleParentRefreshIfSafe(info.sessionID, state.sessions[info.sessionID]?.parentID);
|
|
231
232
|
},
|
|
232
233
|
onAssistantMessageCompleted: async (message) => {
|
|
233
234
|
usageService.markSessionDirty(message.sessionID);
|
|
@@ -1,4 +1,109 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { swallow } from '../../helpers.js';
|
|
2
|
+
import { asRecord, configuredProviderEnabled, fetchWithTimeout, normalizePercent, toIso, } from '../common.js';
|
|
3
|
+
const ANTHROPIC_OAUTH_USAGE_BETA = 'oauth-2025-04-20';
|
|
4
|
+
function parseAnthropicWindow(value, label) {
|
|
5
|
+
const win = asRecord(value);
|
|
6
|
+
if (!win)
|
|
7
|
+
return undefined;
|
|
8
|
+
const usedPercent = normalizePercent(win.utilization);
|
|
9
|
+
if (usedPercent === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
const parsed = {
|
|
12
|
+
label,
|
|
13
|
+
usedPercent,
|
|
14
|
+
remainingPercent: Math.max(0, 100 - usedPercent),
|
|
15
|
+
resetAt: toIso(win.resets_at),
|
|
16
|
+
};
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
async function fetchAnthropicQuota({ providerID, auth, config, }) {
|
|
20
|
+
const checkedAt = Date.now();
|
|
21
|
+
const base = {
|
|
22
|
+
providerID,
|
|
23
|
+
adapterID: 'anthropic',
|
|
24
|
+
label: 'Anthropic',
|
|
25
|
+
shortLabel: 'Anthropic',
|
|
26
|
+
sortOrder: 30,
|
|
27
|
+
};
|
|
28
|
+
if (!auth) {
|
|
29
|
+
return {
|
|
30
|
+
...base,
|
|
31
|
+
status: 'unavailable',
|
|
32
|
+
checkedAt,
|
|
33
|
+
note: 'auth not found',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (auth.type !== 'oauth') {
|
|
37
|
+
return {
|
|
38
|
+
...base,
|
|
39
|
+
status: 'unsupported',
|
|
40
|
+
checkedAt,
|
|
41
|
+
note: 'api key auth has no quota endpoint',
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (typeof auth.access !== 'string' || !auth.access) {
|
|
45
|
+
return {
|
|
46
|
+
...base,
|
|
47
|
+
status: 'unavailable',
|
|
48
|
+
checkedAt,
|
|
49
|
+
note: 'missing oauth access token',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const response = await fetchWithTimeout('https://api.anthropic.com/api/oauth/usage', {
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: {
|
|
55
|
+
Accept: 'application/json',
|
|
56
|
+
Authorization: `Bearer ${auth.access}`,
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'User-Agent': 'opencode-quota-sidebar',
|
|
59
|
+
'anthropic-beta': ANTHROPIC_OAUTH_USAGE_BETA,
|
|
60
|
+
},
|
|
61
|
+
}, config.quota.requestTimeoutMs).catch(swallow('fetchAnthropicQuota:usage'));
|
|
62
|
+
if (!response) {
|
|
63
|
+
return {
|
|
64
|
+
...base,
|
|
65
|
+
status: 'error',
|
|
66
|
+
checkedAt,
|
|
67
|
+
note: 'network request failed',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
return {
|
|
72
|
+
...base,
|
|
73
|
+
status: 'error',
|
|
74
|
+
checkedAt,
|
|
75
|
+
note: `http ${response.status}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const payload = await response
|
|
79
|
+
.json()
|
|
80
|
+
.catch(swallow('fetchAnthropicQuota:json'));
|
|
81
|
+
const usage = asRecord(payload);
|
|
82
|
+
if (!usage) {
|
|
83
|
+
return {
|
|
84
|
+
...base,
|
|
85
|
+
status: 'error',
|
|
86
|
+
checkedAt,
|
|
87
|
+
note: 'invalid response',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const windows = [
|
|
91
|
+
parseAnthropicWindow(usage.five_hour, '5h'),
|
|
92
|
+
parseAnthropicWindow(usage.seven_day, 'Weekly'),
|
|
93
|
+
parseAnthropicWindow(usage.seven_day_sonnet, 'Sonnet 7d'),
|
|
94
|
+
].filter((window) => Boolean(window));
|
|
95
|
+
const primary = windows[0];
|
|
96
|
+
return {
|
|
97
|
+
...base,
|
|
98
|
+
status: primary ? 'ok' : 'error',
|
|
99
|
+
checkedAt,
|
|
100
|
+
usedPercent: primary?.usedPercent,
|
|
101
|
+
remainingPercent: primary?.remainingPercent,
|
|
102
|
+
resetAt: primary?.resetAt,
|
|
103
|
+
note: primary ? undefined : 'missing quota fields',
|
|
104
|
+
windows: windows.length > 0 ? windows : undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
2
107
|
export const anthropicAdapter = {
|
|
3
108
|
id: 'anthropic',
|
|
4
109
|
label: 'Anthropic',
|
|
@@ -6,41 +111,5 @@ export const anthropicAdapter = {
|
|
|
6
111
|
sortOrder: 30,
|
|
7
112
|
matchScore: ({ providerID }) => (providerID === 'anthropic' ? 80 : 0),
|
|
8
113
|
isEnabled: (config) => configuredProviderEnabled(config.quota, 'anthropic', config.quota.includeAnthropic),
|
|
9
|
-
fetch:
|
|
10
|
-
const checkedAt = Date.now();
|
|
11
|
-
if (!auth) {
|
|
12
|
-
return {
|
|
13
|
-
providerID,
|
|
14
|
-
adapterID: 'anthropic',
|
|
15
|
-
label: 'Anthropic',
|
|
16
|
-
shortLabel: 'Anthropic',
|
|
17
|
-
sortOrder: 30,
|
|
18
|
-
status: 'unavailable',
|
|
19
|
-
checkedAt,
|
|
20
|
-
note: 'auth not found',
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
if (auth.type === 'api') {
|
|
24
|
-
return {
|
|
25
|
-
providerID,
|
|
26
|
-
adapterID: 'anthropic',
|
|
27
|
-
label: 'Anthropic',
|
|
28
|
-
shortLabel: 'Anthropic',
|
|
29
|
-
sortOrder: 30,
|
|
30
|
-
status: 'unsupported',
|
|
31
|
-
checkedAt,
|
|
32
|
-
note: 'api key has no public quota endpoint',
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
return {
|
|
36
|
-
providerID,
|
|
37
|
-
adapterID: 'anthropic',
|
|
38
|
-
label: 'Anthropic',
|
|
39
|
-
shortLabel: 'Anthropic',
|
|
40
|
-
sortOrder: 30,
|
|
41
|
-
status: 'unsupported',
|
|
42
|
-
checkedAt,
|
|
43
|
-
note: 'oauth quota endpoint is not publicly documented',
|
|
44
|
-
};
|
|
45
|
-
},
|
|
114
|
+
fetch: fetchAnthropicQuota,
|
|
46
115
|
};
|
package/dist/quota_service.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { TtlValueCache } from './cache.js';
|
|
2
|
-
import { swallow } from './helpers.js';
|
|
2
|
+
import { isRecord, swallow } from './helpers.js';
|
|
3
3
|
import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
|
|
4
4
|
export function createQuotaService(deps) {
|
|
5
5
|
const authCache = new TtlValueCache();
|
|
@@ -16,26 +16,34 @@ export function createQuotaService(deps) {
|
|
|
16
16
|
const cached = providerOptionsCache.get();
|
|
17
17
|
if (cached)
|
|
18
18
|
return cached;
|
|
19
|
-
const
|
|
20
|
-
if (!
|
|
19
|
+
const client = deps.client;
|
|
20
|
+
if (!client.config?.providers && !client.provider?.list) {
|
|
21
21
|
return providerOptionsCache.set({}, 30_000);
|
|
22
22
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
// Newer runtimes expose config.providers; older clients may only expose
|
|
24
|
+
// provider.list with a slightly different response shape.
|
|
25
|
+
const response = await (client.config?.providers
|
|
26
|
+
? client.config.providers({
|
|
27
|
+
query: { directory: deps.directory },
|
|
28
|
+
throwOnError: true,
|
|
29
|
+
})
|
|
30
|
+
: client.provider.list({
|
|
31
|
+
query: { directory: deps.directory },
|
|
32
|
+
throwOnError: true,
|
|
33
|
+
}))
|
|
28
34
|
.catch(swallow('getProviderOptionsMap'));
|
|
29
|
-
const data = response &&
|
|
30
|
-
|
|
31
|
-
'data' in response &&
|
|
32
|
-
response.data &&
|
|
33
|
-
typeof response.data === 'object' &&
|
|
34
|
-
'providers' in response.data
|
|
35
|
-
? response.data.providers
|
|
35
|
+
const data = isRecord(response) && isRecord(response.data)
|
|
36
|
+
? response.data
|
|
36
37
|
: undefined;
|
|
37
|
-
const
|
|
38
|
-
? data.
|
|
38
|
+
const list = Array.isArray(data?.providers)
|
|
39
|
+
? data.providers
|
|
40
|
+
: Array.isArray(data?.all)
|
|
41
|
+
? data.all
|
|
42
|
+
: Array.isArray(data)
|
|
43
|
+
? data
|
|
44
|
+
: undefined;
|
|
45
|
+
const map = Array.isArray(list)
|
|
46
|
+
? list.reduce((acc, item) => {
|
|
39
47
|
if (!item || typeof item !== 'object')
|
|
40
48
|
return acc;
|
|
41
49
|
const record = item;
|