dsh-multi-provider-usage 0.0.0-stage → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-multi-provider-usage contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,381 @@
1
- # Temporary Holding Version
1
+ # dsh-multi-provider-usage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [DSH](https://github.com/deepseek-ai/deepseek-harness) plugin that shows the
4
+ quotas of **OpenCode GO**, **Command Code** and **GLM Code Plan** in one sidebar
5
+ widget. It is modelled on
6
+ [`@astervolans/dsh-opencode-go-usage`](https://github.com/Astervolans/dsh-opencode-go-usage)
7
+ — same dual-half package, same-origin proxy, same sidebar slot, same Plugins
8
+ panel settings page — extended from one provider to three.
9
+
10
+ > The package name is `dsh-multi-provider-usage` because the unscoped
11
+ > `dsh-provider-usage` name is already taken on npm by a different plugin. The
12
+ > HTTP routes (`/provider-usage/*`) and chat command (`/provider-usage`) stay
13
+ > short and stable.
14
+
15
+ ## Features
16
+
17
+ ![Sidebar widget with the three stacked providers](docs/widget.png)
18
+
19
+
20
+ - **Three providers, one column.** All enabled providers are stacked vertically;
21
+ each header carries a health dot, the provider's primary percentage and its
22
+ top-up balance. Click a provider header to collapse/expand that provider's
23
+ plan windows, quota bars and time bars; the collapse state is remembered
24
+ locally, so any subset can stay folded.
25
+ - **OpenCode GO** — 5-hour, weekly and monthly windows (the API calls the 5-hour field `rolling`). Prefers the live
26
+ `dsh-opencode-go` plugin's active account through its `opencodeGoUsage`
27
+ host service, then falls back to the official `GET /v1/usage` endpoint.
28
+ - **Command Code** — monthly credit pool, 5-hour window and weekly window from
29
+ the official billing/usage endpoints (`/alpha/billing/*`,
30
+ `/alpha/usage/summary`).
31
+ - **GLM Code Plan** — 5-hour token window, weekly token window and monthly MCP
32
+ tool limit from Z.ai / BigModel `/api/monitor/usage/quota/limit`.
33
+ - **Top-up balances** — OpenCode GO shows the OpenCode Zen prepaid balance and
34
+ Command Code shows its purchased + free "Extra Credits" balance, both as a
35
+ badge next to the account/plan and in the `/provider-usage` text. The OpenCode
36
+ console token is auto-discovered from `OPENCODE_CONSOLE_TOKEN`, the OpenCode
37
+ CLI auth file, or its local `opencode.db`; the token never leaves the host.
38
+ When that token is missing, the balance slot becomes a clickable "Set up" chip
39
+ that opens the plugin settings instead of silently showing nothing. Command
40
+ Code's tooltip breaks its amount into purchased + free credits; a credits
41
+ response without either pool renders a muted **No top-up** badge.
42
+ - **Auto-update** — on boot and every configured interval the host checks the
43
+ GitHub release feed. A local `link:`/`file:` checkout is fast-forwarded when it
44
+ is on `main`, clean, and a strict fast-forward. An npm / market / tarball copy
45
+ updates itself: the release tarball is unpacked and each packaged file
46
+ (`lib/`, `package.json`, `cordis.patch.yml`, `docs/`) is written to a temp file
47
+ and renamed over the target, so pnpm's hardlinked store is never edited in
48
+ place. Either way the running Host keeps the old module graph, so the
49
+ widget/settings show "updated — restart DSH". The plugin never restarts DSH by
50
+ itself.
51
+ - **Balance-change animation** — the widget polls on its normal interval; when
52
+ a provider's balance differs from the previous poll, the number counts from
53
+ the old value to the refreshed one while a red/green delta chip (for example
54
+ `-0.1`) floats up and fades away. There is no popup and no turn-event
55
+ tracking, so the animation may appear up to one poll interval later.
56
+ - **Same-origin proxy** — the browser only talks to `/provider-usage/usage` and
57
+ `/provider-usage/settings`; API keys stay in the host process.
58
+ - **Failure isolation** — one broken provider never hides the other two. The
59
+ route answers `200` while any provider succeeds and `502` only when all
60
+ enabled providers fail. Partial endpoint failures keep the provider's numbers
61
+ but turn its health dot amber and name the failed calls in the tooltip.
62
+ - **Settings shortcut** — the widget header's **Settings** button opens this
63
+ bundle's page in the sidebar **Plugins** panel, directly on the configuration
64
+ page below.
65
+ - **Bundle settings page** — sidebar **Plugins** panel → this bundle's card:
66
+ enable/disable each provider, edit credential refs and endpoints, choose the
67
+ GLM region, and see non-network detection status. Save applies immediately.
68
+ The GLM card also carries the **referral link** (Z.ai "Invite Friends, Get
69
+ Credits") as a clickable, copyable row. The URL is a bundle constant: it never
70
+ goes through the settings route and a settings save cannot overwrite it.
71
+ - **`/provider-usage` chat command** — prints every enabled provider's numbers
72
+ as text.
73
+ - **Time bars** — under each quota bar, gray is time already elapsed and blue is
74
+ time remaining, matching the reference widget's geometry.
75
+ - **`x-opencode-session` fix** — injects the real per-conversation harness
76
+ session id into OpenCode GO chat requests at runtime, without patching DSH.
77
+ - **Chinese and English UI** through the DSH locale service.
78
+
79
+ ## Install
80
+
81
+ From a checkout:
82
+
83
+ ```bash
84
+ dsh plugin --profile desktop add /path/to/dsh-provider-usage
85
+ ```
86
+
87
+ or, for the web profile:
88
+
89
+ ```bash
90
+ dsh plugin --profile web add /path/to/dsh-provider-usage
91
+ ```
92
+
93
+ The package declares `dsh.bundle.patch`, so the CLI reconciles it into the
94
+ profile's bundle layer stack automatically. The widget and client bundle are
95
+ part of the profile's client-modules graph, so **restart DSH once** after
96
+ installing (a running host does not rebuild its `window.__DSH_BOOT__` manifest
97
+ for a newly linked package).
98
+
99
+ For a local `link:` dependency pnpm prints a peer-dependency warning
100
+ (`@deepseek-ai/schemastery`, `@deepseek-ai/dsh`). That is expected: the linked
101
+ checkout resolves schemastery from its own `node_modules` in development; a
102
+ normal npm install resolves the peers from the DSH runtime.
103
+
104
+ ## Credentials
105
+
106
+ All values are resolved through the DSH credentials domain (the process
107
+ environment, `$DSH_HOME/.credentials.yaml` and `.env` layers). They are never
108
+ returned by the settings route and never reach the browser.
109
+
110
+ | Provider | Default ref | Resolution order | Fallback source |
111
+ |---|---|---|---|
112
+ | OpenCode GO | `OPENCODE_GO_API_KEY` | `opencodeGoApiKeyEnv`, the `dsh-opencode-go` active-account ref (only when that entry is enabled), `OPENCODE_GO_API_KEY`, `OPENCODE_API_KEY` | `GET {baseUrl}/v1/usage` |
113
+ | Command Code | `COMMANDCODE_API_KEY` | `commandCodeApiKeyEnv`, `COMMANDCODE_API_KEY`, then the CLI auth file | `~/.commandcode/auth.json` (`apiKey`) |
114
+ | GLM Code Plan | `ZAI_API_KEY` | `glmApiKeyEnv`, `ZAI_CODING_CN_API_KEY`, `ZAI_API_KEY`, `ZHIPU_API_KEY`, `GLM_API_KEY`, `BIGMODEL_API_KEY` | — |
115
+
116
+ Set a ref in **Plugins → dsh-multi-provider-usage → settings** (or in the
117
+ auto-generated **Settings → Plugins → Plugin configuration** form). A ref is a
118
+ name such as `OPENCODE_GO_API_KEY`; the secret itself lives in DSH's credential
119
+ store.
120
+
121
+ ### OpenCode console balance
122
+
123
+ The GO quota API does not include the account's prepaid balance. That comes from
124
+ `GET https://opencode.ai/console/api/billing/status`, which needs the OpenCode
125
+ **console** OAuth token (the GO API key returns 403 there). The host resolves it
126
+ in this order:
127
+
128
+ 1. `opencodeConsoleApiKeyEnv` (`OPENCODE_CONSOLE_TOKEN` by default), resolved
129
+ through DSH credentials;
130
+ 2. `~/.local/share/opencode/auth.json` (`$XDG_DATA_HOME/opencode/auth.json` when
131
+ set), provider `opencode`;
132
+ 3. the `credential` table of `~/.local/share/opencode/opencode.db` (read via
133
+ `node:sqlite` when available; this is where current OpenCode releases keep
134
+ the OAuth grant).
135
+
136
+ The optional `opencodeConsoleAuthFile` overrides the auth file (and its
137
+ directory is used for `opencode.db`), `opencodeConsoleOrgId` supplies the
138
+ `x-org-id` header, and `opencodeConsoleEnabled: false` turns the whole balance
139
+ read off. Balance failures never hide the GO quota: the provider stays `ok` with
140
+ `balance: null` and a `meta.balanceError`. A missing console credential is
141
+ actionable rather than hidden: the balance slot renders a clickable **Set up**
142
+ chip that opens this bundle's page in the Plugins panel, whose tooltip names the
143
+ fix (`opencode auth login` or `OPENCODE_CONSOLE_TOKEN`). Any other balance
144
+ failure renders a muted **Balance —** badge whose tooltip carries the error.
145
+
146
+ ### Command Code balance
147
+
148
+ Command Code's quota and balance share one API key and one billing family, so
149
+ they cannot diverge the way OpenCode's do: a missing key (`COMMANDCODE_API_KEY`
150
+ or `~/.commandcode/auth.json`) fails the whole provider with an explicit
151
+ `no API key` message rather than blanking only the balance. Two narrower gaps
152
+ are reported instead of hidden:
153
+
154
+ - when `/alpha/billing/credits` fails, that request carries every window, so the
155
+ provider errors as well; its message now includes the underlying interface and
156
+ status instead of only "no usable quota windows", and `meta.balanceError` lets
157
+ the header show a muted **Balance —** badge carrying the cause;
158
+ - when the credits response succeeds but carries neither `purchasedCredits` nor
159
+ `freeCredits`, `balance` stays `null` with `meta.balanceMissing: true` and the
160
+ header shows a muted **No top-up** badge (a real `0` still renders `$0.00`).
161
+
162
+ ### OpenCode GO integration and fallback
163
+
164
+ When `opencodeGoSource` is `opencode-go` (the default), the host probes:
165
+
166
+ 1. a live loader entry named `opencode-go` / `dsh-opencode-go`;
167
+ 2. that entry is not disabled;
168
+ 3. `ctx.get("opencodeGoUsage")` exposes `read()`;
169
+ 4. `read()` returns valid `rolling` / `weekly` / `monthly` data;
170
+ 5. the active account is read from that entry's `apiKeyEnv` + `accounts[]`.
171
+
172
+ If any step fails, the host reads the built-in endpoint and reports a `fallback`
173
+ object with one of: `not-installed`, `entry-disabled`, `service-unavailable`,
174
+ `read-failed`, `invalid-response`. The widget shows a **Built-in** badge with the
175
+ reason and message in its tooltip. `opencodeGoSource: "builtin"` pins the HTTP
176
+ path and never touches the other plugin.
177
+
178
+ ## Plan windows
179
+
180
+ | Provider | Windows | Window length |
181
+ |---|---|---|
182
+ | OpenCode GO | `rolling`, `weekly`, `monthly` | 5 h, 7 d, ~30 d |
183
+ | Command Code | `five-hour`, `weekly`, `monthly` | 5 h, 7 d, real billing period |
184
+ | GLM Code Plan | `five-hour`, `weekly`, `mcp` | 5 h, 7 d, monthly |
185
+
186
+ Percentages are **used** percentages and are clamped to `[0, 100]` for display.
187
+ Command Code monthly utilization is
188
+ `totalCost / (totalCost + monthlyCredits)`; purchased and free credits are
189
+ reported separately in `meta.monthly` and do not change the plan bar, while
190
+ their sum is the visible top-up balance (`balance.extra`). The
191
+ 5-hour/weekly bars use `windowLimits.fiveHour` / `weekly` (`used`, `cap`,
192
+ `resetAt`, `exceeded`).
193
+
194
+ A GLM account without a Coding Plan answers HTTP 200 with
195
+ `{ "success": false, "msg": ... }`; the adapter surfaces that message and the
196
+ widget shows the provider in an error state.
197
+
198
+ ## Configuration
199
+
200
+ The loader reads the exported `Config` schema; every field is `.volatile()`, so
201
+ the settings page and the auto-generated form hot-apply.
202
+
203
+ | Field | Default | Meaning |
204
+ |---|---|---|
205
+ | `opencodeGoEnabled` | `true` | Show OpenCode GO |
206
+ | `opencodeGoSource` | `"opencode-go"` | `opencode-go` or `builtin` |
207
+ | `opencodeGoApiKeyEnv` | `"OPENCODE_GO_API_KEY"` | Credential ref |
208
+ | `opencodeGoBaseUrl` | `"https://opencode.ai/zen/go"` | Gateway base |
209
+ | `opencodeConsoleEnabled` | `true` | Read the Zen prepaid balance |
210
+ | `opencodeConsoleApiKeyEnv` | `"OPENCODE_CONSOLE_TOKEN"` | Console token ref |
211
+ | `opencodeConsoleOrgId` | `""` | Optional `x-org-id` |
212
+ | `opencodeConsoleAuthFile` | `""` | Optional auth.json path; empty auto-discovers |
213
+ | `opencodeConsoleDbFile` | `""` | Optional opencode.db path; empty derives it |
214
+ | `opencodeConsoleBaseUrl` | `"https://opencode.ai"` | Console origin |
215
+ | `commandCodeEnabled` | `true` | Show Command Code |
216
+ | `commandCodeApiKeyEnv` | `"COMMANDCODE_API_KEY"` | Credential ref |
217
+ | `commandCodeBaseUrl` | `"https://api.commandcode.ai"` | API base |
218
+ | `commandCodeAuthFile` | `""` | Empty = `~/.commandcode/auth.json` |
219
+ | `glmEnabled` | `true` | Show GLM Code Plan |
220
+ | `glmApiKeyEnv` | `"ZAI_API_KEY"` | Credential ref |
221
+ | `glmBaseUrl` | `""` | Optional base override |
222
+ | `glmRegion` | `"auto"` | `auto`, `zai`, `bigmodel`, `custom` |
223
+ | `cacheMs` | `30000` | Per-provider host cache TTL |
224
+ | `updateCheck` | `true` | npm update check (24 h) |
225
+ | `autoUpdate` | `true` | Check GitHub releases; fast-forward a clean checkout, otherwise replace an installed package |
226
+ | `autoUpdateCheckHours` | `6` | Hours between auto-update checks (1–168) |
227
+ | `injectSessionHeader` | `true` | OpenCode GO session-header fix |
228
+ | `providerOrder` | all three ids | Provider display order in the widget; drag the cards in the plugin settings to reorder |
229
+
230
+ `glmRegion: "custom"` requires `glmBaseUrl`; `auto` probes
231
+ `https://api.z.ai` first and then `https://open.bigmodel.cn`.
232
+
233
+ ## HTTP API
234
+
235
+ ### `GET /provider-usage/usage`
236
+
237
+ `?refresh=1` (or `?force=1`) bypasses the host cache.
238
+
239
+ ```json
240
+ {
241
+ "ok": true,
242
+ "enabled": ["opencode-go", "command-code", "glm"],
243
+ "providerOrder": ["opencode-go", "command-code", "glm"],
244
+ "providers": [
245
+ {
246
+ "id": "opencode-go",
247
+ "name": "OpenCode GO",
248
+ "short": "GO",
249
+ "status": "ok",
250
+ "account": { "ref": "OPENCODE_GO_API_KEY", "id": null, "name": "" },
251
+ "plan": null,
252
+ "source": "builtin",
253
+ "requested": "opencode-go",
254
+ "fallback": {
255
+ "from": "opencode-go",
256
+ "reason": "not-installed",
257
+ "message": "dsh-opencode-go is not installed, or its profile entry is inactive"
258
+ },
259
+ "balance": {
260
+ "amount": 5.01495352,
261
+ "currency": "USD",
262
+ "labelKey": "balance.zen",
263
+ "source": "opencode-db",
264
+ "details": { "billingMode": "prepaid", "mode": "pay-as-you-go" }
265
+ },
266
+ "windows": [
267
+ {
268
+ "key": "rolling",
269
+ "labelKey": "window.five-hour",
270
+ "hintKey": "window.five-hour.hint",
271
+ "percent": 0,
272
+ "status": "ok",
273
+ "resetsAt": "2026-11-05T13:50:46.363Z",
274
+ "windowMs": 18000000
275
+ }
276
+ ],
277
+ "meta": {}
278
+ }
279
+ ],
280
+ "update": { "current": "0.1.0", "available": false, "latest": null },
281
+ "sessionHeader": { "active": true, "count": 0, "diag": {} }
282
+ }
283
+ ```
284
+
285
+ Failed providers have `"status": "error"`, `code`, `error` and `windows: []`;
286
+ their siblings are still returned. A body with `providers: []` means no provider
287
+ is enabled. `meta.balanceError` carries a tolerated balance failure (the
288
+ provider still answers `ok`); `meta.balanceMissing` marks a Command Code credits
289
+ response without purchased/free pools. `meta.errors` lists partial Command Code
290
+ endpoint failures: the numbers stay, the health dot turns amber, and the tooltip
291
+ names the failed calls. Error cards keep the plan they already resolved.
292
+
293
+ ### `GET /provider-usage/settings`
294
+
295
+ Returns the settings descriptor (`namespace`, `revision`, `value`, `base`,
296
+ `user`) plus a non-network `detection` report:
297
+
298
+ ```json
299
+ {
300
+ "ok": true,
301
+ "namespace": "dsh-multi-provider-usage",
302
+ "revision": 0,
303
+ "value": { "opencodeGoEnabled": true },
304
+ "detection": {
305
+ "providerOrder": ["opencode-go", "command-code", "glm"],
306
+ "enabled": ["opencode-go", "command-code", "glm"],
307
+ "providers": {
308
+ "opencode-go": { "configured": true, "entryFound": false, "fallback": { "...": "..." } },
309
+ "command-code": { "configured": true, "source": "auth-file" },
310
+ "glm": { "configured": true, "region": "auto" }
311
+ }
312
+ }
313
+ }
314
+ ```
315
+
316
+ `503` means the host has no settings service or the entry has no volatile
317
+ fields; the settings page explains how to fix it.
318
+
319
+ ### `POST /provider-usage/settings`
320
+
321
+ Only the fields in the table above are accepted; anything else is ignored.
322
+ `expectedRevision` gets `409` on a concurrent change.
323
+
324
+ ```json
325
+ {
326
+ "patch": { "commandCodeEnabled": false, "providerOrder": ["glm", "opencode-go", "command-code"] },
327
+ "expectedRevision": 0
328
+ }
329
+ ```
330
+
331
+ Cross-origin writes are refused with `403`. The usage route is read-only.
332
+
333
+ ## How it is put together
334
+
335
+ | Half | File | Role |
336
+ |---|---|---|
337
+ | host (Node) | `lib/index.js` | Config schema, routes, command, settings/detection, cache, session fix |
338
+ | host adapters | `lib/providers.js` | Canonical window shape and the three vendor adapters |
339
+ | console balance | `lib/opencode-console.js` | OpenCode console token discovery + billing status |
340
+ | session fix | `lib/opencode-session.js` | `globalThis.fetch` wrapper + `llm/stream` ALS context |
341
+ | browser | `lib/client.js` | Hand-authored `window.__ModuleLoader__.load({ id, factory })` bundle: widget, settings page, locale dictionaries |
342
+ | bundle patch | `cordis.patch.yml` | Inserts the profile entry |
343
+
344
+ The browser bundle is composed by DSH's official client-modules scan from
345
+ `dsh.client` + `exports["./client"]`; it registers into
346
+ `sidebar.footer.action` and `plugins.bundle.config`, exactly like the reference
347
+ plugin.
348
+
349
+ The session fix wraps `globalThis.fetch` once and only adds
350
+ `x-opencode-session` when the request URL starts with the gateway base carried
351
+ by the current `llm/stream` call. The base comes from
352
+ `llm-pi-ai.providers.<route>.baseURL`, falling back to `opencodeGoBaseUrl`.
353
+
354
+ ## Compatibility
355
+
356
+ - DSH `0.1.7` and above, including the `0.2.0` line; verified on `0.2.1-rc.5`.
357
+ - `@deepseek-ai/schemastery` `^3.18.4` (volatile fields / settings page).
358
+ - Node.js ≥ 18 (global `fetch`, `AbortSignal.timeout`).
359
+ - `dsh-opencode-go` is optional; its absence is a normal fallback.
360
+ - Command Code's CLI auth file is optional if `COMMANDCODE_API_KEY` is set.
361
+ - A DSH runtime that does not expose `plugins.bundle.config` loses only the
362
+ settings page; the sidebar widget and `/provider-usage` keep working. Set the
363
+ config fields in the profile patch or the auto-generated form instead.
364
+
365
+ ## Development
366
+
367
+ ```bash
368
+ npm install
369
+ npm test
370
+ ```
371
+
372
+ The tests are hermetic: a fake `globalThis.fetch` and a stub Cordis context, no
373
+ network and no real keys. They cover the schema, all three adapters, OpenCode
374
+ GO service integration/fallback, aggregation and error isolation, caching and
375
+ forced refresh, settings conflicts/whitelisting, the client bundle contract,
376
+ top-up balance discovery (credential and local auth.json) and failure
377
+ isolation, and the session-header injection. 75 tests, all offline.
378
+
379
+ ## License
380
+
381
+ MIT.