@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 +50 -0
- package/README.md +29 -4
- package/extensions/index.ts +246 -26
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
20
|
-
const
|
|
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
|
|
121
|
-
return (model?.provider?.toLowerCase() ?? "") ===
|
|
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-
|
|
313
|
-
function
|
|
315
|
+
// ponytail: duplicated from pi-router — no shared config lib, ~12 lines, acceptable
|
|
316
|
+
function readRouterConfig(): { baseUrl: string } | null {
|
|
314
317
|
try {
|
|
315
|
-
|
|
316
|
-
if (!
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
return { baseUrl
|
|
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
|
|
399
|
-
const cfg =
|
|
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: "
|
|
498
|
+
providerDisplayName: "Router",
|
|
404
499
|
accounts: [],
|
|
405
500
|
fetchedAt: now,
|
|
406
|
-
error: "
|
|
501
|
+
error: "router not configured — set router.baseUrl in ~/.pi/agent/settings.json",
|
|
407
502
|
};
|
|
408
503
|
}
|
|
409
|
-
const
|
|
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: "
|
|
417
|
-
accounts: [
|
|
418
|
-
activeAccount:
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
1151
|
+
renderSubscriptionLine(ctx, state);
|
|
932
1152
|
}
|
|
933
1153
|
});
|
|
934
1154
|
|