@aaroncarry/pi-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/README.zh-CN.md +152 -0
- package/package.json +48 -0
- package/src/config.ts +156 -0
- package/src/credentials.ts +217 -0
- package/src/format.ts +35 -0
- package/src/index.ts +168 -0
- package/src/parse.ts +21 -0
- package/src/providers/anthropic.ts +76 -0
- package/src/providers/auto-detect.ts +343 -0
- package/src/providers/codex.ts +81 -0
- package/src/providers/custom.ts +100 -0
- package/src/providers/deepseek.ts +68 -0
- package/src/providers/github-copilot.ts +101 -0
- package/src/providers/index.ts +11 -0
- package/src/providers/openrouter.ts +52 -0
- package/src/providers/zai.ts +200 -0
- package/src/service.ts +275 -0
- package/src/session-usage.ts +57 -0
- package/src/types.ts +83 -0
- package/src/ui/card.ts +82 -0
- package/src/ui/statusline.ts +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aaron Lin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# pi-usage
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
A [pi](https://github.com/earendil-works/pi) extension that shows the balances and usage windows of your subscription accounts — a footer status line for the account you are currently using, plus a `/usage` card with full details. Query methods adapted from [CodexBar](https://github.com/steipete/CodexBar); credentials come straight from pi's unified `auth.json`, nothing else is read.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
The footer status line (default mode) follows the model you are using and shows its quota windows plus this session's consumption:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Codex 5h 13% · weekly 2% · session 10.0k tok $0.020
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`/usage` prints a card into the conversation (a custom entry — no popup, no focus steal):
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
Usage · 02:15
|
|
19
|
+
|
|
20
|
+
● Codex (Plus)
|
|
21
|
+
5h ░░░░░░░░░░ 0% · resets in 4h 54m
|
|
22
|
+
weekly ░░░░░░░░░░ 2% · resets in 6d 17h
|
|
23
|
+
○ GLM
|
|
24
|
+
Balance ¥21.46 recharged ¥118.00 · spent ¥96.54
|
|
25
|
+
○ DeepSeek
|
|
26
|
+
Balance ¥38.48
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Commands
|
|
30
|
+
|
|
31
|
+
| Command | Effect |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `/usage` | Print the usage card into the session. Re-running refreshes: served from cache within the 5-minute TTL, refetched afterwards (15 s timeout). Cards persist in the session and are re-rendered on `/reload` and session restore. |
|
|
34
|
+
| `/usage active\|all\|off` | Switch the footer status line mode immediately and persist it to `usage.json` (tab-completed). |
|
|
35
|
+
| `pi --usage-status all` | Set the footer mode for this run only (overrides `usage.json`, not written back). |
|
|
36
|
+
|
|
37
|
+
Precedence: `/usage <mode>` (session) > `--usage-status` (this run) > `usage.json` (persistent).
|
|
38
|
+
|
|
39
|
+
## Footer status line
|
|
40
|
+
|
|
41
|
+
| Mode | Shows |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `active` (default) | All windows and balance of the account matching the current model, plus the session consumption segment. Follows model switches. |
|
|
44
|
+
| `all` | Every account on one line — active account first at full brightness, the rest dimmed — consumption appended last. |
|
|
45
|
+
| `off` | Hidden entirely. |
|
|
46
|
+
|
|
47
|
+
- The consumption segment (`session <tokens> tok`) updates immediately after every turn (local data, same accounting as pi's footer). A real cost (`· $0.020`) is appended only when non-zero — subscription providers report 0, which is omitted.
|
|
48
|
+
- A failing account shows as `Label !` in the error color.
|
|
49
|
+
|
|
50
|
+
## Supported accounts
|
|
51
|
+
|
|
52
|
+
Accounts with a credential in pi's `auth.json` are detected automatically; nothing to configure:
|
|
53
|
+
|
|
54
|
+
| auth.json key | Query | Shows |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `openai-codex` | `GET chatgpt.com/backend-api/wham/usage` (Bearer OAuth token) | 5h/weekly windows, credits, monthly spend cap |
|
|
57
|
+
| `anthropic` | `GET api.anthropic.com/api/oauth/usage` (Claude Code OAuth token, `anthropic-beta: oauth-2025-04-20`) | 5h/weekly windows, extra usage |
|
|
58
|
+
| `github-copilot` | `GET github.com/copilot_internal/user` (GitHub OAuth token, `token` scheme) | premium/chat quota windows, plan, reset date |
|
|
59
|
+
| `openrouter` | `GET openrouter.ai/api/v1/credits` (API key or OAuth token) | Prepaid credits balance |
|
|
60
|
+
| `zai` | `GET api.z.ai/api/monitor/usage/quota/limit` (CN region: `open.bigmodel.cn`); falls back to the bigmodel.cn balance endpoint when the key has no coding plan | 5h/weekly/MCP windows, or CNY balance |
|
|
61
|
+
| `deepseek` | `GET api.deepseek.com/user/balance` | Account balance |
|
|
62
|
+
| any other configured provider | **Auto-detection** (see below): probes New API (`/dashboard/billing/*`), Sub2API (`/usage`), MiniMax, and Zhipu relay billing protocols | Balance or plan window, depending on what the gateway exposes |
|
|
63
|
+
| any custom provider | Config-driven generic adapter (see below) | Balance / windows |
|
|
64
|
+
|
|
65
|
+
### Auto-detection for custom providers
|
|
66
|
+
|
|
67
|
+
Providers registered in pi without a built-in adapter (e.g. relays set up via
|
|
68
|
+
[pi-provider-hub](https://github.com/aaroncarry/pi-provider-hub) or `models.json`) are probed
|
|
69
|
+
automatically using their registry base URL and the stored credential:
|
|
70
|
+
|
|
71
|
+
- **New API** gateways: `dashboard/billing/subscription` + `dashboard/billing/usage` (plus
|
|
72
|
+
`/api/usage/token/` when available) → remaining USD balance, used/total note.
|
|
73
|
+
- **Sub2API** gateways: `usage` → remaining balance.
|
|
74
|
+
- Hostname-pinned protocols for `api.deepseek.com`, MiniMax hosts, and `open.bigmodel.cn` / `api.z.ai`.
|
|
75
|
+
|
|
76
|
+
The winning protocol is pinned per session; if every candidate rejects the request
|
|
77
|
+
(e.g. HTTP 404/401), the account shows a "no balance endpoint detected" error without
|
|
78
|
+
re-probing on every refresh. Transient network errors are retried. Disable with
|
|
79
|
+
`"autoDetect": false` in `usage.json` or `providers.<id>.enabled: false` per account.
|
|
80
|
+
|
|
81
|
+
## Credentials and security
|
|
82
|
+
|
|
83
|
+
- OAuth tokens are resolved through pi's model registry (`getProviderAuth`), which refreshes them before expiry and writes them back to `auth.json`. Providers unknown to the registry fall back to reading `auth.json` directly.
|
|
84
|
+
- API keys reuse pi's interpolation rules (`$ENV` / `${ENV}`, `$$` / `$!` escapes, `!command`).
|
|
85
|
+
- The usage endpoints are undocumented provider APIs and may change or throttle; query frequency is capped by the TTL.
|
|
86
|
+
|
|
87
|
+
## Install
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
pi install npm:@aaroncarry/pi-usage
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or add the package to `settings.json` manually, or point at a local checkout while developing:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"packages": ["npm:@aaroncarry/pi-usage"]
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
Optional config file `<agentDir>/usage.json`:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"intervalMinutes": 5,
|
|
108
|
+
"status": "active",
|
|
109
|
+
"providers": {
|
|
110
|
+
"zai": { "region": "cn", "label": "GLM" },
|
|
111
|
+
"deepseek": { "enabled": false },
|
|
112
|
+
"lingsuan": {
|
|
113
|
+
"label": "LingSuan",
|
|
114
|
+
"custom": {
|
|
115
|
+
"url": "https://relay.example/api/status",
|
|
116
|
+
"headers": { "Authorization": "Bearer {token}" },
|
|
117
|
+
"balancePath": "data.availableBalance",
|
|
118
|
+
"currency": "CNY",
|
|
119
|
+
"windowsPath": "data.limits",
|
|
120
|
+
"windowFields": { "label": "name", "percent": "percentage", "resetsAt": "reset_at" }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
- `intervalMinutes`: background refresh interval (default 5, minimum 1).
|
|
128
|
+
- `status`: `active` (default) | `all` | `off`.
|
|
129
|
+
- `autoDetect`: set `false` to disable endpoint auto-detection for unknown providers.
|
|
130
|
+
- `providers.<id>.enabled: false`: hide an account from the status line and card.
|
|
131
|
+
- `providers.<id>.label`: display name override.
|
|
132
|
+
- `providers.<id>.region`: z.ai region — `auto` (default) | `global` | `cn`.
|
|
133
|
+
- `custom`: generic adapter for any JSON endpoint. Header values support `$ENV` interpolation and a `{token}` placeholder for the provider's own `auth.json` credential, if present. `balancePath` / `windowsPath` are JSON dot paths (e.g. `data.list[0].percent`).
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
npm install --ignore-scripts
|
|
139
|
+
npm run typecheck
|
|
140
|
+
npm test
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
After changing the code, `/reload` inside pi picks it up (local checkouts are referenced in place).
|
|
144
|
+
|
|
145
|
+
## Known limitations
|
|
146
|
+
|
|
147
|
+
- Kimi Coding (`kimi-coding`) is not supported yet: pi stores a token for `api.kimi.com/coding`, and no usage endpoint accepting it has been verified.
|
|
148
|
+
- The Claude, Copilot, and OpenRouter adapters mirror the endpoints and response shapes used by CodexBar; they have not been verified against live accounts yet. Issue reports with the actual response payload are welcome.
|
|
149
|
+
- DeepSeek usage data lives behind the platform web session and cannot be queried with the API key; only the balance is shown.
|
|
150
|
+
- The `$` figure shown for subscription (OAuth) accounts is pi's list-price estimate, not an actual charge; real consumption is the server-side quota window.
|
|
151
|
+
- The panel and status line are TUI features; the `/usage` card itself is written in every mode.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# pi-usage
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
一个 [pi](https://github.com/earendil-works/pi) 扩展:查看各订阅账号的余额与用量窗口——footer 状态行常显当前账号,`/usage` 卡片查看全部明细。查询方法参考
|
|
6
|
+
[CodexBar](https://github.com/steipete/CodexBar),凭据直接复用 pi 统一存储的
|
|
7
|
+
`auth.json`,不读取任何第三方凭据文件。
|
|
8
|
+
|
|
9
|
+
## 效果
|
|
10
|
+
|
|
11
|
+
footer 状态行(默认模式)跟随当前模型,显示其额度窗口和本次会话消耗:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
Codex 5h 13% · weekly 2% · session 10.0k tok $0.020
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`/usage` 向会话流打印一张卡片(自定义条目渲染——非弹窗、不抢焦点):
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Usage · 02:15
|
|
21
|
+
|
|
22
|
+
● Codex (Plus)
|
|
23
|
+
5h ░░░░░░░░░░ 0% · resets in 4h 54m
|
|
24
|
+
weekly ░░░░░░░░░░ 2% · resets in 6d 17h
|
|
25
|
+
○ GLM
|
|
26
|
+
Balance ¥21.46 recharged ¥118.00 · spent ¥96.54
|
|
27
|
+
○ DeepSeek
|
|
28
|
+
Balance ¥38.48
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 命令
|
|
32
|
+
|
|
33
|
+
| 命令 | 作用 |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `/usage` | 向会话流打印用量卡片。重复执行即刷新:5 分钟 TTL 内秒回,过期则重新拉取(15 秒超时)。卡片留存在会话里,`/reload`、恢复旧会话时自动重放 |
|
|
36
|
+
| `/usage active\|all\|off` | 立即切换 footer 状态行模式,并持久化到 `usage.json`(输入时有补全) |
|
|
37
|
+
| `pi --usage-status all` | 指定本次运行的 footer 模式(覆盖 `usage.json`,不写回文件) |
|
|
38
|
+
|
|
39
|
+
优先级:`/usage <mode>`(会话内)> `--usage-status`(本次启动)> `usage.json`(持久)。
|
|
40
|
+
|
|
41
|
+
## footer 状态行
|
|
42
|
+
|
|
43
|
+
| 模式 | 显示内容 |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `active`(默认) | 当前模型对应账号的全部窗口 + 余额,末尾追加会话消耗。切换模型即时跟随 |
|
|
46
|
+
| `all` | 全部账号压成一行——当前账号在前、正常亮度,其余置灰,消耗段固定在行尾 |
|
|
47
|
+
| `off` | 整行隐藏 |
|
|
48
|
+
|
|
49
|
+
- 消耗段(`session <tokens> tok`)每轮对话结束**即时更新**(本地数据,与 pi footer 同口径)。真实费用仅在非零时追加(`· $0.020`)——订阅账号恒为 0,自动省略。
|
|
50
|
+
- 账号查询失败时显示红色的 `账号名 !`。
|
|
51
|
+
|
|
52
|
+
## 支持的账号
|
|
53
|
+
|
|
54
|
+
auth.json 里有凭据的自动识别,无需配置:
|
|
55
|
+
|
|
56
|
+
| auth.json key | 查询接口 | 显示内容 |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `openai-codex` | `GET chatgpt.com/backend-api/wham/usage`(Bearer OAuth token) | 5h/周窗口、credits、月度花销上限 |
|
|
59
|
+
| `anthropic` | `GET api.anthropic.com/api/oauth/usage`(Claude Code OAuth token,`anthropic-beta: oauth-2025-04-20`) | 5h/周窗口、extra usage |
|
|
60
|
+
| `github-copilot` | `GET github.com/copilot_internal/user`(GitHub OAuth token,`token` scheme) | premium/chat 配额窗口、计划、重置日期 |
|
|
61
|
+
| `openrouter` | `GET openrouter.ai/api/v1/credits`(API key 或 OAuth token) | 预付余额 |
|
|
62
|
+
| `zai` | `GET api.z.ai/api/monitor/usage/quota/limit`(CN 区 `open.bigmodel.cn`),无 coding plan 时回退 bigmodel.cn 余额接口 | 5h/周/MCP 窗口或人民币余额 |
|
|
63
|
+
| `deepseek` | `GET api.deepseek.com/user/balance` | 账户余额 |
|
|
64
|
+
| 其他任意已配置厂商 | **自动探测**(见下):依次尝试 New API(`/dashboard/billing/*`)、Sub2API(`/usage`)、MiniMax、智谱等中转计费协议 | 余额或计划窗口,取决于网关暴露的接口 |
|
|
65
|
+
| 任意自定义 provider | 配置驱动的通用适配器(见下) | 余额 / 窗口 |
|
|
66
|
+
|
|
67
|
+
### 自定义厂商的自动探测
|
|
68
|
+
|
|
69
|
+
pi 里没有专用适配器的厂商(例如通过
|
|
70
|
+
[pi-provider-hub](https://github.com/aaroncarry/pi-provider-hub) 或 `models.json` 配置的中转),
|
|
71
|
+
会基于 registry 中的 baseUrl 和已存凭据自动探测:
|
|
72
|
+
|
|
73
|
+
- **New API** 网关:`dashboard/billing/subscription` + `dashboard/billing/usage`
|
|
74
|
+
(可用时再加 `/api/usage/token/`)→ 剩余美元余额与 used/total 明细。
|
|
75
|
+
- **Sub2API** 网关:`usage` → 剩余余额。
|
|
76
|
+
- `api.deepseek.com`、MiniMax 各域名、`open.bigmodel.cn` / `api.z.ai` 按域名固定协议。
|
|
77
|
+
|
|
78
|
+
命中的协议在会话内固定;所有候选都拒绝(如 HTTP 404/401)时显示
|
|
79
|
+
"no balance endpoint detected",且不会每次刷新都重复探测;瞬时网络错误会自动重试。
|
|
80
|
+
在 usage.json 里设 `"autoDetect": false` 可全局关闭,或 `providers.<id>.enabled: false` 单独隐藏。
|
|
81
|
+
|
|
82
|
+
## 凭据与安全
|
|
83
|
+
|
|
84
|
+
- OAuth token 经 pi 的 model registry 解析(`getProviderAuth`),临期自动刷新并写回 auth.json;registry 不认识的 provider 回退为直读 auth.json。
|
|
85
|
+
- API key 复用 pi 的插值规则(`$ENV`/`${ENV}`、`$$`/`$!` 转义、`!command`)。
|
|
86
|
+
- 用量接口均为未公开接口,可能随服务商改版失效;查询频率受 TTL 限制。
|
|
87
|
+
|
|
88
|
+
## 安装
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
pi install npm:@aaroncarry/pi-usage
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
或在 settings.json 的 `packages` 里手动添加,开发期也可以指向本地目录:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"packages": ["npm:@aaroncarry/pi-usage"]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 配置
|
|
103
|
+
|
|
104
|
+
可选配置文件 `<agentDir>/usage.json`:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"intervalMinutes": 5,
|
|
109
|
+
"status": "active",
|
|
110
|
+
"providers": {
|
|
111
|
+
"zai": { "region": "cn", "label": "GLM" },
|
|
112
|
+
"deepseek": { "enabled": false },
|
|
113
|
+
"lingsuan": {
|
|
114
|
+
"label": "LingSuan",
|
|
115
|
+
"custom": {
|
|
116
|
+
"url": "https://relay.example/api/status",
|
|
117
|
+
"headers": { "Authorization": "Bearer {token}" },
|
|
118
|
+
"balancePath": "data.availableBalance",
|
|
119
|
+
"currency": "CNY",
|
|
120
|
+
"windowsPath": "data.limits",
|
|
121
|
+
"windowFields": { "label": "name", "percent": "percentage", "resetsAt": "reset_at" }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- `intervalMinutes`:后台刷新间隔(默认 5,最小 1)。
|
|
129
|
+
- `status`:`active`(默认)/ `all` / `off`。
|
|
130
|
+
- `autoDetect`:设为 `false` 关闭对未知厂商的余额端点自动探测。
|
|
131
|
+
- `providers.<id>.enabled: false`:从状态行和卡片隐藏某账号。
|
|
132
|
+
- `providers.<id>.label`:显示名覆盖。
|
|
133
|
+
- `providers.<id>.region`:z.ai 区域 `auto`(默认)/`global`/`cn`。
|
|
134
|
+
- `custom`:通用适配器,接入任意 JSON 接口——`headers` 支持 `$ENV` 插值和 `{token}` 占位符(取该 provider 在 auth.json 里的凭据,若有);`balancePath`/`windowsPath` 为 JSON 点路径(如 `data.list[0].percent`)。
|
|
135
|
+
|
|
136
|
+
## 开发
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
npm install --ignore-scripts
|
|
140
|
+
npm run typecheck
|
|
141
|
+
npm test
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
改完代码在 pi 里 `/reload` 即可生效(本地路径是原地引用)。
|
|
145
|
+
|
|
146
|
+
## 已知限制
|
|
147
|
+
|
|
148
|
+
- Kimi Coding(`kimi-coding`)暂不支持:pi 存的是 `api.kimi.com/coding` 的 token,尚未验证到接受它的用量接口。
|
|
149
|
+
- Claude、Copilot、OpenRouter 适配器的端点与响应结构照搬 CodexBar 的实现,尚未用真实账号验证过;欢迎带实际响应 payload 提 issue。
|
|
150
|
+
- DeepSeek 的用量数据在平台网页 session 后面,API key 查不到,只显示余额。
|
|
151
|
+
- 订阅账号(OAuth 登录)显示的 `$` 是 pi 按模型目录单价估算的理论费用,并非真实扣费;真实消耗以服务端额度窗口为准。
|
|
152
|
+
- 状态行是 TUI 特性;`/usage` 卡片在任意模式下都会写入会话。
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aaroncarry/pi-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Usage and balance viewer for pi: subscription quota windows, prepaid balances, and session consumption in a footer status line and a /usage card",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/aaroncarry/pi-usage.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/aaroncarry/pi-usage/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/aaroncarry/pi-usage#readme",
|
|
15
|
+
"files": ["src", "README.md", "README.zh-CN.md", "LICENSE"],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"pi",
|
|
18
|
+
"pi-extension",
|
|
19
|
+
"pi-coding-agent",
|
|
20
|
+
"usage",
|
|
21
|
+
"balance",
|
|
22
|
+
"quota",
|
|
23
|
+
"codex",
|
|
24
|
+
"openai",
|
|
25
|
+
"glm",
|
|
26
|
+
"zai",
|
|
27
|
+
"deepseek"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": ["./src/index.ts"]
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "node --test"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "0.85.1",
|
|
44
|
+
"@earendil-works/pi-tui": "0.85.1",
|
|
45
|
+
"@types/node": "22.20.2",
|
|
46
|
+
"typescript": "5.9.3"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usage.json configuration: `<agentDir>/usage.json`.
|
|
3
|
+
*
|
|
4
|
+
* The file is optional; everything falls back to sensible defaults. Invalid
|
|
5
|
+
* entries are ignored rather than rejected so a hand-edited file cannot break
|
|
6
|
+
* the whole extension.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export type StatusMode = "active" | "all" | "off";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_INTERVAL_MINUTES = 5;
|
|
15
|
+
export const MIN_INTERVAL_MINUTES = 1;
|
|
16
|
+
|
|
17
|
+
/** Generic adapter configured entirely from usage.json. */
|
|
18
|
+
export interface CustomProviderConfig {
|
|
19
|
+
/** Request URL. */
|
|
20
|
+
url: string;
|
|
21
|
+
/** HTTP method, defaults to GET. */
|
|
22
|
+
method?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Extra request headers. Values support $ENV interpolation and the
|
|
25
|
+
* "{token}" placeholder for the provider's stored auth.json token.
|
|
26
|
+
*/
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
/** Dot path (e.g. "data.availableBalance") to a numeric balance. */
|
|
29
|
+
balancePath?: string;
|
|
30
|
+
/** Currency code for the balance, e.g. "CNY". */
|
|
31
|
+
currency?: string;
|
|
32
|
+
/** Dot path to an array of usage window items. */
|
|
33
|
+
windowsPath?: string;
|
|
34
|
+
/** Field names inside each window item (defaults: label, percent, resetsAt). */
|
|
35
|
+
windowFields?: { label?: string; percent?: string; resetsAt?: string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProviderConfig {
|
|
39
|
+
/** Set false to hide a provider from panel and status line. */
|
|
40
|
+
enabled?: boolean;
|
|
41
|
+
/** Display name override. */
|
|
42
|
+
label?: string;
|
|
43
|
+
/** z.ai region: "auto" (default), "global" or "cn". */
|
|
44
|
+
region?: string;
|
|
45
|
+
/** Generic adapter definition; entries with this are not built-in adapters. */
|
|
46
|
+
custom?: CustomProviderConfig;
|
|
47
|
+
[key: string]: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface BalanceConfig {
|
|
51
|
+
/** Background refresh interval in minutes (min 1, default 5). */
|
|
52
|
+
intervalMinutes?: number;
|
|
53
|
+
/** Footer status line: active account + session consumption (default), all accounts, or off. */
|
|
54
|
+
status?: StatusMode;
|
|
55
|
+
/**
|
|
56
|
+
* Auto-detect balance endpoints for providers without a built-in adapter
|
|
57
|
+
* (New API relays, Sub2API, MiniMax, Zhipu). Default true.
|
|
58
|
+
*/
|
|
59
|
+
autoDetect?: boolean;
|
|
60
|
+
/** Per-provider options; entries with `custom` define generic adapters. */
|
|
61
|
+
providers?: Record<string, ProviderConfig>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function loadBalanceConfig(agentDir: string): BalanceConfig {
|
|
69
|
+
let raw: unknown;
|
|
70
|
+
try {
|
|
71
|
+
raw = JSON.parse(readFileSync(join(agentDir, "usage.json"), "utf8"));
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
if (!isRecord(raw)) return {};
|
|
76
|
+
return sanitizeBalanceConfig(raw);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function intervalMs(config: BalanceConfig): number {
|
|
80
|
+
const minutes = config.intervalMinutes;
|
|
81
|
+
const resolved =
|
|
82
|
+
typeof minutes === "number" && Number.isFinite(minutes)
|
|
83
|
+
? Math.max(MIN_INTERVAL_MINUTES, minutes)
|
|
84
|
+
: DEFAULT_INTERVAL_MINUTES;
|
|
85
|
+
return resolved * 60_000;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Persist a new footer status mode to usage.json, preserving all other
|
|
90
|
+
* fields. An unreadable file is replaced with a minimal valid config.
|
|
91
|
+
*/
|
|
92
|
+
export function saveStatusMode(agentDir: string, status: StatusMode): void {
|
|
93
|
+
const path = join(agentDir, "usage.json");
|
|
94
|
+
let raw: unknown;
|
|
95
|
+
try {
|
|
96
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
97
|
+
} catch {
|
|
98
|
+
raw = undefined;
|
|
99
|
+
}
|
|
100
|
+
const config = isRecord(raw) ? raw : {};
|
|
101
|
+
config.status = status;
|
|
102
|
+
writeFileSync(path, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function sanitizeBalanceConfig(raw: Record<string, unknown>): BalanceConfig {
|
|
106
|
+
const config: BalanceConfig = {};
|
|
107
|
+
if (typeof raw.intervalMinutes === "number" && Number.isFinite(raw.intervalMinutes) && raw.intervalMinutes > 0) {
|
|
108
|
+
config.intervalMinutes = raw.intervalMinutes;
|
|
109
|
+
}
|
|
110
|
+
if (raw.status === "active" || raw.status === "all" || raw.status === "off") {
|
|
111
|
+
config.status = raw.status;
|
|
112
|
+
}
|
|
113
|
+
if (typeof raw.autoDetect === "boolean") config.autoDetect = raw.autoDetect;
|
|
114
|
+
if (isRecord(raw.providers)) {
|
|
115
|
+
const providers: Record<string, ProviderConfig> = {};
|
|
116
|
+
for (const [id, value] of Object.entries(raw.providers)) {
|
|
117
|
+
if (!isRecord(value)) continue;
|
|
118
|
+
providers[id] = sanitizeProviderConfig(value);
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(providers).length > 0) config.providers = providers;
|
|
121
|
+
}
|
|
122
|
+
return config;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sanitizeProviderConfig(raw: Record<string, unknown>): ProviderConfig {
|
|
126
|
+
const entry: ProviderConfig = {};
|
|
127
|
+
if (typeof raw.enabled === "boolean") entry.enabled = raw.enabled;
|
|
128
|
+
if (typeof raw.label === "string") entry.label = raw.label;
|
|
129
|
+
if (typeof raw.region === "string") entry.region = raw.region;
|
|
130
|
+
if (isRecord(raw.custom)) entry.custom = sanitizeCustomConfig(raw.custom);
|
|
131
|
+
return entry;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function sanitizeCustomConfig(raw: Record<string, unknown>): CustomProviderConfig {
|
|
135
|
+
const custom: CustomProviderConfig = { url: typeof raw.url === "string" ? raw.url : "" };
|
|
136
|
+
if (typeof raw.method === "string") custom.method = raw.method;
|
|
137
|
+
if (isRecord(raw.headers)) {
|
|
138
|
+
const headers: Record<string, string> = {};
|
|
139
|
+
for (const [name, value] of Object.entries(raw.headers)) {
|
|
140
|
+
if (typeof value === "string") headers[name] = value;
|
|
141
|
+
}
|
|
142
|
+
custom.headers = headers;
|
|
143
|
+
}
|
|
144
|
+
if (typeof raw.balancePath === "string") custom.balancePath = raw.balancePath;
|
|
145
|
+
if (typeof raw.currency === "string") custom.currency = raw.currency;
|
|
146
|
+
if (typeof raw.windowsPath === "string") custom.windowsPath = raw.windowsPath;
|
|
147
|
+
if (isRecord(raw.windowFields)) {
|
|
148
|
+
const fields = raw.windowFields;
|
|
149
|
+
const windowFields: { label?: string; percent?: string; resetsAt?: string } = {};
|
|
150
|
+
if (typeof fields.label === "string") windowFields.label = fields.label;
|
|
151
|
+
if (typeof fields.percent === "string") windowFields.percent = fields.percent;
|
|
152
|
+
if (typeof fields.resetsAt === "string") windowFields.resetsAt = fields.resetsAt;
|
|
153
|
+
custom.windowFields = windowFields;
|
|
154
|
+
}
|
|
155
|
+
return custom;
|
|
156
|
+
}
|