@bacnh85/pi-sub 0.1.28 → 0.1.30

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,55 @@
1
+ ## 0.1.30 (2026-08-22)
2
+
3
+ - Router usage is now **provider-scoped**: the active router model's upstream
4
+ prefix (e.g. `command-code` from `router/command-code/deepseek/deepseek-v4-flash`)
5
+ is passed as `?provider=` to the OmniRoute usage API, so the footer shows that
6
+ upstream's quota (e.g. opencode-go session/weekly windows).
7
+ - Aliases normalized before the API call: `cmd`→`command-code`, `oc`→`opencode-go`,
8
+ `ds`→`deepseek`, `glmcn`/`glm-cn`→`zai-coding-cn`; generic router aliases
9
+ (auto/aug/no-think/tllm/combo/openrouter/nvidia/…) are filtered out.
10
+ - When the selected upstream has no cached quota ("No cached usage data"),
11
+ the adapter retries without `?provider=` so the footer still shows the best
12
+ snapshot instead of empty windows.
13
+ - Switching between router models with different upstreams now refreshes
14
+ immediately (adapter id includes the prefix, so stale in-flight results are
15
+ discarded via the generation guard).
16
+ - `/sub` breakdown shows only the Provider quota section — personal USD budget
17
+ lines are no longer surfaced.
18
+ - Self-check extended (17 assertions) and now exercises the real
19
+ `routerUpstreamPrefix` (aliases + generic filter).
20
+
21
+ ## 0.1.27 (2026-08-22)
22
+
23
+ - Router adapter now fetches real usage from OmniRoute instances: GET
24
+ `<origin>/api/usage/om-usage` (Bearer = router API key from auth.json or
25
+ ROUTER_API_KEY env). The plain-text report (Personal quota Daily/Weekly +
26
+ Provider quota Session/Weekly) is parsed into the footer R:/W: windows.
27
+ Non-OmniRoute routers 404 → fall back to the endpoint-only display.
28
+ When the per-key usage command is disabled, the footer shows a hint to
29
+ enable it in the OmniRoute dashboard (API Keys → the key).
30
+ - New pure parser `parseOmniUsageText` with an env-gated self-check
31
+ (PI_SUB_SELF_CHECK=1) — pi-sub stays pack-only for CI.
32
+
33
+ ## 0.1.26 (2026-08-21)
34
+
35
+ - Router adapter: reads `router.baseUrl` from `~/.pi/agent/settings.json`
36
+ (env `ROUTER_BASE_URL`/`NINE_ROUTER_BASE_URL`) now that pi-router moved the
37
+ endpoint URL out of the old `9router-config.json` (which is migrated away on
38
+ pi-router load). Footer shows for both new `router/` and legacy `9router/`
39
+ provider models.
40
+
1
41
  # Changelog
2
42
 
43
+ ## 0.1.29 (2026-08-15)
44
+
45
+ ### Features
46
+
47
+ - **tok/s speed now shown for all providers.** Previously the footer was cleared
48
+ for providers without a subscription adapter (e.g. `ollama`); now the last
49
+ response speed renders as a standalone `145 tok/s` line, and `/sub` reports
50
+ provider/model plus last and session-average speed instead of only
51
+ "tracking inactive".
52
+
3
53
  ## 0.1.28 (2026-08-09)
4
54
 
5
55
  ### Improvements
package/README.md CHANGED
@@ -51,12 +51,27 @@ The built-in `zai-coding-cn` provider targets the domestic BigModel endpoint (`o
51
51
  (Z.ai (CN) key#1a2b3c4d) R:55%/2H W:80%/3D 42 tok/s
52
52
  ```
53
53
 
54
- ### 9router
54
+ ### Router (pi-router — formerly 9router)
55
55
 
56
- 9router is a proxy/router with no upstream usage API, so the footer shows the configured endpoint and last response speed:
56
+ For **OmniRoute** instances the footer shows real usage: `GET <origin>/api/usage/om-usage`
57
+ with the router API key (auth.json `router` credential from `/login router`, or
58
+ `ROUTER_API_KEY` env) returns the per-key report — Personal quota (daily/weekly
59
+ USD budgets) and Provider quota (session/weekly connection windows) — rendered
60
+ as `R:`/`W:` remaining-percent windows:
57
61
 
58
62
  ```text
59
- 9router (172.30.55.22:20128) 145 tok/s
63
+ Router usage R:80% W:28% 145 tok/s
64
+ ```
65
+
66
+ Requirements:
67
+ - The router instance must be OmniRoute (other routers 404 → endpoint-only fallback).
68
+ - The API key must have the **usage command enabled** in the OmniRoute dashboard
69
+ (API Keys → the key → enable "usage command"); the footer shows a hint when it's off.
70
+
71
+ The endpoint URL is read from `~/.pi/agent/settings.json` (`router.baseUrl`), env `ROUTER_BASE_URL` overrides:
72
+
73
+ ```text
74
+ Router (172.30.55.22:20128) 145 tok/s
60
75
  ```
61
76
 
62
77
  ### Command Code
@@ -75,7 +90,17 @@ monthly allowance in USD). The `/sub` detail view also shows a
75
90
 
76
91
  `pi-sub` tracks each response's tokens-per-second (tok/s) speed by measuring the time from provider request to message completion against the response's output token count. The last response's speed is shown in the footer next to usage data. The `/sub` detail view shows both the last response speed and the session-wide average.
77
92
 
78
- When the current model provider is not supported, `pi-sub` clears its subscription line and does not refresh subscription data.
93
+ The tok/s speed line is shown for **all** providers including ones without a
94
+ subscription adapter, such as `ollama` and other OpenAI-compatible local
95
+ providers. For those, the footer shows only the speed:
96
+
97
+ ```text
98
+ 145 tok/s
99
+ ```
100
+
101
+ and `/sub` reports the provider/model and speed instead of usage windows.
102
+ `pi-sub` still does not refresh subscription data for unsupported providers
103
+ (there is nothing to fetch).
79
104
 
80
105
  ## Commands
81
106
 
@@ -16,8 +16,11 @@ const ZAI_PROVIDER = "zai";
16
16
  const ZAI_USAGE_URL = "https://api.z.ai/api/monitor/usage/quota/limit";
17
17
  const ZAI_CODING_CN_PROVIDER = "zai-coding-cn";
18
18
  const ZAI_CODING_CN_USAGE_URL = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
19
- const NINE_ROUTER_PROVIDER = "9router";
20
- const NINE_ROUTER_CONFIG_PATH = path.join(os.homedir(), ".pi", "agent", "9router-config.json");
19
+ const ROUTER_PROVIDER = "router";
20
+ const LEGACY_9ROUTER_PROVIDER = "9router";
21
+ // pi-router (formerly pi-9router): URL lives in settings.json `router.baseUrl`
22
+ // (env override ROUTER_BASE_URL), key in auth.json `router` credential.
23
+ const ROUTER_SETTINGS_PATH = path.join(os.homedir(), ".pi", "agent", "settings.json");
21
24
  const COMMAND_CODE_PROVIDER = "commandcode";
22
25
  const COMMAND_CODE_USAGE_URL = "https://api.commandcode.ai/alpha/billing/credits";
23
26
 
@@ -117,8 +120,8 @@ function isZaiCodingCnModel(model: ModelLike): boolean {
117
120
  return (model?.provider?.toLowerCase() ?? "") === ZAI_CODING_CN_PROVIDER;
118
121
  }
119
122
 
120
- function isNineRouterModel(model: ModelLike): boolean {
121
- return (model?.provider?.toLowerCase() ?? "") === NINE_ROUTER_PROVIDER;
123
+ function isRouterModel(model: ModelLike, provider: string = ROUTER_PROVIDER): boolean {
124
+ return (model?.provider?.toLowerCase() ?? "") === provider;
122
125
  }
123
126
 
124
127
  function isCommandCodeModel(model: ModelLike): boolean {
@@ -309,21 +312,113 @@ async function readZaiAuth(providerId: string, label: string): Promise<{ key: st
309
312
  return { key: entry.key, account: authAccountSnapshot(label, entry) };
310
313
  }
311
314
 
312
- // ponytail: duplicated from pi-9router — no shared config lib, 8 lines, acceptable
313
- function readNineRouterConfig(): { baseUrl: string; apiKey?: string } | null {
315
+ // ponytail: duplicated from pi-router — no shared config lib, ~12 lines, acceptable
316
+ function readRouterConfig(): { baseUrl: string } | null {
314
317
  try {
315
- const exists = (p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } };
316
- if (!exists(NINE_ROUTER_CONFIG_PATH)) return null;
317
- const data = JSON.parse(fs.readFileSync(NINE_ROUTER_CONFIG_PATH, "utf8")) as Record<string, unknown>;
318
- const baseUrl = process.env.NINE_ROUTER_BASE_URL || (typeof data.baseUrl === "string" ? data.baseUrl : undefined);
319
- const apiKey = process.env.NINE_ROUTER_API_KEY || (typeof data.apiKey === "string" ? data.apiKey : undefined);
320
- if (!baseUrl || typeof baseUrl !== "string") return null;
321
- return { baseUrl, apiKey };
318
+ let baseUrl = process.env.ROUTER_BASE_URL || process.env.NINE_ROUTER_BASE_URL;
319
+ if (!baseUrl && fs.statSync(ROUTER_SETTINGS_PATH).isFile()) {
320
+ const settings = JSON.parse(fs.readFileSync(ROUTER_SETTINGS_PATH, "utf8")) as Record<string, unknown>;
321
+ const router = settings.router as { baseUrl?: unknown } | undefined;
322
+ if (typeof router?.baseUrl === "string" && router.baseUrl) baseUrl = router.baseUrl;
323
+ }
324
+ return baseUrl ? { baseUrl } : null;
322
325
  } catch {
323
326
  return null;
324
327
  }
325
328
  }
326
329
 
330
+ /** Router API key: auth.json `router` credential (via /login), then env. */
331
+ function readRouterApiKey(): string | undefined {
332
+ const stored = readStoredCredential(ROUTER_PROVIDER, piAuthPath()) as PiAuthEntry | undefined;
333
+ if (stored?.key) return stored.key;
334
+ return process.env.ROUTER_API_KEY || process.env.NINE_ROUTER_API_KEY || undefined;
335
+ }
336
+
337
+ /** Strip a `/v1` suffix so management routes (under the origin) can be
338
+ * derived from the OpenAI-compatible baseUrl. */
339
+ function routerOrigin(baseUrl: string): string {
340
+ return baseUrl.replace(/\/v1\/?$/, "");
341
+ }
342
+
343
+ /** Parse OmniRoute's `/api/usage/om-usage` plain-text report into windows.
344
+ * Sections: "Personal quota" (per-key USD budgets: Daily/Weekly) and
345
+ * "Provider quota" (connection session/weekly). Lines: `<Label>`,
346
+ * `NN% left`, `⏱ reset in <countdown>`. Robust to missing/unknown blocks. */
347
+ export function parseOmniUsageText(text: string): {
348
+ personalDaily?: UsageWindow;
349
+ personalWeekly?: UsageWindow;
350
+ session?: UsageWindow;
351
+ providerWeekly?: UsageWindow;
352
+ } {
353
+ const out: {
354
+ personalDaily?: UsageWindow;
355
+ personalWeekly?: UsageWindow;
356
+ session?: UsageWindow;
357
+ providerWeekly?: UsageWindow;
358
+ } = {};
359
+ const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
360
+ let inPersonal = false;
361
+ for (let i = 0; i < lines.length; i++) {
362
+ const line = lines[i];
363
+ if (line.toLowerCase() === "personal quota") { inPersonal = true; continue; }
364
+ if (line.toLowerCase() === "provider quota") { inPersonal = false; continue; }
365
+ const usedMatch = line.match(/^(\d+)%\s*left$/);
366
+ if (!usedMatch) continue;
367
+ const label = (lines[i - 1] ?? "").toLowerCase();
368
+ const resetMatch = lines[i + 1]?.match(/reset in (.+)$/);
369
+ const remaining = Number(usedMatch[1]);
370
+ if (remaining < 0 || remaining > 100) continue;
371
+ const window: UsageWindow = { remaining };
372
+ if (resetMatch) window.resetLabel = `⏱ ${resetMatch[1].trim()}`;
373
+ if (inPersonal) {
374
+ if (label.includes("daily")) out.personalDaily = window;
375
+ else if (label.includes("weekly")) out.personalWeekly = window;
376
+ } else {
377
+ if (label.includes("session")) out.session = window;
378
+ else if (label.includes("weekly")) out.providerWeekly = window;
379
+ }
380
+ }
381
+ return out;
382
+ }
383
+
384
+ // ponytail: runnable self-check (pi-sub has no test runner — pack gate only)
385
+ if (process.env.PI_SUB_SELF_CHECK === "1") {
386
+ const sample = [
387
+ "Personal quota", "Daily", "80% left", "⏱ reset in 15h 0m", "",
388
+ "Weekly", "90% left", "⏱ reset in 7d 0h 0m", "",
389
+ "Provider quota", "Session", "47% left", "⏱ reset in 9m", "",
390
+ "Weekly", "28% left", "⏱ reset in 1d 0h 0m",
391
+ ].join("\n");
392
+ const p = parseOmniUsageText(sample);
393
+ const assert = (cond: boolean, msg: string) => { if (!cond) throw new Error("pi-sub self-check: " + msg); };
394
+ assert(p.personalDaily?.remaining === 80, "personal daily 80");
395
+ // routerUpstreamPrefix: provider first segment, aliases + generic filtered
396
+ const rp = (id: string) => routerUpstreamPrefix({ id });
397
+ assert(rp("command-code/deepseek/deepseek-v4-flash") === "command-code", "prefix command-code");
398
+ assert(rp("cmd/deepseek/deepseek-v4-flash") === "command-code", "alias cmd → command-code");
399
+ assert(rp("oc/gpt-5") === "opencode-go", "alias oc → opencode-go");
400
+ assert(rp("zai-coding/glm-5.2") === "zai-coding", "prefix zai-coding");
401
+ assert(rp("auto/best") === undefined, "generic auto filtered");
402
+ assert(rp("openrouter/gpt-5") === undefined, "generic openrouter filtered");
403
+ assert(rp("nvidia/gpt-5") === undefined, "generic nvidia filtered");
404
+ assert(p.personalWeekly?.remaining === 90, "personal weekly 90");
405
+ assert(p.session?.remaining === 47, "session 47");
406
+ assert(p.providerWeekly?.remaining === 28, "provider weekly 28");
407
+ assert(p.personalDaily?.resetLabel?.includes("15h"), "daily reset label");
408
+ const disabled = parseOmniUsageText("Usage command is disabled for this API key.");
409
+ assert(Object.keys(disabled).length === 0, "disabled text parses empty");
410
+ // Live-verified: provider without cached data → no windows (endpoint fallback).
411
+ const noCache = parseOmniUsageText("Provider quota\nNo cached usage data available.");
412
+ assert(Object.keys(noCache).length === 0, "no-cached-data parses empty");
413
+ // Live-verified: opencode-go quota via ?provider=opencode-go.
414
+ const live = parseOmniUsageText(
415
+ "Provider quota\nSession\n90% left\n⏱ reset in 1h 59m\n\nWeekly\n0% left\n⏱ reset in 1d 20h 26m"
416
+ );
417
+ assert(live.session?.remaining === 90, "session 90");
418
+ assert(live.providerWeekly?.remaining === 0, "weekly 0");
419
+ assert(live.session?.resetLabel?.includes("1h 59m"), "session reset");
420
+ }
421
+
327
422
  async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
328
423
  const accountId = getCodexAccountId(entry) ?? entry.accountId;
329
424
  if (!accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
@@ -395,27 +490,100 @@ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<Subscription
395
490
  }
396
491
  }
397
492
 
398
- async function fetchNineRouterUsage(_signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
399
- const cfg = readNineRouterConfig();
493
+ async function fetchRouterUsage(signal?: AbortSignal, provider?: string): Promise<SubscriptionUsageSnapshot> {
494
+ const cfg = readRouterConfig();
400
495
  const now = Date.now();
401
496
  if (!cfg) {
402
497
  return {
403
- providerDisplayName: "9router",
498
+ providerDisplayName: "Router",
404
499
  accounts: [],
405
500
  fetchedAt: now,
406
- error: "9router not configured — run /login-9router",
501
+ error: "router not configured — set router.baseUrl in ~/.pi/agent/settings.json",
407
502
  };
408
503
  }
409
- const account: SubscriptionAccountSnapshot = {
504
+ const apiKey = readRouterApiKey();
505
+ const baseAccount: SubscriptionAccountSnapshot = {
410
506
  id: cfg.baseUrl,
411
507
  isActive: true,
412
508
  accountLabel: cfg.baseUrl.replace(/^https?:\/\//, ""),
413
509
  lastActivity: "Now",
414
510
  };
511
+
512
+ // OmniRoute exposes per-key usage at GET <origin>/api/usage/om-usage
513
+ // (Bearer = the router API key). `?provider=` selects that upstream's quota
514
+ // (e.g. command-code/deepseek/deepseek-v4-flash → provider=command-code);
515
+ // without it the report shows the best/all snapshot. Plain text: Personal
516
+ // quota (Daily/Weekly USD budgets) + Provider quota (Session/Weekly).
517
+ // Non-OmniRoute routers 404 here — fall back to endpoint-only display.
518
+ if (apiKey) {
519
+ try {
520
+ const timeoutSignal = AbortSignal.timeout(7_000);
521
+ const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
522
+ const url = `${routerOrigin(cfg.baseUrl)}/api/usage/om-usage` +
523
+ (provider ? `?provider=${encodeURIComponent(provider)}` : "");
524
+ const response = await fetch(url, {
525
+ headers: { Accept: "text/plain", Authorization: `Bearer ${apiKey}` },
526
+ signal: combined,
527
+ });
528
+ if (response.ok) {
529
+ let text = await response.text();
530
+ if (text && !text.includes("disabled")) {
531
+ let w = parseOmniUsageText(text);
532
+ // Provider-scoped call but no cached quota for that upstream — retry
533
+ // without ?provider= so the report shows the best/all snapshot.
534
+ if (provider && !w.session && !w.providerWeekly) {
535
+ const plain = await fetch(url.replace(/\?provider=.*$/, ""), {
536
+ headers: { Accept: "text/plain", Authorization: `Bearer ${apiKey}` },
537
+ signal: combined,
538
+ });
539
+ if (plain.ok) {
540
+ const plainText = await plain.text();
541
+ if (plainText && !plainText.includes("disabled")) {
542
+ w = parseOmniUsageText(plainText);
543
+ text = plainText;
544
+ }
545
+ }
546
+ }
547
+ // personalDaily = per-key budget (nearest reset → R slot),
548
+ // provider weekly/session = upstream quota (W slot). Fall back sensibly.
549
+ const account: SubscriptionAccountSnapshot = {
550
+ ...baseAccount,
551
+ plan: provider ? `Router · ${provider}` : "Router usage",
552
+ fiveHour: w.personalDaily ?? w.session,
553
+ weekly: w.providerWeekly ?? w.personalWeekly,
554
+ // breakdown keeps only the provider-quota section — the raw text can
555
+ // include personal USD budget lines (privacy) and is noisy.
556
+ usageBreakdown: providerQuotaSection(text),
557
+ };
558
+ return {
559
+ providerDisplayName: "Router",
560
+ accounts: [account],
561
+ activeAccount: account,
562
+ fetchedAt: Date.now(),
563
+ };
564
+ }
565
+ // Usage command exists but is disabled for this key — keep the footer
566
+ // clean (endpoint display) and surface the hint in /sub detail only.
567
+ const keyFp = apiKey ? `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}` : "?";
568
+ const hintAccount: SubscriptionAccountSnapshot = {
569
+ ...baseAccount,
570
+ usageBreakdown: `OmniRoute usage command is disabled for the router key ${keyFp} — ` +
571
+ `enable it in the dashboard (API Keys → the key ending ${apiKey?.slice(-4)} → usage command).`,
572
+ };
573
+ return {
574
+ providerDisplayName: "Router",
575
+ accounts: [hintAccount],
576
+ activeAccount: hintAccount,
577
+ fetchedAt: Date.now(),
578
+ };
579
+ }
580
+ } catch { /* non-OmniRoute or transient — fall through to endpoint display */ }
581
+ }
582
+
415
583
  return {
416
- providerDisplayName: "9router",
417
- accounts: [account],
418
- activeAccount: account,
584
+ providerDisplayName: "Router",
585
+ accounts: [baseAccount],
586
+ activeAccount: baseAccount,
419
587
  fetchedAt: now,
420
588
  };
421
589
  }
@@ -704,11 +872,54 @@ function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undef
704
872
  if (isOpenCodeGoModel(model)) return { id: OPC_PROVIDER, displayName: "OpenCode Go", fetchUsage: fetchOpenCodeGoUsage };
705
873
  if (isZaiModel(model)) return { id: ZAI_PROVIDER, displayName: "Z.ai", ...zaiUsageAdapter(ZAI_PROVIDER, ZAI_USAGE_URL, "Z.ai") };
706
874
  if (isZaiCodingCnModel(model)) return { id: ZAI_CODING_CN_PROVIDER, displayName: "Z.ai (CN)", ...zaiUsageAdapter(ZAI_CODING_CN_PROVIDER, ZAI_CODING_CN_USAGE_URL, "Z.ai (CN)") };
707
- if (isNineRouterModel(model)) return { id: NINE_ROUTER_PROVIDER, displayName: "9router", fetchUsage: fetchNineRouterUsage };
875
+ if (isRouterModel(model)) {
876
+ const prefix = routerUpstreamPrefix(model);
877
+ return {
878
+ // Prefix in the id makes adapterChanged fire when switching upstreams
879
+ // (e.g. opencode-go → command-code), so the in-flight fetch from the
880
+ // previous model is discarded via the refreshGeneration guard.
881
+ id: prefix ? `${ROUTER_PROVIDER}:${prefix}` : ROUTER_PROVIDER,
882
+ displayName: "Router",
883
+ // ponytail: capture the upstream provider prefix so fetchRouterUsage can
884
+ // request that provider's quota from the OmniRoute usage API.
885
+ fetchUsage: (signal) => fetchRouterUsage(signal, prefix),
886
+ };
887
+ }
888
+ if (isRouterModel(model, LEGACY_9ROUTER_PROVIDER)) return { id: LEGACY_9ROUTER_PROVIDER, displayName: "9router (legacy)", fetchUsage: fetchRouterUsage };
708
889
  if (isCommandCodeModel(model)) return { id: COMMAND_CODE_PROVIDER, displayName: "Command Code", fetchUsage: fetchCommandCodeUsage };
709
890
  return undefined;
710
891
  }
711
892
 
893
+ /** Strip everything up to and including the "Provider quota" section header
894
+ * so /sub breakdown never shows personal USD budget lines. */
895
+ function providerQuotaSection(text: string): string | undefined {
896
+ const idx = text.indexOf("Provider quota");
897
+ if (idx < 0) return undefined;
898
+ const section = text.slice(idx);
899
+ return section.trim().length > 0 ? section : undefined;
900
+ }
901
+
902
+ /** First path segment of a router model id = the upstream provider OmniRoute
903
+ * routes to (e.g. `command-code/deepseek/deepseek-v4-flash` → `command-code`).
904
+ * `router/provider/model` in pi flattens to id `provider/model`, so the prefix
905
+ * is the first segment. Aliases normalize to the canonical provider id
906
+ * (`cmd` → `command-code`); generic router aliases carry no provider info —
907
+ * return undefined so the usage API picks the best snapshot. */
908
+ function routerUpstreamPrefix(model: ModelLike): string | undefined {
909
+ const id = model?.id ?? "";
910
+ const first = id.split("/")[0]?.toLowerCase();
911
+ if (!first) return undefined;
912
+ // Alias normalization: OmniRoute exposes the same upstream under several ids.
913
+ if (first === "cmd") return "command-code";
914
+ if (first === "oc") return "opencode-go";
915
+ if (first === "ds") return "deepseek";
916
+ if (first === "glmcn" || first === "glm-cn") return "zai-coding-cn";
917
+ // Generic router aliases / upstreams without cached quota data — no provider
918
+ // selection; the usage API returns the best snapshot instead.
919
+ const generic = new Set(["auto", "aug", "no-think", "tllm", "combo", "openrouter", "nvidia", "felo", "pepper", "mcode", "ddgw", "veoaifree-web", "veo-free"]);
920
+ return generic.has(first) ? undefined : first;
921
+ }
922
+
712
923
  function formatRemaining(window: UsageWindow | undefined): string {
713
924
  if (!window) return "?";
714
925
  if (window.remainingLabel) return `${window.remaining}%/${window.remainingLabel}`;
@@ -733,11 +944,12 @@ function windowSegments(account: SubscriptionAccountSnapshot | undefined): strin
733
944
  }
734
945
 
735
946
  function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
947
+ const theme = ctx.ui.theme;
736
948
  if (!state.adapter) {
737
- ctx.ui.setStatus(STATUS_KEY, undefined);
949
+ // Unsupported provider (e.g. Ollama): still show the last response speed.
950
+ ctx.ui.setStatus(STATUS_KEY, state.lastTokPerSec !== undefined ? theme.fg("dim", `${state.lastTokPerSec} tok/s`) : undefined);
738
951
  return;
739
952
  }
740
- const theme = ctx.ui.theme;
741
953
  const snapshot = state.snapshot;
742
954
  let line: string;
743
955
  let color: "dim" | "warning" | "error" = "dim";
@@ -844,7 +1056,15 @@ function pad(value: string, width: number): string {
844
1056
  }
845
1057
 
846
1058
  function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: State): string {
847
- if (!state.adapter) return `Subscription tracking inactive for current model provider (${state.model?.provider ?? "unknown"}).`;
1059
+ if (!state.adapter) {
1060
+ const header = `Provider: ${state.model?.provider ?? "unknown"}${state.model?.id ? ` · Model: ${state.model.id}` : ""}`;
1061
+ if (state.lastTokPerSec === undefined) return `${header}\nSubscription tracking inactive for this provider.`;
1062
+ const tokPerSecLine = `Last response: ${state.lastTokPerSec} tok/s` +
1063
+ (state.cumulativeDurationMs > 0
1064
+ ? ` · Session avg: ${Math.round(state.cumulativeOutput / (state.cumulativeDurationMs / 1000))} tok/s`
1065
+ : "");
1066
+ return `${header}\n${tokPerSecLine}`;
1067
+ }
848
1068
  if (!snapshot) return "Subscription usage has not been loaded yet.";
849
1069
  if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
850
1070
  if (snapshot.accounts.length === 0) {
@@ -928,7 +1148,7 @@ export default function (pi: ExtensionAPI) {
928
1148
  state.cumulativeDurationMs += elapsed;
929
1149
  }
930
1150
  }
931
- if (state.adapter) renderSubscriptionLine(ctx, state);
1151
+ renderSubscriptionLine(ctx, state);
932
1152
  }
933
1153
  });
934
1154
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-sub",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Pi extension showing subscription usage for supported model providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",