pi-provider-status 0.1.0 → 0.2.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/extensions/provider-status.ts +140 -3
- package/package.json +1 -1
|
@@ -17,6 +17,17 @@
|
|
|
17
17
|
* ({"key": "sk-..."}) and each becomes a "Kimi slot" section;
|
|
18
18
|
* the slot matching the live registry key is marked (active).
|
|
19
19
|
*
|
|
20
|
+
* Vercel AI Gateway is not a pi provider, so its keys are read
|
|
21
|
+
* from ~/.ai-cli/config.toml [vercel] (currently disabled in
|
|
22
|
+
* EXTRA_SECTIONS).
|
|
23
|
+
*
|
|
24
|
+
* Volcengine Ark / Tencent TokenHub usage needs console-level
|
|
25
|
+
* signed credentials (AK/SK) and is not included.
|
|
26
|
+
*
|
|
27
|
+
* Aliyun Token Plan (qwen-token-plan-cn) is disabled: it has no
|
|
28
|
+
* key-based usage endpoint and needed a probe chat request, so
|
|
29
|
+
* its FAMILIES entry is commented out (helpers kept).
|
|
30
|
+
*
|
|
20
31
|
* The official kimi-code CLI's OAuth account (~/.kimi-code) is
|
|
21
32
|
* reported as "Kimi cli account"; its token is refreshed through
|
|
22
33
|
* the CLI's own OAuth flow when close to expiry (the rotated
|
|
@@ -179,6 +190,29 @@ async function fetchJson(url: string, apiKey: string, timeoutMs = 8000): Promise
|
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
|
|
193
|
+
/** Reset timestamp from qwen's 429 message ("… quota will reset at 08-11 06:22:00 UTC."). */
|
|
194
|
+
function qwenResetMs(detail: string): number | undefined {
|
|
195
|
+
const m = /reset at (\d{2})-(\d{2}) (\d{2}):(\d{2})(?::(\d{2}))? UTC/.exec(detail);
|
|
196
|
+
if (!m) return undefined;
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
const [mo, d, h, mi, s] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5] ?? 0)];
|
|
199
|
+
let ms = Date.UTC(new Date(now).getUTCFullYear(), mo - 1, d, h, mi, s);
|
|
200
|
+
// The message has no year; if that lands in the past it means next year.
|
|
201
|
+
if (ms < now - 86400e3) ms = Date.UTC(new Date(now).getUTCFullYear() + 1, mo - 1, d, h, mi, s);
|
|
202
|
+
return ms;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Quota window from qwen's 429 message ("1-week quota", "1-day quota", "5-hour quota"). */
|
|
206
|
+
function qwenQuotaWindow(detail: string): { windowMs: number; label: string } | undefined {
|
|
207
|
+
const m = /(\d+)-(week|day|hour)\b/i.exec(detail);
|
|
208
|
+
if (!m) return undefined;
|
|
209
|
+
const n = Number(m[1]);
|
|
210
|
+
const unit = m[2].toLowerCase();
|
|
211
|
+
if (unit === "week") return { windowMs: n * 7 * 86400e3, label: n === 1 ? "Week" : `${n} weeks` };
|
|
212
|
+
if (unit === "day") return { windowMs: n * 86400e3, label: n === 1 ? "Day" : `${n} days` };
|
|
213
|
+
return { windowMs: n * 3600e3, label: `${n}h window` };
|
|
214
|
+
}
|
|
215
|
+
|
|
182
216
|
/** "8-13 14:52" — compact timestamp for reset annotations. */
|
|
183
217
|
function compactTime(d: Date): string {
|
|
184
218
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
@@ -192,6 +226,24 @@ function formatReset(iso: unknown): string {
|
|
|
192
226
|
return `resets ${compactTime(d)}`;
|
|
193
227
|
}
|
|
194
228
|
|
|
229
|
+
function maskKey(key: string): string {
|
|
230
|
+
return key.length <= 8 ? `${key.slice(0, 2)}...` : `${key.slice(0, 4)}...${key.slice(-4)}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Minimal TOML extraction: api_keys / api_key from the [vercel] section. */
|
|
234
|
+
function readVercelKeys(): string[] {
|
|
235
|
+
try {
|
|
236
|
+
const text = readFileSync(join(homedir(), ".ai-cli", "config.toml"), "utf8");
|
|
237
|
+
const section = /^\[vercel\]\s*\n([\s\S]*?)(?=^\[|\s*$(?![\s\S]))/m.exec(text)?.[1] ?? "";
|
|
238
|
+
const list = /api_keys\s*=\s*\[([\s\S]*?)\]/.exec(section);
|
|
239
|
+
if (list) return [...list[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
|
240
|
+
const single = /api_key\s*=\s*"([^"]+)"/.exec(section);
|
|
241
|
+
return single ? [single[1]] : [];
|
|
242
|
+
} catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
195
247
|
// ─── Provider families (the only place that knows about specific vendors) ──
|
|
196
248
|
|
|
197
249
|
interface ProviderFamily {
|
|
@@ -307,6 +359,63 @@ const FAMILIES: ProviderFamily[] = [
|
|
|
307
359
|
}
|
|
308
360
|
return rows;
|
|
309
361
|
}),
|
|
362
|
+
// Aliyun Token Plan (qwen-token-plan-cn): temporarily disabled. It has no
|
|
363
|
+
// key-based usage endpoint, so it was probed with a tiny hardcoded chat
|
|
364
|
+
// request (qwenResetMs/qwenQuotaWindow below parse the 429 body); re-enable
|
|
365
|
+
// by restoring this entry.
|
|
366
|
+
// {
|
|
367
|
+
// provider: "qwen-token-plan-cn",
|
|
368
|
+
// order: 30,
|
|
369
|
+
// query: async (apiKey) => {
|
|
370
|
+
// const url = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions";
|
|
371
|
+
// const started = Date.now();
|
|
372
|
+
// const resp = await fetch(url, {
|
|
373
|
+
// method: "POST",
|
|
374
|
+
// headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
375
|
+
// body: JSON.stringify({
|
|
376
|
+
// model: "qwen3.6-flash",
|
|
377
|
+
// messages: [{ role: "user", content: "hi" }],
|
|
378
|
+
// max_tokens: 1,
|
|
379
|
+
// enable_thinking: false,
|
|
380
|
+
// }),
|
|
381
|
+
// signal: AbortSignal.timeout(15000),
|
|
382
|
+
// });
|
|
383
|
+
// const ms = Date.now() - started;
|
|
384
|
+
// if (resp.ok) {
|
|
385
|
+
// const d = (await resp.json()) as Record<string, any>;
|
|
386
|
+
// const tokens = d?.usage?.total_tokens;
|
|
387
|
+
// return [{
|
|
388
|
+
// label: "Status",
|
|
389
|
+
// text: `key valid, not rate-limited (${ms} ms${tokens ? `, ${tokens} tokens` : ""})`,
|
|
390
|
+
// tone: "ok",
|
|
391
|
+
// }];
|
|
392
|
+
// }
|
|
393
|
+
// const raw = await resp.text();
|
|
394
|
+
// let detail = raw.slice(0, 300);
|
|
395
|
+
// try {
|
|
396
|
+
// const e = (JSON.parse(raw) as Record<string, any>)?.error;
|
|
397
|
+
// if (e) detail = [e.code, e.message].filter(Boolean).join(": ") || detail;
|
|
398
|
+
// } catch {
|
|
399
|
+
// }
|
|
400
|
+
// if (resp.status === 429) {
|
|
401
|
+
// const resetMs = qwenResetMs(detail);
|
|
402
|
+
// const win = qwenQuotaWindow(detail);
|
|
403
|
+
// if (resetMs !== undefined) {
|
|
404
|
+
// return [{
|
|
405
|
+
// label: win?.label ?? "Week",
|
|
406
|
+
// percentLeft: 0,
|
|
407
|
+
// text: "quota exhausted",
|
|
408
|
+
// tone: "warn" as const,
|
|
409
|
+
// hint: win === undefined ? formatReset(resetMs) : undefined,
|
|
410
|
+
// timeline: win !== undefined ? { startMs: resetMs - win.windowMs, endMs: resetMs } : undefined,
|
|
411
|
+
// }];
|
|
412
|
+
// }
|
|
413
|
+
// return [{ label: "Status", text: "rate limited (HTTP 429)", tone: "warn" as const, hint: detail }];
|
|
414
|
+
// }
|
|
415
|
+
// if (resp.status === 401) return [{ label: "Status", text: "key rejected (HTTP 401)", tone: "error" as const, hint: detail }];
|
|
416
|
+
// return [{ label: "Status", text: `probe failed (HTTP ${resp.status})`, tone: "error" as const, hint: detail }];
|
|
417
|
+
// },
|
|
418
|
+
// },
|
|
310
419
|
simpleFamily("deepseek", 20, "https://api.deepseek.com/user/balance", (d) => {
|
|
311
420
|
const infos = d?.balance_infos;
|
|
312
421
|
if (!Array.isArray(infos) || infos.length === 0) return [{ label: "balance", text: "no balance data", tone: "warn" as const }];
|
|
@@ -367,6 +476,27 @@ async function discoverSections(ctx: ExtensionCommandContext): Promise<Section[]
|
|
|
367
476
|
return sections;
|
|
368
477
|
}
|
|
369
478
|
|
|
479
|
+
const queryVercel: Query = async () => {
|
|
480
|
+
const keys = readVercelKeys();
|
|
481
|
+
if (keys.length === 0) return [{ label: "vercel", text: "no keys in ~/.ai-cli/config.toml [vercel]", tone: "warn" }];
|
|
482
|
+
return Promise.all(
|
|
483
|
+
keys.map(async (key, i): Promise<Row> => {
|
|
484
|
+
const label = keys.length > 1 ? `key_${i + 1}` : "key";
|
|
485
|
+
try {
|
|
486
|
+
const d = (await fetchJson("https://ai-gateway.vercel.sh/v1/credits", key)) as Record<string, any>;
|
|
487
|
+
return { label, text: `${maskKey(key)} balance $${d.balance ?? "?"} · used $${d.total_used ?? "?"}` };
|
|
488
|
+
} catch (err) {
|
|
489
|
+
return { label, text: `${maskKey(key)} fetch failed: ${err instanceof Error ? err.message : err}`, tone: "error" };
|
|
490
|
+
}
|
|
491
|
+
}),
|
|
492
|
+
);
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/** Non-registry sources (not pi providers, keys come from elsewhere). */
|
|
496
|
+
const EXTRA_SECTIONS: Section[] = [
|
|
497
|
+
// { label: "Vercel AI Gateway", order: 15, query: queryVercel }, // temporarily disabled
|
|
498
|
+
];
|
|
499
|
+
|
|
370
500
|
// ─── Kimi account slots ───────────────────────────────────────────────────
|
|
371
501
|
|
|
372
502
|
/** Directory holding extra kimi API-key files (<name>.auth.json). */
|
|
@@ -659,11 +789,16 @@ function secToMs(v: unknown): number | undefined {
|
|
|
659
789
|
return n !== undefined ? (n < 1e12 ? n * 1000 : n) : undefined;
|
|
660
790
|
}
|
|
661
791
|
|
|
792
|
+
const CODEX_QUOTA_RANK: Record<string, number> = { Week: 0, "5h window": 1, Quota: 2 };
|
|
793
|
+
|
|
662
794
|
/** Format the codex /wham/usage payload into report rows. */
|
|
663
795
|
function formatCodexUsage(d: any): Row[] {
|
|
664
796
|
const rows: Row[] = [];
|
|
665
797
|
if (d?.plan_type) rows.push({ label: "Plan", text: String(d.plan_type) });
|
|
666
798
|
const rl = d?.rate_limit;
|
|
799
|
+
// Both windows are collected then sorted (Week before 5h window, same as
|
|
800
|
+
// the kimi/zai/claude sections); the API returns primary(5h) first.
|
|
801
|
+
const quotaRows: Row[] = [];
|
|
667
802
|
const pw = rl?.primary_window;
|
|
668
803
|
if (pw && toNum(pw.used_percent) !== undefined) {
|
|
669
804
|
const windowSecs = toNum(pw.limit_window_seconds);
|
|
@@ -673,17 +808,19 @@ function formatCodexUsage(d: any): Row[] {
|
|
|
673
808
|
windowMs: windowSecs !== undefined ? windowSecs * 1000 : 7 * 86400e3,
|
|
674
809
|
});
|
|
675
810
|
if (rl?.limit_reached) row.hint = "limit reached";
|
|
676
|
-
|
|
811
|
+
quotaRows.push(row);
|
|
677
812
|
}
|
|
678
813
|
const sw = rl?.secondary_window;
|
|
679
814
|
if (sw && toNum(sw.used_percent) !== undefined) {
|
|
680
815
|
const windowSecs = toNum(sw.limit_window_seconds);
|
|
681
|
-
|
|
816
|
+
quotaRows.push(quotaRow(windowSecs === 604800 ? "Week" : windowSecs === 18000 ? "5h window" : "Quota", {
|
|
682
817
|
pctLeft: 100 - toNum(sw.used_percent)!,
|
|
683
818
|
reset: secToMs(sw.reset_at),
|
|
684
819
|
windowMs: windowSecs !== undefined ? windowSecs * 1000 : undefined,
|
|
685
820
|
}));
|
|
686
821
|
}
|
|
822
|
+
quotaRows.sort((a, b) => (CODEX_QUOTA_RANK[a.label] ?? 3) - (CODEX_QUOTA_RANK[b.label] ?? 3));
|
|
823
|
+
rows.push(...quotaRows);
|
|
687
824
|
const credits = d?.credits;
|
|
688
825
|
if (credits?.has_credits)
|
|
689
826
|
rows.push({ label: "Credits", text: `$${toNum(credits.balance) ?? "0"} left`, tone: credits.overage_limit_reached ? "warn" : undefined });
|
|
@@ -701,7 +838,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
701
838
|
pi.registerCommand("status", {
|
|
702
839
|
description: "Show remaining quota / balance for all configured providers",
|
|
703
840
|
handler: async (_args, ctx) => {
|
|
704
|
-
const sections = await discoverSections(ctx);
|
|
841
|
+
const sections = [...(await discoverSections(ctx)), ...EXTRA_SECTIONS];
|
|
705
842
|
|
|
706
843
|
// Multi-key slots (KIMI_SWITCH_STORE): report every saved account, not
|
|
707
844
|
// just the currently active key, so usage is visible without switching.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-provider-status",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Show remaining quota and balance for LLM providers in pi — /status panel for Kimi Coding, Zhipu ZAI, MiniMax, DeepSeek, OpenRouter, Claude and Codex subscription windows",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|