pi-cliproxy-usage 0.1.0 → 0.1.1
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/README.md +64 -64
- package/package.json +33 -28
- package/src/parsers.ts +82 -82
- package/src/ui.ts +134 -131
- package/src/usage.ts +131 -131
package/README.md
CHANGED
|
@@ -1,64 +1,64 @@
|
|
|
1
|
-
# pi-cliproxy-usage
|
|
2
|
-
|
|
3
|
-
Compact CLIProxyAPI account usage meters for Pi Coding Agent.
|
|
4
|
-
|
|
5
|
-
Code layout:
|
|
6
|
-
|
|
7
|
-
- `index.ts` — Pi lifecycle and command wiring
|
|
8
|
-
- `src/settings.ts` / `src/settings-ui.ts` — persisted and interactive settings
|
|
9
|
-
- `src/usage.ts` / `src/parsers.ts` — account discovery, provider requests, response mapping
|
|
10
|
-
- `src/ui.ts` — widget rendering and text formatting
|
|
11
|
-
|
|
12
|
-
Shows one colored line below editor per enabled account:
|
|
13
|
-
|
|
14
|
-
```text
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
Percentages and filled bars show usage **consumed**. Colors shift green → yellow at 70% → red at 90%. Codex intentionally shows Session only; weekly meter is omitted.
|
|
21
|
-
|
|
22
|
-
## Install
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
pi install
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
Or test directly:
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
pi -e ./index.ts
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
## Settings
|
|
35
|
-
|
|
36
|
-
User file: `<getAgentDir()>/pi-cliproxy-usage.json` (normally `~/.pi/agent/pi-cliproxy-usage.json`). Missing file uses defaults. Changes from interactive UI apply immediately. Settings reload on session start and `/reload`.
|
|
37
|
-
|
|
38
|
-
Older `~/.pi/agent/extensions/pi-cliproxy-usage/config.json` files migrate automatically when canonical file does not exist.
|
|
39
|
-
|
|
40
|
-
```json
|
|
41
|
-
{
|
|
42
|
-
"accountsDir": "~/.cli-proxy-api",
|
|
43
|
-
"refreshMinutes": 5,
|
|
44
|
-
"providers": {
|
|
45
|
-
"claude": true,
|
|
46
|
-
"codex": true,
|
|
47
|
-
"grok": true
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Extension reads top-level CLIProxyAPI auth JSON files with `type` equal to `claude`, `codex`, or `xai`. Disabled auth files are skipped. Credentials stay local and are sent only to official provider usage endpoints documented by OpenUsage.
|
|
53
|
-
|
|
54
|
-
Accepted values: `accountsDir` is a non-empty string, `refreshMinutes` is an integer of at least `1`, and provider values are booleans. Invalid values are ignored with a warning. Unknown fields are preserved when saving.
|
|
55
|
-
|
|
56
|
-
## Commands
|
|
57
|
-
|
|
58
|
-
- `/cliproxy-usage` — refresh and show detailed percentages
|
|
59
|
-
- `/cliproxy-usage settings` — interactively edit refresh interval and provider toggles
|
|
60
|
-
- `/cliproxy-usage status` — show effective values, source, and settings path
|
|
61
|
-
- `/cliproxy-usage help` — show commands and manual settings path
|
|
62
|
-
- `/cliproxy-usage config` — compatibility alias for `settings`
|
|
63
|
-
|
|
64
|
-
Token refresh is deliberately left to CLIProxyAPI. If provider returns `401`/`403`, let CLIProxyAPI refresh account or log in again.
|
|
1
|
+
# pi-cliproxy-usage
|
|
2
|
+
|
|
3
|
+
Compact CLIProxyAPI account usage meters for Pi Coding Agent.
|
|
4
|
+
|
|
5
|
+
Code layout:
|
|
6
|
+
|
|
7
|
+
- `index.ts` — Pi lifecycle and command wiring
|
|
8
|
+
- `src/settings.ts` / `src/settings-ui.ts` — persisted and interactive settings
|
|
9
|
+
- `src/usage.ts` / `src/parsers.ts` — account discovery, provider requests, response mapping
|
|
10
|
+
- `src/ui.ts` — widget rendering and text formatting
|
|
11
|
+
|
|
12
|
+
Shows one colored line below editor per enabled account:
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
● Claude │ user │ S ███████░░░ 70% │ W ████░░░░░░ 40%
|
|
16
|
+
● Codex │ user │ S █████████░ 90%
|
|
17
|
+
● Grok │ user │ W █████░░░░░ 50%
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Percentages and filled bars show usage **consumed**. Colors shift green → yellow at 70% → red at 90%. Codex intentionally shows Session only; weekly meter is omitted.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:pi-cliproxy-usage
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or test directly:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi -e ./index.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Settings
|
|
35
|
+
|
|
36
|
+
User file: `<getAgentDir()>/pi-cliproxy-usage.json` (normally `~/.pi/agent/pi-cliproxy-usage.json`). Missing file uses defaults. Changes from interactive UI apply immediately. Settings reload on session start and `/reload`.
|
|
37
|
+
|
|
38
|
+
Older `~/.pi/agent/extensions/pi-cliproxy-usage/config.json` files migrate automatically when canonical file does not exist.
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"accountsDir": "~/.cli-proxy-api",
|
|
43
|
+
"refreshMinutes": 5,
|
|
44
|
+
"providers": {
|
|
45
|
+
"claude": true,
|
|
46
|
+
"codex": true,
|
|
47
|
+
"grok": true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Extension reads top-level CLIProxyAPI auth JSON files with `type` equal to `claude`, `codex`, or `xai`. Disabled auth files are skipped. Credentials stay local and are sent only to official provider usage endpoints documented by OpenUsage.
|
|
53
|
+
|
|
54
|
+
Accepted values: `accountsDir` is a non-empty string, `refreshMinutes` is an integer of at least `1`, and provider values are booleans. Invalid values are ignored with a warning. Unknown fields are preserved when saving.
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
- `/cliproxy-usage` — refresh and show detailed percentages
|
|
59
|
+
- `/cliproxy-usage settings` — interactively edit refresh interval and provider toggles
|
|
60
|
+
- `/cliproxy-usage status` — show effective values, source, and settings path
|
|
61
|
+
- `/cliproxy-usage help` — show commands and manual settings path
|
|
62
|
+
- `/cliproxy-usage config` — compatibility alias for `settings`
|
|
63
|
+
|
|
64
|
+
Token refresh is deliberately left to CLIProxyAPI. If provider returns `401`/`403`, let CLIProxyAPI refresh account or log in again.
|
package/package.json
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-cliproxy-usage",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Compact CLIProxyAPI account usage meters for Pi Coding Agent",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"keywords": ["pi-package", "cliproxyapi", "usage"],
|
|
7
|
-
"license": "MIT",
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
},
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"@earendil-works/pi-
|
|
18
|
-
"@earendil-works/pi-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-cliproxy-usage",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Compact CLIProxyAPI account usage meters for Pi Coding Agent",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package", "cliproxyapi", "usage"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Villoh/pi-cliproxy-usage.git"
|
|
11
|
+
},
|
|
12
|
+
"files": ["index.ts", "src", "README.md"],
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": ["./index.ts"]
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
18
|
+
"@earendil-works/pi-tui": "*"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@earendil-works/pi-agent-core": "0.80.10",
|
|
22
|
+
"@earendil-works/pi-ai": "0.80.10",
|
|
23
|
+
"@earendil-works/pi-coding-agent": "0.80.10",
|
|
24
|
+
"@earendil-works/pi-tui": "0.80.10",
|
|
25
|
+
"@types/node": "^24.10.0",
|
|
26
|
+
"tsx": "^4.20.6",
|
|
27
|
+
"typescript": "^5.9.3"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"check": "tsc --noEmit",
|
|
31
|
+
"test": "node --import tsx --test test/**/*.test.ts"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/parsers.ts
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
import type { AccountUsage, UsageWindow } from "./types.js";
|
|
2
|
-
|
|
3
|
-
export function toNumber(value: unknown): number | undefined {
|
|
4
|
-
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
5
|
-
if (typeof value === "string" && value.trim()) {
|
|
6
|
-
const parsed = Number(value);
|
|
7
|
-
if (Number.isFinite(parsed)) return parsed;
|
|
8
|
-
}
|
|
9
|
-
return undefined;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function toDate(value: unknown): Date | undefined {
|
|
13
|
-
if (typeof value === "string") {
|
|
14
|
-
const parsed = new Date(value);
|
|
15
|
-
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
16
|
-
}
|
|
17
|
-
const raw = toNumber(value);
|
|
18
|
-
if (raw === undefined) return undefined;
|
|
19
|
-
return new Date(raw < 1e10 ? raw * 1000 : raw);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function parseClaude(
|
|
23
|
-
body: unknown,
|
|
24
|
-
): Pick<AccountUsage, "session" | "weekly"> {
|
|
25
|
-
const root = body as Record<string, unknown>;
|
|
26
|
-
const window = (value: unknown): UsageWindow | undefined => {
|
|
27
|
-
const item = value as Record<string, unknown> | undefined;
|
|
28
|
-
const used = toNumber(item?.utilization);
|
|
29
|
-
return used === undefined
|
|
30
|
-
? undefined
|
|
31
|
-
: { used, resetsAt: toDate(item?.resets_at) };
|
|
32
|
-
};
|
|
33
|
-
return { session: window(root?.five_hour), weekly: window(root?.seven_day) };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function codexWindow(
|
|
37
|
-
value: unknown,
|
|
38
|
-
): (UsageWindow & { seconds?: number }) | undefined {
|
|
39
|
-
const item = value as Record<string, unknown> | undefined;
|
|
40
|
-
const used = toNumber(item?.used_percent);
|
|
41
|
-
if (used === undefined) return undefined;
|
|
42
|
-
const resetAfter = toNumber(item?.reset_after_seconds);
|
|
43
|
-
return {
|
|
44
|
-
used,
|
|
45
|
-
seconds: toNumber(item?.limit_window_seconds),
|
|
46
|
-
resetsAt:
|
|
47
|
-
toDate(item?.reset_at) ??
|
|
48
|
-
(resetAfter === undefined
|
|
49
|
-
? undefined
|
|
50
|
-
: new Date(Date.now() + resetAfter * 1000)),
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function parseCodex(body: unknown): Pick<AccountUsage, "session"> {
|
|
55
|
-
const rate = (body as Record<string, unknown>)?.rate_limit as
|
|
56
|
-
| Record<string, unknown>
|
|
57
|
-
| undefined;
|
|
58
|
-
const windows = [
|
|
59
|
-
codexWindow(rate?.primary_window),
|
|
60
|
-
codexWindow(rate?.secondary_window),
|
|
61
|
-
].filter(Boolean) as Array<UsageWindow & { seconds?: number }>;
|
|
62
|
-
return {
|
|
63
|
-
session:
|
|
64
|
-
windows.find((item) => item.seconds === 18_000) ??
|
|
65
|
-
windows.find((item) => item.seconds !== 604_800) ??
|
|
66
|
-
windows[0],
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function parseGrok(body: unknown): Pick<AccountUsage, "weekly"> {
|
|
71
|
-
const config = (body as Record<string, unknown>)?.config as
|
|
72
|
-
| Record<string, unknown>
|
|
73
|
-
| undefined;
|
|
74
|
-
const period = config?.currentPeriod as Record<string, unknown> | undefined;
|
|
75
|
-
if (period?.type !== "USAGE_PERIOD_TYPE_WEEKLY") return {};
|
|
76
|
-
return {
|
|
77
|
-
weekly: {
|
|
78
|
-
used: toNumber(config?.creditUsagePercent) ?? 0,
|
|
79
|
-
resetsAt: toDate(period.end),
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
|
1
|
+
import type { AccountUsage, UsageWindow } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function toNumber(value: unknown): number | undefined {
|
|
4
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
5
|
+
if (typeof value === "string" && value.trim()) {
|
|
6
|
+
const parsed = Number(value);
|
|
7
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
8
|
+
}
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toDate(value: unknown): Date | undefined {
|
|
13
|
+
if (typeof value === "string") {
|
|
14
|
+
const parsed = new Date(value);
|
|
15
|
+
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
|
16
|
+
}
|
|
17
|
+
const raw = toNumber(value);
|
|
18
|
+
if (raw === undefined) return undefined;
|
|
19
|
+
return new Date(raw < 1e10 ? raw * 1000 : raw);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseClaude(
|
|
23
|
+
body: unknown,
|
|
24
|
+
): Pick<AccountUsage, "session" | "weekly"> {
|
|
25
|
+
const root = body as Record<string, unknown>;
|
|
26
|
+
const window = (value: unknown): UsageWindow | undefined => {
|
|
27
|
+
const item = value as Record<string, unknown> | undefined;
|
|
28
|
+
const used = toNumber(item?.utilization);
|
|
29
|
+
return used === undefined
|
|
30
|
+
? undefined
|
|
31
|
+
: { used, resetsAt: toDate(item?.resets_at) };
|
|
32
|
+
};
|
|
33
|
+
return { session: window(root?.five_hour), weekly: window(root?.seven_day) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function codexWindow(
|
|
37
|
+
value: unknown,
|
|
38
|
+
): (UsageWindow & { seconds?: number }) | undefined {
|
|
39
|
+
const item = value as Record<string, unknown> | undefined;
|
|
40
|
+
const used = toNumber(item?.used_percent);
|
|
41
|
+
if (used === undefined) return undefined;
|
|
42
|
+
const resetAfter = toNumber(item?.reset_after_seconds);
|
|
43
|
+
return {
|
|
44
|
+
used,
|
|
45
|
+
seconds: toNumber(item?.limit_window_seconds),
|
|
46
|
+
resetsAt:
|
|
47
|
+
toDate(item?.reset_at) ??
|
|
48
|
+
(resetAfter === undefined
|
|
49
|
+
? undefined
|
|
50
|
+
: new Date(Date.now() + resetAfter * 1000)),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function parseCodex(body: unknown): Pick<AccountUsage, "session"> {
|
|
55
|
+
const rate = (body as Record<string, unknown>)?.rate_limit as
|
|
56
|
+
| Record<string, unknown>
|
|
57
|
+
| undefined;
|
|
58
|
+
const windows = [
|
|
59
|
+
codexWindow(rate?.primary_window),
|
|
60
|
+
codexWindow(rate?.secondary_window),
|
|
61
|
+
].filter(Boolean) as Array<UsageWindow & { seconds?: number }>;
|
|
62
|
+
return {
|
|
63
|
+
session:
|
|
64
|
+
windows.find((item) => item.seconds === 18_000) ??
|
|
65
|
+
windows.find((item) => item.seconds !== 604_800) ??
|
|
66
|
+
windows[0],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseGrok(body: unknown): Pick<AccountUsage, "weekly"> {
|
|
71
|
+
const config = (body as Record<string, unknown>)?.config as
|
|
72
|
+
| Record<string, unknown>
|
|
73
|
+
| undefined;
|
|
74
|
+
const period = config?.currentPeriod as Record<string, unknown> | undefined;
|
|
75
|
+
if (period?.type !== "USAGE_PERIOD_TYPE_WEEKLY") return {};
|
|
76
|
+
return {
|
|
77
|
+
weekly: {
|
|
78
|
+
used: toNumber(config?.creditUsagePercent) ?? 0,
|
|
79
|
+
resetsAt: toDate(period.end),
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/ui.ts
CHANGED
|
@@ -1,131 +1,134 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
AccountUsage,
|
|
3
|
-
ProviderName,
|
|
4
|
-
Theme,
|
|
5
|
-
UiContext,
|
|
6
|
-
UsageWindow,
|
|
7
|
-
} from "./types.js";
|
|
8
|
-
|
|
9
|
-
const PROVIDER_LABELS: Record<ProviderName, string> = {
|
|
10
|
-
claude: "Claude",
|
|
11
|
-
codex: "Codex",
|
|
12
|
-
grok: "Grok",
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function accountLabel(label: string): string {
|
|
16
|
-
const local = label.includes("@") ? label.split("@")[0] : label;
|
|
17
|
-
return local.slice(0, 18);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function usageBar(used: number, width = 10): string {
|
|
21
|
-
const percent = Math.max(0, Math.min(100, used));
|
|
22
|
-
const filled = Math.round((percent / 100) * width);
|
|
23
|
-
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function formatCompact(items: AccountUsage[]): string {
|
|
27
|
-
return items
|
|
28
|
-
.map((item) => {
|
|
29
|
-
if (item.error) {
|
|
30
|
-
return `${PROVIDER_LABELS[item.provider]} ${accountLabel(item.label)}: ! ${item.error}`;
|
|
31
|
-
}
|
|
32
|
-
const windows = [
|
|
33
|
-
item.session &&
|
|
34
|
-
`S ${usageBar(item.session.used)} ${Math.round(item.session.used)}%`,
|
|
35
|
-
item.weekly &&
|
|
36
|
-
`W ${usageBar(item.weekly.used)} ${Math.round(item.weekly.used)}%`,
|
|
37
|
-
].filter(Boolean);
|
|
38
|
-
return `${PROVIDER_LABELS[item.provider]} ${accountLabel(item.label)} ${windows.join(" ") || "–"}`;
|
|
39
|
-
})
|
|
40
|
-
.join("\n");
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function formatDetails(items: AccountUsage[]): string {
|
|
44
|
-
if (!items.length) return "No enabled CLIProxyAPI accounts found.";
|
|
45
|
-
return items
|
|
46
|
-
.map((item) => {
|
|
47
|
-
if (item.error) return `${item.provider}/${item.label}: ${item.error}`;
|
|
48
|
-
const windows = [
|
|
49
|
-
item.session && `Session ${item.session.used.toFixed(0)}% used`,
|
|
50
|
-
item.weekly && `Weekly ${item.weekly.used.toFixed(0)}% used`,
|
|
51
|
-
].filter(Boolean);
|
|
52
|
-
return `${item.provider}/${item.label}: ${windows.join(" · ") || "No usage window"}`;
|
|
53
|
-
})
|
|
54
|
-
.join("\n");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function truncateAnsi(text: string, width: number): string {
|
|
58
|
-
let visible = 0;
|
|
59
|
-
let result = "";
|
|
60
|
-
for (let index = 0; index < text.length && visible < width; ) {
|
|
61
|
-
if (text[index] === "\u001b") {
|
|
62
|
-
const match = text.slice(index).match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
|
|
63
|
-
if (match) {
|
|
64
|
-
result += match[0];
|
|
65
|
-
index += match[0].length;
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
const point = text.codePointAt(index);
|
|
70
|
-
if (point === undefined) break;
|
|
71
|
-
result += String.fromCodePoint(point);
|
|
72
|
-
index += point > 0xffff ? 2 : 1;
|
|
73
|
-
visible++;
|
|
74
|
-
}
|
|
75
|
-
return `${result}\u001b[0m`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function clearUsage(ctx: UiContext): void {
|
|
79
|
-
ctx.ui.setStatus("cliproxy-usage", undefined);
|
|
80
|
-
ctx.ui.setWidget("cliproxy-usage", undefined);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function renderUsage(ctx: UiContext, items: AccountUsage[]): void {
|
|
84
|
-
ctx.ui.setStatus("cliproxy-usage", undefined);
|
|
85
|
-
if (!items.length) {
|
|
86
|
-
ctx.ui.setWidget("cliproxy-usage", undefined);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
ctx.ui.setWidget(
|
|
90
|
-
"cliproxy-usage",
|
|
91
|
-
(_tui: unknown, theme: Theme) => ({
|
|
92
|
-
invalidate() {},
|
|
93
|
-
render(width: number): string[] {
|
|
94
|
-
return items.map((item) => {
|
|
95
|
-
const providerColor =
|
|
96
|
-
item.provider === "claude"
|
|
97
|
-
? "warning"
|
|
98
|
-
: item.provider === "codex"
|
|
99
|
-
? "success"
|
|
100
|
-
: "accent";
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
theme.fg(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
1
|
+
import type {
|
|
2
|
+
AccountUsage,
|
|
3
|
+
ProviderName,
|
|
4
|
+
Theme,
|
|
5
|
+
UiContext,
|
|
6
|
+
UsageWindow,
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
|
|
9
|
+
const PROVIDER_LABELS: Record<ProviderName, string> = {
|
|
10
|
+
claude: "Claude",
|
|
11
|
+
codex: "Codex",
|
|
12
|
+
grok: "Grok",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function accountLabel(label: string): string {
|
|
16
|
+
const local = label.includes("@") ? label.split("@")[0] : label;
|
|
17
|
+
return local.slice(0, 18);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function usageBar(used: number, width = 10): string {
|
|
21
|
+
const percent = Math.max(0, Math.min(100, used));
|
|
22
|
+
const filled = Math.round((percent / 100) * width);
|
|
23
|
+
return "█".repeat(filled) + "░".repeat(width - filled);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatCompact(items: AccountUsage[]): string {
|
|
27
|
+
return items
|
|
28
|
+
.map((item) => {
|
|
29
|
+
if (item.error) {
|
|
30
|
+
return `${PROVIDER_LABELS[item.provider]} ${accountLabel(item.label)}: ! ${item.error}`;
|
|
31
|
+
}
|
|
32
|
+
const windows = [
|
|
33
|
+
item.session &&
|
|
34
|
+
`S ${usageBar(item.session.used)} ${Math.round(item.session.used)}%`,
|
|
35
|
+
item.weekly &&
|
|
36
|
+
`W ${usageBar(item.weekly.used)} ${Math.round(item.weekly.used)}%`,
|
|
37
|
+
].filter(Boolean);
|
|
38
|
+
return `${PROVIDER_LABELS[item.provider]} ${accountLabel(item.label)} ${windows.join(" ") || "–"}`;
|
|
39
|
+
})
|
|
40
|
+
.join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatDetails(items: AccountUsage[]): string {
|
|
44
|
+
if (!items.length) return "No enabled CLIProxyAPI accounts found.";
|
|
45
|
+
return items
|
|
46
|
+
.map((item) => {
|
|
47
|
+
if (item.error) return `${item.provider}/${item.label}: ${item.error}`;
|
|
48
|
+
const windows = [
|
|
49
|
+
item.session && `Session ${item.session.used.toFixed(0)}% used`,
|
|
50
|
+
item.weekly && `Weekly ${item.weekly.used.toFixed(0)}% used`,
|
|
51
|
+
].filter(Boolean);
|
|
52
|
+
return `${item.provider}/${item.label}: ${windows.join(" · ") || "No usage window"}`;
|
|
53
|
+
})
|
|
54
|
+
.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function truncateAnsi(text: string, width: number): string {
|
|
58
|
+
let visible = 0;
|
|
59
|
+
let result = "";
|
|
60
|
+
for (let index = 0; index < text.length && visible < width; ) {
|
|
61
|
+
if (text[index] === "\u001b") {
|
|
62
|
+
const match = text.slice(index).match(/^\u001b\[[0-?]*[ -/]*[@-~]/);
|
|
63
|
+
if (match) {
|
|
64
|
+
result += match[0];
|
|
65
|
+
index += match[0].length;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const point = text.codePointAt(index);
|
|
70
|
+
if (point === undefined) break;
|
|
71
|
+
result += String.fromCodePoint(point);
|
|
72
|
+
index += point > 0xffff ? 2 : 1;
|
|
73
|
+
visible++;
|
|
74
|
+
}
|
|
75
|
+
return `${result}\u001b[0m`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function clearUsage(ctx: UiContext): void {
|
|
79
|
+
ctx.ui.setStatus("cliproxy-usage", undefined);
|
|
80
|
+
ctx.ui.setWidget("cliproxy-usage", undefined);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function renderUsage(ctx: UiContext, items: AccountUsage[]): void {
|
|
84
|
+
ctx.ui.setStatus("cliproxy-usage", undefined);
|
|
85
|
+
if (!items.length) {
|
|
86
|
+
ctx.ui.setWidget("cliproxy-usage", undefined);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
ctx.ui.setWidget(
|
|
90
|
+
"cliproxy-usage",
|
|
91
|
+
(_tui: unknown, theme: Theme) => ({
|
|
92
|
+
invalidate() {},
|
|
93
|
+
render(width: number): string[] {
|
|
94
|
+
return items.map((item) => {
|
|
95
|
+
const providerColor =
|
|
96
|
+
item.provider === "claude"
|
|
97
|
+
? "warning"
|
|
98
|
+
: item.provider === "codex"
|
|
99
|
+
? "success"
|
|
100
|
+
: "accent";
|
|
101
|
+
const separator = theme.fg("dim", " │ ");
|
|
102
|
+
const prefix =
|
|
103
|
+
theme.fg(providerColor, `● ${PROVIDER_LABELS[item.provider]}`) +
|
|
104
|
+
separator +
|
|
105
|
+
theme.fg("muted", accountLabel(item.label)) +
|
|
106
|
+
separator;
|
|
107
|
+
if (item.error) {
|
|
108
|
+
return truncateAnsi(
|
|
109
|
+
`${prefix}${theme.fg("error", `! ${item.error}`)}`,
|
|
110
|
+
width,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const meter = (name: string, usage: UsageWindow) => {
|
|
114
|
+
const used = Math.max(0, Math.min(100, usage.used));
|
|
115
|
+
const color =
|
|
116
|
+
used >= 90 ? "error" : used >= 70 ? "warning" : "success";
|
|
117
|
+
const filled = usageBar(used).replace(/░+$/, "");
|
|
118
|
+
const empty = "░".repeat(10 - filled.length);
|
|
119
|
+
return `${theme.fg("muted", name)} ${theme.fg(color, filled)}${theme.fg("dim", empty)} ${theme.fg(color, `${Math.round(used)}%`)}`;
|
|
120
|
+
};
|
|
121
|
+
const windows = [
|
|
122
|
+
item.session && meter("S", item.session),
|
|
123
|
+
item.weekly && meter("W", item.weekly),
|
|
124
|
+
].filter(Boolean);
|
|
125
|
+
return truncateAnsi(
|
|
126
|
+
`${prefix}${windows.join(theme.fg("dim", " │ "))}`,
|
|
127
|
+
width,
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
{ placement: "belowEditor" },
|
|
133
|
+
);
|
|
134
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -1,131 +1,131 @@
|
|
|
1
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { basename, join, resolve } from "node:path";
|
|
4
|
-
import { parseClaude, parseCodex, parseGrok, toNumber } from "./parsers.js";
|
|
5
|
-
import type { AccountUsage, Config, ProviderName } from "./types.js";
|
|
6
|
-
|
|
7
|
-
type AuthFile = {
|
|
8
|
-
type?: string;
|
|
9
|
-
email?: string;
|
|
10
|
-
access_token?: string;
|
|
11
|
-
account_id?: string;
|
|
12
|
-
disabled?: boolean;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
const PROVIDERS = new Set<ProviderName>(["claude", "codex", "grok"]);
|
|
16
|
-
const USAGE_URLS = {
|
|
17
|
-
claude: "https://api.anthropic.com/api/oauth/usage",
|
|
18
|
-
codex: "https://chatgpt.com/backend-api/wham/usage",
|
|
19
|
-
grok: "https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
|
20
|
-
} as const;
|
|
21
|
-
|
|
22
|
-
function expandHome(path: string): string {
|
|
23
|
-
if (path === "~") return homedir();
|
|
24
|
-
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
25
|
-
return join(homedir(), path.slice(2));
|
|
26
|
-
}
|
|
27
|
-
return resolve(path);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function providerName(type?: string): ProviderName | undefined {
|
|
31
|
-
const provider = type === "xai" ? "grok" : type;
|
|
32
|
-
return PROVIDERS.has(provider as ProviderName)
|
|
33
|
-
? (provider as ProviderName)
|
|
34
|
-
: undefined;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function request(
|
|
38
|
-
url: string,
|
|
39
|
-
token: string,
|
|
40
|
-
headers: Record<string, string> = {},
|
|
41
|
-
): Promise<{ body: unknown; headers: Headers }> {
|
|
42
|
-
const response = await fetch(url, {
|
|
43
|
-
headers: {
|
|
44
|
-
Authorization: `Bearer ${token}`,
|
|
45
|
-
Accept: "application/json",
|
|
46
|
-
"User-Agent": "pi-cliproxy-usage",
|
|
47
|
-
...headers,
|
|
48
|
-
},
|
|
49
|
-
signal: AbortSignal.timeout(10_000),
|
|
50
|
-
});
|
|
51
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
52
|
-
return { body: await response.json(), headers: response.headers };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function fetchUsage(
|
|
56
|
-
provider: ProviderName,
|
|
57
|
-
auth: AuthFile,
|
|
58
|
-
file: string,
|
|
59
|
-
): Promise<AccountUsage> {
|
|
60
|
-
const label =
|
|
61
|
-
auth.email || basename(file, ".json").replace(/^(claude|codex|xai)-/, "");
|
|
62
|
-
try {
|
|
63
|
-
if (!auth.access_token) throw new Error("missing access_token");
|
|
64
|
-
if (provider === "claude") {
|
|
65
|
-
const { body } = await request(USAGE_URLS.claude, auth.access_token, {
|
|
66
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
67
|
-
"Content-Type": "application/json",
|
|
68
|
-
});
|
|
69
|
-
return { provider, label, ...parseClaude(body) };
|
|
70
|
-
}
|
|
71
|
-
if (provider === "codex") {
|
|
72
|
-
const headers: Record<string, string> = {};
|
|
73
|
-
if (auth.account_id) headers["ChatGPT-Account-Id"] = auth.account_id;
|
|
74
|
-
const response = await request(
|
|
75
|
-
USAGE_URLS.codex,
|
|
76
|
-
auth.access_token,
|
|
77
|
-
headers,
|
|
78
|
-
);
|
|
79
|
-
const parsed = parseCodex(response.body);
|
|
80
|
-
if (!parsed.session) {
|
|
81
|
-
const used = toNumber(
|
|
82
|
-
response.headers.get("x-codex-primary-used-percent"),
|
|
83
|
-
);
|
|
84
|
-
if (used !== undefined) parsed.session = { used };
|
|
85
|
-
}
|
|
86
|
-
return { provider, label, ...parsed };
|
|
87
|
-
}
|
|
88
|
-
const { body } = await request(USAGE_URLS.grok, auth.access_token, {
|
|
89
|
-
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
90
|
-
});
|
|
91
|
-
return { provider, label, ...parseGrok(body) };
|
|
92
|
-
} catch (error) {
|
|
93
|
-
return {
|
|
94
|
-
provider,
|
|
95
|
-
label,
|
|
96
|
-
error: error instanceof Error ? error.message : String(error),
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async function readAccount(
|
|
102
|
-
dir: string,
|
|
103
|
-
name: string,
|
|
104
|
-
config: Config,
|
|
105
|
-
): Promise<AccountUsage | undefined> {
|
|
106
|
-
try {
|
|
107
|
-
const file = join(dir, name);
|
|
108
|
-
const auth = JSON.parse(await readFile(file, "utf8")) as AuthFile;
|
|
109
|
-
const provider = providerName(auth.type);
|
|
110
|
-
if (!provider || auth.disabled || !config.providers[provider])
|
|
111
|
-
return undefined;
|
|
112
|
-
return fetchUsage(provider, auth, file);
|
|
113
|
-
} catch {
|
|
114
|
-
return undefined;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function readAccounts(config: Config): Promise<AccountUsage[]> {
|
|
119
|
-
const dir = expandHome(config.accountsDir);
|
|
120
|
-
try {
|
|
121
|
-
const names = (await readdir(dir)).filter((name) =>
|
|
122
|
-
name.toLowerCase().endsWith(".json"),
|
|
123
|
-
);
|
|
124
|
-
const accounts = await Promise.all(
|
|
125
|
-
names.map((name) => readAccount(dir, name, config)),
|
|
126
|
-
);
|
|
127
|
-
return accounts.filter(Boolean) as AccountUsage[];
|
|
128
|
-
} catch {
|
|
129
|
-
return [];
|
|
130
|
-
}
|
|
131
|
-
}
|
|
1
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { parseClaude, parseCodex, parseGrok, toNumber } from "./parsers.js";
|
|
5
|
+
import type { AccountUsage, Config, ProviderName } from "./types.js";
|
|
6
|
+
|
|
7
|
+
type AuthFile = {
|
|
8
|
+
type?: string;
|
|
9
|
+
email?: string;
|
|
10
|
+
access_token?: string;
|
|
11
|
+
account_id?: string;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const PROVIDERS = new Set<ProviderName>(["claude", "codex", "grok"]);
|
|
16
|
+
const USAGE_URLS = {
|
|
17
|
+
claude: "https://api.anthropic.com/api/oauth/usage",
|
|
18
|
+
codex: "https://chatgpt.com/backend-api/wham/usage",
|
|
19
|
+
grok: "https://cli-chat-proxy.grok.com/v1/billing?format=credits",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
function expandHome(path: string): string {
|
|
23
|
+
if (path === "~") return homedir();
|
|
24
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
25
|
+
return join(homedir(), path.slice(2));
|
|
26
|
+
}
|
|
27
|
+
return resolve(path);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function providerName(type?: string): ProviderName | undefined {
|
|
31
|
+
const provider = type === "xai" ? "grok" : type;
|
|
32
|
+
return PROVIDERS.has(provider as ProviderName)
|
|
33
|
+
? (provider as ProviderName)
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function request(
|
|
38
|
+
url: string,
|
|
39
|
+
token: string,
|
|
40
|
+
headers: Record<string, string> = {},
|
|
41
|
+
): Promise<{ body: unknown; headers: Headers }> {
|
|
42
|
+
const response = await fetch(url, {
|
|
43
|
+
headers: {
|
|
44
|
+
Authorization: `Bearer ${token}`,
|
|
45
|
+
Accept: "application/json",
|
|
46
|
+
"User-Agent": "pi-cliproxy-usage",
|
|
47
|
+
...headers,
|
|
48
|
+
},
|
|
49
|
+
signal: AbortSignal.timeout(10_000),
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
52
|
+
return { body: await response.json(), headers: response.headers };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function fetchUsage(
|
|
56
|
+
provider: ProviderName,
|
|
57
|
+
auth: AuthFile,
|
|
58
|
+
file: string,
|
|
59
|
+
): Promise<AccountUsage> {
|
|
60
|
+
const label =
|
|
61
|
+
auth.email || basename(file, ".json").replace(/^(claude|codex|xai)-/, "");
|
|
62
|
+
try {
|
|
63
|
+
if (!auth.access_token) throw new Error("missing access_token");
|
|
64
|
+
if (provider === "claude") {
|
|
65
|
+
const { body } = await request(USAGE_URLS.claude, auth.access_token, {
|
|
66
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
});
|
|
69
|
+
return { provider, label, ...parseClaude(body) };
|
|
70
|
+
}
|
|
71
|
+
if (provider === "codex") {
|
|
72
|
+
const headers: Record<string, string> = {};
|
|
73
|
+
if (auth.account_id) headers["ChatGPT-Account-Id"] = auth.account_id;
|
|
74
|
+
const response = await request(
|
|
75
|
+
USAGE_URLS.codex,
|
|
76
|
+
auth.access_token,
|
|
77
|
+
headers,
|
|
78
|
+
);
|
|
79
|
+
const parsed = parseCodex(response.body);
|
|
80
|
+
if (!parsed.session) {
|
|
81
|
+
const used = toNumber(
|
|
82
|
+
response.headers.get("x-codex-primary-used-percent"),
|
|
83
|
+
);
|
|
84
|
+
if (used !== undefined) parsed.session = { used };
|
|
85
|
+
}
|
|
86
|
+
return { provider, label, ...parsed };
|
|
87
|
+
}
|
|
88
|
+
const { body } = await request(USAGE_URLS.grok, auth.access_token, {
|
|
89
|
+
"X-XAI-Token-Auth": "xai-grok-cli",
|
|
90
|
+
});
|
|
91
|
+
return { provider, label, ...parseGrok(body) };
|
|
92
|
+
} catch (error) {
|
|
93
|
+
return {
|
|
94
|
+
provider,
|
|
95
|
+
label,
|
|
96
|
+
error: error instanceof Error ? error.message : String(error),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function readAccount(
|
|
102
|
+
dir: string,
|
|
103
|
+
name: string,
|
|
104
|
+
config: Config,
|
|
105
|
+
): Promise<AccountUsage | undefined> {
|
|
106
|
+
try {
|
|
107
|
+
const file = join(dir, name);
|
|
108
|
+
const auth = JSON.parse(await readFile(file, "utf8")) as AuthFile;
|
|
109
|
+
const provider = providerName(auth.type);
|
|
110
|
+
if (!provider || auth.disabled || !config.providers[provider])
|
|
111
|
+
return undefined;
|
|
112
|
+
return fetchUsage(provider, auth, file);
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function readAccounts(config: Config): Promise<AccountUsage[]> {
|
|
119
|
+
const dir = expandHome(config.accountsDir);
|
|
120
|
+
try {
|
|
121
|
+
const names = (await readdir(dir)).filter((name) =>
|
|
122
|
+
name.toLowerCase().endsWith(".json"),
|
|
123
|
+
);
|
|
124
|
+
const accounts = await Promise.all(
|
|
125
|
+
names.map((name) => readAccount(dir, name, config)),
|
|
126
|
+
);
|
|
127
|
+
return accounts.filter(Boolean) as AccountUsage[];
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|