@bacnh85/pi-sub 0.1.25 → 0.1.27
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 +62 -0
- package/README.md +58 -6
- package/extensions/index.ts +271 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
|
+
## 0.1.27 (2026-08-22)
|
|
2
|
+
|
|
3
|
+
- Router adapter now fetches real usage from OmniRoute instances: GET
|
|
4
|
+
`<origin>/api/usage/om-usage` (Bearer = router API key from auth.json or
|
|
5
|
+
ROUTER_API_KEY env). The plain-text report (Personal quota Daily/Weekly +
|
|
6
|
+
Provider quota Session/Weekly) is parsed into the footer R:/W: windows.
|
|
7
|
+
Non-OmniRoute routers 404 → fall back to the endpoint-only display.
|
|
8
|
+
When the per-key usage command is disabled, the footer shows a hint to
|
|
9
|
+
enable it in the OmniRoute dashboard (API Keys → the key).
|
|
10
|
+
- New pure parser `parseOmniUsageText` with an env-gated self-check
|
|
11
|
+
(PI_SUB_SELF_CHECK=1) — pi-sub stays pack-only for CI.
|
|
12
|
+
|
|
13
|
+
## 0.1.26 (2026-08-21)
|
|
14
|
+
|
|
15
|
+
- Router adapter: reads `router.baseUrl` from `~/.pi/agent/settings.json`
|
|
16
|
+
(env `ROUTER_BASE_URL`/`NINE_ROUTER_BASE_URL`) now that pi-router moved the
|
|
17
|
+
endpoint URL out of the old `9router-config.json` (which is migrated away on
|
|
18
|
+
pi-router load). Footer shows for both new `router/` and legacy `9router/`
|
|
19
|
+
provider models.
|
|
20
|
+
|
|
1
21
|
# Changelog
|
|
2
22
|
|
|
23
|
+
## 0.1.29 (2026-08-15)
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
- **tok/s speed now shown for all providers.** Previously the footer was cleared
|
|
28
|
+
for providers without a subscription adapter (e.g. `ollama`); now the last
|
|
29
|
+
response speed renders as a standalone `145 tok/s` line, and `/sub` reports
|
|
30
|
+
provider/model plus last and session-average speed instead of only
|
|
31
|
+
"tracking inactive".
|
|
32
|
+
|
|
33
|
+
## 0.1.28 (2026-08-09)
|
|
34
|
+
|
|
35
|
+
### Improvements
|
|
36
|
+
|
|
37
|
+
- **Command Code monthly balance now shown in the footer.** The footer adds a
|
|
38
|
+
compact `M:$X.XX` segment (monthly credit balance from
|
|
39
|
+
`/alpha/billing/credits`) next to the 5-hour/weekly windows, e.g.
|
|
40
|
+
`(Command Code key#…) R:100%/5H W:100%/7D M:$69.99`. The `/sub` detail view
|
|
41
|
+
keeps the `Monthly: $X remaining` breakdown line.
|
|
42
|
+
|
|
43
|
+
## 0.1.27 (2026-08-09)
|
|
44
|
+
|
|
45
|
+
### Features
|
|
46
|
+
|
|
47
|
+
- **Command Code now shows live usage windows.** The adapter fetches
|
|
48
|
+
`https://api.commandcode.ai/alpha/billing/credits` with the same Provider
|
|
49
|
+
API key used for `/provider/v1` models and renders the 5-hour and weekly
|
|
50
|
+
rolling windows (USD used/cap, remaining% + reset countdown) plus a
|
|
51
|
+
`Monthly: $X remaining` balance line — matching what the `cmd /usage` CLI
|
|
52
|
+
shows. Previously the footer showed session cost only.
|
|
53
|
+
|
|
54
|
+
## 0.1.26 (2026-08-09)
|
|
55
|
+
|
|
56
|
+
### Features
|
|
57
|
+
|
|
58
|
+
- Added Command Code (`commandcode`) provider support. When a `commandcode`
|
|
59
|
+
model is active, the footer shows the account label plus session cost and
|
|
60
|
+
tok/s, and `/sub` reports the provider. Command Code's Provider API does not
|
|
61
|
+
expose usage windows via its API key (the rolling-window data is
|
|
62
|
+
web-session-cookie only), so live 5h/weekly meters are not shown — behavior
|
|
63
|
+
matches `opencode-go` and `9router`.
|
|
64
|
+
|
|
3
65
|
## 0.1.25 (2026-08-07)
|
|
4
66
|
|
|
5
67
|
### Improvements
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Pi extension that shows subscription usage for the currently selected supported model provider.
|
|
4
4
|
|
|
5
|
-
Supports OpenAI Codex (`openai-codex`) with live usage windows from ChatGPT's usage endpoint, OpenCode Go (`opencode-go`) with session cost tracking, and Z.ai GLM Coding Plan — both the international (`zai`) and China (`zai-coding-cn`, `open.bigmodel.cn`) endpoints — with quota monitoring. Displays a subscription footer status after Pi's built-in status/token usage line.
|
|
5
|
+
Supports OpenAI Codex (`openai-codex`) with live usage windows from ChatGPT's usage endpoint, OpenCode Go (`opencode-go`) with session cost tracking, and Z.ai GLM Coding Plan — both the international (`zai`) and China (`zai-coding-cn`, `open.bigmodel.cn`) endpoints — with quota monitoring. Also tracks Command Code (`commandcode`) 5-hour/weekly windows and monthly credit balance. Displays a subscription footer status after Pi's built-in status/token usage line.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -51,19 +51,56 @@ 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
|
|
60
64
|
```
|
|
61
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
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Command Code
|
|
78
|
+
|
|
79
|
+
Command Code exposes live usage windows via its `/alpha/billing/credits` endpoint (same Provider API key used for `/provider/v1` models — no cookies). The footer shows the active account/key label, 5-hour and weekly remaining windows with reset countdowns, and last response speed:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
(Command Code key#1a2b3c4d) R:99%/4H W:99%/6D M:$69.99 42 tok/s
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The `M:$X.XX` segment is the monthly credit balance (the plan's remaining
|
|
86
|
+
monthly allowance in USD). The `/sub` detail view also shows a
|
|
87
|
+
`Monthly: $X remaining` line.
|
|
88
|
+
|
|
62
89
|
### Tokens per second
|
|
63
90
|
|
|
64
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.
|
|
65
92
|
|
|
66
|
-
|
|
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).
|
|
67
104
|
|
|
68
105
|
## Commands
|
|
69
106
|
|
|
@@ -98,6 +135,20 @@ Last response: 42 tok/s · Session avg: 39 tok/s
|
|
|
98
135
|
OpenCode Go does not expose usage windows.
|
|
99
136
|
```
|
|
100
137
|
|
|
138
|
+
For Command Code, `/sub` shows the provider/model, active account/key label, rolling windows, and speed:
|
|
139
|
+
|
|
140
|
+
```text
|
|
141
|
+
Provider: Command Code · Model: deepseek/deepseek-v4-flash · Fetched: 14:23
|
|
142
|
+
Session cost: $0.05
|
|
143
|
+
Last response: 42 tok/s · Session avg: 39 tok/s
|
|
144
|
+
|
|
145
|
+
ACCOUNT ROLLING WEEKLY LAST ACTIVITY
|
|
146
|
+
-----------------------------------------------------------------
|
|
147
|
+
* Command Code key#1a2b3c4d 99%/4H 99%/6D Now
|
|
148
|
+
|
|
149
|
+
Monthly: $69.99 remaining
|
|
150
|
+
```
|
|
151
|
+
|
|
101
152
|
For Z.ai, `/sub` shows the rolling and weekly quota windows and speed:
|
|
102
153
|
|
|
103
154
|
```text
|
|
@@ -130,6 +181,7 @@ Refreshes are cached briefly to avoid excessive usage endpoint calls.
|
|
|
130
181
|
|
|
131
182
|
- **OpenAI Codex**: Pi auth must contain an `openai-codex` OAuth entry in `~/.pi/agent/auth.json` or `$PI_CODING_AGENT_DIR/auth.json`. The entry must include `access` and `accountId` fields.
|
|
132
183
|
- **OpenCode Go**: Pi auth must contain an `opencode-go` API key entry (via `/login` or env var). The entry must have a `key` field or an `accountId` field. OpenCode Go/Zen usage windows and Zen balance are not shown because no public API is currently documented for those values.
|
|
184
|
+
- **Command Code**: Pi auth must contain a `commandcode` API key entry (via `/login commandcode` or the `COMMAND_CODE_API_KEY` env var). The entry must have a `key` field or an `accountId` field. Usage is read from `https://api.commandcode.ai/alpha/billing/credits` with the same key; 5-hour and weekly windows plus the monthly credit balance are displayed.
|
|
133
185
|
- **Z.ai**: Pi auth must contain a `zai` entry in `auth.json` with a `key` field (the same API key used for Z.ai model access via `@czottmann/pi-zai-api`). The Z.ai provider must be registered (e.g., `pi install npm:@czottmann/pi-zai-api`).
|
|
134
186
|
- **Z.ai Coding Plan (China)**: The built-in `zai-coding-cn` provider targets `https://open.bigmodel.cn/api/coding/paas/v4`. Pi auth must contain a `zai-coding-cn` entry with a `key` field (set via `/login` or the `ZAI_CODING_CN_API_KEY` env var). Quota is read from the BigModel endpoint `https://open.bigmodel.cn/api/monitor/usage/quota/limit`.
|
|
135
187
|
- For API-key-only providers, account labels come from stored auth metadata (`email`, `label`, `name`, or `accountId`) when available; otherwise `pi-sub` displays a non-secret SHA-256 key fingerprint such as `Z.ai key#1a2b3c4d`.
|
|
@@ -141,4 +193,4 @@ See [CHANGELOG.md](CHANGELOG.md) for release history.
|
|
|
141
193
|
|
|
142
194
|
## Design notes
|
|
143
195
|
|
|
144
|
-
The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. Supports OpenAI Codex (live usage API), OpenCode Go (session cost only),
|
|
196
|
+
The extension is named `pi-sub` rather than `pi-codex-usage` so future subscription providers can be added as separate adapters. Supports OpenAI Codex (live usage API), OpenCode Go (session cost only), the Z.ai GLM Coding Plan (international `zai` and China `zai-coding-cn`, which share a quota response format and are served by one parameterized adapter), and Command Code (live 5-hour/weekly windows + monthly balance via `/alpha/billing/credits`).
|
package/extensions/index.ts
CHANGED
|
@@ -16,8 +16,13 @@ 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");
|
|
24
|
+
const COMMAND_CODE_PROVIDER = "commandcode";
|
|
25
|
+
const COMMAND_CODE_USAGE_URL = "https://api.commandcode.ai/alpha/billing/credits";
|
|
21
26
|
|
|
22
27
|
type ModelLike = { provider?: string; id?: string } | undefined;
|
|
23
28
|
|
|
@@ -59,6 +64,8 @@ interface SubscriptionAccountSnapshot {
|
|
|
59
64
|
plan?: string;
|
|
60
65
|
fiveHour?: UsageWindow;
|
|
61
66
|
weekly?: UsageWindow;
|
|
67
|
+
// Command Code-only: monthly credit balance in USD (not a rolling window).
|
|
68
|
+
monthlyCredits?: number;
|
|
62
69
|
// Z.ai-only extras surfaced in the /sub detail view.
|
|
63
70
|
mcpMonthly?: UsageWindow; // from TIME_LIMIT already present in the quota response
|
|
64
71
|
usageBreakdown?: string; // per-model / per-tool summary line(s)
|
|
@@ -113,8 +120,12 @@ function isZaiCodingCnModel(model: ModelLike): boolean {
|
|
|
113
120
|
return (model?.provider?.toLowerCase() ?? "") === ZAI_CODING_CN_PROVIDER;
|
|
114
121
|
}
|
|
115
122
|
|
|
116
|
-
function
|
|
117
|
-
return (model?.provider?.toLowerCase() ?? "") ===
|
|
123
|
+
function isRouterModel(model: ModelLike, provider: string = ROUTER_PROVIDER): boolean {
|
|
124
|
+
return (model?.provider?.toLowerCase() ?? "") === provider;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isCommandCodeModel(model: ModelLike): boolean {
|
|
128
|
+
return (model?.provider?.toLowerCase() ?? "") === COMMAND_CODE_PROVIDER;
|
|
118
129
|
}
|
|
119
130
|
|
|
120
131
|
function piAuthPath(): string {
|
|
@@ -301,21 +312,94 @@ async function readZaiAuth(providerId: string, label: string): Promise<{ key: st
|
|
|
301
312
|
return { key: entry.key, account: authAccountSnapshot(label, entry) };
|
|
302
313
|
}
|
|
303
314
|
|
|
304
|
-
// ponytail: duplicated from pi-
|
|
305
|
-
function
|
|
315
|
+
// ponytail: duplicated from pi-router — no shared config lib, ~12 lines, acceptable
|
|
316
|
+
function readRouterConfig(): { baseUrl: string } | null {
|
|
306
317
|
try {
|
|
307
|
-
|
|
308
|
-
if (!
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
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;
|
|
314
325
|
} catch {
|
|
315
326
|
return null;
|
|
316
327
|
}
|
|
317
328
|
}
|
|
318
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
|
+
assert(p.personalWeekly?.remaining === 90, "personal weekly 90");
|
|
396
|
+
assert(p.session?.remaining === 47, "session 47");
|
|
397
|
+
assert(p.providerWeekly?.remaining === 28, "provider weekly 28");
|
|
398
|
+
assert(p.personalDaily?.resetLabel?.includes("15h"), "daily reset label");
|
|
399
|
+
const disabled = parseOmniUsageText("Usage command is disabled for this API key.");
|
|
400
|
+
assert(Object.keys(disabled).length === 0, "disabled text parses empty");
|
|
401
|
+
}
|
|
402
|
+
|
|
319
403
|
async function fetchUsageFromPiAuth(entry: PiAuthEntry, signal?: AbortSignal): Promise<UsageApiSnapshot | undefined> {
|
|
320
404
|
const accountId = getCodexAccountId(entry) ?? entry.accountId;
|
|
321
405
|
if (!accountId) throw new Error("Missing openai-codex OAuth entry in Pi auth");
|
|
@@ -340,6 +424,7 @@ function redactedError(error: unknown, provider = "Codex"): string {
|
|
|
340
424
|
if (/missing openai-codex/i.test(message)) return "openai-codex auth not found";
|
|
341
425
|
if (/missing opencode-go/i.test(message)) return "opencode-go auth not found";
|
|
342
426
|
if (/missing zai/i.test(message)) return "zai auth not found";
|
|
427
|
+
if (/missing commandcode/i.test(message)) return "commandcode auth not found";
|
|
343
428
|
if (/timed out|timeout|aborted/i.test(message)) return `${provider} usage refresh timed out`;
|
|
344
429
|
if (/401|403|auth|token|unauthorized|forbidden/i.test(message)) return `${provider} auth unavailable`;
|
|
345
430
|
return `${provider} usage unavailable`;
|
|
@@ -386,31 +471,136 @@ async function fetchOpenCodeGoUsage(_signal?: AbortSignal): Promise<Subscription
|
|
|
386
471
|
}
|
|
387
472
|
}
|
|
388
473
|
|
|
389
|
-
async function
|
|
390
|
-
const cfg =
|
|
474
|
+
async function fetchRouterUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
|
|
475
|
+
const cfg = readRouterConfig();
|
|
391
476
|
const now = Date.now();
|
|
392
477
|
if (!cfg) {
|
|
393
478
|
return {
|
|
394
|
-
providerDisplayName: "
|
|
479
|
+
providerDisplayName: "Router",
|
|
395
480
|
accounts: [],
|
|
396
481
|
fetchedAt: now,
|
|
397
|
-
error: "
|
|
482
|
+
error: "router not configured — set router.baseUrl in ~/.pi/agent/settings.json",
|
|
398
483
|
};
|
|
399
484
|
}
|
|
400
|
-
const
|
|
485
|
+
const apiKey = readRouterApiKey();
|
|
486
|
+
const baseAccount: SubscriptionAccountSnapshot = {
|
|
401
487
|
id: cfg.baseUrl,
|
|
402
488
|
isActive: true,
|
|
403
489
|
accountLabel: cfg.baseUrl.replace(/^https?:\/\//, ""),
|
|
404
490
|
lastActivity: "Now",
|
|
405
491
|
};
|
|
492
|
+
|
|
493
|
+
// OmniRoute exposes per-key usage at GET <origin>/api/usage/om-usage
|
|
494
|
+
// (Bearer = the router API key). The report is plain text: Personal quota
|
|
495
|
+
// (Daily/Weekly USB budgets) + Provider quota (Session/Weekly connections).
|
|
496
|
+
// Non-OmniRoute routers 404 here — fall back to endpoint-only display.
|
|
497
|
+
if (apiKey) {
|
|
498
|
+
try {
|
|
499
|
+
const timeoutSignal = AbortSignal.timeout(7_000);
|
|
500
|
+
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
501
|
+
const response = await fetch(`${routerOrigin(cfg.baseUrl)}/api/usage/om-usage`, {
|
|
502
|
+
headers: { Accept: "text/plain", Authorization: `Bearer ${apiKey}` },
|
|
503
|
+
signal: combined,
|
|
504
|
+
});
|
|
505
|
+
if (response.ok) {
|
|
506
|
+
const text = await response.text();
|
|
507
|
+
if (text && !text.includes("disabled")) {
|
|
508
|
+
const w = parseOmniUsageText(text);
|
|
509
|
+
// personalDaily = per-key budget (nearest reset → R slot),
|
|
510
|
+
// provider weekly = connection quota (W slot). Fall back sensibly.
|
|
511
|
+
const account: SubscriptionAccountSnapshot = {
|
|
512
|
+
...baseAccount,
|
|
513
|
+
plan: "Router usage",
|
|
514
|
+
fiveHour: w.personalDaily ?? w.session,
|
|
515
|
+
weekly: w.providerWeekly ?? w.personalWeekly,
|
|
516
|
+
usageBreakdown: text.length > 120 ? text : undefined,
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
providerDisplayName: "Router",
|
|
520
|
+
accounts: [account],
|
|
521
|
+
activeAccount: account,
|
|
522
|
+
fetchedAt: Date.now(),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
// Usage command exists but is disabled for this key — keep the footer
|
|
526
|
+
// clean (endpoint display) and surface the hint in /sub detail only.
|
|
527
|
+
const hintAccount: SubscriptionAccountSnapshot = {
|
|
528
|
+
...baseAccount,
|
|
529
|
+
usageBreakdown: "OmniRoute usage command is disabled for this API key — " +
|
|
530
|
+
"enable it in the dashboard (API Keys → this key → usage command).",
|
|
531
|
+
};
|
|
532
|
+
return {
|
|
533
|
+
providerDisplayName: "Router",
|
|
534
|
+
accounts: [hintAccount],
|
|
535
|
+
activeAccount: hintAccount,
|
|
536
|
+
fetchedAt: Date.now(),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
} catch { /* non-OmniRoute or transient — fall through to endpoint display */ }
|
|
540
|
+
}
|
|
541
|
+
|
|
406
542
|
return {
|
|
407
|
-
providerDisplayName: "
|
|
408
|
-
accounts: [
|
|
409
|
-
activeAccount:
|
|
543
|
+
providerDisplayName: "Router",
|
|
544
|
+
accounts: [baseAccount],
|
|
545
|
+
activeAccount: baseAccount,
|
|
410
546
|
fetchedAt: now,
|
|
411
547
|
};
|
|
412
548
|
}
|
|
413
549
|
|
|
550
|
+
// Command Code's /alpha/billing/credits endpoint (auth: same Provider API key
|
|
551
|
+
// as /provider/v1 models) returns live 5-hour and weekly rolling windows plus
|
|
552
|
+
// the monthly credit balance — the same data as the cmd /usage CLI.
|
|
553
|
+
async function fetchCommandCodeUsage(signal?: AbortSignal): Promise<SubscriptionUsageSnapshot> {
|
|
554
|
+
try {
|
|
555
|
+
const { key: apiKey, account: authAccount } = await readZaiAuth(COMMAND_CODE_PROVIDER, "Command Code");
|
|
556
|
+
const timeoutSignal = AbortSignal.timeout(7_000);
|
|
557
|
+
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
558
|
+
|
|
559
|
+
const headers = {
|
|
560
|
+
Accept: "application/json",
|
|
561
|
+
Authorization: `Bearer ${apiKey}`,
|
|
562
|
+
"User-Agent": "pi-sub/0.1.27",
|
|
563
|
+
};
|
|
564
|
+
const response = await fetch(COMMAND_CODE_USAGE_URL, { headers, signal: combinedSignal });
|
|
565
|
+
if (!response.ok) {
|
|
566
|
+
throw new Error(`Command Code usage request failed with HTTP ${response.status}`);
|
|
567
|
+
}
|
|
568
|
+
const body = (await response.json()) as CommandCodeCreditsApiResponse;
|
|
569
|
+
|
|
570
|
+
const credits = body.credits;
|
|
571
|
+
const windowLimits = body.windowLimits;
|
|
572
|
+
const fiveHour = commandCodeWindowToUsageWindow(windowLimits?.fiveHour);
|
|
573
|
+
const weekly = commandCodeWindowToUsageWindow(windowLimits?.weekly);
|
|
574
|
+
|
|
575
|
+
// Monthly allowance is an absolute USD balance, not a rolling window.
|
|
576
|
+
const monthlyCredits = typeof credits?.monthlyCredits === "number" ? credits.monthlyCredits : undefined;
|
|
577
|
+
const monthlyLine = monthlyCredits !== undefined ? `Monthly: $${monthlyCredits.toFixed(2)} remaining` : undefined;
|
|
578
|
+
const breakdown = [monthlyLine].filter((s): s is string => !!s);
|
|
579
|
+
|
|
580
|
+
const account: SubscriptionAccountSnapshot = {
|
|
581
|
+
...authAccount,
|
|
582
|
+
fiveHour,
|
|
583
|
+
weekly,
|
|
584
|
+
monthlyCredits,
|
|
585
|
+
usageBreakdown: breakdown.length > 0 ? breakdown.join("\n") : undefined,
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
return {
|
|
589
|
+
providerDisplayName: "Command Code",
|
|
590
|
+
accounts: [account],
|
|
591
|
+
activeAccount: account,
|
|
592
|
+
fetchedAt: Date.now(),
|
|
593
|
+
};
|
|
594
|
+
} catch (error) {
|
|
595
|
+
return {
|
|
596
|
+
providerDisplayName: "Command Code",
|
|
597
|
+
accounts: [],
|
|
598
|
+
fetchedAt: Date.now(),
|
|
599
|
+
error: redactedError(error, "Command Code"),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
414
604
|
// ---------------------------------------------------------------------------
|
|
415
605
|
// Z.ai adapter
|
|
416
606
|
// ---------------------------------------------------------------------------
|
|
@@ -438,6 +628,48 @@ interface ZaiUsageApiError {
|
|
|
438
628
|
success?: boolean;
|
|
439
629
|
}
|
|
440
630
|
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// Command Code adapter
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// Live-verified 2026-08-09: GET https://api.commandcode.ai/alpha/billing/credits
|
|
635
|
+
// with the Provider API key (same user_... key as /provider/v1 models) returns
|
|
636
|
+
// USD windows + monthly credit balance. resetAt is epoch milliseconds.
|
|
637
|
+
|
|
638
|
+
interface CommandCodeWindowApi {
|
|
639
|
+
used: number;
|
|
640
|
+
cap: number;
|
|
641
|
+
exceeded?: boolean | null;
|
|
642
|
+
resetAt?: number;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
interface CommandCodeCreditsApiResponse {
|
|
646
|
+
credits?: {
|
|
647
|
+
monthlyCredits?: number;
|
|
648
|
+
purchasedCredits?: number;
|
|
649
|
+
freeCredits?: number;
|
|
650
|
+
belowThreshold?: boolean;
|
|
651
|
+
};
|
|
652
|
+
windowLimits?: {
|
|
653
|
+
limited?: boolean;
|
|
654
|
+
fiveHour?: CommandCodeWindowApi;
|
|
655
|
+
weekly?: CommandCodeWindowApi;
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Map a Command Code USD window (used/cap in dollars, resetAt in ms) into
|
|
660
|
+
* the shared UsageWindow shape (remaining%, reset labels). */
|
|
661
|
+
function commandCodeWindowToUsageWindow(window: CommandCodeWindowApi | undefined): UsageWindow | undefined {
|
|
662
|
+
if (!window || typeof window.used !== "number" || typeof window.cap !== "number" || window.cap <= 0) return undefined;
|
|
663
|
+
const usedPct = Math.round((window.used / window.cap) * 100);
|
|
664
|
+
const percent = Math.min(100, usedPct);
|
|
665
|
+
const remaining = Math.max(0, 100 - percent);
|
|
666
|
+
// resetAt is epoch ms; format helpers expect seconds.
|
|
667
|
+
const resetAtSec = window.resetAt ? window.resetAt / 1000 : undefined;
|
|
668
|
+
const resetLabel = formatReset(resetAtSec);
|
|
669
|
+
const remainingLabel = formatRemainingTime(resetAtSec);
|
|
670
|
+
return { percent, remaining, remainingLabel, resetLabel };
|
|
671
|
+
}
|
|
672
|
+
|
|
441
673
|
function zaiLimitToUsageWindow(limit: ZaiLimitEntry): UsageWindow | undefined {
|
|
442
674
|
if (typeof limit.percentage !== "number") return undefined;
|
|
443
675
|
const percent = Math.round(limit.percentage);
|
|
@@ -599,7 +831,9 @@ function supportedAdapter(model: ModelLike): SubscriptionProviderAdapter | undef
|
|
|
599
831
|
if (isOpenCodeGoModel(model)) return { id: OPC_PROVIDER, displayName: "OpenCode Go", fetchUsage: fetchOpenCodeGoUsage };
|
|
600
832
|
if (isZaiModel(model)) return { id: ZAI_PROVIDER, displayName: "Z.ai", ...zaiUsageAdapter(ZAI_PROVIDER, ZAI_USAGE_URL, "Z.ai") };
|
|
601
833
|
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)") };
|
|
602
|
-
if (
|
|
834
|
+
if (isRouterModel(model)) return { id: ROUTER_PROVIDER, displayName: "Router", fetchUsage: fetchRouterUsage };
|
|
835
|
+
if (isRouterModel(model, LEGACY_9ROUTER_PROVIDER)) return { id: LEGACY_9ROUTER_PROVIDER, displayName: "9router (legacy)", fetchUsage: fetchRouterUsage };
|
|
836
|
+
if (isCommandCodeModel(model)) return { id: COMMAND_CODE_PROVIDER, displayName: "Command Code", fetchUsage: fetchCommandCodeUsage };
|
|
603
837
|
return undefined;
|
|
604
838
|
}
|
|
605
839
|
|
|
@@ -627,11 +861,12 @@ function windowSegments(account: SubscriptionAccountSnapshot | undefined): strin
|
|
|
627
861
|
}
|
|
628
862
|
|
|
629
863
|
function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
|
|
864
|
+
const theme = ctx.ui.theme;
|
|
630
865
|
if (!state.adapter) {
|
|
631
|
-
|
|
866
|
+
// Unsupported provider (e.g. Ollama): still show the last response speed.
|
|
867
|
+
ctx.ui.setStatus(STATUS_KEY, state.lastTokPerSec !== undefined ? theme.fg("dim", `${state.lastTokPerSec} tok/s`) : undefined);
|
|
632
868
|
return;
|
|
633
869
|
}
|
|
634
|
-
const theme = ctx.ui.theme;
|
|
635
870
|
const snapshot = state.snapshot;
|
|
636
871
|
let line: string;
|
|
637
872
|
let color: "dim" | "warning" | "error" = "dim";
|
|
@@ -647,6 +882,8 @@ function renderSubscriptionLine(ctx: ExtensionContext, state: State): void {
|
|
|
647
882
|
const segments = accountPart ? [accountPart, ...windowParts] : [...windowParts];
|
|
648
883
|
const cost = state.cumulativeCost;
|
|
649
884
|
const hasWindows = windowParts.length > 0;
|
|
885
|
+
// Command Code monthly balance: compact USD figure, e.g. M:$69.99.
|
|
886
|
+
if (typeof account?.monthlyCredits === "number") segments.push(`M:$${account.monthlyCredits.toFixed(2)}`);
|
|
650
887
|
if (cost > 0) segments.push(`$${cost.toFixed(2)}`);
|
|
651
888
|
if (state.lastTokPerSec !== undefined) segments.push(`${state.lastTokPerSec} tok/s`);
|
|
652
889
|
if (segments.length === 0) {
|
|
@@ -736,7 +973,15 @@ function pad(value: string, width: number): string {
|
|
|
736
973
|
}
|
|
737
974
|
|
|
738
975
|
function buildDetails(snapshot: SubscriptionUsageSnapshot | undefined, state: State): string {
|
|
739
|
-
if (!state.adapter)
|
|
976
|
+
if (!state.adapter) {
|
|
977
|
+
const header = `Provider: ${state.model?.provider ?? "unknown"}${state.model?.id ? ` · Model: ${state.model.id}` : ""}`;
|
|
978
|
+
if (state.lastTokPerSec === undefined) return `${header}\nSubscription tracking inactive for this provider.`;
|
|
979
|
+
const tokPerSecLine = `Last response: ${state.lastTokPerSec} tok/s` +
|
|
980
|
+
(state.cumulativeDurationMs > 0
|
|
981
|
+
? ` · Session avg: ${Math.round(state.cumulativeOutput / (state.cumulativeDurationMs / 1000))} tok/s`
|
|
982
|
+
: "");
|
|
983
|
+
return `${header}\n${tokPerSecLine}`;
|
|
984
|
+
}
|
|
740
985
|
if (!snapshot) return "Subscription usage has not been loaded yet.";
|
|
741
986
|
if (snapshot.error) return `${snapshot.providerDisplayName}: ${snapshot.error}`;
|
|
742
987
|
if (snapshot.accounts.length === 0) {
|
|
@@ -820,7 +1065,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
820
1065
|
state.cumulativeDurationMs += elapsed;
|
|
821
1066
|
}
|
|
822
1067
|
}
|
|
823
|
-
|
|
1068
|
+
renderSubscriptionLine(ctx, state);
|
|
824
1069
|
}
|
|
825
1070
|
});
|
|
826
1071
|
|