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

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.
@@ -0,0 +1,919 @@
1
+ // @ts-check
2
+ /**
3
+ * dsh-multi-provider-usage — provider adapters.
4
+ *
5
+ * Every adapter turns one subscription's vendor API into the same canonical
6
+ * shape, so the host routes and the browser widget never need provider-specific
7
+ * branches:
8
+ *
9
+ * {
10
+ * id, name, short,
11
+ * status: "ok" | "error",
12
+ * account: { name, ref, id } | null,
13
+ * plan: { id, name, status, periodStart, periodEnd, totalCredits } | null,
14
+ * source: string, // credential ref / auth file / vendor path
15
+ * requested: string | null, // for the OpenCode GO integration fallback
16
+ * fallback: { from, reason, message } | null,
17
+ * windows: [{
18
+ * key, labelKey, hintKey, percent, status, resetsAt, windowMs, note
19
+ * }],
20
+ * meta: object
21
+ * }
22
+ *
23
+ * The adapters are dependency-injected (`fetchImpl`, `resolveCredential`,
24
+ * `service`) so the hermetic tests can run them without network or real keys.
25
+ */
26
+
27
+ import { readFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { isCredentialRefName, uniqueStrings } from "./util.js";
31
+ import { readOpencodeBalance } from "./opencode-console.js";
32
+
33
+ // ── provider catalogue ─────────────────────────────────────────────────────
34
+
35
+ /** Stable provider ids, in display order. */
36
+ export const PROVIDER_IDS = ["opencode-go", "command-code", "glm"];
37
+
38
+ /** Display metadata for the widget tabs and the settings page. */
39
+ export const PROVIDERS = [
40
+ { id: "opencode-go", name: "OpenCode GO", short: "GO", order: 0 },
41
+ { id: "command-code", name: "Command Code", short: "CC", order: 1 },
42
+ { id: "glm", name: "GLM Code Plan", short: "GLM", order: 2 }
43
+ ];
44
+
45
+ /** One provider's display metadata by id. */
46
+ export function providerInfo(id) {
47
+ return PROVIDERS.find((provider) => provider.id === id) ?? { id, name: id, short: id.toUpperCase(), order: 99 };
48
+ }
49
+
50
+ /**
51
+ * The plan windows each provider can expose. `windowMs` is the window LENGTH
52
+ * used by the client's time bar; adapters may override it (Command Code's
53
+ * monthly window is the real billing period).
54
+ */
55
+ export const WINDOW_DEFS = {
56
+ rolling: { labelKey: "window.five-hour", hintKey: "window.five-hour.hint", windowMs: 5 * 3600e3 },
57
+ "five-hour": { labelKey: "window.five-hour", hintKey: "window.five-hour.hint", windowMs: 5 * 3600e3 },
58
+ weekly: { labelKey: "window.weekly", hintKey: null, windowMs: 7 * 86400e3 },
59
+ monthly: { labelKey: "window.monthly", hintKey: null, windowMs: 30 * 86400e3 },
60
+ mcp: { labelKey: "window.mcp", hintKey: "window.mcp.hint", windowMs: null },
61
+ token: { labelKey: "window.token", hintKey: null, windowMs: null },
62
+ other: { labelKey: "window.other", hintKey: null, windowMs: null }
63
+ };
64
+
65
+ /** Canonical window key for the alias spellings vendors use. */
66
+ export function canonicalWindowKey(key) {
67
+ if (key === "fiveHour" || key === "five-hour") return "five-hour";
68
+ return key;
69
+ }
70
+
71
+ // ── small parsing helpers ──────────────────────────────────────────────────
72
+
73
+ /** Finite number or null. */
74
+ export function finiteNumber(value) {
75
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
76
+ }
77
+
78
+ /** Clamp a percentage into [0, 100]; null stays null. */
79
+ export function clampPercent(value) {
80
+ const number = finiteNumber(value);
81
+ if (number === null) return null;
82
+ return Math.max(0, Math.min(100, number));
83
+ }
84
+
85
+ /** ISO string for a Date, epoch-ms number, or date string; null when invalid/zero. */
86
+ export function toIso(value) {
87
+ if (value === null || value === undefined) return null;
88
+ if (typeof value === "string") {
89
+ const trimmed = value.trim();
90
+ if (trimmed === "") return null;
91
+ const ms = Date.parse(trimmed);
92
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
93
+ }
94
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
95
+ // Vendor timestamps are milliseconds; accept seconds defensively.
96
+ const ms = value < 10_000_000_000 ? value * 1000 : value;
97
+ return new Date(ms).toISOString();
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** Build one canonical window, omitting fields the vendor did not send. */
103
+ export function makeWindow(key, percent, options = {}) {
104
+ const canonical = canonicalWindowKey(key);
105
+ const def = WINDOW_DEFS[canonical] ?? WINDOW_DEFS.other;
106
+ const value = clampPercent(percent);
107
+ return {
108
+ key: canonical,
109
+ labelKey: options.labelKey ?? def.labelKey,
110
+ hintKey: options.hintKey ?? def.hintKey ?? null,
111
+ percent: value,
112
+ status: typeof options.status === "string" && options.status.length > 0
113
+ ? options.status
114
+ : value !== null && value >= 100 ? "exceeded" : "ok",
115
+ resetsAt: toIso(options.resetsAt),
116
+ windowMs: typeof options.windowMs === "number" && Number.isFinite(options.windowMs) && options.windowMs > 0
117
+ ? options.windowMs
118
+ : options.windowMs === null ? null : def.windowMs ?? null,
119
+ ...(typeof options.note === "string" && options.note.length > 0 ? { note: options.note } : {})
120
+ };
121
+ }
122
+
123
+ /** Provider failure with a machine-readable code and optional public details. */
124
+ export class ProviderError extends Error {
125
+ /**
126
+ * @param {string} code - stable diagnostic code (`no-credential`, `http-error`, …).
127
+ * @param {string} message - human-readable, already safe to show.
128
+ * @param {Record<string, unknown>} [details] - public fields merged onto the error result.
129
+ */
130
+ constructor(code, message, details = {}) {
131
+ super(message);
132
+ this.name = "ProviderError";
133
+ this.code = code;
134
+ Object.assign(this, details);
135
+ }
136
+ }
137
+
138
+ /** JSON fetch with a timeout and a bounded error body. */
139
+ async function fetchJson(fetchImpl, url, init = {}, label = "request") {
140
+ let res;
141
+ try {
142
+ res = await fetchImpl(url, {
143
+ ...init,
144
+ signal: init.signal ?? AbortSignal.timeout(15_000)
145
+ });
146
+ } catch (error) {
147
+ throw new ProviderError("network-error", `${label}: ${error instanceof Error ? error.message : String(error)}`);
148
+ }
149
+ if (!res.ok) {
150
+ const body = await res.text().catch(() => "");
151
+ throw new ProviderError("http-error", `${label} ${res.status}: ${body.slice(0, 300)}`);
152
+ }
153
+ try {
154
+ return await res.json();
155
+ } catch {
156
+ throw new ProviderError("invalid-response", `${label}: response is not JSON`);
157
+ }
158
+ }
159
+
160
+ // ── OpenCode GO ────────────────────────────────────────────────────────────
161
+
162
+ export const DEFAULT_OPENCODE_BASE_URL = "https://opencode.ai/zen/go";
163
+ export const DEFAULT_OPENCODE_API_KEY_ENV = "OPENCODE_GO_API_KEY";
164
+
165
+ /**
166
+ * Validate and canonicalize the gateway's `{ usage: { rolling, weekly, monthly } }`
167
+ * payload (also accepts the flat shape `dsh-opencode-go`'s service returns).
168
+ * Throws instead of turning absent data into zeroes.
169
+ * @param {unknown} payload
170
+ */
171
+ export function normalizeOpencodeUsage(payload) {
172
+ const usage = payload !== null && typeof payload === "object" && payload.usage !== null && typeof payload.usage === "object"
173
+ ? payload.usage
174
+ : payload;
175
+ const out = [];
176
+ for (const [key, windowKey] of [["rolling", "rolling"], ["weekly", "weekly"], ["monthly", "monthly"]]) {
177
+ const row = usage?.[key];
178
+ if (row === null || typeof row !== "object" || finiteNumber(row.percent) === null) {
179
+ throw new ProviderError("invalid-response", `invalid usage payload: missing ${key}.percent`);
180
+ }
181
+ out.push(makeWindow(windowKey, row.percent, {
182
+ status: typeof row.status === "string" ? row.status : "ok",
183
+ resetsAt: row.resetsAt
184
+ }));
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Probe the optional `dsh-opencode-go` integration without a network call.
191
+ * @param {unknown} service - the `opencodeGoUsage` service, or null.
192
+ * @param {{ found?: boolean, enabled?: boolean|null, account?: object, ref?: string, baseURL?: string|null } | null} entry
193
+ */
194
+ export function probeOpencodeGo(service, entry) {
195
+ if (!entry || entry.found !== true) {
196
+ return {
197
+ ok: false,
198
+ reason: "not-installed",
199
+ message: "dsh-opencode-go is not installed, or its profile entry is inactive",
200
+ entryFound: false,
201
+ enabled: null,
202
+ service: service !== null && service !== undefined
203
+ };
204
+ }
205
+ if (entry.enabled === false) {
206
+ return {
207
+ ok: false,
208
+ reason: "entry-disabled",
209
+ message: "dsh-opencode-go is installed but disabled in this profile",
210
+ entryFound: true,
211
+ enabled: false,
212
+ service: service !== null && service !== undefined
213
+ };
214
+ }
215
+ if (service === null || service === undefined || typeof service.read !== "function") {
216
+ return {
217
+ ok: false,
218
+ reason: "service-unavailable",
219
+ message: "dsh-opencode-go does not expose the opencodeGoUsage service (unsupported version)",
220
+ entryFound: true,
221
+ enabled: true,
222
+ service: false
223
+ };
224
+ }
225
+ return { ok: true, reason: null, message: null, entryFound: true, enabled: true, service: true };
226
+ }
227
+
228
+ /** Start the optional OpenCode console balance read (never rejects). */
229
+ function startOpencodeBalance(consoleConfig, resolveCredential, fetchImpl) {
230
+ if (consoleConfig === null || consoleConfig === undefined || consoleConfig.enabled !== true) {
231
+ return Promise.resolve(null);
232
+ }
233
+ return readOpencodeBalance({ config: consoleConfig, resolveCredential, fetchImpl })
234
+ .then((balance) => ({ ok: true, balance }))
235
+ .catch((error) => ({ ok: false, error }));
236
+ }
237
+
238
+ /**
239
+ * Project a settled balance read into public result fields.
240
+ *
241
+ * A failed read carries `balanceSetup` when the user can act on it (no console
242
+ * login, an API-key-only login, or an expired/revoked session); the widget
243
+ * turns that into a setup chip instead of a mute dash.
244
+ */
245
+ function balanceFields(settled) {
246
+ if (settled === null) return { balance: null, balanceError: null, balanceSetup: null };
247
+ if (settled.ok) return { balance: settled.balance, balanceError: null, balanceSetup: null };
248
+ const error = settled.error;
249
+ const code = error?.code;
250
+ return {
251
+ balance: null,
252
+ balanceError: error instanceof Error ? error.message : String(error),
253
+ balanceSetup: code === "no-console-credential"
254
+ ? "no-credential"
255
+ : code === "console-api-key"
256
+ ? "api-key"
257
+ : code === "console-unauthorized" ? "expired" : null
258
+ };
259
+ }
260
+
261
+ /** Merge a settled balance read into one provider result. */
262
+ function attachBalance(result, settled) {
263
+ const fields = balanceFields(settled);
264
+ return {
265
+ ...result,
266
+ balance: fields.balance,
267
+ meta: {
268
+ ...(result.meta ?? {}),
269
+ ...(fields.balanceError === null ? {} : { balanceError: fields.balanceError }),
270
+ ...(fields.balanceSetup === null ? {} : { balanceSetup: fields.balanceSetup })
271
+ }
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Build the OpenCode GO error result shared by every failure path: the
277
+ * account, the integration probe, the fallback and the settled balance read
278
+ * always travel together so the widget never hides the quota that did arrive.
279
+ */
280
+ async function opencodeGoFailure(code, message, accountRef, probe, fallback, balancePromise) {
281
+ const balance = balanceFields(await balancePromise);
282
+ return new ProviderError(code, message, {
283
+ account: { ref: accountRef, id: null, name: "" },
284
+ fallback,
285
+ balance: balance.balance,
286
+ meta: {
287
+ opencodeGo: probe,
288
+ ...(balance.balanceError === null ? {} : { balanceError: balance.balanceError }),
289
+ ...(balance.balanceSetup === null ? {} : { balanceSetup: balance.balanceSetup })
290
+ }
291
+ });
292
+ }
293
+
294
+ /**
295
+ * Read OpenCode GO quota.
296
+ *
297
+ * `source: "opencode-go"` prefers the live `dsh-opencode-go` service (so the
298
+ * numbers follow its active account), then falls back to the official
299
+ * `${baseUrl}/v1/usage` endpoint with a resolved credential. `source:
300
+ * "builtin"` pins the HTTP path.
301
+ *
302
+ * @param {{
303
+ * config: { source?: string, apiKeyEnv?: string, baseUrl?: string },
304
+ * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
305
+ * service?: { read?: () => Promise<unknown> } | null,
306
+ * entry?: object | null,
307
+ * fetchImpl?: typeof fetch,
308
+ * }} input
309
+ */
310
+ export async function readOpencodeGo(input) {
311
+ const { config, resolveCredential, service = null, entry = null, fetchImpl = fetch } = input;
312
+ const source = config.source === "builtin" ? "builtin" : "opencode-go";
313
+ const probe = probeOpencodeGo(service, entry);
314
+ const balancePromise = startOpencodeBalance(config.console, resolveCredential, fetchImpl);
315
+ let fallback = null;
316
+
317
+ if (source === "opencode-go") {
318
+ if (probe.ok) {
319
+ let raw;
320
+ try {
321
+ raw = await service.read();
322
+ } catch (error) {
323
+ fallback = {
324
+ from: "opencode-go",
325
+ reason: "read-failed",
326
+ message: error instanceof Error ? error.message : String(error)
327
+ };
328
+ }
329
+ if (fallback === null) {
330
+ try {
331
+ const windows = normalizeOpencodeUsage(raw);
332
+ return attachBalance({
333
+ ...providerInfo("opencode-go"),
334
+ status: "ok",
335
+ account: entry?.account ?? null,
336
+ plan: null,
337
+ source: "opencode-go",
338
+ requested: "opencode-go",
339
+ fallback: null,
340
+ windows,
341
+ meta: { opencodeGo: { ...probe, compatible: true }, lastSwitch: raw?.lastSwitch ?? null }
342
+ }, await balancePromise);
343
+ } catch (error) {
344
+ fallback = {
345
+ from: "opencode-go",
346
+ reason: "invalid-response",
347
+ message: error instanceof Error ? error.message : String(error)
348
+ };
349
+ }
350
+ }
351
+ } else {
352
+ fallback = { from: "opencode-go", reason: probe.reason, message: probe.message };
353
+ }
354
+ }
355
+
356
+ const refs = uniqueStrings([
357
+ config.apiKeyEnv,
358
+ entry?.enabled === true ? entry.ref : null,
359
+ DEFAULT_OPENCODE_API_KEY_ENV,
360
+ "OPENCODE_API_KEY"
361
+ ]).filter(isCredentialRefName);
362
+ const credential = await resolveCredential(refs);
363
+ if (credential === null) {
364
+ const reason = fallback === null ? null : `; ${fallback.reason}: ${fallback.message}`;
365
+ throw await opencodeGoFailure(
366
+ "no-credential",
367
+ `OpenCode GO: no API key (set one of ${refs.join(", ")})${reason ?? ""}`,
368
+ config.apiKeyEnv ?? DEFAULT_OPENCODE_API_KEY_ENV,
369
+ probe,
370
+ fallback,
371
+ balancePromise
372
+ );
373
+ }
374
+
375
+ const baseUrl = String(config.baseUrl || entry?.baseURL || DEFAULT_OPENCODE_BASE_URL).replace(/\/+$/, "");
376
+ let payload;
377
+ try {
378
+ payload = await fetchJson(
379
+ fetchImpl,
380
+ `${baseUrl}/v1/usage`,
381
+ { headers: { Authorization: `Bearer ${credential.value}` } },
382
+ "OpenCode GO usage"
383
+ );
384
+ } catch (error) {
385
+ const reason = fallback === null ? "" : ` — dsh-opencode-go: ${fallback.reason}: ${fallback.message}`;
386
+ throw await opencodeGoFailure(
387
+ error instanceof ProviderError ? error.code : "read-failed",
388
+ `${error instanceof Error ? error.message : String(error)}${reason}`,
389
+ credential.ref,
390
+ probe,
391
+ fallback,
392
+ balancePromise
393
+ );
394
+ }
395
+
396
+ let windows;
397
+ try {
398
+ windows = normalizeOpencodeUsage(payload);
399
+ } catch (error) {
400
+ throw await opencodeGoFailure(
401
+ "invalid-response",
402
+ error instanceof Error ? error.message : String(error),
403
+ credential.ref,
404
+ probe,
405
+ fallback,
406
+ balancePromise
407
+ );
408
+ }
409
+
410
+ return attachBalance({
411
+ ...providerInfo("opencode-go"),
412
+ status: "ok",
413
+ // The numbers came from the built-in credential, so this account
414
+ // describes that ref; the dsh-opencode-go probe lives in meta.
415
+ account: { ref: credential.ref, id: null, name: "" },
416
+ plan: null,
417
+ source: "builtin",
418
+ requested: source,
419
+ fallback,
420
+ windows,
421
+ meta: {
422
+ opencodeGo: {
423
+ ...probe,
424
+ reason: fallback?.reason ?? probe.reason,
425
+ message: fallback?.message ?? probe.message,
426
+ compatible: fallback === null && probe.ok
427
+ }
428
+ }
429
+ }, await balancePromise);
430
+ }
431
+
432
+ // ── Command Code ───────────────────────────────────────────────────────────
433
+
434
+ export const DEFAULT_COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
435
+ export const DEFAULT_COMMAND_CODE_API_KEY_ENV = "COMMANDCODE_API_KEY";
436
+
437
+ /** Plan totals documented by the Command Code CLI (credits). */
438
+ const COMMAND_CODE_PLAN_TOTALS = {
439
+ "individual-go": 10,
440
+ "individual-go-v1": 10,
441
+ "individual-goat": 70,
442
+ "individual-pro": 30,
443
+ "individual-pro-v1": 80,
444
+ "individual-provider": 15,
445
+ "individual-max": 150,
446
+ "individual-ultra": 300,
447
+ "teams-pro": 40
448
+ };
449
+ const COMMAND_CODE_PLAN_NAMES = {
450
+ "individual-go": "Go",
451
+ "individual-go-v1": "Go",
452
+ "individual-goat": "GOAT",
453
+ "individual-pro": "Pro",
454
+ "individual-pro-v1": "Pro",
455
+ "individual-provider": "Provider",
456
+ "individual-max": "Max",
457
+ "individual-ultra": "Ultra",
458
+ "teams-pro": "Teams Pro"
459
+ };
460
+ const COMMAND_CODE_PLAN_IDS = Object.keys(COMMAND_CODE_PLAN_TOTALS).sort((a, b) => b.length - a.length);
461
+
462
+ /** Resolve a subscription plan id to its display name and monthly credit total. */
463
+ export function commandCodePlan(planId) {
464
+ if (typeof planId !== "string" || planId.length === 0) return null;
465
+ const normalized = planId.toLowerCase().replace(/_/g, "-");
466
+ const id = COMMAND_CODE_PLAN_IDS.find((candidate) => normalized.startsWith(candidate));
467
+ if (id === undefined) return { id: normalized, name: planId, totalCredits: null };
468
+ return { id, name: COMMAND_CODE_PLAN_NAMES[id] ?? id, totalCredits: COMMAND_CODE_PLAN_TOTALS[id] ?? null };
469
+ }
470
+
471
+ /** Expand `~` and environment variables in a user-supplied path. */
472
+ function expandHome(pathValue) {
473
+ const raw = String(pathValue ?? "").trim();
474
+ if (raw === "") return join(homedir(), ".commandcode", "auth.json");
475
+ if (raw === "~") return homedir();
476
+ if (raw.startsWith("~/")) return join(homedir(), raw.slice(2));
477
+ return raw;
478
+ }
479
+
480
+ /** Read a Command Code CLI auth file and return its API key, if any. */
481
+ export function readCommandCodeAuthFile(pathValue) {
482
+ const path = expandHome(pathValue);
483
+ try {
484
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
485
+ for (const key of ["apiKey", "api_key", "token", "accessToken"]) {
486
+ if (typeof parsed?.[key] === "string" && parsed[key].length > 0) {
487
+ return { key: parsed[key], path, userName: typeof parsed.userName === "string" ? parsed.userName : "" };
488
+ }
489
+ }
490
+ } catch {
491
+ // absent / unreadable auth file is the same as "not configured"
492
+ }
493
+ return null;
494
+ }
495
+
496
+ /**
497
+ * Read Command Code quota from its billing/usage APIs.
498
+ *
499
+ * The CLI's `/alpha/billing/credits` response supplies the 5-hour and weekly
500
+ * windows, `/alpha/billing/subscriptions` supplies the billing period, and
501
+ * `/alpha/usage/summary` supplies period spend. Monthly utilization is
502
+ * `totalCost / (totalCost + remaining credits)`, which is the same relation the
503
+ * official CLI exposes (and what its usage screen displays).
504
+ *
505
+ * @param {{
506
+ * config: { apiKeyEnv?: string, baseUrl?: string, authFile?: string },
507
+ * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
508
+ * fetchImpl?: typeof fetch,
509
+ * }} input
510
+ */
511
+ export async function readCommandCode(input) {
512
+ const { config, resolveCredential, fetchImpl = fetch } = input;
513
+ const refs = uniqueStrings([config.apiKeyEnv, DEFAULT_COMMAND_CODE_API_KEY_ENV]).filter(isCredentialRefName);
514
+ const credential = await resolveCredential(refs);
515
+ let auth = null;
516
+ let source = null;
517
+ let accountRef = "";
518
+ if (credential !== null) {
519
+ auth = credential.value;
520
+ source = `credential:${credential.ref}`;
521
+ accountRef = credential.ref;
522
+ } else {
523
+ const file = readCommandCodeAuthFile(config.authFile);
524
+ if (file !== null) {
525
+ auth = file.key;
526
+ source = "auth-file";
527
+ accountRef = file.path;
528
+ }
529
+ }
530
+ if (auth === null) {
531
+ throw new ProviderError(
532
+ "no-credential",
533
+ `Command Code: no API key (set ${refs[0] ?? DEFAULT_COMMAND_CODE_API_KEY_ENV}, or sign in with the Command Code CLI)`,
534
+ { account: { ref: refs[0] ?? DEFAULT_COMMAND_CODE_API_KEY_ENV, id: null, name: "" } }
535
+ );
536
+ }
537
+
538
+ const baseUrl = String(config.baseUrl || DEFAULT_COMMAND_CODE_BASE_URL).replace(/\/+$/, "");
539
+ const headers = {
540
+ Authorization: `Bearer ${auth}`,
541
+ "Content-Type": "application/json",
542
+ "User-Agent": "dsh-multi-provider-usage/0.1"
543
+ };
544
+ /** @param {string} path */
545
+ const get = async (path) => {
546
+ try {
547
+ return { data: await fetchJson(fetchImpl, baseUrl + path, { headers }, `Command Code ${path.split("?")[0]}`), error: null };
548
+ } catch (error) {
549
+ return { data: null, error: error instanceof Error ? error.message : String(error) };
550
+ }
551
+ };
552
+
553
+ const [whoamiResult, subscriptionResult, creditsResult] = await Promise.all([
554
+ get("/alpha/whoami?limits=1"),
555
+ get("/alpha/billing/subscriptions"),
556
+ get("/alpha/billing/credits")
557
+ ]);
558
+ const subscription = subscriptionResult.data?.data ?? null;
559
+ const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart : null;
560
+ const summaryResult = await get(`/alpha/usage/summary${periodStart === null ? "" : `?since=${encodeURIComponent(periodStart)}`}`);
561
+ const summary = summaryResult.data ?? null;
562
+ const credits = creditsResult.data?.credits ?? null;
563
+ const windowLimits = creditsResult.data?.windowLimits ?? null;
564
+
565
+ const errors = [whoamiResult.error, subscriptionResult.error, creditsResult.error, summaryResult.error].filter(Boolean);
566
+ if (whoamiResult.data === null && creditsResult.data === null && subscriptionResult.data === null && summaryResult.data === null) {
567
+ throw new ProviderError("read-failed", `Command Code: ${errors.join("; ") || "all billing endpoints failed"}`);
568
+ }
569
+
570
+ const whoami = whoamiResult.data ?? null;
571
+ const planId = typeof subscription?.planId === "string" ? subscription.planId : null;
572
+ const planInfo = commandCodePlan(planId);
573
+ const accountName = typeof whoami?.user?.name === "string" && whoami.user.name.length > 0
574
+ ? whoami.user.name
575
+ : typeof whoami?.user?.userName === "string" ? whoami.user.userName : "";
576
+ const account = {
577
+ ref: accountRef,
578
+ id: typeof whoami?.user?.id === "string" ? whoami.user.id : null,
579
+ name: accountName
580
+ };
581
+ const plan = planInfo === null ? null : {
582
+ id: planInfo.id,
583
+ name: planInfo.name,
584
+ status: typeof subscription?.status === "string" ? subscription.status : null,
585
+ periodStart,
586
+ periodEnd: typeof subscription?.currentPeriodEnd === "string" ? subscription.currentPeriodEnd : null,
587
+ totalCredits: planInfo.totalCredits
588
+ };
589
+
590
+ const windows = [];
591
+
592
+ // Monthly credits: `used` is the billing-period spend and `remaining` is
593
+ // the plan's recurring monthly credit balance. Purchased and free credits
594
+ // are extra top-ups, reported separately (the official CLI's usage screen
595
+ // calls them "Extra Credits"); they are not part of the monthly plan bar.
596
+ const used = finiteNumber(summary?.totalCost);
597
+ const monthlyRemaining = finiteNumber(credits?.monthlyCredits);
598
+ const purchasedRemaining = finiteNumber(credits?.purchasedCredits);
599
+ const freeRemaining = finiteNumber(credits?.freeCredits);
600
+ // The credits endpoint is the only source for the top-up balance. When it
601
+ // fails, or answers without either credit pool, the widget still needs to
602
+ // say why instead of leaving the balance slot blank.
603
+ const balanceError = creditsResult.error === null ? null : `Command Code balance: ${creditsResult.error}`;
604
+ const balanceMissing = creditsResult.error === null && purchasedRemaining === null && freeRemaining === null;
605
+ let monthlyCap = null;
606
+ let monthlyPercent = null;
607
+ if (used !== null && monthlyRemaining !== null) {
608
+ monthlyCap = used + monthlyRemaining;
609
+ monthlyPercent = monthlyCap > 0 ? (used / monthlyCap) * 100 : 0;
610
+ } else if (planInfo !== null && monthlyRemaining !== null && planInfo.totalCredits !== null) {
611
+ monthlyCap = planInfo.totalCredits;
612
+ monthlyPercent = monthlyCap > 0 ? ((monthlyCap - Math.max(0, monthlyRemaining)) / monthlyCap) * 100 : 0;
613
+ }
614
+ let monthlyWindow = null;
615
+ if (monthlyPercent !== null) {
616
+ const start = periodStart === null ? null : Date.parse(periodStart);
617
+ const end = typeof subscription?.currentPeriodEnd === "string" ? Date.parse(subscription.currentPeriodEnd) : null;
618
+ monthlyWindow = makeWindow("monthly", monthlyPercent, {
619
+ status: monthlyPercent >= 100 ? "exceeded" : "ok",
620
+ resetsAt: subscription?.currentPeriodEnd,
621
+ windowMs: Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : null
622
+ });
623
+ }
624
+
625
+ // 5-hour and weekly rolling windows. `resetAt` is epoch milliseconds and 0
626
+ // when the window has not started / is unlimited.
627
+ for (const [key, label] of [["five-hour", "fiveHour"], ["weekly", "weekly"]]) {
628
+ const limit = windowLimits?.[label];
629
+ if (limit === null || typeof limit !== "object") continue;
630
+ const cap = finiteNumber(limit.cap);
631
+ const windowUsed = finiteNumber(limit.used);
632
+ if (cap === null || windowUsed === null || cap <= 0) continue;
633
+ const percent = (windowUsed / cap) * 100;
634
+ windows.push(makeWindow(key, percent, {
635
+ status: limit.exceeded === true || percent >= 100 ? "exceeded" : "ok",
636
+ resetsAt: limit.resetAt
637
+ }));
638
+ }
639
+ // The monthly billing window always renders last for Command Code.
640
+ if (monthlyWindow !== null) windows.push(monthlyWindow);
641
+
642
+ const extraCredits = purchasedRemaining !== null || freeRemaining !== null
643
+ ? (purchasedRemaining ?? 0) + (freeRemaining ?? 0)
644
+ : null;
645
+ const balance = extraCredits === null ? null : {
646
+ amount: extraCredits,
647
+ currency: "USD",
648
+ labelKey: "balance.extra",
649
+ source: "command-code-credits",
650
+ details: { purchased: purchasedRemaining, free: freeRemaining }
651
+ };
652
+
653
+ if (windows.length === 0) {
654
+ // The credits request carries the windows, so a failure there leaves
655
+ // nothing to render; name the underlying error instead of a generic
656
+ // "no usable windows" (the 5xx used to be swallowed into meta.errors).
657
+ const cause = errors.length === 0 ? "" : ` (${errors.join("; ")})`;
658
+ throw new ProviderError("invalid-response", `Command Code: billing responses contained no usable quota windows${cause}`, {
659
+ account,
660
+ plan,
661
+ balance,
662
+ meta: { errors, ...(balanceError === null ? {} : { balanceError }) }
663
+ });
664
+ }
665
+
666
+ // Useful diagnostics for the settings page and the chat command.
667
+ const totalDays = subscription?.currentPeriodStart && subscription?.currentPeriodEnd
668
+ ? (Date.parse(subscription.currentPeriodEnd) - Date.parse(subscription.currentPeriodStart)) / 86400e3
669
+ : null;
670
+ const elapsedDays = subscription?.currentPeriodStart
671
+ ? (Date.now() - Date.parse(subscription.currentPeriodStart)) / 86400e3
672
+ : null;
673
+ const dailyRate = used !== null && elapsedDays !== null && elapsedDays > 0 ? used / elapsedDays : null;
674
+ const runsOutInDays = dailyRate !== null && dailyRate > 0 && monthlyRemaining !== null ? monthlyRemaining / dailyRate : null;
675
+
676
+ return {
677
+ ...providerInfo("command-code"),
678
+ status: "ok",
679
+ account,
680
+ plan,
681
+ source,
682
+ requested: null,
683
+ fallback: null,
684
+ balance,
685
+ windows,
686
+ meta: {
687
+ planId,
688
+ errors,
689
+ ...(balanceError === null ? {} : { balanceError }),
690
+ ...(balanceMissing ? { balanceMissing: true } : {}),
691
+ monthly: {
692
+ used,
693
+ remaining: monthlyRemaining,
694
+ purchased: purchasedRemaining,
695
+ free: freeRemaining,
696
+ extra: extraCredits,
697
+ cap: monthlyCap,
698
+ percent: monthlyPercent,
699
+ periodBasis: summary?.periodBasis ?? null
700
+ },
701
+ totals: summary === null ? null : {
702
+ requests: summary.totalCount ?? null,
703
+ successRate: summary.successRate ?? null,
704
+ completed: summary.completedCount ?? null,
705
+ failed: summary.failedCount ?? null,
706
+ tokensIn: summary.totalTokensIn ?? null,
707
+ tokensOut: summary.totalTokensOut ?? null
708
+ },
709
+ projection: {
710
+ elapsedDays: Number.isFinite(elapsedDays) ? elapsedDays : null,
711
+ totalDays: Number.isFinite(totalDays) ? totalDays : null,
712
+ dailyRate,
713
+ runsOutInDays
714
+ }
715
+ }
716
+ };
717
+ }
718
+
719
+ // ── GLM Code Plan (Z.ai / BigModel) ────────────────────────────────────────
720
+
721
+ export const DEFAULT_GLM_API_KEY_ENV = "ZAI_API_KEY";
722
+ export const GLM_ZAI_BASE_URL = "https://api.z.ai";
723
+ export const GLM_BIGMODEL_BASE_URL = "https://open.bigmodel.cn";
724
+ export const GLM_KNOWN_REFS = [
725
+ "ZAI_CODING_CN_API_KEY",
726
+ "ZAI_API_KEY",
727
+ "ZHIPU_API_KEY",
728
+ "GLM_API_KEY",
729
+ "BIGMODEL_API_KEY"
730
+ ];
731
+
732
+ /** Build the quota URL for a base that may or may not include `/api`. */
733
+ export function glmQuotaUrl(baseUrl) {
734
+ const base = String(baseUrl ?? "").replace(/\/+$/, "");
735
+ return base.endsWith("/api")
736
+ ? `${base}/monitor/usage/quota/limit`
737
+ : `${base}/api/monitor/usage/quota/limit`;
738
+ }
739
+
740
+ /** Candidate base URLs for the configured region, in probe order. */
741
+ export function glmBases(config = {}) {
742
+ const explicit = typeof config.baseUrl === "string" ? config.baseUrl.trim() : "";
743
+ if (config.region === "custom") return explicit.length === 0 ? [] : [explicit.replace(/\/+$/, "")];
744
+ if (explicit.length > 0) return [explicit.replace(/\/+$/, "")];
745
+ const region = config.region === "bigmodel" || config.region === "zai" ? config.region : "auto";
746
+ if (region === "zai") return [GLM_ZAI_BASE_URL];
747
+ if (region === "bigmodel") return [GLM_BIGMODEL_BASE_URL];
748
+ return [GLM_ZAI_BASE_URL, GLM_BIGMODEL_BASE_URL];
749
+ }
750
+
751
+ /** Extract the `limits` array from the several envelope shapes the API uses. */
752
+ export function glmLimits(payload) {
753
+ if (Array.isArray(payload?.limits)) return payload.limits;
754
+ if (Array.isArray(payload?.data?.limits)) return payload.data.limits;
755
+ if (Array.isArray(payload?.data?.data?.limits)) return payload.data.data.limits;
756
+ return null;
757
+ }
758
+
759
+ /**
760
+ * Turn the GLM quota payload into canonical windows.
761
+ *
762
+ * `TOKENS_LIMIT` with `unit: 3, number: 5` is the 5-hour token window and
763
+ * `unit: 6, number: 1` is the weekly one; `TIME_LIMIT` is the monthly MCP tool
764
+ * limit. Unknown limits are still surfaced, labeled by their raw type.
765
+ * @param {unknown} payload
766
+ */
767
+ export function normalizeGlmUsage(payload) {
768
+ const limits = glmLimits(payload);
769
+ if (limits === null || limits.length === 0) {
770
+ throw new ProviderError("invalid-response", "GLM Code Plan: response has no limits array");
771
+ }
772
+ const windows = [];
773
+ const seen = new Map();
774
+ for (let index = 0; index < limits.length; index++) {
775
+ const raw = limits[index];
776
+ if (raw === null || typeof raw !== "object") continue;
777
+ const percent = finiteNumber(raw.percentage);
778
+ if (percent === null) continue;
779
+ const type = typeof raw.type === "string" ? raw.type : "UNKNOWN";
780
+ let key;
781
+ let labelKey;
782
+ let hintKey = null;
783
+ let note;
784
+ if (type === "TOKENS_LIMIT") {
785
+ const unit = finiteNumber(raw.unit);
786
+ const number = finiteNumber(raw.number);
787
+ if (unit === 3 && number === 5) {
788
+ key = "five-hour";
789
+ } else if (unit === 6 && number === 1) {
790
+ key = "weekly";
791
+ } else {
792
+ key = "token";
793
+ note = unit !== null && number !== null ? `${unit}/${number}` : undefined;
794
+ }
795
+ } else if (type === "TIME_LIMIT") {
796
+ key = "mcp";
797
+ labelKey = "window.mcp";
798
+ hintKey = "window.mcp.hint";
799
+ } else {
800
+ key = "other";
801
+ labelKey = "window.other";
802
+ note = type;
803
+ }
804
+ const occurrence = seen.get(key) ?? 0;
805
+ seen.set(key, occurrence + 1);
806
+ if (occurrence > 0) key = `${key}-${occurrence + 1}`;
807
+ windows.push(makeWindow(key, percent, {
808
+ ...(labelKey === undefined ? {} : { labelKey }),
809
+ hintKey,
810
+ status: percent >= 100 ? "exceeded" : "ok",
811
+ resetsAt: raw.nextResetTime,
812
+ note
813
+ }));
814
+ }
815
+ if (windows.length === 0) {
816
+ throw new ProviderError("invalid-response", "GLM Code Plan: limits contained no percentage values");
817
+ }
818
+ const order = { "five-hour": 0, weekly: 1, mcp: 2, token: 3, other: 4 };
819
+ windows.sort((a, b) => (order[/** @type {keyof typeof order} */ (a.key)] ?? 9) - (order[/** @type {keyof typeof order} */ (b.key)] ?? 9));
820
+ return windows;
821
+ }
822
+
823
+ /**
824
+ * Read GLM Code Plan quota.
825
+ *
826
+ * The API uses a raw `Authorization: <token>` header (no Bearer prefix) and
827
+ * answers HTTP 200 with `{ success: false, msg }` for account-level failures.
828
+ * In `auto` region mode both the international (`api.z.ai`) and China
829
+ * (`open.bigmodel.cn`) hosts are probed.
830
+ *
831
+ * @param {{
832
+ * config: { apiKeyEnv?: string, baseUrl?: string, region?: string },
833
+ * resolveCredential: (refs: string[]) => Promise<{ ref: string, value: string } | null>,
834
+ * fetchImpl?: typeof fetch,
835
+ * }} input
836
+ */
837
+ export async function readGlm(input) {
838
+ const { config, resolveCredential, fetchImpl = fetch } = input;
839
+ const refs = uniqueStrings([config.apiKeyEnv, ...GLM_KNOWN_REFS]).filter(isCredentialRefName);
840
+ const credential = await resolveCredential(refs);
841
+ if (credential === null) {
842
+ throw new ProviderError(
843
+ "no-credential",
844
+ `GLM Code Plan: no API key (set ${refs[0] ?? DEFAULT_GLM_API_KEY_ENV}, or ZAI_CODING_CN_API_KEY / ZHIPU_API_KEY)`,
845
+ { account: { ref: refs[0] ?? DEFAULT_GLM_API_KEY_ENV, id: null, name: "" } }
846
+ );
847
+ }
848
+
849
+ const bases = glmBases(config);
850
+ if (bases.length === 0) {
851
+ throw new ProviderError("no-endpoint", "GLM Code Plan: custom region needs glmBaseUrl", {
852
+ account: { ref: credential.ref, id: null, name: "" }
853
+ });
854
+ }
855
+ let lastError = null;
856
+ for (const base of bases) {
857
+ let payload;
858
+ try {
859
+ payload = await fetchJson(
860
+ fetchImpl,
861
+ glmQuotaUrl(base),
862
+ {
863
+ headers: {
864
+ // The GLM quota endpoint expects the raw token, not a Bearer scheme.
865
+ Authorization: String(credential.value).replace(/^Bearer\s+/i, ""),
866
+ "Content-Type": "application/json",
867
+ "Accept-Language": "en-US,en"
868
+ }
869
+ },
870
+ `GLM Code Plan (${base})`
871
+ );
872
+ } catch (error) {
873
+ lastError = error;
874
+ continue;
875
+ }
876
+ if (payload?.success === false) {
877
+ const message = typeof payload.msg === "string" && payload.msg.length > 0
878
+ ? payload.msg
879
+ : typeof payload.message === "string" && payload.message.length > 0 ? payload.message : "request unsuccessful";
880
+ lastError = new ProviderError("api-error", `GLM Code Plan (${base}): ${message}`);
881
+ continue;
882
+ }
883
+ let windows;
884
+ try {
885
+ windows = normalizeGlmUsage(payload);
886
+ } catch (error) {
887
+ lastError = error;
888
+ continue;
889
+ }
890
+ const data = payload?.data !== null && typeof payload?.data === "object" ? payload.data : {};
891
+ const planName = [data.planName, data.plan, data.packageName, data.level]
892
+ .find((value) => typeof value === "string" && value.length > 0) ?? null;
893
+ const accountName = [data.userName, data.user?.name, data.name]
894
+ .find((value) => typeof value === "string" && value.length > 0) ?? "";
895
+ return {
896
+ ...providerInfo("glm"),
897
+ status: "ok",
898
+ account: { ref: credential.ref, id: null, name: accountName },
899
+ plan: planName === null ? { id: null, name: "GLM Code Plan", status: null, periodStart: null, periodEnd: null, totalCredits: null } : {
900
+ id: null,
901
+ name: planName,
902
+ status: null,
903
+ periodStart: null,
904
+ periodEnd: null,
905
+ totalCredits: null
906
+ },
907
+ source: `credential:${credential.ref}`,
908
+ requested: null,
909
+ fallback: null,
910
+ windows,
911
+ meta: { region: base }
912
+ };
913
+ }
914
+
915
+ const message = lastError instanceof Error ? lastError.message : String(lastError ?? "no endpoint answered");
916
+ throw new ProviderError(lastError instanceof ProviderError ? lastError.code : "read-failed", message, {
917
+ account: { ref: credential.ref, id: null, name: "" }
918
+ });
919
+ }