@leo000001/opencode-quota-sidebar 1.13.2 → 1.13.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 +25 -0
- package/CONTRIBUTING.md +138 -102
- package/README.md +95 -5
- package/SECURITY.md +5 -0
- package/dist/format.js +1 -1
- package/dist/providers/index.d.ts +2 -1
- package/dist/providers/index.js +3 -1
- package/dist/providers/third_party/buzz.d.ts +2 -0
- package/dist/providers/third_party/buzz.js +156 -0
- package/dist/quota_service.js +64 -2
- package/package.json +1 -1
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
|
-
##
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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';
|
package/dist/providers/index.js
CHANGED
|
@@ -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,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: '¥',
|
|
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
|
+
};
|
package/dist/quota_service.js
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
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];
|