pi-sub-quota 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,7 +65,7 @@ zai-coding-cn coding plan · Max · active
65
65
 
66
66
  deepseek pay as you go · ¥19.67 + ¥1.00 granted · $2.50
67
67
 
68
- kimi-coding coding plan · Allegretto
68
+ kimi-coding coding plan · Allegretto · booster ¥100.00/¥200.00
69
69
  5h ▓▓▓▓▓▓▓░░░ 30% left reset 17:10
70
70
  7d ▓░░░░░░░░░ 89% left reset Mon 00:00
71
71
 
@@ -79,7 +79,7 @@ The footer shows how much you have used; the panel shows what is left, as a mete
79
79
  | Provider | What you see |
80
80
  | --- | --- |
81
81
  | ZAI / GLM coding plans — `zai-coding-cn`, `zai-coding`, GLM and bigmodel aliases | 5h / 7d usage windows with resets, your plan tier (Lite / Pro / Max) |
82
- | Kimi — `kimi-coding` | weekly request quota + 5h rate limit, membership tier (Andante / Moderato / Allegretto) |
82
+ | Kimi — `kimi-coding` | 5h / 7d usage windows (+ 30d monthly pool on plans that report one), membership tier (Andante / Moderato / Allegretto) from the account endpoint, and the booster wallet balance (`booster ¥5.00/¥20.00`) once topped up |
83
83
  | DeepSeek — `deepseek` | prepaid balance per currency, topped-up + granted split |
84
84
 
85
85
  Custom providers work without configuration too: detection falls back to the model's base URL host (`bigmodel.cn`, `*.z.ai` with a `/coding` path, `*.kimi.com` / `*.kimi.ai` with a `/coding` path, `*.deepseek.com`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sub-quota",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Coding-plan quota and prepaid balance in the pi footer, shown only while a subscription provider such as ZAI/GLM, DeepSeek, or Kimi is active",
5
5
  "keywords": [
6
6
  "pi-package",
package/providers/kimi.ts CHANGED
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * Kimi coding plans: `GET <base>/usages` returns the weekly request quota
3
- * (top-level `usage`, whose limit maps to the membership tier) plus shorter
4
- * windows in `limits[]`, such as the 5-hour rate limit. Works with an API
5
- * key or the OAuth access token pi stores for kimi-coding; `opts.baseUrl`
6
- * overrides the regional endpoint (api.kimi.com vs api.kimi.ai) for custom
7
- * providers. Endpoint details follow CodexBar's documented API.
3
+ * (top-level `usage`) plus shorter windows in `limits[]`, such as the 5-hour
4
+ * rate limit. `GET <base>/me` reports the account's membership tier
5
+ * (`user_level_name`); the legacy limit→tier map only backs it up when that
6
+ * endpoint is unavailable (e.g. API-key auth without account scope).
7
+ * Works with an API key or the OAuth access token pi stores for kimi-coding;
8
+ * `opts.baseUrl` overrides the regional endpoint (api.kimi.com vs api.kimi.ai)
9
+ * for custom providers. Endpoint details follow CodexBar's documented API.
8
10
  */
9
11
 
10
12
  import { clampPercent, fetchJson, hostMatchesApex, urlParts } from "./http.ts";
11
- import type { FetchOptions, QuotaProvider, QuotaSnapshot, QuotaWindow } from "./types.ts";
13
+ import type { FetchOptions, QuotaBalance, QuotaProvider, QuotaSnapshot, QuotaWindow } from "./types.ts";
12
14
 
13
15
  const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
14
- /** Kimi membership tiers keyed by weekly request limit (per CodexBar docs). */
16
+ const KIMI_ME_URL = "https://api.kimi.com/coding/v1/me";
17
+ /** Legacy membership tiers keyed by weekly request limit (per CodexBar docs); newer plans report other limits, so `/me` is the source of truth. */
15
18
  const KIMI_TIERS: Record<string, string> = { "1024": "andante", "2048": "moderato", "7168": "allegretto" };
16
19
  const KIMI_PROVIDER_IDS = new Set(["kimi-coding", "kimi"]);
17
20
 
@@ -25,12 +28,45 @@ interface KimiUsagePool {
25
28
  interface KimiUsagesResponse {
26
29
  usage?: KimiUsagePool;
27
30
  limits?: { window?: { duration?: number; timeUnit?: string }; detail?: KimiUsagePool }[];
31
+ /** Ratio-based pools: fraction used + ISO reset, repeating 5h/7d and adding the monthly quota. */
32
+ usages?: KimiRatioPools;
33
+ /** Pay-as-you-go overage wallet; absent until the account activates it. */
34
+ booster_wallet?: KimiBoosterWallet;
35
+ }
36
+
37
+ interface KimiRatioPool {
38
+ used_ratio?: number | string;
39
+ reset_time?: string;
40
+ }
41
+ interface KimiRatioPools {
42
+ limit_5h?: KimiRatioPool;
43
+ limit_7d?: KimiRatioPool;
44
+ /** The plan's 30-day quota; `limit_month_code` (the coding share of it) is a breakdown detail, not a window. */
45
+ limit_month_total?: KimiRatioPool;
46
+ }
47
+
48
+ /** Money amount as documented by Kimi's own CLI: cents in `priceInCents`. */
49
+ interface KimiMoney {
50
+ currency?: string;
51
+ priceInCents?: string;
52
+ }
53
+ interface KimiBoosterBalance {
54
+ type?: string;
55
+ amount?: string | number;
56
+ amountLeft?: string | number;
57
+ }
58
+ interface KimiBoosterWallet {
59
+ balance?: KimiBoosterBalance;
60
+ monthlyChargeLimit?: KimiMoney;
61
+ monthlyUsed?: KimiMoney;
28
62
  }
29
63
 
30
64
  function kimiPercent(pool: KimiUsagePool): number {
31
65
  const limit = Number(pool.limit);
32
- const used = Number(pool.used);
33
- if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(used) || used < 0) return 0;
66
+ if (!Number.isFinite(limit) || limit <= 0) return 0;
67
+ // Newer plans omit `used` and report `remaining` instead.
68
+ const used = pool.used !== undefined ? Number(pool.used) : limit - Number(pool.remaining);
69
+ if (!Number.isFinite(used) || used < 0) return 0;
34
70
  return clampPercent((used / limit) * 100);
35
71
  }
36
72
 
@@ -57,22 +93,97 @@ function kimiWindowLabel(minutes: number): string {
57
93
  return `${Math.round(minutes / 60)}h`;
58
94
  }
59
95
 
60
- /** Build the usages URL from a custom base, ignoring any query or fragment. */
61
- function kimiUsagesUrl(baseUrl: string): string {
96
+ /**
97
+ * The `usages` ratio pools, keyed with their window length in minutes
98
+ * (monthly ≈ 30d for ordering). Entries the backend omits are skipped.
99
+ */
100
+ const KIMI_RATIO_POOLS: { key: keyof KimiRatioPools; minutes: number }[] = [
101
+ { key: "limit_5h", minutes: 300 },
102
+ { key: "limit_7d", minutes: 10_080 },
103
+ { key: "limit_month_total", minutes: 43_200 },
104
+ ];
105
+
106
+ /** A ratio pool as a window; undefined when its ratio is missing or invalid. */
107
+ function kimiRatioWindow(pool: KimiRatioPool | undefined, minutes: number): QuotaWindow | undefined {
108
+ if (!pool) return undefined;
109
+ const ratio = Number(pool.used_ratio);
110
+ if (!Number.isFinite(ratio) || ratio < 0) return undefined;
111
+ const resetMs = typeof pool.reset_time === "string" ? Date.parse(pool.reset_time) : Number.NaN;
112
+ return {
113
+ type: "window",
114
+ resource: "tokens",
115
+ label: kimiWindowLabel(minutes),
116
+ usedPercent: clampPercent(ratio * 100),
117
+ resetAt: Number.isFinite(resetMs) ? resetMs : undefined,
118
+ durationMs: minutes * 60_000,
119
+ };
120
+ }
121
+
122
+ /** Build an endpoint URL from a custom base, ignoring any query or fragment. */
123
+ function kimiUrl(baseUrl: string | undefined, suffix: string, fallback: string): string {
124
+ if (!baseUrl) return fallback;
62
125
  try {
63
126
  const url = new URL(baseUrl);
64
127
  url.search = "";
65
128
  url.hash = "";
66
- url.pathname = `${url.pathname.replace(/\/+$/, "")}/usages`;
129
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}${suffix}`;
67
130
  return url.toString();
68
131
  } catch {
69
132
  throw new Error("Kimi quota fetch failed: invalid baseUrl");
70
133
  }
71
134
  }
72
135
 
136
+ interface KimiMeResponse {
137
+ user_level_name?: string;
138
+ }
139
+
140
+ /** Membership tier name from the account endpoint; undefined when absent or malformed. */
141
+ function kimiPlanLevel(body: unknown): string | undefined {
142
+ if (typeof body !== "object" || body === null) return undefined;
143
+ const name = (body as KimiMeResponse).user_level_name;
144
+ return typeof name === "string" && name.trim() !== "" ? name : undefined;
145
+ }
146
+
147
+ /** Booster amounts are fixed-point: value / 1e6 = cents (per Kimi's CLI). */
148
+ const KIMI_BOOSTER_SCALE = 1_000_000;
149
+
150
+ /** Positive fixed-point amount to cents; undefined for missing, invalid, or empty values. */
151
+ function kimiBoosterCents(value: string | number | undefined): number | undefined {
152
+ const n = Number(value);
153
+ return Number.isFinite(n) && n > 0 ? Math.round(n / KIMI_BOOSTER_SCALE) : undefined;
154
+ }
155
+
156
+ /**
157
+ * The booster wallet as a balance item, mirroring Kimi's CLI: only a
158
+ * BOOSTER-typed balance with a positive top-up exists — an inactive wallet
159
+ * (no `amount`) has nothing to show. `total` is what is left to spend;
160
+ * `toppedUp` keeps the original amount for context ("¥5.00/¥20.00").
161
+ */
162
+ function kimiBoosterBalance(wallet: KimiBoosterWallet | undefined): QuotaBalance | undefined {
163
+ const balance = wallet?.balance;
164
+ if (balance?.type !== "BOOSTER") return undefined;
165
+ const toppedUpCents = kimiBoosterCents(balance.amount);
166
+ if (toppedUpCents === undefined) return undefined;
167
+ const currency = wallet?.monthlyChargeLimit?.currency ?? wallet?.monthlyUsed?.currency;
168
+ const symbol = currency === "USD" ? "$" : currency === "CNY" || currency === undefined ? "¥" : currency;
169
+ return {
170
+ type: "balance",
171
+ symbol,
172
+ total: (kimiBoosterCents(balance.amountLeft) ?? 0) / 100,
173
+ granted: 0,
174
+ toppedUp: toppedUpCents / 100,
175
+ label: "booster",
176
+ };
177
+ }
178
+
73
179
  /** Fetch Kimi coding-plan usage: weekly quota plus rate-limit windows. */
74
180
  export async function fetchKimiQuota(apiKey: string, opts: FetchOptions = {}): Promise<QuotaSnapshot> {
75
- const url = opts.baseUrl ? kimiUsagesUrl(opts.baseUrl) : KIMI_USAGE_URL;
181
+ const url = kimiUrl(opts.baseUrl, "/usages", KIMI_USAGE_URL);
182
+ // The account call runs alongside the usage call; failing it (auth without
183
+ // account scope, transient 5xx) only drops the tier name, never the windows.
184
+ const meBody = fetchJson(kimiUrl(opts.baseUrl, "/me", KIMI_ME_URL), apiKey, opts)
185
+ .then(kimiPlanLevel)
186
+ .catch(() => undefined);
76
187
  const body = (await fetchJson(url, apiKey, opts)) as KimiUsagesResponse;
77
188
  const toWindow = (pool: KimiUsagePool, label: string, durationMs: number): QuotaWindow => ({
78
189
  type: "window",
@@ -91,15 +202,24 @@ export async function fetchKimiQuota(apiKey: string, opts: FetchOptions = {}): P
91
202
  if (minutes === undefined || !entry?.detail) continue;
92
203
  windows.push(toWindow(entry.detail, kimiWindowLabel(minutes), minutes * 60_000));
93
204
  }
205
+ // Ratio pools only fill durations the count-based windows did not cover:
206
+ // 5h/7d repeat there with exact counts, so in practice this adds the
207
+ // monthly quota on plans that have one.
208
+ for (const { key, minutes } of KIMI_RATIO_POOLS) {
209
+ if (windows.some((window) => window.durationMs === minutes * 60_000)) continue;
210
+ const window = kimiRatioWindow(body?.usages?.[key], minutes);
211
+ if (window) windows.push(window);
212
+ }
94
213
  if (windows.length === 0) throw new Error("Kimi usage response had no usage windows");
95
214
  // Shortest window first: the 5-hour rate limit always reads before the weekly
96
215
  // quota, even when the weekly reset happens to come sooner.
97
216
  windows.sort((a, b) => (a.durationMs ?? Number.MAX_SAFE_INTEGER) - (b.durationMs ?? Number.MAX_SAFE_INTEGER));
217
+ const booster = kimiBoosterBalance(body?.booster_wallet);
98
218
  return {
99
219
  kind: "kimi",
100
220
  fetchedAt: (opts.now ?? Date.now)(),
101
- items: windows,
102
- planLevel: KIMI_TIERS[String(weeklyLimit)],
221
+ items: booster ? [...windows, booster] : windows,
222
+ planLevel: (await meBody) ?? KIMI_TIERS[String(weeklyLimit)],
103
223
  };
104
224
  }
105
225
 
@@ -39,10 +39,14 @@ export interface QuotaBalance {
39
39
  type: "balance";
40
40
  /** Currency symbol, e.g. "¥" or "$". */
41
41
  symbol: string;
42
- /** Total balance (topped up + granted), in the account currency. */
42
+ /** Spendable amount right now (topped up + granted, minus what depleting wallets already spent). */
43
43
  total: number;
44
44
  /** Granted (promotional) part of the balance; 0 when there is none. */
45
45
  granted: number;
46
+ /** Original top-up of a depleting wallet (e.g. Kimi's booster); `total` then reads as remaining/toppedUp. */
47
+ toppedUp?: number;
48
+ /** Short qualifier for the panel header, e.g. "booster". */
49
+ label?: string;
46
50
  }
47
51
 
48
52
  /** One displayable quota item; a snapshot is an ordered list of these. */
package/view.ts CHANGED
@@ -135,10 +135,15 @@ export function formatQuota(snapshot: QuotaSnapshot, now: number, opts?: { stale
135
135
  * balance is money, so nothing is rounded. With `detail` and a granted part
136
136
  * of at least one cent, topped-up and granted amounts sit side by side,
137
137
  * each with its currency symbol ("¥18.67+¥1.00") so multi-currency accounts
138
- * stay readable; otherwise the exact total ("¥19.67").
138
+ * stay readable; otherwise the exact total ("¥19.67"). A depleting wallet
139
+ * with a known top-up shows both amounts in every mode ("¥5.00/¥20.00"),
140
+ * since the remaining money only reads against what was paid in.
139
141
  */
140
142
  export function formatBalanceCompact(balance: QuotaBalance, opts?: { detail?: boolean }): string {
141
143
  const cents = (value: number) => value.toFixed(2);
144
+ if (balance.toppedUp !== undefined) {
145
+ return `${balance.symbol}${cents(balance.total)}/${balance.symbol}${cents(balance.toppedUp)}`;
146
+ }
142
147
  if (!opts?.detail || balance.granted < 0.01) return `${balance.symbol}${cents(balance.total)}`;
143
148
  return `${balance.symbol}${cents(balance.total - balance.granted)}+${balance.symbol}${cents(balance.granted)}`;
144
149
  }
@@ -264,12 +269,18 @@ export interface QuotaPanelRow {
264
269
  note?: string;
265
270
  }
266
271
 
267
- /** Exact balance for the panel header: "¥19.67 + ¥1.00 granted" or "¥19.67". */
272
+ /**
273
+ * Exact balance for the panel header: "¥19.67 + ¥1.00 granted", "¥19.67",
274
+ * or "booster ¥5.00/¥20.00" for a depleting wallet (its label tells the
275
+ * two apart from a plain prepaid balance).
276
+ */
268
277
  function formatBalancePanel(balance: QuotaBalance): string {
278
+ const label = balance.label ? `${balance.label} ` : "";
269
279
  // Same one-cent threshold as formatBalanceCompact, so footer and panel
270
280
  // always agree on whether a granted part is shown at all.
281
+ if (balance.toppedUp !== undefined) return `${label}${formatBalanceCompact(balance)}`;
271
282
  const granted = balance.granted >= 0.01 ? ` + ${balance.symbol}${balance.granted.toFixed(2)} granted` : "";
272
- return `${balance.symbol}${balance.total.toFixed(2)}${granted}`;
283
+ return `${label}${balance.symbol}${balance.total.toFixed(2)}${granted}`;
273
284
  }
274
285
 
275
286
  /**