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.
@@ -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
- rows.push(row);
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
- rows.push(quotaRow(windowSecs === 604800 ? "Week" : windowSecs === 18000 ? "5h window" : "Quota", {
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.1.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",