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 +2 -2
- package/package.json +1 -1
- package/providers/kimi.ts +135 -15
- package/providers/types.ts +5 -1
- package/view.ts +14 -3
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` |
|
|
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
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
/**
|
|
61
|
-
|
|
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(/\/+$/, "")}
|
|
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 =
|
|
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
|
|
package/providers/types.ts
CHANGED
|
@@ -39,10 +39,14 @@ export interface QuotaBalance {
|
|
|
39
39
|
type: "balance";
|
|
40
40
|
/** Currency symbol, e.g. "¥" or "$". */
|
|
41
41
|
symbol: string;
|
|
42
|
-
/**
|
|
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
|
-
/**
|
|
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
|
/**
|