@leo000001/opencode-quota-sidebar 1.13.1 → 1.13.3

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 CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Add Buzz API balance support for OpenAI-compatible providers that use a Buzz `baseURL`.
6
+ - Document Buzz configuration, rendering, and outbound billing endpoints.
7
+
8
+ ## 1.13.2
9
+
10
+ - Publish Anthropic quota fixes and cache invalidation updates.
11
+ - Keep npm release aligned with the current `dist/` output.
12
+
13
+ ## 1.13.1
14
+
15
+ - Invalidate legacy cached Anthropic `unsupported` snapshots so OAuth quota can refetch correctly.
16
+ - Accept both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
17
+
18
+ ## 1.13.0
19
+
20
+ - Add Anthropic Claude OAuth quota support via `GET https://api.anthropic.com/api/oauth/usage`.
21
+ - Show precise reset times for short quota windows (`5h`, `1d`, `Daily`) while keeping long windows date-only.
22
+ - Expand rendering and quota tests for multi-window output and reset formatting.
23
+
24
+ ## 1.12.0
25
+
26
+ - Add default configuration examples and rendering examples for sidebar/quota display.
27
+
3
28
  ## 1.0.0
4
29
 
5
30
  Initial release.
package/CONTRIBUTING.md CHANGED
@@ -1,102 +1,138 @@
1
- # Contributing
2
-
3
- Thanks for contributing to opencode-quota-sidebar.
4
-
5
- The plugin now uses a provider adapter registry, so adding a new provider does not require editing core dispatch logic.
6
-
7
- ## Architecture overview
8
-
9
- - Provider adapters live in `src/providers/`
10
- - Built-in adapters live in `src/providers/core/`
11
- - Third-party/community adapters live in `src/providers/third_party/`
12
- - `src/providers/registry.ts` resolves which adapter handles a provider
13
- - `src/quota.ts` provides `createQuotaRuntime()`; runtime methods delegate to resolved adapter and manage auth/cache glue
14
- - `src/format.ts` renders generic sidebar/report output from `QuotaSnapshot`
15
-
16
- ## Add a new provider
17
-
18
- ### 1) Create an adapter file
19
-
20
- Add `src/providers/third_party/<your-provider>.ts` and implement `QuotaProviderAdapter`.
21
-
22
- Minimal shape:
23
-
24
- ```ts
25
- import type { QuotaProviderAdapter } from './types.js'
26
-
27
- export const myProviderAdapter: QuotaProviderAdapter = {
28
- id: 'my-provider',
29
- label: 'My Provider',
30
- shortLabel: 'MyProv',
31
- sortOrder: 40,
32
- matchScore: ({ providerID, providerOptions }) => {
33
- if (providerID === 'my-provider') return 80
34
- if (providerOptions?.baseURL === 'https://api.example.com') return 100
35
- return 0
36
- },
37
- isEnabled: (config) =>
38
- config.quota.providers?.['my-provider']?.enabled ?? true,
39
- fetch: async ({ providerID, auth, config }) => {
40
- const checkedAt = Date.now()
41
- // ... call endpoint, parse payload, return QuotaSnapshot
42
- return {
43
- providerID,
44
- adapterID: 'my-provider',
45
- label: 'My Provider',
46
- shortLabel: 'MyProv',
47
- sortOrder: 40,
48
- status: 'ok',
49
- checkedAt,
50
- remainingPercent: 80,
51
- }
52
- },
53
- }
54
- ```
55
-
56
- ### 2) Register the adapter
57
-
58
- Edit `src/providers/index.ts` and add `registry.register(myProviderAdapter)`.
59
-
60
- If your provider has ID variants, implement `normalizeID` in the adapter.
61
-
62
- ### 3) Optional config toggle
63
-
64
- Add to user config:
65
-
66
- ```json
67
- {
68
- "quota": {
69
- "providers": {
70
- "my-provider": { "enabled": true }
71
- }
72
- }
73
- }
74
- ```
75
-
76
- ### 4) Add tests
77
-
78
- At minimum:
79
-
80
- - adapter selection (`matchScore`)
81
- - successful response parsing
82
- - error/unavailable paths
83
- - format output if using special fields (e.g. `balance`)
84
-
85
- ## QuotaSnapshot rules
86
-
87
- - Always fill `status` (`ok`, `error`, `unavailable`, `unsupported`)
88
- - Keep `providerID` as runtime provider identity
89
- - Set `adapterID`, `label`, `shortLabel`, `sortOrder`
90
- - Use `windows` for percent-based quota windows
91
- - Use `balance` for balance-based providers
92
- - Never throw in adapter `fetch`; return an error snapshot instead
93
-
94
- ## Development workflow
95
-
96
- ```bash
97
- npm install
98
- npm run build
99
- npm test
100
- ```
101
-
102
- Both build and tests must pass before submitting a PR.
1
+ # Contributing
2
+
3
+ Thanks for contributing to opencode-quota-sidebar.
4
+
5
+ The plugin now uses a provider adapter registry, so adding a new provider does not require editing core dispatch logic.
6
+
7
+ ## Architecture overview
8
+
9
+ - Provider adapters live in `src/providers/`
10
+ - Built-in adapters live in `src/providers/core/`
11
+ - Third-party/community adapters live in `src/providers/third_party/`
12
+ - `src/providers/registry.ts` resolves which adapter handles a provider
13
+ - `src/quota.ts` provides `createQuotaRuntime()`; runtime methods delegate to resolved adapter and manage auth/cache glue
14
+ - `src/format.ts` renders generic sidebar/report output from `QuotaSnapshot`
15
+
16
+ ## Common adapter patterns
17
+
18
+ - Direct provider ID match: best for first-party providers with stable IDs
19
+ - `baseURL` match: best for OpenAI-compatible relays such as RightCode or Buzz
20
+ - Prefix/variant normalization: best when one provider has multiple runtime IDs
21
+ - Balance-only providers should prefer `balance` over inventing fake percent windows
22
+
23
+ ## Add a new provider
24
+
25
+ ### 1) Create an adapter file
26
+
27
+ Add `src/providers/third_party/<your-provider>.ts` and implement `QuotaProviderAdapter`.
28
+
29
+ Minimal shape:
30
+
31
+ ```ts
32
+ import type { QuotaProviderAdapter } from './types.js'
33
+
34
+ export const myProviderAdapter: QuotaProviderAdapter = {
35
+ id: 'my-provider',
36
+ label: 'My Provider',
37
+ shortLabel: 'MyProv',
38
+ sortOrder: 40,
39
+ matchScore: ({ providerID, providerOptions }) => {
40
+ if (providerID === 'my-provider') return 80
41
+ if (providerOptions?.baseURL === 'https://api.example.com') return 100
42
+ return 0
43
+ },
44
+ isEnabled: (config) =>
45
+ config.quota.providers?.['my-provider']?.enabled ?? true,
46
+ fetch: async ({ providerID, auth, config }) => {
47
+ const checkedAt = Date.now()
48
+ // ... call endpoint, parse payload, return QuotaSnapshot
49
+ return {
50
+ providerID,
51
+ adapterID: 'my-provider',
52
+ label: 'My Provider',
53
+ shortLabel: 'MyProv',
54
+ sortOrder: 40,
55
+ status: 'ok',
56
+ checkedAt,
57
+ remainingPercent: 80,
58
+ }
59
+ },
60
+ }
61
+ ```
62
+
63
+ ### 2) Register the adapter
64
+
65
+ Edit `src/providers/index.ts` and add `registry.register(myProviderAdapter)`.
66
+
67
+ If your provider has ID variants, implement `normalizeID` in the adapter.
68
+
69
+ If your provider is an OpenAI-compatible relay, prefer matching on
70
+ `providerOptions.baseURL` instead of the runtime `providerID`; that keeps custom
71
+ aliases working without extra user config.
72
+
73
+ If the new provider should appear in default `quota_summary` reports even when
74
+ it has not yet been used in the current session, also update
75
+ `listDefaultQuotaProviderIDs()` in `src/quota.ts`.
76
+
77
+ ### 3) Optional config toggle
78
+
79
+ Add to user config:
80
+
81
+ ```json
82
+ {
83
+ "quota": {
84
+ "providers": {
85
+ "my-provider": { "enabled": true }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ ### 4) Add tests
92
+
93
+ At minimum:
94
+
95
+ - adapter selection (`matchScore`)
96
+ - successful response parsing
97
+ - error/unavailable paths
98
+ - format output if using special fields (e.g. `balance`)
99
+ - cache compatibility if the change replaces an older snapshot shape
100
+ - mixed-provider rendering if the new provider will commonly appear next to
101
+ OpenAI/Copilot/RightCode in sidebar or toast output
102
+
103
+ If the provider introduces new rendering rules or multi-window behavior, add
104
+ coverage in both `src/__tests__/quota.test.ts` and `src/__tests__/format.test.ts`.
105
+
106
+ ## QuotaSnapshot rules
107
+
108
+ - Always fill `status` (`ok`, `error`, `unavailable`, `unsupported`)
109
+ - Keep `providerID` as runtime provider identity
110
+ - Set `adapterID`, `label`, `shortLabel`, `sortOrder`
111
+ - Use `windows` for percent-based quota windows
112
+ - Use `balance` for balance-based providers
113
+ - Never throw in adapter `fetch`; return an error snapshot instead
114
+
115
+ ## Development workflow
116
+
117
+ ```bash
118
+ npm install
119
+ npm run build
120
+ npm test
121
+ ```
122
+
123
+ Both build and tests must pass before submitting a PR.
124
+
125
+ If you change TypeScript types, config loading, or public behavior, also run:
126
+
127
+ ```bash
128
+ npm run typecheck
129
+ ```
130
+
131
+ ## Documentation checklist
132
+
133
+ When a change affects users, update the relevant docs in the same PR:
134
+
135
+ - `README.md` for install, config, behavior, examples, or troubleshooting
136
+ - `CONTRIBUTING.md` if the change affects adapter patterns or provider authoring guidance
137
+ - `CHANGELOG.md` for released user-facing changes
138
+ - `SECURITY.md` if the change affects auth handling, external requests, or data storage
package/README.md CHANGED
@@ -13,13 +13,15 @@ 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"]
16
+ "plugin": ["@leo000001/opencode-quota-sidebar@1.13.2"]
17
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: []`).
21
21
  This plugin also accepts both `config.providers` and older `provider.list` runtime shapes when discovering provider options.
22
22
 
23
+ If you prefer automatic upgrades, you can still use `@latest`, but pinning an exact version makes behavior easier to reproduce when debugging.
24
+
23
25
  ## Development (build from source)
24
26
 
25
27
  ```bash
@@ -44,6 +46,7 @@ On Windows, use forward slashes: `"file:///D:/Lab/opencode-quota-sidebar/dist/in
44
46
  | OpenAI Codex | `chatgpt.com/backend-api/wham/usage` | OAuth (ChatGPT) | Multi-window (short-term + weekly) |
45
47
  | GitHub Copilot | `api.github.com/copilot_internal/user` | OAuth | Monthly quota |
46
48
  | RightCode | `www.right.codes/account/summary` | API key | Subscription or balance (by prefix) |
49
+ | Buzz | `buzzai.cc/v1/dashboard/billing/*` | API key | Balance only (computed from total-used) |
47
50
  | Anthropic | `api.anthropic.com/api/oauth/usage` | OAuth | Multi-window (5h + weekly / plan-based) |
48
51
 
49
52
  Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware AI, etc.)? See [CONTRIBUTING.md](CONTRIBUTING.md).
@@ -68,6 +71,7 @@ Want to add support for another provider (Google Antigravity, Zhipu AI, Firmware
68
71
  - OpenAI Codex OAuth (`/backend-api/wham/usage`)
69
72
  - GitHub Copilot OAuth (`/copilot_internal/user`)
70
73
  - RightCode API key (`/account/summary`)
74
+ - Buzz API key (`/v1/dashboard/billing/subscription` + `/v1/dashboard/billing/usage`)
71
75
  - Anthropic Claude OAuth (`/api/oauth/usage`, with beta header)
72
76
  - OpenAI OAuth quota checks auto-refresh expired access token (using refresh token)
73
77
  - API key providers still show usage aggregation (quota only applies to subscription providers)
@@ -110,6 +114,37 @@ memory on startup. Chunk files remain on disk for historical range scans.
110
114
  - OpenCode: plugin SDK `@opencode-ai/plugin` ^1.2.10
111
115
  - OpenCode config split: if you are on `>=1.2.15`, keep this plugin in `opencode.json` and keep TUI-only keys in `tui.json`.
112
116
 
117
+ ## Force refresh after npm update
118
+
119
+ If `npm view @leo000001/opencode-quota-sidebar version` shows a newer version but OpenCode still behaves like an older release, OpenCode/Bun is usually reusing an older installed copy.
120
+
121
+ Recommended recovery steps:
122
+
123
+ 1. Pin the target plugin version in `opencode.json`.
124
+ 2. Fully exit OpenCode.
125
+ 3. Delete any cached installed copies of the plugin.
126
+ 4. Start OpenCode again so it reinstalls the package.
127
+ 5. Verify the actual installed `package.json` version under the plugin directory.
128
+
129
+ Common install/cache locations:
130
+
131
+ - `~/.cache/opencode/node_modules/@leo000001/opencode-quota-sidebar`
132
+ - `~/node_modules/@leo000001/opencode-quota-sidebar`
133
+
134
+ Windows PowerShell example:
135
+
136
+ ```powershell
137
+ Remove-Item -Recurse -Force "$HOME\.cache\opencode\node_modules\@leo000001\opencode-quota-sidebar" -ErrorAction SilentlyContinue
138
+ Remove-Item -Recurse -Force "$HOME\node_modules\@leo000001\opencode-quota-sidebar" -ErrorAction SilentlyContinue
139
+ ```
140
+
141
+ macOS / Linux example:
142
+
143
+ ```bash
144
+ rm -rf ~/.cache/opencode/node_modules/@leo000001/opencode-quota-sidebar
145
+ rm -rf ~/node_modules/@leo000001/opencode-quota-sidebar
146
+ ```
147
+
113
148
  ## Optional commands
114
149
 
115
150
  You can add these command templates in `opencode.json` so you can run `/qday`, `/qweek`, `/qmonth`, `/qtoggle`:
@@ -169,10 +204,12 @@ Resolution order (low -> high):
169
204
 
170
205
  Values are layered; later sources override earlier ones.
171
206
 
172
- ## Defaults
207
+ ## Configuration
173
208
 
174
209
  If you do not provide any config file, the plugin uses the built-in defaults below.
175
210
 
211
+ ### Built-in defaults
212
+
176
213
  Sidebar defaults:
177
214
 
178
215
  - `sidebar.enabled`: `true`
@@ -192,7 +229,7 @@ Quota defaults:
192
229
  - `quota.includeOpenAI`: `true`
193
230
  - `quota.includeCopilot`: `true`
194
231
  - `quota.includeAnthropic`: `true`
195
- - `quota.providers`: `{}` (per-adapter switches, for example `rightcode.enabled`)
232
+ - `quota.providers`: `{}` (per-adapter switches, for example `rightcode.enabled` or `buzz.enabled`)
196
233
  - `quota.refreshAccessToken`: `false`
197
234
  - `quota.requestTimeoutMs`: `8000` (clamped to `>=1000`)
198
235
 
@@ -201,7 +238,7 @@ Other defaults:
201
238
  - `toast.durationMs`: `12000` (clamped to `>=1000`)
202
239
  - `retentionDays`: `730`
203
240
 
204
- Example config:
241
+ ### Full example config
205
242
 
206
243
  ```json
207
244
  {
@@ -223,6 +260,9 @@ Example config:
223
260
  "includeCopilot": true,
224
261
  "includeAnthropic": true,
225
262
  "providers": {
263
+ "buzz": {
264
+ "enabled": true
265
+ },
226
266
  "rightcode": {
227
267
  "enabled": true
228
268
  }
@@ -237,7 +277,7 @@ Example config:
237
277
  }
238
278
  ```
239
279
 
240
- Notes:
280
+ ### Notes
241
281
 
242
282
  - `sidebar.showCost` controls API-cost visibility in sidebar title, `quota_summary` markdown report, and toast message.
243
283
  - `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).
@@ -253,6 +293,30 @@ Notes:
253
293
  - `quota.providers` is the extensible per-adapter switch map.
254
294
  - 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.
255
295
 
296
+ ### Buzz provider example
297
+
298
+ Buzz matching is based on the provider `baseURL`, similar to RightCode. Any OpenAI-compatible provider that points at `https://buzzai.cc` will be recognized by the Buzz adapter and rendered as a balance-only quota source.
299
+
300
+ Provider options example:
301
+
302
+ ```json
303
+ {
304
+ "id": "openai",
305
+ "options": {
306
+ "baseURL": "https://buzzai.cc",
307
+ "apiKey": "sk-..."
308
+ }
309
+ }
310
+ ```
311
+
312
+ The adapter also tolerates `https://buzzai.cc/v1`, but `https://buzzai.cc` is the recommended example.
313
+
314
+ With that setup, the sidebar/toast quota line will look like:
315
+
316
+ ```text
317
+ Buzz Balance CNY 10.17
318
+ ```
319
+
256
320
  ## Rendering examples
257
321
 
258
322
  These examples show the quota block portion of the sidebar title.
@@ -306,12 +370,28 @@ Copilot
306
370
  Monthly 78% Rst 04-01
307
371
  ```
308
372
 
373
+ 2+ providers mixed (window providers + Buzz balance):
374
+
375
+ ```text
376
+ OpenAI
377
+ 5h 78% Rst 05:05
378
+ Copilot
379
+ Monthly 78% Rst 04-01
380
+ Buzz Balance CNY 10.2
381
+ ```
382
+
309
383
  Balance-style quota:
310
384
 
311
385
  ```text
312
386
  RC Balance $260
313
387
  ```
314
388
 
389
+ Buzz balance quota:
390
+
391
+ ```text
392
+ Buzz Balance CNY 10.17
393
+ ```
394
+
315
395
  Multi-detail quota (window + balance):
316
396
 
317
397
  ```text
@@ -336,6 +416,12 @@ Quota is rendered inline as part of a single-line title:
336
416
  <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | ...
337
417
  ```
338
418
 
419
+ Mixed with Buzz balance:
420
+
421
+ ```text
422
+ <base> | Input ... | Output ... | OpenAI 5h 78%+ | Copilot Monthly 78% | Buzz Balance CNY 10.2
423
+ ```
424
+
339
425
  `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.
340
426
 
341
427
  ## Debug logging
@@ -354,6 +440,8 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
354
440
  - OpenAI Codex: `https://chatgpt.com/backend-api/wham/usage`
355
441
  - GitHub Copilot: `https://api.github.com/copilot_internal/user`
356
442
  - RightCode: `https://www.right.codes/account/summary`
443
+ - Buzz: `https://buzzai.cc/v1/dashboard/billing/subscription` and `https://buzzai.cc/v1/dashboard/billing/usage`
444
+ - Anthropic: `https://api.anthropic.com/api/oauth/usage`
357
445
  - **Screen-sharing warning**: Session titles and toasts surface usage/quota
358
446
  information. If you are screen-sharing or recording, consider toggling the
359
447
  sidebar display off (`/qtoggle` or `quota_show` tool) to avoid leaking
@@ -363,6 +451,8 @@ Set `OPENCODE_QUOTA_DEBUG=1` to enable debug logging to stderr. This logs:
363
451
  - OpenAI OAuth token refresh is disabled by default; set
364
452
  `quota.refreshAccessToken=true` if you want the plugin to refresh access
365
453
  tokens when expired.
454
+ - Anthropic quota currently uses a beta/internal-style OAuth usage endpoint and
455
+ request header; response fields may change without notice.
366
456
  - State/chunk file writes refuse to write through symlinked targets (best-effort defense-in-depth).
367
457
  - The `OPENCODE_QUOTA_DATA_HOME` env var overrides the OpenCode data directory
368
458
  path (for testing); do not set this in production.
package/SECURITY.md CHANGED
@@ -24,3 +24,8 @@ We will acknowledge reports as quickly as possible and provide a remediation tim
24
24
  - This plugin reads credentials from OpenCode local auth storage; treat all auth data as sensitive.
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
+ - Quota fetching may contact provider-operated endpoints such as OpenAI, GitHub,
28
+ RightCode, Buzz, and Anthropic; review any new provider integration for
29
+ outbound data exposure and header/token handling.
30
+ - Some quota integrations rely on beta or internal-style endpoints; document
31
+ instability risks clearly and avoid assuming long-term API compatibility.
package/dist/format.js CHANGED
@@ -181,7 +181,7 @@ function compactQuotaInline(quota) {
181
181
  return `${label}${summary ? ` ${summary}` : ''}${hasMore ? '+' : ''}`;
182
182
  }
183
183
  if (quota.balance) {
184
- return `${label} ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
184
+ return `${label} Balance ${formatCurrency(quota.balance.amount, quota.balance.currency)}`;
185
185
  }
186
186
  if (quota.remainingPercent !== undefined) {
187
187
  return `${label} ${Math.round(quota.remainingPercent)}%`;
@@ -1,8 +1,9 @@
1
1
  import { anthropicAdapter } from './core/anthropic.js';
2
+ import { buzzAdapter } from './third_party/buzz.js';
2
3
  import { copilotAdapter } from './core/copilot.js';
3
4
  import { openaiAdapter } from './core/openai.js';
4
5
  import { QuotaProviderRegistry } from './registry.js';
5
6
  import { rightCodeAdapter } from './third_party/rightcode.js';
6
7
  export declare function createDefaultProviderRegistry(): QuotaProviderRegistry;
7
- export { anthropicAdapter, copilotAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
8
+ export { anthropicAdapter, buzzAdapter, copilotAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
8
9
  export type { AuthUpdate, AuthValue, ProviderResolveContext, QuotaFetchContext, QuotaProviderAdapter, RefreshedOAuthAuth, } from './types.js';
@@ -1,4 +1,5 @@
1
1
  import { anthropicAdapter } from './core/anthropic.js';
2
+ import { buzzAdapter } from './third_party/buzz.js';
2
3
  import { copilotAdapter } from './core/copilot.js';
3
4
  import { openaiAdapter } from './core/openai.js';
4
5
  import { QuotaProviderRegistry } from './registry.js';
@@ -6,9 +7,10 @@ import { rightCodeAdapter } from './third_party/rightcode.js';
6
7
  export function createDefaultProviderRegistry() {
7
8
  const registry = new QuotaProviderRegistry();
8
9
  registry.register(rightCodeAdapter);
10
+ registry.register(buzzAdapter);
9
11
  registry.register(openaiAdapter);
10
12
  registry.register(copilotAdapter);
11
13
  registry.register(anthropicAdapter);
12
14
  return registry;
13
15
  }
14
- export { anthropicAdapter, copilotAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
16
+ export { anthropicAdapter, buzzAdapter, copilotAdapter, openaiAdapter, rightCodeAdapter, QuotaProviderRegistry, };
@@ -0,0 +1,2 @@
1
+ import type { QuotaProviderAdapter } from '../types.js';
2
+ export declare const buzzAdapter: QuotaProviderAdapter;
@@ -0,0 +1,156 @@
1
+ import { isRecord, swallow } from '../../helpers.js';
2
+ import { asNumber, configuredProviderEnabled, fetchWithTimeout, sanitizeBaseURL, toIso, } from '../common.js';
3
+ function isBuzzBaseURL(value) {
4
+ const normalized = sanitizeBaseURL(value);
5
+ if (!normalized)
6
+ return false;
7
+ try {
8
+ const parsed = new URL(normalized);
9
+ if (parsed.protocol !== 'https:')
10
+ return false;
11
+ return parsed.host === 'buzzai.cc' || parsed.host === 'www.buzzai.cc';
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ function resolveApiKey(auth, providerOptions) {
18
+ const optionKey = providerOptions?.apiKey;
19
+ if (typeof optionKey === 'string' && optionKey)
20
+ return optionKey;
21
+ if (!auth)
22
+ return undefined;
23
+ if (auth.type === 'api' && typeof auth.key === 'string' && auth.key) {
24
+ return auth.key;
25
+ }
26
+ if (auth.type === 'wellknown') {
27
+ if (typeof auth.key === 'string' && auth.key)
28
+ return auth.key;
29
+ if (typeof auth.token === 'string' && auth.token)
30
+ return auth.token;
31
+ }
32
+ if (auth.type === 'oauth' && typeof auth.access === 'string' && auth.access) {
33
+ return auth.access;
34
+ }
35
+ return undefined;
36
+ }
37
+ function dashboardUrl(baseURL, pathname) {
38
+ const normalized = sanitizeBaseURL(baseURL);
39
+ if (normalized) {
40
+ try {
41
+ return new URL(pathname, normalized).toString();
42
+ }
43
+ catch {
44
+ // Fall through to the stable default host below.
45
+ }
46
+ }
47
+ return `https://buzzai.cc${pathname}`;
48
+ }
49
+ async function fetchBuzzQuota({ sourceProviderID, providerID, providerOptions, auth, config, }) {
50
+ const checkedAt = Date.now();
51
+ const runtimeProviderID = typeof sourceProviderID === 'string' && sourceProviderID
52
+ ? sourceProviderID
53
+ : providerID;
54
+ const base = {
55
+ providerID: runtimeProviderID,
56
+ adapterID: 'buzz',
57
+ label: 'Buzz',
58
+ shortLabel: 'Buzz',
59
+ sortOrder: 6,
60
+ };
61
+ const apiKey = resolveApiKey(auth, providerOptions);
62
+ if (!apiKey) {
63
+ return {
64
+ ...base,
65
+ status: 'unavailable',
66
+ checkedAt,
67
+ note: 'missing api key',
68
+ };
69
+ }
70
+ const subscriptionEndpoint = dashboardUrl(providerOptions?.baseURL, '/v1/dashboard/billing/subscription');
71
+ const usageEndpoint = dashboardUrl(providerOptions?.baseURL, '/v1/dashboard/billing/usage');
72
+ const requestInit = {
73
+ headers: {
74
+ Accept: 'application/json',
75
+ Authorization: `Bearer ${apiKey}`,
76
+ 'User-Agent': 'opencode-quota-sidebar',
77
+ },
78
+ };
79
+ const [subscriptionResponse, usageResponse] = await Promise.all([
80
+ fetchWithTimeout(subscriptionEndpoint, requestInit, config.quota.requestTimeoutMs).catch(swallow('fetchBuzzQuota:subscription')),
81
+ fetchWithTimeout(usageEndpoint, requestInit, config.quota.requestTimeoutMs).catch(swallow('fetchBuzzQuota:usage')),
82
+ ]);
83
+ if (!subscriptionResponse || !usageResponse) {
84
+ return {
85
+ ...base,
86
+ status: 'error',
87
+ checkedAt,
88
+ note: 'network request failed',
89
+ };
90
+ }
91
+ if (!subscriptionResponse.ok || !usageResponse.ok) {
92
+ const note = [
93
+ !subscriptionResponse.ok
94
+ ? `subscription http ${subscriptionResponse.status}`
95
+ : undefined,
96
+ !usageResponse.ok ? `usage http ${usageResponse.status}` : undefined,
97
+ ]
98
+ .filter((value) => Boolean(value))
99
+ .join(', ');
100
+ return {
101
+ ...base,
102
+ status: 'error',
103
+ checkedAt,
104
+ note,
105
+ };
106
+ }
107
+ const [subscriptionPayload, usagePayload] = await Promise.all([
108
+ subscriptionResponse
109
+ .json()
110
+ .catch(swallow('fetchBuzzQuota:subscriptionJson')),
111
+ usageResponse.json().catch(swallow('fetchBuzzQuota:usageJson')),
112
+ ]);
113
+ if (!isRecord(subscriptionPayload) || !isRecord(usagePayload)) {
114
+ return {
115
+ ...base,
116
+ status: 'error',
117
+ checkedAt,
118
+ note: 'invalid response',
119
+ };
120
+ }
121
+ const totalQuota = asNumber(subscriptionPayload.soft_limit_usd);
122
+ const totalUsage = asNumber(usagePayload.total_usage);
123
+ if (totalQuota === undefined || totalUsage === undefined) {
124
+ return {
125
+ ...base,
126
+ status: 'error',
127
+ checkedAt,
128
+ note: 'missing billing fields',
129
+ };
130
+ }
131
+ const accessUntil = asNumber(subscriptionPayload.access_until);
132
+ const resetAt = accessUntil !== undefined && accessUntil > 0
133
+ ? toIso(accessUntil)
134
+ : undefined;
135
+ const balance = totalQuota - totalUsage / 100;
136
+ return {
137
+ ...base,
138
+ status: 'ok',
139
+ checkedAt,
140
+ resetAt,
141
+ balance: {
142
+ amount: balance,
143
+ currency: 'CNY ',
144
+ },
145
+ note: 'remaining balance = soft_limit_usd - total_usage / 100',
146
+ };
147
+ }
148
+ export const buzzAdapter = {
149
+ id: 'buzz',
150
+ label: 'Buzz',
151
+ shortLabel: 'Buzz',
152
+ sortOrder: 6,
153
+ matchScore: ({ providerOptions }) => isBuzzBaseURL(providerOptions?.baseURL) ? 100 : 0,
154
+ isEnabled: (config) => configuredProviderEnabled(config.quota, 'buzz', true),
155
+ fetch: fetchBuzzQuota,
156
+ };
@@ -2,6 +2,11 @@ import { TtlValueCache } from './cache.js';
2
2
  import { isRecord, swallow } from './helpers.js';
3
3
  import { listDefaultQuotaProviderIDs, loadAuthMap, quotaSort } from './quota.js';
4
4
  export function createQuotaService(deps) {
5
+ const ERROR_CACHE_TTL_MS = 30_000;
6
+ const ZERO_QUOTA_CACHE_TTL_MS = 15_000;
7
+ const LOW_QUOTA_CACHE_TTL_MS = 30_000;
8
+ const SOON_RESET_CACHE_TTL_MS = 15_000;
9
+ const SOON_RESET_WINDOW_MS = 2 * 60 * 1000;
5
10
  const authCache = new TtlValueCache();
6
11
  const providerOptionsCache = new TtlValueCache();
7
12
  const inFlight = new Map();
@@ -75,6 +80,62 @@ export function createQuotaService(deps) {
75
80
  return false;
76
81
  return true;
77
82
  };
83
+ const parseResetAtMs = (value) => {
84
+ if (!value)
85
+ return undefined;
86
+ const parsed = Date.parse(value);
87
+ return Number.isNaN(parsed) ? undefined : parsed;
88
+ };
89
+ const snapshotRemainingPercents = (snapshot) => {
90
+ const values = [];
91
+ if (typeof snapshot.remainingPercent === 'number' &&
92
+ Number.isFinite(snapshot.remainingPercent)) {
93
+ values.push(snapshot.remainingPercent);
94
+ }
95
+ if (snapshot.windows && snapshot.windows.length > 0) {
96
+ for (const window of snapshot.windows) {
97
+ if (typeof window.remainingPercent === 'number' &&
98
+ Number.isFinite(window.remainingPercent)) {
99
+ values.push(window.remainingPercent);
100
+ }
101
+ }
102
+ }
103
+ return values;
104
+ };
105
+ const snapshotResetTimes = (snapshot) => {
106
+ const values = [];
107
+ const topLevel = parseResetAtMs(snapshot.resetAt);
108
+ if (topLevel !== undefined)
109
+ values.push(topLevel);
110
+ if (snapshot.windows && snapshot.windows.length > 0) {
111
+ for (const window of snapshot.windows) {
112
+ const parsed = parseResetAtMs(window.resetAt);
113
+ if (parsed !== undefined)
114
+ values.push(parsed);
115
+ }
116
+ }
117
+ return values;
118
+ };
119
+ const effectiveQuotaCacheTtl = (snapshot, now = Date.now()) => {
120
+ let ttlMs = deps.config.quota.refreshMs;
121
+ if (snapshot.status !== 'ok') {
122
+ ttlMs = Math.min(ttlMs, ERROR_CACHE_TTL_MS);
123
+ }
124
+ const remainingPercents = snapshotRemainingPercents(snapshot);
125
+ if (remainingPercents.some((value) => value <= 0)) {
126
+ ttlMs = Math.min(ttlMs, ZERO_QUOTA_CACHE_TTL_MS);
127
+ }
128
+ else if (remainingPercents.some((value) => value <= 1)) {
129
+ ttlMs = Math.min(ttlMs, LOW_QUOTA_CACHE_TTL_MS);
130
+ }
131
+ const resetTimes = snapshotResetTimes(snapshot);
132
+ if (resetTimes.some((resetAt) => resetAt <= now))
133
+ return 0;
134
+ if (resetTimes.some((resetAt) => resetAt - now <= SOON_RESET_WINDOW_MS)) {
135
+ ttlMs = Math.min(ttlMs, SOON_RESET_CACHE_TTL_MS);
136
+ }
137
+ return Math.max(0, ttlMs);
138
+ };
78
139
  const getQuotaSnapshots = async (providerIDs, options) => {
79
140
  const allowDefault = options?.allowDefault === true;
80
141
  const [authMap, providerOptionsMap] = await Promise.all([
@@ -148,8 +209,9 @@ export function createQuotaService(deps) {
148
209
  const baseKey = deps.quotaRuntime.quotaCacheKey(providerID, providerOptions);
149
210
  const cacheKey = `${baseKey}#${authScopeFor(providerID, providerOptions)}`;
150
211
  const cached = deps.state.quotaCache[cacheKey];
151
- if (cached &&
152
- Date.now() - cached.checkedAt <= deps.config.quota.refreshMs) {
212
+ const now = Date.now();
213
+ const cacheTtl = cached ? effectiveQuotaCacheTtl(cached, now) : 0;
214
+ if (cached && cacheTtl > 0 && now - cached.checkedAt <= cacheTtl) {
153
215
  if (isValidQuotaCache(cached))
154
216
  return Promise.resolve(cached);
155
217
  delete deps.state.quotaCache[cacheKey];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leo000001/opencode-quota-sidebar",
3
- "version": "1.13.1",
3
+ "version": "1.13.3",
4
4
  "description": "OpenCode plugin that shows quota and token usage in session titles",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",