@hk_net/pi-usage-bars 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-07-22
4
+
5
+ ### Added
6
+
7
+ - Added Kimi For Coding quota support through Pi's `kimi-coding` credential and the first-party five-hour/weekly usage endpoint.
8
+ - Added separate MiniMax Global and China Coding/Token Plan support, including the current token-plan endpoint and legacy coding-plan fallback.
9
+ - Added provider-specific quota labels and support for MiniMax responses that expose an interval quota without a weekly quota.
10
+ - Added neutral MiniMax purchased-Credits balance rendering when a key-authenticated first-party response exposes a balance.
11
+ - Added OpenRouter account balance and daily/weekly/monthly key spend using the first-party Credits and Key APIs.
12
+ - Added OpenRouter per-key limit bars only when the key has a real configured credit limit.
13
+ - Added DeepSeek total, topped-up, and granted balance support through the official key-authenticated balance API.
14
+ - Added separate Moonshot/Kimi API Global and China available, cash, and voucher balance support.
15
+ - Added a typed financial-metrics roadmap for further balance/spend providers.
16
+ - Added provider parsing, regional routing, endpoint fallback, financial rendering, authentication lifecycle, and smoke tests.
17
+ - Added CI, reproducible installs, production auditing, and a maintainer release guide.
18
+
19
+ ### Changed
20
+
21
+ - Treat MiniMax status `2062` as a neutral “No active Token Plan” account state rather than an API error; the cookie-only console balance endpoint remains out of scope.
22
+ - Expanded endpoint configuration documentation for Kimi, MiniMax, OpenRouter, DeepSeek, and Moonshot.
23
+ - Changed npm publishing to an explicit manual workflow so source tags cannot accidentally republish an already released version.
24
+ - Bumped the package version to 0.4.0.
25
+
3
26
  ## [0.3.0] - 2026-07-22
4
27
 
5
28
  ### Breaking changes
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # pi-usage-bars
2
2
 
3
- Subscription usage indicators for [Pi](https://github.com/earendil-works/pi).
3
+ Quota, balance, and spend indicators for [Pi](https://github.com/earendil-works/pi).
4
4
 
5
5
  ![Codex footer bar](https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/codex.png)
6
6
 
7
7
  It adds:
8
8
 
9
- - a footer status bar for the active subscription provider
10
- - a `/usage` command showing every configured supported subscription
9
+ - a footer status bar for the active supported provider
10
+ - a `/usage` command showing configured quota, balance, and spend data
11
11
 
12
12
  ## Supported providers
13
13
 
@@ -17,6 +17,19 @@ It adds:
17
17
  | Anthropic Claude | `anthropic` | Claude Pro/Max OAuth |
18
18
  | ZAI Coding Plan (Global) | `zai` | ZAI API key |
19
19
  | ZAI Coding Plan (China) | `zai-coding-cn` | ZAI China API key |
20
+ | Kimi For Coding | `kimi-coding` | Kimi Coding Plan API key |
21
+ | MiniMax Coding Plan (Global) | `minimax` | MiniMax Global API key |
22
+ | MiniMax Coding Plan (China) | `minimax-cn` | MiniMax China API key |
23
+ | OpenRouter | `openrouter` | OpenRouter API key |
24
+ | DeepSeek | `deepseek` | DeepSeek API key |
25
+ | Moonshot/Kimi API (Global) | `moonshotai` | Moonshot Global API key |
26
+ | Moonshot/Kimi API (China) | `moonshotai-cn` | Moonshot China API key |
27
+
28
+ DeepSeek shows total, topped-up, and granted balances in the currency returned by the API. Moonshot shows available, cash, and voucher balances; this is separate from the Kimi For Coding subscription provider. Pi uses `MOONSHOT_API_KEY` for both Moonshot regions, so `/usage` automatically hides the expected authentication failure from the region where a shared environment key is not valid.
29
+
30
+ OpenRouter shows the account credit balance and current daily, weekly, and monthly key spend. If the API key has a configured credit limit, that limit is also rendered as a usage bar.
31
+
32
+ MiniMax Subscription Keys can represent an active Token Plan, purchased Credits, or both. The extension shows quota windows when present and a neutral credit-balance line if a first-party key-authenticated response exposes `points_balance`/`credits_balance`. MiniMax currently exposes Credits-only balances through a console endpoint requiring browser-cookie authentication, so a key-only Credits account is shown as “No active Token Plan” with a direction to check the console rather than a fabricated percentage. The extension does not import browser cookies.
20
33
 
21
34
  Google Gemini CLI and Google Antigravity are not supported because Pi removed those built-in providers in version 0.71.0.
22
35
 
@@ -43,11 +56,17 @@ pi install https://github.com/hknet/pi-usage-bars
43
56
 
44
57
  Restart Pi after installation, or use `/reload` when the package is already installed.
45
58
 
59
+ Update an existing npm installation with:
60
+
61
+ ```bash
62
+ pi update npm:@hk_net/pi-usage-bars
63
+ ```
64
+
46
65
  ## Use
47
66
 
48
67
  ### Footer usage bars
49
68
 
50
- When the active model belongs to a configured supported provider, the footer shows session and weekly usage with reset countdowns when available.
69
+ When the active model belongs to a configured supported provider, the footer shows its available quota windows with reset countdowns when provided by the service.
51
70
 
52
71
  ![Claude footer bar](https://raw.githubusercontent.com/hknet/pi-usage-bars/main/assets/claude.png)
53
72
 
@@ -61,17 +80,31 @@ Run `/usage` to open a searchable list of configured supported providers.
61
80
 
62
81
  ## Endpoint configuration
63
82
 
64
- The ZAI monitoring endpoints can be overridden:
83
+ First-party monitoring endpoints can be overridden:
65
84
 
66
85
  | Variable | Default |
67
86
  | --- | --- |
68
87
  | `PI_ZAI_USAGE_ENDPOINT` | `https://api.z.ai/api/monitor/usage/quota/limit` |
69
88
  | `PI_ZAI_CODING_CN_USAGE_ENDPOINT` | `https://open.bigmodel.cn/api/monitor/usage/quota/limit` |
89
+ | `PI_KIMI_USAGE_ENDPOINT` | `https://api.kimi.com/coding/v1/usages` |
90
+ | `PI_MINIMAX_USAGE_ENDPOINT` | `https://api.minimax.io/v1/token_plan/remains` |
91
+ | `PI_MINIMAX_LEGACY_USAGE_ENDPOINT` | `https://api.minimax.io/v1/api/openplatform/coding_plan/remains` |
92
+ | `PI_MINIMAX_CN_USAGE_ENDPOINT` | `https://api.minimaxi.com/v1/token_plan/remains` |
93
+ | `PI_MINIMAX_CN_LEGACY_USAGE_ENDPOINT` | `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` |
94
+ | `PI_OPENROUTER_CREDITS_ENDPOINT` | `https://openrouter.ai/api/v1/credits` |
95
+ | `PI_OPENROUTER_KEY_ENDPOINT` | `https://openrouter.ai/api/v1/key` |
96
+ | `PI_DEEPSEEK_BALANCE_ENDPOINT` | `https://api.deepseek.com/user/balance` |
97
+ | `PI_MOONSHOT_BALANCE_ENDPOINT` | `https://api.moonshot.ai/v1/users/me/balance` |
98
+ | `PI_MOONSHOT_CN_BALANCE_ENDPOINT` | `https://api.moonshot.cn/v1/users/me/balance` |
70
99
 
71
100
  **Security:** the corresponding provider token is sent as a bearer token to the configured endpoint. Only override these variables with an endpoint you trust.
72
101
 
73
102
  The Codex and Claude usage endpoints are fixed to their first-party services. Claude responses are cached briefly in the system temporary directory to coordinate multiple Pi processes and reduce rate limiting. The cache contains usage values, not credentials.
74
103
 
104
+ ## Financial metrics roadmap
105
+
106
+ Quota percentages and monetary account data have different meaning and color semantics. OpenRouter, DeepSeek, Moonshot, and MiniMax financial data are rendered as neutral account values; percentages are used only when an actual limit exists. See [Financial metrics plan](docs/financial-metrics-plan.md) for the normalized model and future provider rollout.
107
+
75
108
  ## Development
76
109
 
77
110
  Install Node.js 22.19+ and Bun 1.3, then run:
@@ -83,6 +116,8 @@ npm run check
83
116
 
84
117
  `npm run check` performs a strict TypeScript check, runs the Bun test suite, and smoke-loads the extension with the pinned current Pi release.
85
118
 
119
+ Maintainers should follow the complete [release and npm publication guide](docs/releasing.md). npm publication is manual; pushing a Git tag does not publish automatically.
120
+
86
121
  ## Credits
87
122
 
88
123
  This extension is based on and inspired by:
@@ -0,0 +1,75 @@
1
+ # Financial metrics plan
2
+
3
+ ## Goal
4
+
5
+ Add an optional account-value view without treating balances and spend as subscription quota. Quota usage increases toward exhaustion; a balance decreases toward exhaustion; spend may be meaningful only relative to a budget. Those metrics must retain distinct labels and color rules.
6
+
7
+ ## Normalized model
8
+
9
+ A future core revision should replace the two fixed quota fields with a list of typed metrics:
10
+
11
+ ```ts
12
+ type UsageMetric =
13
+ | {
14
+ kind: "quota";
15
+ id: string;
16
+ label: string;
17
+ usedPercent: number;
18
+ resetsAt?: string;
19
+ }
20
+ | {
21
+ kind: "money";
22
+ id: string;
23
+ label: string;
24
+ currency: string;
25
+ balance?: number;
26
+ spent?: number;
27
+ limit?: number;
28
+ period?: "day" | "week" | "month" | "lifetime";
29
+ resetsAt?: string;
30
+ };
31
+ ```
32
+
33
+ Provider fetchers should return normalized metrics. Rendering, not provider parsing, decides which metrics appear in the footer or `/usage` dialog.
34
+
35
+ ## View behavior
36
+
37
+ The initial OpenRouter implementation displays both balance and spend in `/usage`. The footer prefers the balance and current-month spend, while a configured per-key limit remains the primary bar. A later persistent view setting may provide:
38
+
39
+ - `auto`: prefer quota windows; otherwise show a financial summary.
40
+ - `balance`: show remaining prepaid/credit balance.
41
+ - `spent`: show spend and a percentage only when a real limit or budget exists.
42
+ - `off`: retain quota-only behavior.
43
+
44
+ No environment variable or config key is reserved for that future view selector yet.
45
+
46
+ Color semantics:
47
+
48
+ - quota `usedPercent`: high is bad;
49
+ - balance: low remaining balance is bad only when a known starting balance or warning threshold exists;
50
+ - spend/limit: high is bad;
51
+ - unconstrained spend and raw balance: neutral colors, never a manufactured percentage.
52
+
53
+ ## Provider rollout
54
+
55
+ 1. **OpenRouter — implemented in 0.4.0**
56
+ - `GET /api/v1/credits`: purchased credits, total usage, derived balance.
57
+ - `GET /api/v1/key`: configured key limit and daily/weekly/monthly spend where supplied.
58
+ - Uses only the API key resolved through Pi's provider registry.
59
+ 2. **Balance providers**
60
+ - DeepSeek and Moonshot/Kimi API prepaid balances are implemented in 0.4.0 using their official key-authenticated endpoints.
61
+ - Mistral balance or monthly-plan data when available through an API credential.
62
+ - MiniMax purchased Credits have a limited provider-specific implementation: when a first-party key-authenticated quota response includes `points_balance` or `credits_balance`, it is rendered neutrally. In current MiniMax deployments, the dedicated `/backend/account/token_plan_credit` endpoint requires browser-cookie authentication and does not accept the Subscription Key, so it remains out of scope. API status `2062` is rendered as a neutral “No active Token Plan” account state and never converted into a quota percentage.
63
+ 3. **Spend providers**
64
+ - OpenAI organization spend requires an Admin API key and therefore should appear only when Pi can resolve an appropriate credential.
65
+ - AWS Bedrock spend requires explicit Cost Explorer permissions and should remain a separate opt-in integration.
66
+
67
+ Browser cookies and direct reads of Pi's credential files remain out of scope.
68
+
69
+ ## Migration steps
70
+
71
+ 1. Introduce `UsageMetric` alongside the existing `session`/`weekly` compatibility fields.
72
+ 2. Convert quota fetchers and both renderers to metric lists.
73
+ 3. Add persistent view selection; neutral financial formatting is already covered by OpenRouter tests.
74
+ 4. Add further key-authenticated financial providers beyond OpenRouter, DeepSeek, and Moonshot.
75
+ 5. Remove compatibility fields only in a documented major release.
@@ -0,0 +1,106 @@
1
+ # Releasing
2
+
3
+ This repository uses semantic versions. The package version, changelog heading, Git tag, and npm version must agree.
4
+
5
+ The npm publish workflow is manual-only. Pushing a Git tag does not publish the package, which prevents a tag pushed after a local npm release from attempting to publish the same version twice.
6
+
7
+ ## Prerequisites
8
+
9
+ - Write access to `hknet/pi-usage-bars`.
10
+ - Publish access to the npm scope `@hk_net`.
11
+ - npm authentication for the `hk_net` account (`npm whoami`).
12
+ - Node.js 22.19 or newer.
13
+ - Bun 1.3.0. The commands below use a temporary pinned Bun installation, so a global Bun installation is not required.
14
+
15
+ Never place an npm token in the repository, command history, or endpoint override. Complete npm's browser or OTP authorization when prompted.
16
+
17
+ ## 1. Verify the source release
18
+
19
+ From the repository root:
20
+
21
+ ```bash
22
+ git switch main
23
+ git pull --ff-only origin main
24
+ git status --short
25
+ node -p "require('./package.json').version"
26
+ ```
27
+
28
+ For this release, the version must be `0.4.0` and the working tree must be clean.
29
+
30
+ Run the same checks used by CI and npm's `prepublishOnly` hook:
31
+
32
+ ```bash
33
+ npx --yes --package bun@1.3.0 -- npm run check
34
+ npm audit --omit=dev --audit-level=high
35
+ npm pack --dry-run
36
+ ```
37
+
38
+ Expected results for `0.4.0`:
39
+
40
+ - strict TypeScript check passes;
41
+ - 32 tests pass;
42
+ - Pi smoke test passes;
43
+ - production audit has no vulnerabilities;
44
+ - the dry-run package is `@hk_net/pi-usage-bars@0.4.0` and contains the extension, documentation, changelog, and license.
45
+
46
+ ## 2. Verify npm state
47
+
48
+ ```bash
49
+ npm whoami
50
+ npm view @hk_net/pi-usage-bars version
51
+ ```
52
+
53
+ Before publishing `0.4.0`, the registry should still report `0.3.0`. If it already reports `0.4.0`, do not publish it again.
54
+
55
+ Optionally inspect exactly what npm will receive:
56
+
57
+ ```bash
58
+ npm pack --dry-run --json
59
+ ```
60
+
61
+ ## 3. Publish manually
62
+
63
+ Local npm publication cannot use GitHub's OIDC provenance attestation, so explicitly disable provenance for this manual release:
64
+
65
+ ```bash
66
+ npx --yes --package bun@1.3.0 -- \
67
+ npm publish --access public --provenance=false
68
+ ```
69
+
70
+ The `prepublishOnly` hook reruns the complete check. Complete the npm browser/OTP authorization if requested. Do not retry blindly after an ambiguous network failure; verify the registry first.
71
+
72
+ ## 4. Verify the published package
73
+
74
+ ```bash
75
+ npm view @hk_net/pi-usage-bars version dist-tags.latest
76
+ npm view @hk_net/pi-usage-bars@0.4.0 dist.integrity
77
+ ```
78
+
79
+ Both version values should report `0.4.0`, and the integrity field should be present.
80
+
81
+ Test installation or update through Pi:
82
+
83
+ ```bash
84
+ pi install npm:@hk_net/pi-usage-bars
85
+ # If it is already installed:
86
+ pi update npm:@hk_net/pi-usage-bars
87
+ ```
88
+
89
+ Restart Pi, or use `/reload`, then run `/usage`.
90
+
91
+ ## 5. Tag the verified source
92
+
93
+ Only tag after npm verification succeeds:
94
+
95
+ ```bash
96
+ git switch main
97
+ git pull --ff-only origin main
98
+ git tag -a v0.4.0 -m "Release v0.4.0"
99
+ git push origin v0.4.0
100
+ ```
101
+
102
+ The tag does not trigger npm publication. It records the exact source corresponding to the already-published package. A GitHub release can then be created from `v0.4.0` using the `0.4.0` changelog section.
103
+
104
+ ## CI publishing alternative
105
+
106
+ The **Publish to npm (manual)** GitHub Actions workflow remains available as an alternative. It requires the `NPM_TOKEN` repository secret and publishes with npm provenance. Do not run it after publishing the same version locally.
@@ -2,18 +2,62 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
 
5
- export type ProviderKey = "codex" | "claude" | "zai" | "zai-cn";
6
- export type PiProviderId = "openai-codex" | "anthropic" | "zai" | "zai-coding-cn";
5
+ export type ProviderKey =
6
+ | "codex"
7
+ | "claude"
8
+ | "zai"
9
+ | "zai-cn"
10
+ | "kimi"
11
+ | "minimax"
12
+ | "minimax-cn"
13
+ | "openrouter"
14
+ | "deepseek"
15
+ | "moonshot"
16
+ | "moonshot-cn";
17
+ export type PiProviderId =
18
+ | "openai-codex"
19
+ | "anthropic"
20
+ | "zai"
21
+ | "zai-coding-cn"
22
+ | "kimi-coding"
23
+ | "minimax"
24
+ | "minimax-cn"
25
+ | "openrouter"
26
+ | "deepseek"
27
+ | "moonshotai"
28
+ | "moonshotai-cn";
29
+
30
+ export interface AccountBalance {
31
+ amount: number;
32
+ unit: string;
33
+ label: string;
34
+ }
35
+
36
+ export interface AccountSpend {
37
+ unit: string;
38
+ daily?: number;
39
+ weekly?: number;
40
+ monthly?: number;
41
+ lifetime?: number;
42
+ }
7
43
 
8
44
  export interface UsageData {
9
45
  session: number;
10
46
  weekly: number;
47
+ quotaHidden?: boolean;
48
+ accountBalance?: AccountBalance;
49
+ accountBalanceDetails?: AccountBalance[];
50
+ accountSpend?: AccountSpend;
11
51
  sessionResetsIn?: string;
12
52
  weeklyResetsIn?: string;
13
53
  sessionResetsAt?: string;
14
54
  weeklyResetsAt?: string;
15
55
  extraSpend?: number;
16
56
  extraLimit?: number;
57
+ sessionLabel?: string;
58
+ weeklyLabel?: string;
59
+ weeklyHidden?: boolean;
60
+ notice?: string;
17
61
  warning?: string;
18
62
  stale?: boolean;
19
63
  fetchedAt?: number;
@@ -26,6 +70,16 @@ export type UsageTokens = Partial<Record<ProviderKey, string>>;
26
70
  export interface UsageEndpoints {
27
71
  zai: string;
28
72
  zaiCn: string;
73
+ kimi: string;
74
+ minimax: string;
75
+ minimaxLegacy: string;
76
+ minimaxCn: string;
77
+ minimaxCnLegacy: string;
78
+ openRouterCredits: string;
79
+ openRouterKey: string;
80
+ deepSeekBalance: string;
81
+ moonshotBalance: string;
82
+ moonshotCnBalance: string;
29
83
  }
30
84
 
31
85
  export interface HeadersLike {
@@ -108,6 +162,16 @@ const CLAUDE_LOCK_STALE_MS = 20_000;
108
162
  export const DEFAULT_USAGE_CACHE_FILE = path.join(os.tmpdir(), "pi", "usage-bars-cache.json");
109
163
  export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
110
164
  export const DEFAULT_ZAI_CN_USAGE_ENDPOINT = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
165
+ export const DEFAULT_KIMI_USAGE_ENDPOINT = "https://api.kimi.com/coding/v1/usages";
166
+ export const DEFAULT_MINIMAX_USAGE_ENDPOINT = "https://api.minimax.io/v1/token_plan/remains";
167
+ export const DEFAULT_MINIMAX_LEGACY_USAGE_ENDPOINT = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains";
168
+ export const DEFAULT_MINIMAX_CN_USAGE_ENDPOINT = "https://api.minimaxi.com/v1/token_plan/remains";
169
+ export const DEFAULT_MINIMAX_CN_LEGACY_USAGE_ENDPOINT = "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains";
170
+ export const DEFAULT_OPENROUTER_CREDITS_ENDPOINT = "https://openrouter.ai/api/v1/credits";
171
+ export const DEFAULT_OPENROUTER_KEY_ENDPOINT = "https://openrouter.ai/api/v1/key";
172
+ export const DEFAULT_DEEPSEEK_BALANCE_ENDPOINT = "https://api.deepseek.com/user/balance";
173
+ export const DEFAULT_MOONSHOT_BALANCE_ENDPOINT = "https://api.moonshot.ai/v1/users/me/balance";
174
+ export const DEFAULT_MOONSHOT_CN_BALANCE_ENDPOINT = "https://api.moonshot.cn/v1/users/me/balance";
111
175
 
112
176
  export function resolveUsageEndpoints(env: NodeJS.ProcessEnv = process.env): UsageEndpoints {
113
177
  const configured = (value: string | undefined, fallback: string) => {
@@ -118,6 +182,16 @@ export function resolveUsageEndpoints(env: NodeJS.ProcessEnv = process.env): Usa
118
182
  return {
119
183
  zai: configured(env.PI_ZAI_USAGE_ENDPOINT, DEFAULT_ZAI_USAGE_ENDPOINT),
120
184
  zaiCn: configured(env.PI_ZAI_CODING_CN_USAGE_ENDPOINT, DEFAULT_ZAI_CN_USAGE_ENDPOINT),
185
+ kimi: configured(env.PI_KIMI_USAGE_ENDPOINT, DEFAULT_KIMI_USAGE_ENDPOINT),
186
+ minimax: configured(env.PI_MINIMAX_USAGE_ENDPOINT, DEFAULT_MINIMAX_USAGE_ENDPOINT),
187
+ minimaxLegacy: configured(env.PI_MINIMAX_LEGACY_USAGE_ENDPOINT, DEFAULT_MINIMAX_LEGACY_USAGE_ENDPOINT),
188
+ minimaxCn: configured(env.PI_MINIMAX_CN_USAGE_ENDPOINT, DEFAULT_MINIMAX_CN_USAGE_ENDPOINT),
189
+ minimaxCnLegacy: configured(env.PI_MINIMAX_CN_LEGACY_USAGE_ENDPOINT, DEFAULT_MINIMAX_CN_LEGACY_USAGE_ENDPOINT),
190
+ openRouterCredits: configured(env.PI_OPENROUTER_CREDITS_ENDPOINT, DEFAULT_OPENROUTER_CREDITS_ENDPOINT),
191
+ openRouterKey: configured(env.PI_OPENROUTER_KEY_ENDPOINT, DEFAULT_OPENROUTER_KEY_ENDPOINT),
192
+ deepSeekBalance: configured(env.PI_DEEPSEEK_BALANCE_ENDPOINT, DEFAULT_DEEPSEEK_BALANCE_ENDPOINT),
193
+ moonshotBalance: configured(env.PI_MOONSHOT_BALANCE_ENDPOINT, DEFAULT_MOONSHOT_BALANCE_ENDPOINT),
194
+ moonshotCnBalance: configured(env.PI_MOONSHOT_CN_BALANCE_ENDPOINT, DEFAULT_MOONSHOT_CN_BALANCE_ENDPOINT),
121
195
  };
122
196
  }
123
197
 
@@ -384,12 +458,20 @@ function snapshotUsage(usage: UsageData, nowMs = Date.now()): UsageData {
384
458
  return {
385
459
  session: usage.session,
386
460
  weekly: usage.weekly,
461
+ quotaHidden: usage.quotaHidden,
462
+ accountBalance: usage.accountBalance,
463
+ accountBalanceDetails: usage.accountBalanceDetails,
464
+ accountSpend: usage.accountSpend,
387
465
  sessionResetsAt: usage.sessionResetsAt,
388
466
  weeklyResetsAt: usage.weeklyResetsAt,
389
467
  sessionResetsIn: usage.sessionResetsIn,
390
468
  weeklyResetsIn: usage.weeklyResetsIn,
391
469
  extraSpend: usage.extraSpend,
392
470
  extraLimit: usage.extraLimit,
471
+ sessionLabel: usage.sessionLabel,
472
+ weeklyLabel: usage.weeklyLabel,
473
+ weeklyHidden: usage.weeklyHidden,
474
+ notice: usage.notice,
393
475
  fetchedAt: usage.fetchedAt ?? nowMs,
394
476
  };
395
477
  }
@@ -545,6 +627,463 @@ export async function fetchClaudeUsageWithFallback(
545
627
  }
546
628
  }
547
629
 
630
+ function readNumber(value: unknown): number | null {
631
+ if (typeof value === "number" && Number.isFinite(value)) return value;
632
+ if (typeof value === "string" && value.trim()) {
633
+ const parsed = Number(value);
634
+ if (Number.isFinite(parsed)) return parsed;
635
+ }
636
+ return null;
637
+ }
638
+
639
+ function usedPercentFromCounts(
640
+ value: Record<string, unknown> | null | undefined,
641
+ options: { remainingPercent?: string; used?: string; total?: string; remaining?: string } = {},
642
+ ): number | null {
643
+ if (!value) return null;
644
+ const remainingPercent = readNumber(value[options.remainingPercent ?? "remaining_percent"]);
645
+ if (remainingPercent !== null) return Math.max(0, Math.min(100, 100 - remainingPercent));
646
+
647
+ const total = readNumber(value[options.total ?? "limit"]);
648
+ const used = readNumber(value[options.used ?? "used"]);
649
+ const remaining = readNumber(value[options.remaining ?? "remaining"]);
650
+ if (total === null || total <= 0) return null;
651
+ if (used !== null) return Math.max(0, Math.min(100, used / total * 100));
652
+ if (remaining !== null) return Math.max(0, Math.min(100, (total - remaining) / total * 100));
653
+ return null;
654
+ }
655
+
656
+ function normalizeIsoDate(value: unknown): string | undefined {
657
+ if (typeof value !== "string" || !value.trim()) return undefined;
658
+ const normalized = value.trim().replace(/(\.\d{3})\d+(?=Z|[+-]\d\d:\d\d$)/, "$1");
659
+ return Number.isFinite(new Date(normalized).getTime()) ? normalized : undefined;
660
+ }
661
+
662
+ function isoFromEpoch(value: unknown): string | undefined {
663
+ const raw = readNumber(value);
664
+ if (raw === null || raw <= 0) return undefined;
665
+ const milliseconds = raw > 1_000_000_000_000 ? raw : raw * 1000;
666
+ const date = new Date(milliseconds);
667
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
668
+ }
669
+
670
+ function resetFromRemains(value: unknown, nowMs: number): string | undefined {
671
+ const raw = readNumber(value);
672
+ if (raw === null || raw <= 0) return undefined;
673
+ const milliseconds = raw > 1_000_000 ? raw : raw * 1000;
674
+ return new Date(nowMs + milliseconds).toISOString();
675
+ }
676
+
677
+ export function extractKimiUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
678
+ const root = asObject(payload);
679
+ if (!root) return null;
680
+ const webUsages = Array.isArray(root.usages) ? root.usages : undefined;
681
+ const codingUsage = webUsages?.map(asObject).find((entry) =>
682
+ String(entry?.scope ?? "").toUpperCase() === "FEATURE_CODING") ?? root;
683
+ const dataRows = Array.isArray(codingUsage.data) ? codingUsage.data.map(asObject).filter(Boolean) : [];
684
+ const usage = asObject(codingUsage.usage) ?? asObject(codingUsage.detail) ??
685
+ dataRows.find((entry) => String(entry?.model_name ?? entry?.modelName ?? "").toLowerCase() === "all");
686
+ const limits = Array.isArray(codingUsage.limits)
687
+ ? codingUsage.limits
688
+ : dataRows.filter((entry) => entry !== usage);
689
+ const sessionLimit = limits.map(asObject).find((entry) => {
690
+ const window = asObject(entry?.window);
691
+ const duration = readNumber(window?.duration);
692
+ const unit = String(window?.timeUnit ?? window?.time_unit ?? "").toUpperCase();
693
+ return duration === 300 && unit.includes("MINUTE");
694
+ }) ?? limits.map(asObject).find((entry) => entry !== null);
695
+ const sessionDetail = asObject(sessionLimit?.detail) ?? sessionLimit;
696
+
697
+ const session = usedPercentFromCounts(sessionDetail);
698
+ const weekly = usedPercentFromCounts(usage);
699
+ if (session === null || weekly === null) return null;
700
+
701
+ const sessionReset = normalizeIsoDate(sessionDetail?.resetTime ?? sessionDetail?.reset_at ?? sessionDetail?.reset_time);
702
+ const weeklyReset = normalizeIsoDate(usage?.resetTime ?? usage?.reset_at ?? usage?.reset_time);
703
+ return hydrateUsageResets({
704
+ ...normalizeUsagePair(session, weekly),
705
+ sessionLabel: "5-hour",
706
+ weeklyLabel: "Weekly",
707
+ sessionResetsAt: sessionReset,
708
+ weeklyResetsAt: weeklyReset,
709
+ }, nowMs);
710
+ }
711
+
712
+ export async function fetchKimiUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
713
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
714
+ const result = await requestJson(endpoints.kimi, {
715
+ headers: {
716
+ Authorization: `Bearer ${token}`,
717
+ "User-Agent": "KimiCLI/1.5",
718
+ },
719
+ }, config);
720
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
721
+ return extractKimiUsageFromPayload(result.data) ?? {
722
+ session: 0,
723
+ weekly: 0,
724
+ error: "unrecognized response shape",
725
+ };
726
+ }
727
+
728
+ interface MiniMaxWindow {
729
+ percent: number;
730
+ resetsAt?: string;
731
+ }
732
+
733
+ function pickHighestWindow(windows: MiniMaxWindow[]): MiniMaxWindow | undefined {
734
+ return windows.reduce<MiniMaxWindow | undefined>((highest, window) =>
735
+ !highest || window.percent > highest.percent ? window : highest, undefined);
736
+ }
737
+
738
+ function miniMaxResetAt(value: Record<string, unknown>, prefix: "current" | "weekly", nowMs: number): string | undefined {
739
+ const end = prefix === "current"
740
+ ? value.end_time ?? value.endTime
741
+ : value.weekly_end_time ?? value.weeklyEndTime;
742
+ const remains = prefix === "current"
743
+ ? value.remains_time ?? value.remainsTime
744
+ : value.weekly_remains_time ?? value.weeklyRemainsTime;
745
+ const resetsAt = prefix === "current"
746
+ ? value.current_resets_at ?? value.currentResetsAt
747
+ : value.weekly_resets_at ?? value.weeklyResetsAt;
748
+ return normalizeIsoDate(resetsAt) ?? isoFromEpoch(end) ?? resetFromRemains(remains, nowMs);
749
+ }
750
+
751
+ function extractMiniMaxCreditBalance(payload: unknown): AccountBalance | undefined {
752
+ const root = asObject(payload);
753
+ const data = asObject(root?.data) ?? root;
754
+ if (!data) return undefined;
755
+ const amount = readNumber(
756
+ data.points_balance ?? data.pointsBalance ??
757
+ data.point_balance ?? data.pointBalance ??
758
+ data.credits_balance ?? data.creditsBalance ??
759
+ data.credit_balance ?? data.creditBalance,
760
+ );
761
+ return amount === null ? undefined : { amount, unit: "credits", label: "Credit balance" };
762
+ }
763
+
764
+ export function extractMiniMaxUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
765
+ const root = asObject(payload);
766
+ const data = asObject(root?.data) ?? root;
767
+ if (!data) return null;
768
+ const accountBalance = extractMiniMaxCreditBalance(payload);
769
+
770
+ const intervalWindows: MiniMaxWindow[] = [];
771
+ const weeklyWindows: MiniMaxWindow[] = [];
772
+ if (Array.isArray(data.services)) {
773
+ for (const rawService of data.services) {
774
+ const service = asObject(rawService);
775
+ if (!service) continue;
776
+ const directPercent = readPercentCandidate(readNumber(service.percent));
777
+ const percent = directPercent ?? usedPercentFromCounts(service, { total: "limit", used: "usage" });
778
+ if (percent === null) continue;
779
+ const windowType = String(service.window_type ?? service.windowType ?? "").toLowerCase();
780
+ const resetsAt = normalizeIsoDate(service.resets_at ?? service.reset_time ?? service.end_time);
781
+ (windowType.includes("week") ? weeklyWindows : intervalWindows).push({ percent, resetsAt });
782
+ }
783
+ }
784
+
785
+ if (Array.isArray(data.model_remains ?? data.modelRemains)) {
786
+ for (const rawModel of (data.model_remains ?? data.modelRemains) as unknown[]) {
787
+ const raw = asObject(rawModel);
788
+ if (!raw) continue;
789
+ const model: Record<string, unknown> = {
790
+ ...raw,
791
+ current_interval_remaining_percent:
792
+ raw.current_interval_remaining_percent ?? raw.currentIntervalRemainingPercent,
793
+ current_interval_total_count: raw.current_interval_total_count ?? raw.currentIntervalTotalCount,
794
+ current_interval_usage_count: raw.current_interval_usage_count ?? raw.currentIntervalUsageCount,
795
+ current_interval_status: raw.current_interval_status ?? raw.currentIntervalStatus,
796
+ current_weekly_remaining_percent:
797
+ raw.current_weekly_remaining_percent ?? raw.currentWeeklyRemainingPercent,
798
+ current_weekly_total_count: raw.current_weekly_total_count ?? raw.currentWeeklyTotalCount,
799
+ current_weekly_usage_count: raw.current_weekly_usage_count ?? raw.currentWeeklyUsageCount,
800
+ current_weekly_status: raw.current_weekly_status ?? raw.currentWeeklyStatus,
801
+ };
802
+ const unavailable = (prefix: "interval" | "weekly") =>
803
+ readNumber(model[`current_${prefix}_status`]) === 3 &&
804
+ (readNumber(model[`current_${prefix}_remaining_percent`]) ?? 0) >= 100 &&
805
+ (readNumber(model[`current_${prefix}_total_count`]) ?? 0) === 0 &&
806
+ (readNumber(model[`current_${prefix}_usage_count`]) ?? 0) === 0;
807
+ const interval = unavailable("interval") ? null : usedPercentFromCounts(model, {
808
+ remainingPercent: "current_interval_remaining_percent",
809
+ total: "current_interval_total_count",
810
+ remaining: "current_interval_usage_count",
811
+ });
812
+ if (interval !== null) {
813
+ intervalWindows.push({ percent: interval, resetsAt: miniMaxResetAt(model, "current", nowMs) });
814
+ }
815
+ const weekly = unavailable("weekly") ? null : usedPercentFromCounts(model, {
816
+ remainingPercent: "current_weekly_remaining_percent",
817
+ total: "current_weekly_total_count",
818
+ remaining: "current_weekly_usage_count",
819
+ });
820
+ if (weekly !== null) {
821
+ weeklyWindows.push({ percent: weekly, resetsAt: miniMaxResetAt(model, "weekly", nowMs) });
822
+ }
823
+ }
824
+ }
825
+
826
+ const session = pickHighestWindow(intervalWindows);
827
+ const weekly = pickHighestWindow(weeklyWindows);
828
+ if (!session) {
829
+ return accountBalance
830
+ ? { session: 0, weekly: 0, quotaHidden: true, accountBalance }
831
+ : null;
832
+ }
833
+ return hydrateUsageResets({
834
+ session: Number(session.percent.toFixed(2)),
835
+ accountBalance,
836
+ weekly: Number((weekly?.percent ?? 0).toFixed(2)),
837
+ sessionLabel: "Interval",
838
+ weeklyLabel: "Weekly",
839
+ weeklyHidden: !weekly,
840
+ sessionResetsAt: session.resetsAt,
841
+ weeklyResetsAt: weekly?.resetsAt,
842
+ }, nowMs);
843
+ }
844
+
845
+ function miniMaxPayloadStatus(payload: unknown): number | null {
846
+ const root = asObject(payload);
847
+ const data = asObject(root?.data);
848
+ const baseResponse = asObject(data?.base_resp ?? data?.baseResp ?? root?.base_resp ?? root?.baseResp);
849
+ return readNumber(baseResponse?.status_code ?? baseResponse?.statusCode);
850
+ }
851
+
852
+ function miniMaxPayloadError(payload: unknown): string | null {
853
+ const root = asObject(payload);
854
+ const data = asObject(root?.data);
855
+ const baseResponse = asObject(data?.base_resp ?? data?.baseResp ?? root?.base_resp ?? root?.baseResp);
856
+ const status = miniMaxPayloadStatus(payload);
857
+ if (status === null || status === 0) return null;
858
+ const message = baseResponse?.status_msg ?? baseResponse?.statusMessage;
859
+ return typeof message === "string" && message.trim()
860
+ ? `API ${status}: ${message.trim()}`
861
+ : `API ${status}`;
862
+ }
863
+
864
+ export async function fetchMiniMaxUsage(
865
+ token: string,
866
+ provider: "minimax" | "minimax-cn" = "minimax",
867
+ config: FetchConfig = {},
868
+ ): Promise<UsageData> {
869
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
870
+ const candidates = provider === "minimax-cn"
871
+ ? [endpoints.minimaxCn, endpoints.minimaxCnLegacy]
872
+ : [endpoints.minimax, endpoints.minimaxLegacy];
873
+ let lastError = "usage request failed";
874
+ let credentialError: string | undefined;
875
+ let noActiveTokenPlan = false;
876
+
877
+ for (const endpoint of [...new Set(candidates)]) {
878
+ const result = await requestJson(endpoint, {
879
+ headers: {
880
+ Authorization: `Bearer ${token}`,
881
+ Accept: "application/json",
882
+ },
883
+ }, config);
884
+ if (!result.ok) {
885
+ lastError = result.error;
886
+ if (result.status === 401 || result.status === 403) credentialError ??= result.error;
887
+ if (config.signal?.aborted) break;
888
+ continue;
889
+ }
890
+ const payloadStatus = miniMaxPayloadStatus(result.data);
891
+ const payloadError = miniMaxPayloadError(result.data);
892
+ const usage = extractMiniMaxUsageFromPayload(result.data);
893
+ if (usage && (!payloadError || usage.quotaHidden)) return usage;
894
+ if (payloadStatus === 2062) {
895
+ noActiveTokenPlan = true;
896
+ continue;
897
+ }
898
+ if (payloadError) {
899
+ lastError = payloadError;
900
+ continue;
901
+ }
902
+ lastError = "unrecognized response shape";
903
+ }
904
+
905
+ if (noActiveTokenPlan) {
906
+ return {
907
+ session: 0,
908
+ weekly: 0,
909
+ quotaHidden: true,
910
+ notice: "No active Token Plan · check Credit balance in the MiniMax console",
911
+ };
912
+ }
913
+ return { session: 0, weekly: 0, error: credentialError ?? lastError };
914
+ }
915
+
916
+ export function extractOpenRouterUsageFromPayloads(
917
+ creditsPayload: unknown,
918
+ keyPayload: unknown,
919
+ ): UsageData | null {
920
+ const credits = asObject(asObject(creditsPayload)?.data) ?? asObject(creditsPayload);
921
+ const key = asObject(asObject(keyPayload)?.data) ?? asObject(keyPayload);
922
+
923
+ const totalCredits = readNumber(credits?.total_credits ?? credits?.totalCredits);
924
+ const totalUsage = readNumber(credits?.total_usage ?? credits?.totalUsage);
925
+ const accountBalance = totalCredits !== null && totalUsage !== null
926
+ ? {
927
+ amount: Number((totalCredits - totalUsage).toFixed(6)),
928
+ unit: "USD",
929
+ label: "Balance",
930
+ }
931
+ : undefined;
932
+
933
+ const spendValues = {
934
+ daily: readNumber(key?.usage_daily ?? key?.usageDaily),
935
+ weekly: readNumber(key?.usage_weekly ?? key?.usageWeekly),
936
+ monthly: readNumber(key?.usage_monthly ?? key?.usageMonthly),
937
+ lifetime: readNumber(key?.usage),
938
+ };
939
+ const accountSpend = Object.values(spendValues).some((value) => value !== null)
940
+ ? {
941
+ unit: "USD",
942
+ daily: spendValues.daily ?? undefined,
943
+ weekly: spendValues.weekly ?? undefined,
944
+ monthly: spendValues.monthly ?? undefined,
945
+ lifetime: spendValues.lifetime ?? undefined,
946
+ }
947
+ : undefined;
948
+
949
+ const limit = readNumber(key?.limit);
950
+ const remaining = readNumber(key?.limit_remaining ?? key?.limitRemaining);
951
+ const limitUsed = limit !== null && limit > 0 && remaining !== null
952
+ ? Math.max(0, Math.min(limit, limit - remaining))
953
+ : null;
954
+ const limitPercent = limitUsed !== null && limit !== null ? limitUsed / limit * 100 : null;
955
+ if (!accountBalance && !accountSpend && limitPercent === null) return null;
956
+
957
+ return {
958
+ session: limitPercent === null ? 0 : Number(limitPercent.toFixed(2)),
959
+ weekly: 0,
960
+ quotaHidden: limitPercent === null,
961
+ weeklyHidden: true,
962
+ sessionLabel: "Key limit",
963
+ accountBalance,
964
+ accountSpend,
965
+ };
966
+ }
967
+
968
+ export async function fetchOpenRouterUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
969
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
970
+ const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
971
+ const [creditsResult, keyResult] = await Promise.all([
972
+ requestJson(endpoints.openRouterCredits, { headers }, config),
973
+ requestJson(endpoints.openRouterKey, { headers }, config),
974
+ ]);
975
+ const usage = extractOpenRouterUsageFromPayloads(
976
+ creditsResult.ok ? creditsResult.data : undefined,
977
+ keyResult.ok ? keyResult.data : undefined,
978
+ );
979
+ if (usage) return usage;
980
+
981
+ const errors = [
982
+ creditsResult.ok ? undefined : `credits: ${creditsResult.error}`,
983
+ keyResult.ok ? undefined : `key: ${keyResult.error}`,
984
+ ].filter((value): value is string => Boolean(value));
985
+ return {
986
+ session: 0,
987
+ weekly: 0,
988
+ error: errors.length > 0 ? errors.join("; ") : "unrecognized response shape",
989
+ };
990
+ }
991
+
992
+ export function extractDeepSeekBalanceFromPayload(payload: unknown): UsageData | null {
993
+ const root = asObject(payload);
994
+ const rawBalances = Array.isArray(root?.balance_infos) ? root.balance_infos : [];
995
+ const balances = rawBalances.map(asObject).filter((value): value is Record<string, unknown> => value !== null);
996
+ if (balances.length === 0) return null;
997
+
998
+ const parsed = balances.flatMap((balance) => {
999
+ const unit = typeof balance.currency === "string" ? balance.currency.toUpperCase() : "USD";
1000
+ const total = readNumber(balance.total_balance ?? balance.totalBalance);
1001
+ if (total === null) return [];
1002
+ return [{
1003
+ total: { amount: total, unit, label: "Total balance" } satisfies AccountBalance,
1004
+ toppedUp: readNumber(balance.topped_up_balance ?? balance.toppedUpBalance),
1005
+ granted: readNumber(balance.granted_balance ?? balance.grantedBalance),
1006
+ }];
1007
+ });
1008
+ const primary = parsed[0];
1009
+ if (!primary) return null;
1010
+
1011
+ const details: AccountBalance[] = [];
1012
+ if (primary.toppedUp !== null) {
1013
+ details.push({ amount: primary.toppedUp, unit: primary.total.unit, label: "Topped up" });
1014
+ }
1015
+ if (primary.granted !== null) {
1016
+ details.push({ amount: primary.granted, unit: primary.total.unit, label: "Granted" });
1017
+ }
1018
+ for (const additional of parsed.slice(1)) details.push(additional.total);
1019
+
1020
+ return {
1021
+ session: 0,
1022
+ weekly: 0,
1023
+ quotaHidden: true,
1024
+ accountBalance: primary.total,
1025
+ accountBalanceDetails: details,
1026
+ warning: root?.is_available === false ? "Balance is not currently available for API use" : undefined,
1027
+ };
1028
+ }
1029
+
1030
+ export async function fetchDeepSeekBalance(token: string, config: FetchConfig = {}): Promise<UsageData> {
1031
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1032
+ const result = await requestJson(endpoints.deepSeekBalance, {
1033
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
1034
+ }, config);
1035
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1036
+ return extractDeepSeekBalanceFromPayload(result.data) ?? {
1037
+ session: 0,
1038
+ weekly: 0,
1039
+ error: "unrecognized response shape",
1040
+ };
1041
+ }
1042
+
1043
+ export function extractMoonshotBalanceFromPayload(
1044
+ payload: unknown,
1045
+ provider: "moonshot" | "moonshot-cn" = "moonshot",
1046
+ ): UsageData | null {
1047
+ const root = asObject(payload);
1048
+ const data = asObject(root?.data) ?? root;
1049
+ if (!data) return null;
1050
+ const available = readNumber(data.available_balance ?? data.availableBalance);
1051
+ if (available === null) return null;
1052
+ const cash = readNumber(data.cash_balance ?? data.cashBalance);
1053
+ const voucher = readNumber(data.voucher_balance ?? data.voucherBalance);
1054
+ const unit = provider === "moonshot-cn" ? "CNY" : "USD";
1055
+ const details: AccountBalance[] = [];
1056
+ if (cash !== null) details.push({ amount: cash, unit, label: "Cash" });
1057
+ if (voucher !== null) details.push({ amount: voucher, unit, label: "Voucher" });
1058
+
1059
+ return {
1060
+ session: 0,
1061
+ weekly: 0,
1062
+ quotaHidden: true,
1063
+ accountBalance: { amount: available, unit, label: "Available balance" },
1064
+ accountBalanceDetails: details,
1065
+ warning: available <= 0 ? "Balance exhausted; inference requests may be rejected" : undefined,
1066
+ };
1067
+ }
1068
+
1069
+ export async function fetchMoonshotBalance(
1070
+ token: string,
1071
+ provider: "moonshot" | "moonshot-cn" = "moonshot",
1072
+ config: FetchConfig = {},
1073
+ ): Promise<UsageData> {
1074
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1075
+ const endpoint = provider === "moonshot-cn" ? endpoints.moonshotCnBalance : endpoints.moonshotBalance;
1076
+ const result = await requestJson(endpoint, {
1077
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
1078
+ }, config);
1079
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1080
+ return extractMoonshotBalanceFromPayload(result.data, provider) ?? {
1081
+ session: 0,
1082
+ weekly: 0,
1083
+ error: "unrecognized response shape",
1084
+ };
1085
+ }
1086
+
548
1087
  /** Parse ZAI limits where unit 3 is the five-hour window and unit 6 the weekly window. */
549
1088
  export function extractZaiUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
550
1089
  const data = payload as any;
@@ -597,6 +1136,13 @@ export function detectProvider(
597
1136
  case "anthropic": return "claude";
598
1137
  case "zai": return "zai";
599
1138
  case "zai-coding-cn": return "zai-cn";
1139
+ case "kimi-coding": return "kimi";
1140
+ case "minimax": return "minimax";
1141
+ case "minimax-cn": return "minimax-cn";
1142
+ case "openrouter": return "openrouter";
1143
+ case "deepseek": return "deepseek";
1144
+ case "moonshotai": return "moonshot";
1145
+ case "moonshotai-cn": return "moonshot-cn";
600
1146
  default: return null;
601
1147
  }
602
1148
  }
@@ -607,6 +1153,13 @@ export function providerToPiProviderId(provider: ProviderKey): PiProviderId {
607
1153
  case "claude": return "anthropic";
608
1154
  case "zai": return "zai";
609
1155
  case "zai-cn": return "zai-coding-cn";
1156
+ case "kimi": return "kimi-coding";
1157
+ case "minimax": return "minimax";
1158
+ case "minimax-cn": return "minimax-cn";
1159
+ case "openrouter": return "openrouter";
1160
+ case "deepseek": return "deepseek";
1161
+ case "moonshot": return "moonshotai";
1162
+ case "moonshot-cn": return "moonshotai-cn";
610
1163
  }
611
1164
  }
612
1165
 
@@ -626,7 +1179,19 @@ export async function fetchAllUsages(
626
1179
  config: FetchAllUsagesConfig = {},
627
1180
  ): Promise<UsageByProvider> {
628
1181
  const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
629
- const results: UsageByProvider = { codex: null, claude: null, zai: null, "zai-cn": null };
1182
+ const results: UsageByProvider = {
1183
+ codex: null,
1184
+ claude: null,
1185
+ zai: null,
1186
+ "zai-cn": null,
1187
+ kimi: null,
1188
+ minimax: null,
1189
+ "minimax-cn": null,
1190
+ openrouter: null,
1191
+ deepseek: null,
1192
+ moonshot: null,
1193
+ "moonshot-cn": null,
1194
+ };
630
1195
  const tasks: Promise<void>[] = [];
631
1196
 
632
1197
  const assign = (provider: ProviderKey, request: Promise<UsageData>) => {
@@ -647,7 +1212,29 @@ export async function fetchAllUsages(
647
1212
  }
648
1213
  if (tokens.zai) assign("zai", fetchZaiUsage(tokens.zai, "zai", { ...config, endpoints }));
649
1214
  if (tokens["zai-cn"]) assign("zai-cn", fetchZaiUsage(tokens["zai-cn"], "zai-cn", { ...config, endpoints }));
1215
+ if (tokens.kimi) assign("kimi", fetchKimiUsage(tokens.kimi, { ...config, endpoints }));
1216
+ if (tokens.minimax) assign("minimax", fetchMiniMaxUsage(tokens.minimax, "minimax", { ...config, endpoints }));
1217
+ if (tokens["minimax-cn"]) {
1218
+ assign("minimax-cn", fetchMiniMaxUsage(tokens["minimax-cn"], "minimax-cn", { ...config, endpoints }));
1219
+ }
1220
+ if (tokens.openrouter) assign("openrouter", fetchOpenRouterUsage(tokens.openrouter, { ...config, endpoints }));
1221
+ if (tokens.deepseek) assign("deepseek", fetchDeepSeekBalance(tokens.deepseek, { ...config, endpoints }));
1222
+ if (tokens.moonshot) assign("moonshot", fetchMoonshotBalance(tokens.moonshot, "moonshot", { ...config, endpoints }));
1223
+ if (tokens["moonshot-cn"]) {
1224
+ assign("moonshot-cn", fetchMoonshotBalance(tokens["moonshot-cn"], "moonshot-cn", { ...config, endpoints }));
1225
+ }
650
1226
 
651
1227
  await Promise.all(tasks);
1228
+
1229
+ // Pi intentionally uses MOONSHOT_API_KEY for both regional providers. When one
1230
+ // key works in only one region, hide the expected regional auth failure from
1231
+ // the all-provider view while preserving active-provider polling behavior.
1232
+ if (tokens.moonshot && tokens.moonshot === tokens["moonshot-cn"]) {
1233
+ if (results.moonshot && !results.moonshot.error && results["moonshot-cn"]?.error) {
1234
+ results["moonshot-cn"] = null;
1235
+ } else if (results["moonshot-cn"] && !results["moonshot-cn"].error && results.moonshot?.error) {
1236
+ results.moonshot = null;
1237
+ }
1238
+ }
652
1239
  return results;
653
1240
  }
@@ -1,4 +1,4 @@
1
- /** Usage bars for subscription-backed providers supported by current Pi releases. */
1
+ /** Quota, balance, and spend indicators for providers supported by current Pi releases. */
2
2
 
3
3
  import {
4
4
  DynamicBorder,
@@ -22,9 +22,16 @@ import {
22
22
  fetchAllUsages,
23
23
  fetchClaudeUsageWithFallback,
24
24
  fetchCodexUsage,
25
+ fetchDeepSeekBalance,
26
+ fetchKimiUsage,
27
+ fetchMiniMaxUsage,
28
+ fetchMoonshotBalance,
29
+ fetchOpenRouterUsage,
25
30
  fetchZaiUsage,
26
31
  providerToPiProviderId,
27
32
  resolveUsageEndpoints,
33
+ type AccountBalance,
34
+ type AccountSpend,
28
35
  type ProviderKey,
29
36
  type UsageByProvider,
30
37
  type UsageData,
@@ -33,15 +40,63 @@ import {
33
40
 
34
41
  const POLL_INTERVAL_MS = 2 * 60 * 1000;
35
42
  const STATUS_KEY = "usage-bars";
36
- const PROVIDERS: readonly ProviderKey[] = ["codex", "claude", "zai", "zai-cn"];
43
+ const PROVIDERS: readonly ProviderKey[] = [
44
+ "codex",
45
+ "claude",
46
+ "zai",
47
+ "zai-cn",
48
+ "kimi",
49
+ "minimax",
50
+ "minimax-cn",
51
+ "openrouter",
52
+ "deepseek",
53
+ "moonshot",
54
+ "moonshot-cn",
55
+ ];
37
56
 
38
57
  const PROVIDER_LABELS: Record<ProviderKey, string> = {
39
58
  codex: "Codex",
40
59
  claude: "Claude",
41
60
  zai: "ZAI Coding Plan (Global)",
42
61
  "zai-cn": "ZAI Coding Plan (China)",
62
+ kimi: "Kimi For Coding",
63
+ minimax: "MiniMax Coding Plan (Global)",
64
+ "minimax-cn": "MiniMax Coding Plan (China)",
65
+ openrouter: "OpenRouter",
66
+ deepseek: "DeepSeek",
67
+ moonshot: "Moonshot/Kimi API (Global)",
68
+ "moonshot-cn": "Moonshot/Kimi API (China)",
43
69
  };
44
70
 
71
+ function formatFinancialAmount(amount: number, unit: string): string {
72
+ if (/^[A-Z]{3}$/.test(unit)) {
73
+ return new Intl.NumberFormat("en-US", {
74
+ style: "currency",
75
+ currency: unit,
76
+ minimumFractionDigits: 2,
77
+ maximumFractionDigits: 5,
78
+ }).format(amount);
79
+ }
80
+ const formatted = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(amount);
81
+ return `${formatted} ${unit}`;
82
+ }
83
+
84
+ function formatAccountBalance(balance: AccountBalance): string {
85
+ return `${balance.label} · ${formatFinancialAmount(balance.amount, balance.unit)}`;
86
+ }
87
+
88
+ function formatAccountSpend(spend: AccountSpend): string {
89
+ const values = [
90
+ spend.daily === undefined ? undefined : `today ${formatFinancialAmount(spend.daily, spend.unit)}`,
91
+ spend.weekly === undefined ? undefined : `week ${formatFinancialAmount(spend.weekly, spend.unit)}`,
92
+ spend.monthly === undefined ? undefined : `month ${formatFinancialAmount(spend.monthly, spend.unit)}`,
93
+ ].filter((value): value is string => Boolean(value));
94
+ if (values.length === 0 && spend.lifetime !== undefined) {
95
+ values.push(`lifetime ${formatFinancialAmount(spend.lifetime, spend.unit)}`);
96
+ }
97
+ return `Spent · ${values.join(" · ")}`;
98
+ }
99
+
45
100
  interface SubscriptionItem {
46
101
  name: string;
47
102
  provider: ProviderKey;
@@ -135,12 +190,12 @@ class UsageSelectorComponent extends Container implements Focusable {
135
190
 
136
191
  private updateHint(): void {
137
192
  if (this.hint === "loading") {
138
- this.hintText.setText(this.theme.fg("dim", "Fetching usage from configured subscription providers…"));
193
+ this.hintText.setText(this.theme.fg("dim", "Fetching quota, balance, and spend from configured providers…"));
139
194
  } else if (this.hint === "error") {
140
195
  this.hintText.setText(this.theme.fg("error", "Failed to fetch usage data"));
141
196
  } else {
142
197
  this.hintText.setText(
143
- this.theme.fg("muted", "Only showing configured subscription providers. ") +
198
+ this.theme.fg("muted", "Only showing configured usage providers. ") +
144
199
  this.theme.fg("dim", "✓ = active provider"),
145
200
  );
146
201
  }
@@ -197,19 +252,49 @@ class UsageSelectorComponent extends Container implements Focusable {
197
252
  const weeklyReset = item.data.weeklyResetsIn
198
253
  ? theme.fg("dim", ` resets in ${item.data.weeklyResetsIn}`)
199
254
  : "";
255
+ const sessionLabel = (item.data.sessionLabel ?? "Session").slice(0, 9).padEnd(10);
256
+ const weeklyLabel = (item.data.weeklyLabel ?? "Weekly").slice(0, 9).padEnd(10);
200
257
 
201
- this.listContainer.addChild(new Text(
202
- indent + theme.fg("muted", "Session ") + this.renderBar(session) + " " +
203
- theme.fg(colorForPercent(session), `${session}%`.padStart(4)) + sessionReset,
204
- 0,
205
- 0,
206
- ));
207
- this.listContainer.addChild(new Text(
208
- indent + theme.fg("muted", "Weekly ") + this.renderBar(weekly) + " " +
209
- theme.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) + weeklyReset,
210
- 0,
211
- 0,
212
- ));
258
+ if (!item.data.quotaHidden) {
259
+ this.listContainer.addChild(new Text(
260
+ indent + theme.fg("muted", sessionLabel) + this.renderBar(session) + " " +
261
+ theme.fg(colorForPercent(session), `${session}%`.padStart(4)) + sessionReset,
262
+ 0,
263
+ 0,
264
+ ));
265
+ if (!item.data.weeklyHidden) {
266
+ this.listContainer.addChild(new Text(
267
+ indent + theme.fg("muted", weeklyLabel) + this.renderBar(weekly) + " " +
268
+ theme.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) + weeklyReset,
269
+ 0,
270
+ 0,
271
+ ));
272
+ }
273
+ }
274
+ if (item.data.accountBalance) {
275
+ this.listContainer.addChild(new Text(
276
+ indent + theme.fg("muted", formatAccountBalance(item.data.accountBalance)),
277
+ 0,
278
+ 0,
279
+ ));
280
+ }
281
+ for (const balance of item.data.accountBalanceDetails ?? []) {
282
+ this.listContainer.addChild(new Text(
283
+ indent + theme.fg("dim", formatAccountBalance(balance)),
284
+ 0,
285
+ 0,
286
+ ));
287
+ }
288
+ if (item.data.accountSpend) {
289
+ this.listContainer.addChild(new Text(
290
+ indent + theme.fg("muted", formatAccountSpend(item.data.accountSpend)),
291
+ 0,
292
+ 0,
293
+ ));
294
+ }
295
+ if (item.data.notice) {
296
+ this.listContainer.addChild(new Text(indent + theme.fg("muted", item.data.notice), 0, 0));
297
+ }
213
298
 
214
299
  if (typeof item.data.extraSpend === "number" && typeof item.data.extraLimit === "number") {
215
300
  this.listContainer.addChild(new Text(
@@ -300,6 +385,13 @@ export default function (pi: ExtensionAPI): void {
300
385
  claude: null,
301
386
  zai: null,
302
387
  "zai-cn": null,
388
+ kimi: null,
389
+ minimax: null,
390
+ "minimax-cn": null,
391
+ openrouter: null,
392
+ deepseek: null,
393
+ moonshot: null,
394
+ "moonshot-cn": null,
303
395
  activeProvider: null,
304
396
  available: {},
305
397
  };
@@ -343,15 +435,43 @@ export default function (pi: ExtensionAPI): void {
343
435
  ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${label} usage unavailable (${data.error})`));
344
436
  return;
345
437
  }
438
+ if (data.quotaHidden) {
439
+ const financial = [
440
+ data.accountBalance ? formatAccountBalance(data.accountBalance) : undefined,
441
+ data.accountSpend?.monthly === undefined
442
+ ? undefined
443
+ : `Month · ${formatFinancialAmount(data.accountSpend.monthly, data.accountSpend.unit)}`,
444
+ ].filter((value): value is string => Boolean(value));
445
+ const summary = financial.length > 0 ? financial.join(" · ") : data.notice;
446
+ ctx.ui.setStatus(
447
+ STATUS_KEY,
448
+ summary ? theme.fg("dim", `${label} `) + theme.fg("muted", summary) : undefined,
449
+ );
450
+ return;
451
+ }
346
452
 
347
453
  const session = clampPercent(data.session);
348
454
  const weekly = clampPercent(data.weekly);
455
+ const sessionPrefix = data.sessionLabel === "5-hour"
456
+ ? "5h "
457
+ : data.sessionLabel === "Interval"
458
+ ? "I "
459
+ : data.sessionLabel === "Key limit"
460
+ ? "L "
461
+ : "S ";
462
+ const weeklyStatus = data.weeklyHidden
463
+ ? ""
464
+ : theme.fg("muted", " W ") + renderBar(theme, weekly) + " " + renderPercent(theme, weekly) +
465
+ (data.weeklyResetsIn ? theme.fg("dim", ` ⟳ ${data.weeklyResetsIn}`) : "");
349
466
  const status =
350
467
  theme.fg("dim", `${label} `) +
351
- theme.fg("muted", "S ") + renderBar(theme, session) + " " + renderPercent(theme, session) +
468
+ theme.fg("muted", sessionPrefix) + renderBar(theme, session) + " " + renderPercent(theme, session) +
352
469
  (data.sessionResetsIn ? theme.fg("dim", ` ⟳ ${data.sessionResetsIn}`) : "") +
353
- theme.fg("muted", " W ") + renderBar(theme, weekly) + " " + renderPercent(theme, weekly) +
354
- (data.weeklyResetsIn ? theme.fg("dim", ` ${data.weeklyResetsIn}`) : "") +
470
+ weeklyStatus +
471
+ (data.accountBalance ? theme.fg("muted", ` · ${formatAccountBalance(data.accountBalance)}`) : "") +
472
+ (data.accountSpend?.monthly === undefined
473
+ ? ""
474
+ : theme.fg("muted", ` · Month ${formatFinancialAmount(data.accountSpend.monthly, data.accountSpend.unit)}`)) +
355
475
  (data.stale ? theme.fg("warning", " stale") : "") +
356
476
  (data.warning && !data.stale ? theme.fg("warning", " ⚠") : "");
357
477
  ctx.ui.setStatus(STATUS_KEY, status);
@@ -404,6 +524,25 @@ export default function (pi: ExtensionAPI): void {
404
524
  if (provider === "claude") state.claude = await fetchClaudeUsageWithFallback(credential.token, { signal });
405
525
  if (provider === "zai") state.zai = await fetchZaiUsage(credential.token, "zai", { endpoints, signal });
406
526
  if (provider === "zai-cn") state["zai-cn"] = await fetchZaiUsage(credential.token, "zai-cn", { endpoints, signal });
527
+ if (provider === "kimi") state.kimi = await fetchKimiUsage(credential.token, { endpoints, signal });
528
+ if (provider === "minimax") {
529
+ state.minimax = await fetchMiniMaxUsage(credential.token, "minimax", { endpoints, signal });
530
+ }
531
+ if (provider === "minimax-cn") {
532
+ state["minimax-cn"] = await fetchMiniMaxUsage(credential.token, "minimax-cn", { endpoints, signal });
533
+ }
534
+ if (provider === "openrouter") {
535
+ state.openrouter = await fetchOpenRouterUsage(credential.token, { endpoints, signal });
536
+ }
537
+ if (provider === "deepseek") {
538
+ state.deepseek = await fetchDeepSeekBalance(credential.token, { endpoints, signal });
539
+ }
540
+ if (provider === "moonshot") {
541
+ state.moonshot = await fetchMoonshotBalance(credential.token, "moonshot", { endpoints, signal });
542
+ }
543
+ if (provider === "moonshot-cn") {
544
+ state["moonshot-cn"] = await fetchMoonshotBalance(credential.token, "moonshot-cn", { endpoints, signal });
545
+ }
407
546
  }
408
547
 
409
548
  async function runPoll(): Promise<void> {
@@ -493,7 +632,7 @@ export default function (pi: ExtensionAPI): void {
493
632
  });
494
633
 
495
634
  pi.registerCommand("usage", {
496
- description: "Show subscription usage for configured providers",
635
+ description: "Show quota, balance, and spend for configured providers",
497
636
  handler: async (_args, ctx) => {
498
637
  currentContext = ctx;
499
638
  updateProviderFrom(ctx.model);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hk_net/pi-usage-bars",
3
- "version": "0.3.0",
4
- "description": "Subscription usage bars for current Pi providers (Codex, Claude, and ZAI Coding Plan)",
3
+ "version": "0.4.0",
4
+ "description": "Quota, balance, and spend indicators for current Pi providers",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",
@@ -9,7 +9,12 @@
9
9
  "usage",
10
10
  "codex",
11
11
  "anthropic",
12
- "zai"
12
+ "zai",
13
+ "kimi",
14
+ "minimax",
15
+ "openrouter",
16
+ "deepseek",
17
+ "moonshot"
13
18
  ],
14
19
  "license": "MIT",
15
20
  "repository": {
@@ -27,6 +32,7 @@
27
32
  "type": "module",
28
33
  "files": [
29
34
  "extensions",
35
+ "docs",
30
36
  "README.md",
31
37
  "CHANGELOG.md",
32
38
  "LICENSE"