pi-quota-dashboard 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 +186 -0
- package/README.zh-CN.md +186 -0
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/auth.mjs +122 -0
- package/core.mjs +208 -0
- package/docs/footer.png +0 -0
- package/footer.mjs +92 -0
- package/index.ts +17 -0
- package/package.json +23 -0
- package/quota.mjs +112 -0
- package/runtime.mjs +179 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mufeiyu-ayu
|
|
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,186 @@
|
|
|
1
|
+
# pi-quota-dashboard
|
|
2
|
+
|
|
3
|
+
**English** · [简体中文](./README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
A read-only quota, balance and session dashboard for the [pi coding agent](https://pi.dev) — **Claude, OpenAI Codex and DeepSeek in a single footer line**.
|
|
6
|
+
|
|
7
|
+
Every other pi extension covers either subscription windows (Anthropic OAuth, Codex) *or* API balance (DeepSeek). This one does both, because they are different billing concepts and a dashboard should not pretend otherwise.
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
That shot is a Codex Pro account: `7d 25% (4d9h)` is the single 7-day window it returns — no 5h, because Pro does not have one. The branch segment is absent because that session is not in a git repository. The quota segment on the other providers:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
5h 98% (3h12m) · 7d 49% · Fable 13% Claude — one entry per window
|
|
15
|
+
(nothing) DeepSeek — a funded balance stays out of the way
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Verified against **pi 0.85.1 / Node 22.20.0**.
|
|
19
|
+
|
|
20
|
+
## What it does
|
|
21
|
+
|
|
22
|
+
- **Shows remaining quota, not consumed** — you care about what is left.
|
|
23
|
+
- **One line, not three.** pi's built-in footer takes three rows (cwd / stats / extension statuses). This collapses all of them into one.
|
|
24
|
+
- **Only the windows your account actually has.** A Codex Pro account returns exactly one 7-day window — no 5h, no code-review bucket. Those are not invented as "unavailable" rows.
|
|
25
|
+
- **Never writes credentials.** No auto-login, no token refresh, no writes to `auth.json`. An expired credential is reported as `expired` and left alone.
|
|
26
|
+
- **Never claims more than it knows.** `activeAccountVerified` is permanently `false` — see [Honest by construction](#honest-by-construction).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pi install git:github.com/mufeiyu-ayu/pi-quota-dashboard
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then run `/reload` in your pi session. No `npm install` needed — pi provides the dependencies.
|
|
35
|
+
|
|
36
|
+
For local development, clone it and point pi at the directory:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
git clone https://github.com/mufeiyu-ayu/pi-quota-dashboard.git
|
|
40
|
+
pi install ./pi-quota-dashboard
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The footer line
|
|
44
|
+
|
|
45
|
+
pi's built-in footer is three rows: working directory, stats, extension statuses. This extension uses `ui.setFooter()` to collapse all three into **one**, segments separated by `│`:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
🤖 high · gpt-6-astra │ main │ ██░░░░░░░░ 18.5% 185k/1.0M │ $0.061 │ 7d 25% (4d11h)
|
|
49
|
+
└─── model ────┘ └branch┘ └──────── context ─────────┘ └─ cost ─┘ └── quota ──┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Everything is left-aligned — nothing is pushed to the far right where it gets truncated first.
|
|
53
|
+
|
|
54
|
+
- **Thinking level comes before the model name**, coloured with pi's own thinking scale (grey → blue → violet → magenta), so the strength reads at a glance.
|
|
55
|
+
- **The provider is not shown.** `gpt-6-astra`, `deepseek-v4-flash` and `claude-fable-5-1` already say which vendor they are; the prefix cost 16 columns for nothing.
|
|
56
|
+
- **Only the git branch, no working directory.** The branch is the part that changes and is worth watching; you already know which project you are in. Outside a repository the segment disappears entirely.
|
|
57
|
+
|
|
58
|
+
### Context
|
|
59
|
+
|
|
60
|
+
A 10-cell progress bar, the percentage, and the absolute count. Bar and number share a colour, using pi's own thresholds: **70% amber, 90% red**. Right after a compaction the percentage shows `?` until the next response — pi genuinely does not know yet, so nothing is guessed.
|
|
61
|
+
|
|
62
|
+
### Quota
|
|
63
|
+
|
|
64
|
+
| Element | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `25%` colour | Graded on **remaining**: >50% green, >20% amber, else red. Windows are separated by `·` |
|
|
67
|
+
| `~` | Data is stale (not refreshed this cycle). Percentages also lose their colour — a colour scale only means something on freshly fetched data |
|
|
68
|
+
| `5h` `7d` `1d` | Window length, derived from `durationSeconds`. Model-scoped weekly windows get the model name (`7d Opus`), `weekly_scoped` uses the service's own display name (`Fable`), Codex code-review buckets are prefixed `CR` |
|
|
69
|
+
| `25%` | **Remaining**, not consumed |
|
|
70
|
+
| `(4d11h)` | Reset countdown, shown only for the shortest window — it resets first and is the one you hit |
|
|
71
|
+
| `¥0.00` (red) | API balance, shown **only when it runs dry** (the service reports `is_available:false`, or the amount truncates to zero/negative). A balance does not run out mid-session the way a quota does, so it stays out of the way. Full figures via `/dashboard` |
|
|
72
|
+
| `…` / status word | Querying / could not fetch (`?`, `n/a`, `auth`, `expired`, `403`, `429`, `error`). Definite problems are red, merely-missing data stays dim. A healthy `ok` prints no status word at all |
|
|
73
|
+
|
|
74
|
+
Windows with no data are **omitted entirely** rather than occupying a `?%` slot. If the active provider has no quota concept, the whole segment is withdrawn instead of leaving a gap.
|
|
75
|
+
|
|
76
|
+
### Responsive degradation
|
|
77
|
+
|
|
78
|
+
A narrow terminal must not push the quota — the whole point of the extension — off the end of the line. So segments are dropped by priority instead of truncating blindly:
|
|
79
|
+
|
|
80
|
+
| Width | Shows |
|
|
81
|
+
|---|---|
|
|
82
|
+
| Wide | `🤖 high · model │ main │ ██░░░░░░░░ 18.5% 185k/1.0M │ $0.061 │ quota` |
|
|
83
|
+
| Narrower | absolute token count dropped — the bar and the percentage already say it |
|
|
84
|
+
| Narrower still | branch dropped |
|
|
85
|
+
| Narrowest | the line is truncated from the right. Model, quota and other extension statuses are never dropped as segments |
|
|
86
|
+
|
|
87
|
+
`setStatus()` is still published normally, so if another extension takes over the footer this one keeps showing up there. `session_shutdown` hands the built-in footer back.
|
|
88
|
+
|
|
89
|
+
**Only this extension's own status is rendered.** pi normally lines up every extension's `setStatus()` output on a third row; this line replaces that row, so other status-publishing extensions have nowhere to show. A deliberate trade-off for a clean single line — if you rely on another statusline-style extension, this one will hide it.
|
|
90
|
+
|
|
91
|
+
### Differences from the built-in footer
|
|
92
|
+
|
|
93
|
+
These are extension-API boundaries, not preferences:
|
|
94
|
+
|
|
95
|
+
| Item | Why |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `(auto)` | **Not shown.** `autoCompactionEnabled` lives on pi's internal session object and is unreachable from `ExtensionContext`. Better absent than possibly stale |
|
|
98
|
+
| provider prefix | **Not shown.** pi prints `(openai-codex)` when several providers are configured; the model id already identifies the vendor |
|
|
99
|
+
| `(sub)` | Mirrors pi's `isUsingSubscription`, but the `snapshot.auth` map behind `isUsingOAuth` is unreachable, so it uses `readStoredCredential(id)?.type === 'oauth' && provider.auth.oauth.isSubscription`. OAuth from environment/runtime is missed — under-reporting beats guessing |
|
|
100
|
+
| `↑↓RW` `CH` | **Not shown.** The per-direction token breakdown and cache-hit rate are covered by the cost and context segments; cumulative token counts remain in `/dashboard`. |
|
|
101
|
+
| `$?` | pi prints the running total; this keeps the conservative reading — if a **billable** response reports a cost of 0, the figure is untrustworthy, so it says so. An all-zero empty response (an abort, a model switch) is self-consistent and does not poison the session |
|
|
102
|
+
|
|
103
|
+
Everything else — context thresholds, truncation behaviour — matches the built-in footer.
|
|
104
|
+
|
|
105
|
+
## Commands
|
|
106
|
+
|
|
107
|
+
| Command | Effect |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `/dashboard` | Re-reads the stored/environment credential source and prints the full redacted JSON snapshot. Quota is reused within a 60s TTL |
|
|
110
|
+
| `/dashboard refresh` | Bypasses the TTL — but **not** the failure backoff |
|
|
111
|
+
|
|
112
|
+
Snapshots never enter the model's context and are never written to disk.
|
|
113
|
+
|
|
114
|
+
## Honest by construction
|
|
115
|
+
|
|
116
|
+
The reason this extension is worth reading: it is careful about what it claims.
|
|
117
|
+
|
|
118
|
+
- **`activeAccountVerified` is always `false`.** The guards below reject visible overrides, but they cannot rule out a `before_provider_headers` hook silently swapping accounts, and on-disk config can differ from loaded config. So even a successful 200 is labelled as representing *the stored/environment account*, not a verified active request account. It never reads private runtime state or executes header hooks to guess.
|
|
119
|
+
- **Subscription quota and API balance are strictly separate** (`kind`). An allowance and a balance are not interchangeable numbers.
|
|
120
|
+
- **Balances stay decimal strings.** No float conversion, no currency conversion. The footer truncates to two places and appends `+` when it drops non-zero digits, so a tiny positive balance never renders as a flat `0.00`.
|
|
121
|
+
- **Zero is not free.** pi initialises missing prices to zero; a billable response reporting `cost: 0` is reported as unknown rather than as a measured free call.
|
|
122
|
+
- **Stale is not fresh.** A failed refresh keeps the previous `fetchedAt` and is marked `stale` — it is never relabelled as a successful fetch.
|
|
123
|
+
|
|
124
|
+
### Credential handling
|
|
125
|
+
|
|
126
|
+
- Reuses pi's exported `readStoredCredential` and the **stock** provider `oauth.toAuth` / DeepSeek `apiKey.resolve`. Never calls `getProviderAuth` / `getApiKeyAndHeaders`, which may refresh or write.
|
|
127
|
+
- API keys accept a literal, or a single `$ENV` / `${ENV}` reference (provider-scoped env wins). `!command` credentials and compound templates are **never executed** — they report `unsupported`.
|
|
128
|
+
- Runtime credential overrides, registered provider overrides, non-official `baseUrl`, visible headers, and per-provider auth config in `models.json` all fall back to `unsupported`.
|
|
129
|
+
- Requests go to fixed official HTTPS paths only. Redirects are refused (`redirect: 'manual'`), the model's `baseUrl` is never used as a quota target, and tokens, headers, raw error bodies, account ids and fingerprints are never logged.
|
|
130
|
+
|
|
131
|
+
### Endpoints
|
|
132
|
+
|
|
133
|
+
| Provider | Endpoint | Auth |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| Claude | `GET https://api.anthropic.com/api/oauth/usage` | OAuth bearer + `anthropic-beta: oauth-2025-04-20` |
|
|
136
|
+
| Codex | `GET https://chatgpt.com/backend-api/wham/usage` | OAuth bearer + `ChatGPT-Account-Id` |
|
|
137
|
+
| DeepSeek | `GET https://api.deepseek.com/user/balance` | Bearer API key |
|
|
138
|
+
|
|
139
|
+
Claude and Codex expose internal/unstable quota endpoints with no compatibility promise. DeepSeek follows its [documented balance API](https://api-docs.deepseek.com/api/get-user-balance).
|
|
140
|
+
|
|
141
|
+
### Caching and lifecycle
|
|
142
|
+
|
|
143
|
+
Cache is keyed by a SHA-256 fingerprint of provider + fixed endpoint + token + account (never emitted), so two accounts can never read each other's data. Concurrent requests for one key are coalesced, at most 16 entries. HTTP deadline 8s including the body, auth derivation 8s separately, response body capped at 256 KiB. Failures back off 60/120/240/300s and honour `Retry-After` (seconds or HTTP-date, capped at one day). 401/403/429 are never routed around.
|
|
144
|
+
|
|
145
|
+
Refresh polls every 60s, and only in UI mode. Every publish path and async continuation independently re-checks provider/model/baseUrl identity, so a model switch cancels in-flight requests and discards late results rather than attributing them to the new model. Credentials are re-read after the HTTP call — if they changed mid-flight, the result is dropped instead of published against the wrong account. Cache is memory-only.
|
|
146
|
+
|
|
147
|
+
## Provider support
|
|
148
|
+
|
|
149
|
+
| Provider | Reported |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `anthropic` | `five_hour`, `seven_day`, model-scoped weekly windows (`seven_day_opus`, `seven_day_sonnet`, `weekly_scoped`) |
|
|
152
|
+
| `openai-codex` | `rate_limit` and `code_review_rate_limit` primary/secondary windows — whichever the account actually returns |
|
|
153
|
+
| `deepseek` | Exact CNY/USD API balances as decimal strings |
|
|
154
|
+
|
|
155
|
+
Any other provider reports `unsupported` and the segment is withdrawn.
|
|
156
|
+
|
|
157
|
+
> **Note on Claude:** pi's docs state that third-party harness calls against a Claude subscription consume extra usage and are billed per token rather than against plan windows. The 5h/7d figures here are the account's plan allowance as returned by the official endpoint — **not a pi budget**. No `extra_usage` field or session cost estimate is mixed into the plan windows.
|
|
158
|
+
|
|
159
|
+
## Development
|
|
160
|
+
|
|
161
|
+
The npm package ships only the runtime files, so clone the repository to run the tests:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
git clone https://github.com/mufeiyu-ayu/pi-quota-dashboard.git
|
|
165
|
+
cd pi-quota-dashboard
|
|
166
|
+
node --test test/*.test.mjs
|
|
167
|
+
|
|
168
|
+
# prints usage only, no network
|
|
169
|
+
node scripts/smoke.mjs
|
|
170
|
+
|
|
171
|
+
# explicit, free, read-only real GET; prints redacted business fields and HTTP status, never credentials
|
|
172
|
+
node scripts/smoke.mjs --live
|
|
173
|
+
node scripts/smoke.mjs --live deepseek
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
All `FAKE_*` fixtures are simulated. Tests also cover the real installed extension loader, the stock auth functions against fake credentials, and both the built-in `FooterComponent` and this extension's merged footer at 0/1/2/3/8/20/40/80/160 columns — without starting the pi CLI or issuing a model request. The merged footer is rendered through the factory registered by pi's real extension loader, not by bypassing the load path.
|
|
177
|
+
|
|
178
|
+
Standalone scripts locate pi from the current Node's global install directory; set `PI_DASHBOARD_PI_ROOT` for other layouts. Integration tests assert `pi.VERSION === '0.85.1'` — on a pi upgrade that assertion fails deliberately, as a prompt to re-check the auth, loader and footer APIs before bumping it.
|
|
179
|
+
|
|
180
|
+
## Attribution
|
|
181
|
+
|
|
182
|
+
Endpoint and response-field research for Claude and Codex referenced [claude-dashboard](https://github.com/uppinote20/claude-dashboard) by uppinote (MIT). This package implements its own credential handling, parsers, cache and lifecycle integration, and does not execute or redistribute that bundle. See [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md).
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# pi-quota-dashboard
|
|
2
|
+
|
|
3
|
+
[English](./README.md) · **简体中文**
|
|
4
|
+
|
|
5
|
+
给 [pi coding agent](https://pi.dev) 的只读额度 / 余额 / 会话面板 —— **Claude、OpenAI Codex、DeepSeek 三家挤进底栏一行**。
|
|
6
|
+
|
|
7
|
+
生态里其他扩展要么只查订阅窗口(Anthropic OAuth、Codex),要么只查 API 余额(DeepSeek)。这个两样都做,因为它们本来就是两种计费概念,面板不该把它们混成一个数字。
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
这张图是 Codex Pro 账户:`7d 25% (4d9h)` 就是它返回的唯一一个 7 天窗口 —— 没有 5h,因为 Pro 本来就没有。分支段不在,是因为那个会话不在 git 仓库里。换成其他 provider 时,额度段会是:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
5h 98% (3h12m) · 7d 49% · Fable 13% Claude —— 一个窗口一条
|
|
15
|
+
(什么都没有) DeepSeek —— 余额充足就不占位
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
验证版本 **pi 0.85.1 / Node 22.20.0**。
|
|
19
|
+
|
|
20
|
+
## 特点
|
|
21
|
+
|
|
22
|
+
- **显示剩余额度,不是已用** —— 关心的是还能用多少。
|
|
23
|
+
- **一行,不是三行。** pi 内置 footer 占三行(工作目录 / 统计 / 扩展状态),这里全部并成一行。
|
|
24
|
+
- **只显示账户真正有的窗口。** Codex Pro 账户只返回一个 7 天窗口,没有 5h、没有 code review 桶 —— 这些不会被凭空造成 "unavailable" 条目。
|
|
25
|
+
- **绝不写凭据。** 不自动登录、不刷新 token、不写 `auth.json`。凭据过期就如实报 `expired`,不动它。
|
|
26
|
+
- **不声称自己不知道的事。** `activeAccountVerified` 恒为 `false`,理由见[诚实是设计出来的](#诚实是设计出来的)。
|
|
27
|
+
|
|
28
|
+
## 安装
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pi install git:github.com/mufeiyu-ayu/pi-quota-dashboard
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
然后在 pi 会话里执行 `/reload`。无需 `npm install`,依赖由 pi 提供。
|
|
35
|
+
|
|
36
|
+
本地开发:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
git clone https://github.com/mufeiyu-ayu/pi-quota-dashboard.git
|
|
40
|
+
pi install ./pi-quota-dashboard
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 底栏
|
|
44
|
+
|
|
45
|
+
pi 内置 footer 占三行:工作目录、统计、扩展状态。本扩展用 `ui.setFooter()` 把三行全部并成**一行**,用 `│` 分段:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
🤖 high · gpt-6-astra │ main │ ██░░░░░░░░ 18.5% 185k/1.0M │ $0.061 │ 7d 25% (4d11h)
|
|
49
|
+
└─── 模型 ────┘ └分支┘ └───────── 上下文 ────────┘ └─ 费用 ─┘ └── 额度 ──┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
整行左对齐 —— 没有任何东西被推到最右边、最先被截断的位置。
|
|
53
|
+
|
|
54
|
+
- **思考强度排在模型名前面**,用 pi 自己的 thinking 分级色(灰 → 蓝 → 紫 → 品红),强度一眼可辨。
|
|
55
|
+
- **不显示厂商。** `gpt-6-astra`、`deepseek-v4-flash`、`claude-fable-5-1` 本身就说明是哪一家,这个前缀白占 16 列。
|
|
56
|
+
- **只显示 git 分支,不显示工作目录。** 分支才是会变、值得盯的那部分;自己在哪个项目你本来就知道。不在 git 仓库里时整段消失。
|
|
57
|
+
|
|
58
|
+
### 上下文
|
|
59
|
+
|
|
60
|
+
10 格进度条 + 百分比 + 绝对计数。条身与数字同色,阈值沿用 pi 的 **70% 黄 / 90% 红**。压缩之后到下一次响应之前,百分比显示 `?` —— pi 此时确实不知道,不猜。
|
|
61
|
+
|
|
62
|
+
### 额度
|
|
63
|
+
|
|
64
|
+
| 元素 | 含义 |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `25%` 颜色 | 按**剩余**分档:>50% 绿 / >20% 黄 / 其余红。多个窗口之间用 `·` 分隔 |
|
|
67
|
+
| `~` | 数据过期未刷新(stale),同时百分比不着色 —— 色阶只在当次刷新过的数据上才代表真实水位 |
|
|
68
|
+
| `5h` `7d` `1d` | 窗口时长,从 `durationSeconds` 推导。模型专属周窗补模型名(`7d Opus`),`weekly_scoped` 用服务返回的显示名(`Fable`),Codex code review 桶前缀 `CR` |
|
|
69
|
+
| `25%` | **剩余** 25%,不是已用 |
|
|
70
|
+
| `(4d11h)` | 重置倒计时,只给最短的窗口显示 —— 它最先重置,也最常撞上 |
|
|
71
|
+
| `¥0.00`(红) | API 余额**只在见底时**提示(服务报 `is_available:false`,或截断到 2 位后为零/负)。余额不像配额那样会在会话中途用光,平时不占位,完整数字看 `/dashboard` |
|
|
72
|
+
| `…` / 状态词 | 查询中 / 取不到(`?`、`n/a`、`auth`、`expired`、`403`、`429`、`error`)。确定出问题的标红,只是取不到数的保持 dim。健康的 `ok` 不写状态词 |
|
|
73
|
+
|
|
74
|
+
**没有数据的窗口整个略去**,不占一个 `?%` 的位置。当前 provider 没有额度概念时整条撤掉,不留空位。
|
|
75
|
+
|
|
76
|
+
### 按宽度降级
|
|
77
|
+
|
|
78
|
+
窄终端不该把额度 —— 这个扩展的主角 —— 挤出行外。所以是按优先级丢段,不是无脑截断:
|
|
79
|
+
|
|
80
|
+
| 终端宽度 | 显示 |
|
|
81
|
+
|---|---|
|
|
82
|
+
| 宽 | `🤖 high · model │ main │ ██░░░░░░░░ 18.5% 185k/1.0M │ $0.061 │ 额度` |
|
|
83
|
+
| 窄 | 丢绝对 token 计数 —— 进度条和百分比已经说明同一件事 |
|
|
84
|
+
| 更窄 | 再丢分支 |
|
|
85
|
+
| 最窄 | 整行从右侧截断。模型、额度和其他扩展状态不会作为整段被丢掉 |
|
|
86
|
+
|
|
87
|
+
`setStatus()` 照常发布,所以别的扩展接管 footer 时本插件依然显示;`session_shutdown` 交还内置 footer。
|
|
88
|
+
|
|
89
|
+
**只渲染本扩展自己的状态。** pi 原本把所有扩展的 `setStatus()` 内容排在第三行,这一行取代了它,因此其他会发布状态的扩展没有显示位置。这是为了单行整洁而有意做的取舍 —— 如果你依赖别的状态类扩展,它会被这一行盖掉。
|
|
90
|
+
|
|
91
|
+
### 与内置 footer 的差异
|
|
92
|
+
|
|
93
|
+
都是扩展 API 的边界所致,不是选择:
|
|
94
|
+
|
|
95
|
+
| 项 | 说明 |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `(auto)` | **不显示**。`autoCompactionEnabled` 只在 pi 内部的 session 对象上,`ExtensionContext` 拿不到。与其显示一个可能过期的标记,不如不显示 |
|
|
98
|
+
| 厂商前缀 | **不显示**。配置了多个 provider 时 pi 会打印 `(openai-codex)`;模型 id 已经能说明是哪一家 |
|
|
99
|
+
| `(sub)` | 与 pi 的 `isUsingSubscription` 同构,但 `isUsingOAuth` 依赖的 `snapshot.auth` 不可达,改用 `readStoredCredential(id)?.type === 'oauth' && provider.auth.oauth.isSubscription`。environment/runtime 的 OAuth 会漏标 —— 宁可漏也不猜 |
|
|
100
|
+
| `↑↓RW` `CH` | **不显示**。分方向的 token 明细和缓存命中率已由费用段与上下文段覆盖;累计 token 数仍在 `/dashboard` 里 |
|
|
101
|
+
| `$?` | pi 直接显示累加值;这里保持保守口径 —— **计费过的**响应报 cost=0 时这个数不可信,就明说。分项全为 0 的空响应(中断、切模型)零成本自洽,不会抹掉整轮会话 |
|
|
102
|
+
|
|
103
|
+
其余(上下文着色阈值、截断行为)与内置 footer 一致。
|
|
104
|
+
|
|
105
|
+
## 命令
|
|
106
|
+
|
|
107
|
+
| 命令 | 作用 |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `/dashboard` | 重新读取存储/环境凭据来源,打印完整的脱敏 JSON 快照。额度在 60 秒 TTL 内复用 |
|
|
110
|
+
| `/dashboard refresh` | 绕过正常 TTL,但**不绕过失败退避** |
|
|
111
|
+
|
|
112
|
+
快照不进入模型上下文,也不落盘。
|
|
113
|
+
|
|
114
|
+
## 诚实是设计出来的
|
|
115
|
+
|
|
116
|
+
这个扩展值得一读的地方,在于它对"自己知道什么"很克制。
|
|
117
|
+
|
|
118
|
+
- **`activeAccountVerified` 恒为 `false`。** 下面那些 guards 能挡住可见的覆盖,但挡不住 `before_provider_headers` 钩子悄悄换账户,磁盘配置也可能与已加载配置不同。所以即使拿到 200,也只标注为"代表*存储/环境账户*",而非已验证的活动请求账户。不读私有 runtime,不执行 header hooks 去猜。
|
|
119
|
+
- **订阅额度与 API 余额严格分开**(`kind`)。额度和余额不是可以互换的数字。
|
|
120
|
+
- **余额保持十进制字符串。** 不做浮点换算,不换汇。底栏截断到 2 位,丢掉非零位时补 `+`,所以极小的正余额不会显示成干巴巴的 `0.00`。
|
|
121
|
+
- **零不等于免费。** pi 会把缺失的价格初始化为零;计费过的响应报 `cost: 0` 时报 unknown,不当成实测的免费调用。
|
|
122
|
+
- **过期不等于新鲜。** 刷新失败保留原来的 `fetchedAt` 并标 `stale`,绝不改头换面成一次成功刷新。
|
|
123
|
+
|
|
124
|
+
### 凭据处理
|
|
125
|
+
|
|
126
|
+
- 重用 pi 导出的 `readStoredCredential` 与**原装** provider `oauth.toAuth` / DeepSeek `apiKey.resolve`。绝不调用可能刷新/写入的 `getProviderAuth` / `getApiKeyAndHeaders`。
|
|
127
|
+
- API key 支持明文,或单个 `$ENV` / `${ENV}` 引用(provider-scoped env 优先)。`!command` 凭据和复合模板**绝不执行**,直接报 `unsupported`。
|
|
128
|
+
- runtime 临时凭据、已注册 provider 覆盖、非官方 `baseUrl`、可见 headers、`models.json` 里当前 provider/model 的鉴权配置,一律退回 `unsupported`。
|
|
129
|
+
- 请求只进入固定的官方 HTTPS 路径。拒绝 redirect(`redirect: 'manual'`),不把模型的 `baseUrl` 当额度目标,不记录 token、headers、raw error body、账号或指纹。
|
|
130
|
+
|
|
131
|
+
### 接口
|
|
132
|
+
|
|
133
|
+
| Provider | 端点 | 鉴权 |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| Claude | `GET https://api.anthropic.com/api/oauth/usage` | OAuth bearer + `anthropic-beta: oauth-2025-04-20` |
|
|
136
|
+
| Codex | `GET https://chatgpt.com/backend-api/wham/usage` | OAuth bearer + `ChatGPT-Account-Id` |
|
|
137
|
+
| DeepSeek | `GET https://api.deepseek.com/user/balance` | Bearer API key |
|
|
138
|
+
|
|
139
|
+
Claude 和 Codex 用的是官方服务的内部/非稳定额度接口,不承诺永久兼容。DeepSeek 依据其[官方余额文档](https://api-docs.deepseek.com/api/get-user-balance)。
|
|
140
|
+
|
|
141
|
+
### 缓存与生命周期
|
|
142
|
+
|
|
143
|
+
缓存按 provider + 固定端点 + token + account 的 SHA-256 指纹隔离(指纹不输出),两个账户永远读不到彼此的数据。同 key 的并发请求合并,最多 16 项。HTTP 8 秒含响应体,鉴权单独 8 秒,响应体上限 256 KiB。失败按 60/120/240/300 秒退避,支持 `Retry-After`(秒或 HTTP-date,上限一天)。401/403/429 不做任何绕路。
|
|
144
|
+
|
|
145
|
+
仅 UI 模式下每 60 秒轮询。所有发布入口和异步结果都独立比对 provider/model/baseUrl 身份,模型切换会取消在途请求并丢弃迟到结果,而不是把它算到新模型头上。HTTP 调用后重读凭据 —— 期间变过就丢弃结果,不发布到错误的账户名下。缓存只在内存。
|
|
146
|
+
|
|
147
|
+
## Provider 支持
|
|
148
|
+
|
|
149
|
+
| Provider | 上报内容 |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `anthropic` | `five_hour`、`seven_day`、模型专属周窗(`seven_day_opus`、`seven_day_sonnet`、`weekly_scoped`) |
|
|
152
|
+
| `openai-codex` | `rate_limit` 与 `code_review_rate_limit` 的 primary/secondary 窗口 —— 账户实际返回哪些就是哪些 |
|
|
153
|
+
| `deepseek` | 精确的 CNY/USD API 余额,十进制字符串 |
|
|
154
|
+
|
|
155
|
+
其他 provider 报 `unsupported`,该段整条撤掉。
|
|
156
|
+
|
|
157
|
+
> **关于 Claude:** pi 文档明确,第三方 harness 对 Claude 订阅的调用消耗 extra usage、按 token 计费,而不走计划窗口。这里的 5h/7d 是官方接口返回的账户计划额度,**不是 pi 的可用预算**。没有把 `extra_usage` 字段或会话估算成本混进计划窗口。
|
|
158
|
+
|
|
159
|
+
## 开发
|
|
160
|
+
|
|
161
|
+
npm 包只带运行时文件,跑测试请先 clone 仓库:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
git clone https://github.com/mufeiyu-ayu/pi-quota-dashboard.git
|
|
165
|
+
cd pi-quota-dashboard
|
|
166
|
+
node --test test/*.test.mjs
|
|
167
|
+
|
|
168
|
+
# 只打印用法,不联网
|
|
169
|
+
node scripts/smoke.mjs
|
|
170
|
+
|
|
171
|
+
# 显式、免费、只读的真实 GET;输出脱敏业务字段与 HTTP 状态,绝不输出凭据
|
|
172
|
+
node scripts/smoke.mjs --live
|
|
173
|
+
node scripts/smoke.mjs --live deepseek
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
测试中的 `FAKE_*` 均为模拟。另有真实 installed loader、原装 auth 函数(fake credential)、内置 `FooterComponent` 与本扩展合并 footer 各自在 0/1/2/3/8/20/40/80/160 列的检查 —— 不启动 pi CLI,不发模型请求。合并 footer 经 pi 真实扩展 loader 注册的 factory 渲染,不绕过加载链路。
|
|
177
|
+
|
|
178
|
+
独立脚本默认从当前 Node 的全局安装目录定位 pi,其他布局可设 `PI_DASHBOARD_PI_ROOT`。集成测试断言 `pi.VERSION === '0.85.1'` —— pi 升级时这条会故意变红,提示先复核 auth、loader 和 footer API 再更新断言。
|
|
179
|
+
|
|
180
|
+
## 署名
|
|
181
|
+
|
|
182
|
+
Claude 与 Codex 的端点及响应字段研究参考了 uppinote 的 [claude-dashboard](https://github.com/uppinote20/claude-dashboard)(MIT)。本包自行实现凭据处理、解析、缓存与生命周期接入,不执行也不再分发该 bundle。详见 [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md)。
|
|
183
|
+
|
|
184
|
+
## 许可
|
|
185
|
+
|
|
186
|
+
MIT
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Reference attribution
|
|
2
|
+
|
|
3
|
+
Claude/Codex endpoint and response-field research used the locally installed
|
|
4
|
+
**claude-dashboard 1.31.1** by **uppinote**, specifically `dist/index.js`
|
|
5
|
+
(api-client and codex-client). This package independently implements its own
|
|
6
|
+
strict read-only credential handling, parsers, cache and lifecycle integration;
|
|
7
|
+
it does not execute or redistribute the dashboard bundle. The reference license
|
|
8
|
+
is retained below.
|
|
9
|
+
|
|
10
|
+
## claude-dashboard — MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 uppinote
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
|
|
32
|
+
Pi packages are imported from the user's existing installation, not vendored.
|
|
33
|
+
Their own licenses remain with that installation.
|
package/auth.mjs
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { ENDPOINTS, officialBase, unavailable, number } from './core.mjs';
|
|
4
|
+
|
|
5
|
+
const secretString = (v) => typeof v === 'string' && v.length > 0 && v.length < 32768 && !/[\s\x00-\x1f\x7f]/.test(v);
|
|
6
|
+
export function authFingerprint(provider, token, accountId = '') {
|
|
7
|
+
return createHash('sha256').update(JSON.stringify([provider, ENDPOINTS[provider], token, accountId])).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
export function codexIdentity(token) {
|
|
10
|
+
try {
|
|
11
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
|
12
|
+
return { accountId: payload['https://api.openai.com/auth']?.chatgpt_account_id, expires: number(payload.exp) === null ? null : payload.exp * 1000 };
|
|
13
|
+
} catch { return {}; }
|
|
14
|
+
}
|
|
15
|
+
// Only plain literals and whole-variable references are accepted here. Never run
|
|
16
|
+
// !commands (including keychain/CLI helpers) from a read-only status integration.
|
|
17
|
+
export function readOnlyKey(raw, scoped = {}, env = process.env) {
|
|
18
|
+
if (raw === undefined) return { key: undefined };
|
|
19
|
+
if (typeof raw !== 'string' || raw.startsWith('!')) return { reason: 'command_or_invalid_credential' };
|
|
20
|
+
if (raw.includes('$')) {
|
|
21
|
+
const match = raw.match(/^\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})$/);
|
|
22
|
+
if (!match) return { reason: 'credential_template_unsupported' };
|
|
23
|
+
const name = match[1] ?? match[2];
|
|
24
|
+
return { key: scoped[name] || env[name] || undefined, credentialSource: scoped[name] ? 'pi_scoped_environment' : 'environment' };
|
|
25
|
+
}
|
|
26
|
+
return { key: raw, credentialSource: 'pi_stored_api_key' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 与 pi 的 isUsingSubscription 同构(isUsingOAuth && provider.auth.oauth.isSubscription),
|
|
30
|
+
// 但只认 Pi 存储的 OAuth:environment/runtime 凭据这一层看不到,宁可漏标 (sub) 也不猜。
|
|
31
|
+
export function isSubscription(ctx, readCredential) {
|
|
32
|
+
const id = ctx.model?.provider;
|
|
33
|
+
if (id === undefined) return false;
|
|
34
|
+
if (id === 'kimi-coding') return true;
|
|
35
|
+
try {
|
|
36
|
+
return readCredential(id)?.type === 'oauth'
|
|
37
|
+
&& ctx.modelRegistry.getProvider(id)?.auth?.oauth?.isSubscription === true;
|
|
38
|
+
} catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function readModelConfig(path) {
|
|
42
|
+
try { return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')); }
|
|
43
|
+
catch (error) { if (error?.code === 'ENOENT') return {}; throw new Error('configuration_unverified'); }
|
|
44
|
+
}
|
|
45
|
+
export function authConfigOverride(config, id, modelId) {
|
|
46
|
+
const record = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
47
|
+
if (!record(config) || (config.providers !== undefined && !record(config.providers))) return 'configuration_unverified';
|
|
48
|
+
const p = config.providers?.[id];
|
|
49
|
+
if (p === undefined) return null;
|
|
50
|
+
if (!record(p)) return 'configuration_unverified';
|
|
51
|
+
if (p.models !== undefined && !Array.isArray(p.models)) return 'configuration_unverified';
|
|
52
|
+
if (p.modelOverrides !== undefined && !record(p.modelOverrides)) return 'configuration_unverified';
|
|
53
|
+
const model = p.models?.find((m) => m?.id === modelId);
|
|
54
|
+
const override = p.modelOverrides?.[modelId];
|
|
55
|
+
const present = (v) => v !== undefined && v !== null && v !== '' && v !== false && !(record(v) && Object.keys(v).length === 0);
|
|
56
|
+
return [p.apiKey, p.headers, p.authHeader, p.oauth, model?.headers, override?.headers].some(present)
|
|
57
|
+
? 'custom_auth_configuration_unconfirmed' : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 仅解析 Pi 存储/环境凭据;公开配置检查不能排除已加载的隐藏 headers 或请求 hook,
|
|
61
|
+
// 因此这些凭据始终不代表已验证的活动请求账户。
|
|
62
|
+
export async function resolveCredential(ctx, { readCredential, providers, readConfig, env = process.env }, signal, now = Date.now()) {
|
|
63
|
+
const id = ctx.model?.provider;
|
|
64
|
+
let credentialSource = 'unresolved';
|
|
65
|
+
const fail = (state, reason) => ({ result: { ...unavailable(id, state, reason), credentialSource } });
|
|
66
|
+
if (!Object.hasOwn(ENDPOINTS, id ?? '')) return fail('unsupported', 'provider_not_supported');
|
|
67
|
+
const registry = ctx.modelRegistry;
|
|
68
|
+
const activeProvider = registry.getProvider(id);
|
|
69
|
+
if (!officialBase(id, ctx.model?.baseUrl) || !officialBase(id, activeProvider?.baseUrl))
|
|
70
|
+
return fail('unsupported', 'custom_endpoint');
|
|
71
|
+
if (registry.getRegisteredProviderConfig(id) || registry.getRegisteredNativeProvider(id) ||
|
|
72
|
+
Object.keys(ctx.model?.headers ?? {}).length || Object.keys(activeProvider?.headers ?? {}).length)
|
|
73
|
+
return fail('unsupported', 'custom_provider_or_headers');
|
|
74
|
+
try {
|
|
75
|
+
const configReason = authConfigOverride(readConfig(), id, ctx.model?.id);
|
|
76
|
+
if (configReason) return fail('unsupported', configReason);
|
|
77
|
+
} catch { return fail('unsupported', 'configuration_unverified'); }
|
|
78
|
+
const source = registry.getProviderAuthStatus(id)?.source;
|
|
79
|
+
if (source && !['stored', 'environment'].includes(source)) return fail('unsupported', 'auth_source_unconfirmed');
|
|
80
|
+
try {
|
|
81
|
+
signal.throwIfAborted();
|
|
82
|
+
const stored = readCredential(id);
|
|
83
|
+
const provider = providers[id];
|
|
84
|
+
let token, accountId;
|
|
85
|
+
if (id === 'deepseek') {
|
|
86
|
+
if (stored && stored.type !== 'api_key') return fail('unsupported', 'auth_type_unsupported');
|
|
87
|
+
const resolved = readOnlyKey(stored?.key, stored?.env, env);
|
|
88
|
+
if (resolved.reason) return fail('unsupported', resolved.reason);
|
|
89
|
+
credentialSource = resolved.key ? resolved.credentialSource : 'unresolved';
|
|
90
|
+
// 存储 key 无法解析时,不静默改用环境中的其他账户。
|
|
91
|
+
if (stored?.key !== undefined && !resolved.key) return fail('unauthenticated', 'credential_unresolved');
|
|
92
|
+
const auth = await provider.auth.apiKey.resolve({
|
|
93
|
+
credential: stored ? { type: 'api_key', key: resolved.key } : undefined,
|
|
94
|
+
ctx: { env: async (name) => {
|
|
95
|
+
const value = stored?.env?.[name] || env[name];
|
|
96
|
+
if (value) credentialSource = stored?.env?.[name] ? 'pi_scoped_environment' : 'environment';
|
|
97
|
+
return value;
|
|
98
|
+
}, fileExists: async () => false }, signal,
|
|
99
|
+
});
|
|
100
|
+
token = auth?.auth.apiKey;
|
|
101
|
+
} else {
|
|
102
|
+
if (!stored) return fail('unauthenticated', 'pi_oauth_missing');
|
|
103
|
+
if (stored.type !== 'oauth') return fail('unsupported', 'subscription_requires_pi_oauth');
|
|
104
|
+
credentialSource = 'pi_stored_oauth';
|
|
105
|
+
if (number(stored.expires) === null) return fail('unknown', 'credential_expiry_missing');
|
|
106
|
+
if (stored.expires <= now) return fail('expired', 'pi_oauth_expired_no_refresh');
|
|
107
|
+
token = (await provider.auth.oauth.toAuth(stored))?.apiKey;
|
|
108
|
+
if (id === 'openai-codex') {
|
|
109
|
+
const identity = codexIdentity(token ?? '');
|
|
110
|
+
accountId = identity.accountId;
|
|
111
|
+
if (!secretString(accountId)) return fail('unknown', 'account_id_missing');
|
|
112
|
+
if (identity.expires !== null && identity.expires !== undefined && identity.expires <= now)
|
|
113
|
+
return fail('expired', 'pi_oauth_expired_no_refresh');
|
|
114
|
+
if (stored.accountId && stored.accountId !== accountId) return fail('unknown', 'account_id_mismatch');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
signal.throwIfAborted();
|
|
118
|
+
if (!token) return fail('unauthenticated', 'credential_missing');
|
|
119
|
+
if (!secretString(token)) return fail('error', 'credential_invalid');
|
|
120
|
+
return { provider: id, token, accountId, credentialSource, activeAccountVerified: false, key: authFingerprint(id, token, accountId) };
|
|
121
|
+
} catch { return fail(signal.aborted ? 'unavailable' : 'error', signal.aborted ? 'cancelled' : 'credential_resolution_failed'); }
|
|
122
|
+
}
|
package/core.mjs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export const TTL_MS = 60_000;
|
|
2
|
+
// Core sorts status keys; keep common existing indicators first.
|
|
3
|
+
export const STATUS_KEY = 'zz-pi-quota-dashboard';
|
|
4
|
+
export const ENDPOINTS = Object.freeze({
|
|
5
|
+
anthropic: 'https://api.anthropic.com/api/oauth/usage',
|
|
6
|
+
'openai-codex': 'https://chatgpt.com/backend-api/wham/usage',
|
|
7
|
+
deepseek: 'https://api.deepseek.com/user/balance',
|
|
8
|
+
});
|
|
9
|
+
const BASES = {
|
|
10
|
+
anthropic: ['https://api.anthropic.com', 'https://api.anthropic.com/v1'],
|
|
11
|
+
'openai-codex': ['https://chatgpt.com/backend-api', 'https://chatgpt.com/backend-api/codex'],
|
|
12
|
+
deepseek: ['https://api.deepseek.com', 'https://api.deepseek.com/v1'],
|
|
13
|
+
};
|
|
14
|
+
export const number = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
|
|
15
|
+
const percent = (v) => number(v) !== null && v <= 100 ? v : null;
|
|
16
|
+
const object = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
17
|
+
export const safeLabel = (v) => typeof v === 'string' ? v.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, '').slice(0, 120) : null;
|
|
18
|
+
const decimal = (v) => typeof v === 'string' && v.length <= 128 && /^-?\d+(?:\.\d+)?$/.test(v) ? v : null;
|
|
19
|
+
const iso = (v) => typeof v === 'string' && /^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(v) && Number.isFinite(Date.parse(v)) ? new Date(v).toISOString() : null;
|
|
20
|
+
const secondsISO = (v) => number(v) !== null && v <= 253402300799 ? new Date(v * 1000).toISOString() : null;
|
|
21
|
+
export function officialBase(provider, baseUrl) {
|
|
22
|
+
return typeof baseUrl === 'string' && BASES[provider]?.includes(baseUrl.replace(/\/$/, '')) === true;
|
|
23
|
+
}
|
|
24
|
+
export function unavailable(provider, state, reason) {
|
|
25
|
+
return { provider: provider ?? null, kind: provider === 'deepseek' ? 'api_balance' : 'subscription_quota', credentialSource: 'unresolved', activeAccountVerified: false, state, reason, windows: [], balances: [] };
|
|
26
|
+
}
|
|
27
|
+
function window(id, raw, durationSeconds, isCodex = false, now = Date.now()) {
|
|
28
|
+
const usedPercent = percent(isCodex ? raw?.used_percent : raw?.utilization);
|
|
29
|
+
const resetAt = isCodex
|
|
30
|
+
? secondsISO(raw?.reset_at) ?? (number(raw?.reset_after_seconds) !== null ? secondsISO(now / 1000 + raw.reset_after_seconds) : null)
|
|
31
|
+
: iso(raw?.resets_at);
|
|
32
|
+
return {
|
|
33
|
+
id, state: !object(raw) ? 'unavailable' : usedPercent === null ? 'unknown' : 'ok',
|
|
34
|
+
durationSeconds, usedPercent, remainingPercent: usedPercent === null ? null : 100 - usedPercent, resetAt,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function parseQuota(provider, raw, now = Date.now()) {
|
|
38
|
+
if (!object(raw)) return unavailable(provider, 'error', 'invalid_response');
|
|
39
|
+
const result = unavailable(provider, 'unknown', 'fields_missing');
|
|
40
|
+
if (provider === 'anthropic') {
|
|
41
|
+
for (const id of ['five_hour', 'seven_day', 'seven_day_sonnet', 'seven_day_opus']) {
|
|
42
|
+
if (id === 'five_hour' || id === 'seven_day' || Object.hasOwn(raw, id))
|
|
43
|
+
result.windows.push(window(id, raw[id], id === 'five_hour' ? 18000 : 604800));
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(raw.limits)) for (const item of raw.limits.slice(0, 30)) {
|
|
46
|
+
if (item?.kind === 'weekly_scoped') {
|
|
47
|
+
const scope = safeLabel(item.scope?.model?.display_name);
|
|
48
|
+
result.windows.push({ ...window('weekly_scoped', { utilization: item.percent, resets_at: item.resets_at }, 604800), scope });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} else if (provider === 'openai-codex') {
|
|
52
|
+
// 账户实际返回哪些窗口就是哪些:Pro 只有一个 7d 的 primary_window,
|
|
53
|
+
// 没有 5h secondary,也没有 code review 桶。缺席的窗口不凭空造成 unavailable。
|
|
54
|
+
for (const group of ['rate_limit', 'code_review_rate_limit']) {
|
|
55
|
+
if (!object(raw[group])) continue;
|
|
56
|
+
for (const id of ['primary_window', 'secondary_window']) {
|
|
57
|
+
if (!Object.hasOwn(raw[group], id)) continue;
|
|
58
|
+
const w = raw[group][id];
|
|
59
|
+
result.windows.push(window(`${group}.${id}`, w, number(w?.limit_window_seconds), true, now));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} else if (provider === 'deepseek') {
|
|
63
|
+
result.isAvailable = typeof raw.is_available === 'boolean' ? raw.is_available : null;
|
|
64
|
+
if (Array.isArray(raw.balance_infos)) result.balances = raw.balance_infos.slice(0, 30).map((b) => ({
|
|
65
|
+
currency: typeof b?.currency === 'string' && /^[A-Z]{3}$/.test(b.currency) ? b.currency : null,
|
|
66
|
+
total: decimal(b?.total_balance), granted: decimal(b?.granted_balance), toppedUp: decimal(b?.topped_up_balance),
|
|
67
|
+
}));
|
|
68
|
+
if (result.balances.some((b) => b.currency !== null && b.total !== null)) {
|
|
69
|
+
result.state = result.isAvailable === false ? 'unavailable' : 'ok';
|
|
70
|
+
result.reason = result.isAvailable === false ? 'balance_insufficient' : null;
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
} else return unavailable(provider, 'unsupported', 'provider_not_supported');
|
|
74
|
+
if (result.windows.some((w) => w.state === 'ok')) {
|
|
75
|
+
result.state = 'ok';
|
|
76
|
+
result.reason = null;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Pi initializes missing provider usage/prices to zero. Keep the reported values,
|
|
82
|
+
// but never call an all-zero response or an unpriced request a measured free call.
|
|
83
|
+
export function sessionUsage(entries) {
|
|
84
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
|
|
85
|
+
let count = 0, cost = 0, tokensKnown = true, costKnown = true;
|
|
86
|
+
const missing = new Set();
|
|
87
|
+
for (const e of entries) {
|
|
88
|
+
const assistant = e.type === 'message' && e.message?.role === 'assistant';
|
|
89
|
+
const usage = e.type === 'message' && ['assistant', 'toolResult'].includes(e.message?.role) ? e.message?.usage : ['compaction', 'branch_summary'].includes(e.type) ? e.usage : undefined;
|
|
90
|
+
if (!assistant && usage === undefined) continue;
|
|
91
|
+
count++;
|
|
92
|
+
let reported = 0;
|
|
93
|
+
for (const key of Object.keys(totals)) {
|
|
94
|
+
const value = number(usage?.[key]);
|
|
95
|
+
if (value === null) { tokensKnown = false; missing.add(key); }
|
|
96
|
+
else {
|
|
97
|
+
totals[key] += value;
|
|
98
|
+
if (key !== 'totalTokens') reported += value;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// 分项全为 0 是一次空响应(中断、切换模型),零 token 零成本自洽,不是缺数据。
|
|
102
|
+
// 只有计费过的响应报出 0 才可疑:那可能是 pi 的零价,不能当成实测的免费调用。
|
|
103
|
+
const empty = reported === 0;
|
|
104
|
+
if (assistant && !empty && !(number(usage?.totalTokens) > 0)) tokensKnown = false;
|
|
105
|
+
const value = number(usage?.cost?.total);
|
|
106
|
+
if (value === null || (value === 0 && !empty)) costKnown = false;
|
|
107
|
+
else cost += value;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
scope: 'all_entries_in_current_session', responsesWithUsage: count,
|
|
111
|
+
tokens: { state: tokensKnown ? (count ? 'reported' : 'known') : 'unknown', ...Object.fromEntries(Object.entries(totals).map(([key, value]) => [key, missing.has(key) ? null : value])), totalTokens: tokensKnown ? totals.totalTokens : null, reportedSubtotal: totals.totalTokens },
|
|
112
|
+
cost: { state: costKnown ? (count ? 'estimated' : 'known') : 'unknown', amount: costKnown ? cost : null, estimatedSubtotal: cost, currency: 'USD', source: 'pi_usage_cost_not_invoice' },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function snapshot(ctx, quota) {
|
|
116
|
+
const usage = ctx.getContextUsage();
|
|
117
|
+
const tokens = number(usage?.tokens);
|
|
118
|
+
const entries = ctx.sessionManager.getEntries();
|
|
119
|
+
const last = entries.findLast((e) => e.type === 'message' && e.message?.role === 'assistant')?.message;
|
|
120
|
+
return {
|
|
121
|
+
lastResponse: last ? { provider: safeLabel(last.provider), requestedModel: safeLabel(last.model), returnedModel: safeLabel(last.responseModel) } : null,
|
|
122
|
+
model: { provider: safeLabel(ctx.model?.provider), id: safeLabel(ctx.model?.id), name: safeLabel(ctx.model?.name), thinking: safeLabel(ctx.thinkingLevel) },
|
|
123
|
+
context: { state: tokens === null ? 'unknown' : 'estimated', tokens, windowTokens: number(usage?.contextWindow ?? ctx.model?.contextWindow), usedPercent: tokens === null ? null : number(usage?.percent), source: 'pi_getContextUsage' },
|
|
124
|
+
session: sessionUsage(entries),
|
|
125
|
+
quota: { ...quota, credentialSource: quota.credentialSource ?? 'unresolved', activeAccountVerified: false },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// pi-tui 的 visibleWidth/truncateToWidth 按可见宽度计算,SGR 序列不占列也不会被截断切坏。
|
|
129
|
+
const RESET = '\x1b[0m';
|
|
130
|
+
const DIM = '\x1b[2m';
|
|
131
|
+
const HEAT = ['\x1b[32m', '\x1b[33m', '\x1b[31m'];
|
|
132
|
+
const paint = (code, text) => `${code}${text}${RESET}`;
|
|
133
|
+
const dim = (text) => paint(DIM, text);
|
|
134
|
+
// 分档看的是剩余量:还剩一半以上宽裕,两成以下告急。
|
|
135
|
+
const level = (remainingPercent) => remainingPercent > 50 ? 0 : remainingPercent > 20 ? 1 : 2;
|
|
136
|
+
const CURRENCY = { USD: '$', CNY: '¥', EUR: '€', GBP: '£', JPY: '¥' };
|
|
137
|
+
const STATE_LABEL = {
|
|
138
|
+
unknown: '?', unavailable: 'n/a', unauthenticated: 'auth',
|
|
139
|
+
expired: 'expired', forbidden: '403', rate_limited: '429', error: 'error',
|
|
140
|
+
};
|
|
141
|
+
// 确定出了问题的状态标红;只是取不到数的保持 dim,不虚张声势。
|
|
142
|
+
const PROBLEM = new Set(['unauthenticated', 'expired', 'forbidden', 'rate_limited', 'error']);
|
|
143
|
+
const shortDuration = (seconds) => seconds === null ? null
|
|
144
|
+
: seconds % 86400 === 0 ? `${seconds / 86400}d`
|
|
145
|
+
: seconds % 3600 === 0 ? `${seconds / 3600}h`
|
|
146
|
+
: seconds % 60 === 0 ? `${seconds / 60}m` : `${seconds}s`;
|
|
147
|
+
// 只保留两个最大单位:底栏宽度有限,宁可少写一位也不撑长这一行。
|
|
148
|
+
function countdown(resetAt, now) {
|
|
149
|
+
const ms = Date.parse(resetAt ?? '') - now;
|
|
150
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
151
|
+
const minutes = Math.floor(ms / 60_000), hours = Math.floor(minutes / 60), days = Math.floor(hours / 24);
|
|
152
|
+
return days ? `${days}d${hours % 24}h` : hours ? `${hours}h${minutes % 60}m` : `${minutes}m`;
|
|
153
|
+
}
|
|
154
|
+
// 窗口时长本身就是最短的标签;模型专属周窗和 code review 桶各自补一个限定词区分。
|
|
155
|
+
function windowLabel(w) {
|
|
156
|
+
if (w.scope) return w.scope;
|
|
157
|
+
const model = /^seven_day_(.+)$/.exec(w.id)?.[1];
|
|
158
|
+
const fallback = w.id.includes('secondary') ? '2nd' : w.id.includes('primary') ? '1st' : w.id;
|
|
159
|
+
return [
|
|
160
|
+
w.id.startsWith('code_review_') ? 'CR' : null,
|
|
161
|
+
shortDuration(w.durationSeconds) ?? fallback,
|
|
162
|
+
model ? model[0].toUpperCase() + model.slice(1) : null,
|
|
163
|
+
].filter(Boolean).join(' ');
|
|
164
|
+
}
|
|
165
|
+
// 服务返回的十进制字符串按位截断,不做浮点换算,也不四舍五入抬高余额。
|
|
166
|
+
// 截断掉的非零位补 `+`,免得极小的正余额在底栏显示成 0.00。
|
|
167
|
+
const shortAmount = (value) => {
|
|
168
|
+
const [int, frac] = value.split('.');
|
|
169
|
+
if (frac === undefined) return int;
|
|
170
|
+
return `${int}.${frac.slice(0, 2)}${/[1-9]/.test(frac.slice(2)) ? '+' : ''}`;
|
|
171
|
+
};
|
|
172
|
+
export function statusText(s, now = Date.now()) {
|
|
173
|
+
const q = s.quota;
|
|
174
|
+
// 模型、思考级别、上下文、会话 tokens 和费用由 pi 自带 footer 显示,这里只补它没有的额度。
|
|
175
|
+
// 底栏不写插件名、凭据来源、账户核验提示和厂商;这些字段完整保留在 /dashboard 的快照里。
|
|
176
|
+
if (q.state === 'unsupported') return '';
|
|
177
|
+
if (q.state === 'loading') return dim('…');
|
|
178
|
+
// 数据过期未刷新时百分比不着色并缀 ~:色阶只在当次刷新过的数据上才代表真实水位。
|
|
179
|
+
const stale = q.state === 'stale';
|
|
180
|
+
const parts = [];
|
|
181
|
+
// 服务直接说余额不足时以它为准,不靠数字自行判断。
|
|
182
|
+
const insufficient = q.isAvailable === false;
|
|
183
|
+
if (q.kind === 'api_balance') for (const b of q.balances) {
|
|
184
|
+
// 余额不像配额那样会在会话中途用光,平时不占位;见底了才提示,完整数字在 /dashboard。
|
|
185
|
+
// 色阶与判空都按底栏实际显示的位数,所见即所得。
|
|
186
|
+
if (b.total === null) continue;
|
|
187
|
+
const short = shortAmount(b.total);
|
|
188
|
+
if (!insufficient && !/^-|^0+(?:\.0*)?$/.test(short)) continue;
|
|
189
|
+
const symbol = b.currency === null ? '' : CURRENCY[b.currency] ?? '';
|
|
190
|
+
const suffix = symbol === '' && b.currency !== null ? ` ${b.currency}` : '';
|
|
191
|
+
parts.push(paint(stale ? DIM : HEAT[2], `${symbol}${short}${suffix}`));
|
|
192
|
+
} else {
|
|
193
|
+
// 显示的是剩余额度,不是已用:关心的是还能用多少。
|
|
194
|
+
const shown = q.windows.filter((w) => w.remainingPercent !== null);
|
|
195
|
+
// 只给最短的窗口带重置倒计时:它最先重置,也最常撞上。
|
|
196
|
+
const shortest = Math.min(...shown.map((w) => w.durationSeconds ?? Infinity));
|
|
197
|
+
for (const w of shown) {
|
|
198
|
+
const rank = level(w.remainingPercent);
|
|
199
|
+
const left = w.durationSeconds === shortest ? countdown(w.resetAt, now) : null;
|
|
200
|
+
parts.push(`${dim(windowLabel(w))} ${paint(stale ? DIM : HEAT[rank], `${Math.round(w.remainingPercent)}%`)}${left ? dim(` (${left})`) : ''}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// 窗口之间用 · 分隔,与 footer 段落间的 │ 区分层级。
|
|
204
|
+
if (parts.length) return parts.join(dim(' · ')) + (stale ? dim(' ~') : '');
|
|
205
|
+
// 查到了数据但没有需要提示的(余额充足、窗口全无数据):不占位,也不报警。
|
|
206
|
+
if (q.state === 'ok' || q.state === 'stale') return '';
|
|
207
|
+
return paint(PROBLEM.has(q.state) ? HEAT[2] : DIM, STATE_LABEL[q.state] ?? q.state);
|
|
208
|
+
}
|
package/docs/footer.png
ADDED
|
Binary file
|
package/footer.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { visibleWidth, truncateToWidth } from '@earendil-works/pi-tui';
|
|
2
|
+
import { sessionUsage, STATUS_KEY } from './core.mjs';
|
|
3
|
+
import { isSubscription } from './auth.mjs';
|
|
4
|
+
|
|
5
|
+
// 口径对齐 pi core 的 footer(dist/modes/interactive/components/footer.js)。
|
|
6
|
+
// 接管 footer 只为把扩展状态并进统计行;保留的显示尽量与内置一致,pi 升级时由
|
|
7
|
+
// 集成测试的版本断言提示复核。两处有意不同:拿不到 autoCompactionEnabled,
|
|
8
|
+
// 不显示 (auto);token 明细(↑↓RW/CH)已由费用和上下文段覆盖,不再复刻。
|
|
9
|
+
export const formatTokens = (count) =>
|
|
10
|
+
count < 1000 ? `${count}`
|
|
11
|
+
: count < 10_000 ? `${(count / 1000).toFixed(1)}k`
|
|
12
|
+
: count < 1_000_000 ? `${Math.round(count / 1000)}k`
|
|
13
|
+
: count < 10_000_000 ? `${(count / 1_000_000).toFixed(1)}M`
|
|
14
|
+
: `${Math.round(count / 1_000_000)}M`;
|
|
15
|
+
|
|
16
|
+
const sanitize = (text) => text.replace(/[\r\n\t]/g, ' ').replace(/ +/g, ' ').trim();
|
|
17
|
+
const MODEL_ICON = '🤖';
|
|
18
|
+
const BAR_WIDTH = 10;
|
|
19
|
+
const BAR_FILLED = '█';
|
|
20
|
+
const BAR_EMPTY = '░';
|
|
21
|
+
// 用 pi 自己的 thinking 分级色(灰→蓝→紫→品红),强度一眼可辨且与 pi 其余界面一致。
|
|
22
|
+
// thinkingMax 在主题里是可选色,退回 Xhigh 以免主题没定义时取不到。
|
|
23
|
+
const THINKING_COLOR = {
|
|
24
|
+
off: 'thinkingOff', minimal: 'thinkingMinimal', low: 'thinkingLow', medium: 'thinkingMedium',
|
|
25
|
+
high: 'thinkingHigh', xhigh: 'thinkingXhigh', max: 'thinkingMax',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class MergedFooter {
|
|
29
|
+
constructor(theme, footerData, getContext, readCredential) {
|
|
30
|
+
Object.assign(this, { theme, footerData, getContext, readCredential });
|
|
31
|
+
}
|
|
32
|
+
// 进度条 + 百分比 + 计数,阈值沿用 pi 的 70/90。条身与数字同色,空槽走边框色。
|
|
33
|
+
contextSegment(ctx, withCounts = true) {
|
|
34
|
+
const usage = ctx.getContextUsage();
|
|
35
|
+
const windowTokens = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
36
|
+
const percent = usage?.percent ?? null;
|
|
37
|
+
const tokens = usage?.tokens ?? null;
|
|
38
|
+
const level = percent === null ? 'muted' : percent > 90 ? 'error' : percent > 70 ? 'warning' : 'success';
|
|
39
|
+
const filled = percent === null ? 0 : Math.round(Math.min(100, Math.max(0, percent)) / 100 * BAR_WIDTH);
|
|
40
|
+
const bar = (filled ? this.theme.fg(level, BAR_FILLED.repeat(filled)) : '')
|
|
41
|
+
+ (filled < BAR_WIDTH ? this.theme.fg('borderMuted', BAR_EMPTY.repeat(BAR_WIDTH - filled)) : '');
|
|
42
|
+
const pct = this.theme.fg(level, percent === null ? '?' : `${percent.toFixed(1)}%`);
|
|
43
|
+
if (!withCounts) return `${bar} ${pct}`;
|
|
44
|
+
const counts = `${tokens === null ? '?' : formatTokens(tokens)}/${formatTokens(windowTokens)}`;
|
|
45
|
+
return `${bar} ${pct} ${this.theme.fg('dim', counts)}`;
|
|
46
|
+
}
|
|
47
|
+
// 思考强度在前、模型名在后;不显示厂商,模型 id 本身已经说明是哪一家。
|
|
48
|
+
modelSegment(ctx) {
|
|
49
|
+
const name = this.theme.fg('dim', ctx.model?.id ?? 'no-model');
|
|
50
|
+
if (!ctx.model?.reasoning) return name;
|
|
51
|
+
const level = ctx.thinkingLevel || 'off';
|
|
52
|
+
const color = THINKING_COLOR[level] ?? 'thinkingXhigh';
|
|
53
|
+
return `${this.theme.fg(color, level)}${this.theme.fg('borderMuted', ' · ')}${name}`;
|
|
54
|
+
}
|
|
55
|
+
render(width) {
|
|
56
|
+
const ctx = this.getContext();
|
|
57
|
+
if (!ctx || width <= 0) return [];
|
|
58
|
+
const dim = (text) => this.theme.fg('dim', text);
|
|
59
|
+
const usage = sessionUsage(ctx.sessionManager.getEntries());
|
|
60
|
+
|
|
61
|
+
// 工作目录里唯一会变、值得盯的是分支;目录名自己知道,不占列。
|
|
62
|
+
// 不在 git 仓库里就整段消失。
|
|
63
|
+
const branch = this.footerData.getGitBranch();
|
|
64
|
+
const subscription = isSubscription(ctx, this.readCredential);
|
|
65
|
+
const cost = usage.cost.amount;
|
|
66
|
+
// 费用未知时显示 $?,不拿 0 冒充已知的零花费。
|
|
67
|
+
const costText = cost || subscription || usage.cost.state === 'unknown'
|
|
68
|
+
? `$${cost === null ? '?' : cost.toFixed(3)}${subscription ? ' (sub)' : ''}` : null;
|
|
69
|
+
|
|
70
|
+
// 只取本扩展自己的状态。pi 原本把所有扩展的 setStatus 内容排在第三行,
|
|
71
|
+
// 这一行取代了它,因此其他扩展的状态不会出现在这里 —— 是有意的取舍,
|
|
72
|
+
// 代价是同时装了别的状态类扩展时,它们没有显示位置。
|
|
73
|
+
const quota = sanitize(this.footerData.getExtensionStatuses().get(STATUS_KEY) ?? '');
|
|
74
|
+
// 放不下就逐级降级:先丢上下文的绝对计数(进度条和百分比已经说明了同一件事),
|
|
75
|
+
// 再丢分支。额度是这一行的主角,任何一级都不丢。
|
|
76
|
+
// 段落靠 │ 分隔,只有模型段带图标。
|
|
77
|
+
const separator = this.theme.fg('borderMuted', ' │ ');
|
|
78
|
+
const compose = (level) => {
|
|
79
|
+
const groups = [`${MODEL_ICON} ${this.modelSegment(ctx)}`];
|
|
80
|
+
if (level < 2 && branch) groups.push(dim(branch));
|
|
81
|
+
groups.push(this.contextSegment(ctx, level < 1));
|
|
82
|
+
if (costText) groups.push(this.theme.fg('warning', costText));
|
|
83
|
+
if (quota) groups.push(quota);
|
|
84
|
+
return groups.join(separator);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// 单行、左对齐:pi 内置 footer 的三行全部并进这一行,模型不再右对齐。
|
|
88
|
+
let line = compose(0);
|
|
89
|
+
for (let level = 1; level <= 2 && visibleWidth(line) > width; level++) line = compose(level);
|
|
90
|
+
return [truncateToWidth(line, width, dim('…'))];
|
|
91
|
+
}
|
|
92
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { getAgentDir, readStoredCredential } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readModelConfig } from './auth.mjs';
|
|
5
|
+
import { builtinProviders } from '@earendil-works/pi-ai/providers/all';
|
|
6
|
+
import { Dashboard, registerDashboard } from './runtime.mjs';
|
|
7
|
+
import { MergedFooter } from './footer.mjs';
|
|
8
|
+
|
|
9
|
+
export default function (pi: ExtensionAPI) {
|
|
10
|
+
registerDashboard(pi, new Dashboard({
|
|
11
|
+
readCredential: readStoredCredential,
|
|
12
|
+
readConfig: () => readModelConfig(join(getAgentDir(), 'models.json')),
|
|
13
|
+
providers: Object.fromEntries(builtinProviders()
|
|
14
|
+
.filter((provider) => ['anthropic', 'openai-codex', 'deepseek'].includes(provider.id))
|
|
15
|
+
.map((provider) => [provider.id, provider])),
|
|
16
|
+
}, { createFooter: (...args) => new MergedFooter(...args) }));
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-quota-dashboard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Read-only provider quota, balance and session dashboard for the pi coding agent — Claude, OpenAI Codex and DeepSeek in one footer line",
|
|
5
|
+
"keywords": ["pi-package", "pi-extension", "pi", "quota", "usage", "statusline", "footer", "anthropic", "openai-codex", "deepseek"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "mufeiyu-ayu",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/mufeiyu-ayu/pi-quota-dashboard.git" },
|
|
9
|
+
"bugs": { "url": "https://github.com/mufeiyu-ayu/pi-quota-dashboard/issues" },
|
|
10
|
+
"homepage": "https://github.com/mufeiyu-ayu/pi-quota-dashboard#readme",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": ["index.ts", "*.mjs", "docs", "THIRD_PARTY_NOTICES.md"],
|
|
13
|
+
"engines": { "node": ">=22.19.0" },
|
|
14
|
+
"pi": { "extensions": ["./index.ts"] },
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
17
|
+
"@earendil-works/pi-ai": "*"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"smoke": "node scripts/smoke.mjs"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/quota.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ENDPOINTS, TTL_MS, parseQuota, unavailable } from './core.mjs';
|
|
2
|
+
|
|
3
|
+
export function retryAfterMs(value, now) {
|
|
4
|
+
if (typeof value !== 'string') return 0;
|
|
5
|
+
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : null;
|
|
6
|
+
const ms = seconds === null ? Date.parse(value) - now : seconds * 1000;
|
|
7
|
+
return Number.isFinite(ms) ? Math.max(0, Math.min(ms, 86400_000)) : 0;
|
|
8
|
+
}
|
|
9
|
+
// A fixed endpoint per adapter; no caller URL and no redirect-following path.
|
|
10
|
+
export async function requestQuota(auth, signal, fetcher = fetch, now = Date.now) {
|
|
11
|
+
const url = ENDPOINTS[auth.provider];
|
|
12
|
+
if (!Object.hasOwn(ENDPOINTS, auth.provider)) return unavailable(auth.provider, 'unsupported', 'provider_not_supported');
|
|
13
|
+
const headers = { Accept: 'application/json', Authorization: `Bearer ${auth.token}` };
|
|
14
|
+
if (auth.provider === 'anthropic') headers['anthropic-beta'] = 'oauth-2025-04-20';
|
|
15
|
+
if (auth.provider === 'openai-codex') headers['ChatGPT-Account-Id'] = auth.accountId;
|
|
16
|
+
const response = await fetcher(url, { method: 'GET', headers, redirect: 'manual', signal });
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
await response.body?.cancel().catch(() => {});
|
|
19
|
+
const state = response.status === 401 ? 'unauthenticated' : response.status === 403 ? 'forbidden' : response.status === 429 ? 'rate_limited' : 'error';
|
|
20
|
+
return { ...unavailable(auth.provider, state, response.status >= 300 && response.status < 400 ? 'redirect_blocked' : 'http_error'), httpStatus: response.status, retryAfterMs: retryAfterMs(response.headers.get('retry-after'), now()) };
|
|
21
|
+
}
|
|
22
|
+
// Bound untrusted responses; timeout stays active through body consumption.
|
|
23
|
+
if (!response.body) return unavailable(auth.provider, 'error', 'invalid_response');
|
|
24
|
+
const reader = response.body.getReader();
|
|
25
|
+
const cancelBody = () => { void reader.cancel().catch(() => {}); };
|
|
26
|
+
signal.addEventListener('abort', cancelBody, { once: true });
|
|
27
|
+
if (signal.aborted) cancelBody();
|
|
28
|
+
const chunks = [];
|
|
29
|
+
let size = 0;
|
|
30
|
+
try {
|
|
31
|
+
for (;;) {
|
|
32
|
+
const { value, done } = await reader.read();
|
|
33
|
+
if (done) break;
|
|
34
|
+
size += value.byteLength;
|
|
35
|
+
if (size > 256 * 1024) {
|
|
36
|
+
await reader.cancel();
|
|
37
|
+
return unavailable(auth.provider, 'error', 'response_too_large');
|
|
38
|
+
}
|
|
39
|
+
chunks.push(value);
|
|
40
|
+
}
|
|
41
|
+
return { ...parseQuota(auth.provider, JSON.parse(Buffer.concat(chunks).toString('utf8')), now()), httpStatus: response.status };
|
|
42
|
+
} catch {
|
|
43
|
+
return unavailable(auth.provider, 'error', signal.aborted ? 'timeout_or_cancelled' : 'invalid_response');
|
|
44
|
+
} finally {
|
|
45
|
+
signal.removeEventListener('abort', cancelBody);
|
|
46
|
+
reader.releaseLock();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class QuotaCache {
|
|
51
|
+
constructor({ fetcher = fetch, now = Date.now, timeoutMs = 8000, ttlMs = TTL_MS } = {}) {
|
|
52
|
+
Object.assign(this, { fetcher, now, timeoutMs, ttlMs });
|
|
53
|
+
this.entries = new Map();
|
|
54
|
+
this.pending = new Map();
|
|
55
|
+
this.epoch = 0;
|
|
56
|
+
}
|
|
57
|
+
view(key) {
|
|
58
|
+
const e = this.entries.get(key);
|
|
59
|
+
if (!e) return undefined;
|
|
60
|
+
const stale = e.result.fetchedAt !== undefined && this.now() - e.result.fetchedAt >= this.ttlMs;
|
|
61
|
+
return stale ? { ...e.result, state: 'stale' } : e.result;
|
|
62
|
+
}
|
|
63
|
+
async get(auth, force = false) {
|
|
64
|
+
const existing = this.pending.get(auth.key);
|
|
65
|
+
if (existing) return { ...await existing.promise, credentialSource: auth.credentialSource ?? 'unresolved', activeAccountVerified: false };
|
|
66
|
+
const e = this.entries.get(auth.key);
|
|
67
|
+
if (e && (this.now() < e.retryAt || (!force && this.now() < e.expiresAt)))
|
|
68
|
+
return { ...this.view(auth.key), credentialSource: auth.credentialSource ?? 'unresolved', activeAccountVerified: false };
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const epoch = this.epoch;
|
|
71
|
+
let timer;
|
|
72
|
+
const aborted = new Promise((resolve) => {
|
|
73
|
+
controller.signal.addEventListener('abort', () => resolve(unavailable(auth.provider, 'error', 'timeout_or_cancelled')), { once: true });
|
|
74
|
+
timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
75
|
+
});
|
|
76
|
+
const promise = (async () => {
|
|
77
|
+
let result = await Promise.race([
|
|
78
|
+
requestQuota(auth, controller.signal, this.fetcher, this.now).catch(() => unavailable(auth.provider, 'error', 'network_error')),
|
|
79
|
+
aborted,
|
|
80
|
+
]);
|
|
81
|
+
const now = this.now();
|
|
82
|
+
const failed = !['ok', 'unknown', 'unavailable'].includes(result.state);
|
|
83
|
+
const failures = failed ? (e?.failures ?? 0) + 1 : 0;
|
|
84
|
+
const backoff = failed ? Math.max(result.retryAfterMs ?? 0, Math.min(300_000, this.ttlMs * 2 ** Math.min(failures - 1, 4))) : 0;
|
|
85
|
+
// 仅复用同一存储/环境凭据指纹的数据,不把失败刷新标成新数据。
|
|
86
|
+
if (!failed && result.httpStatus === 200) result = { ...result, fetchedAt: now };
|
|
87
|
+
else if (e?.result.fetchedAt !== undefined) result = {
|
|
88
|
+
...e.result, state: 'stale', reason: result.reason, lastError: result.state, httpStatus: result.httpStatus ?? null,
|
|
89
|
+
};
|
|
90
|
+
result = { ...result, credentialSource: auth.credentialSource ?? 'unresolved', activeAccountVerified: false, checkedAt: now, retryAt: backoff ? now + backoff : null };
|
|
91
|
+
if (epoch === this.epoch) {
|
|
92
|
+
if (this.entries.size >= 16 && !this.entries.has(auth.key)) this.entries.delete(this.entries.keys().next().value);
|
|
93
|
+
this.entries.set(auth.key, { result, failures, retryAt: now + backoff, expiresAt: now + this.ttlMs });
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
})().finally(() => {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
if (this.pending.get(auth.key)?.promise === promise) this.pending.delete(auth.key);
|
|
99
|
+
});
|
|
100
|
+
this.pending.set(auth.key, { promise, controller });
|
|
101
|
+
return promise;
|
|
102
|
+
}
|
|
103
|
+
cancelPending() {
|
|
104
|
+
this.epoch++;
|
|
105
|
+
for (const p of this.pending.values()) p.controller.abort();
|
|
106
|
+
this.pending.clear();
|
|
107
|
+
}
|
|
108
|
+
clear() {
|
|
109
|
+
this.cancelPending();
|
|
110
|
+
this.entries.clear();
|
|
111
|
+
}
|
|
112
|
+
}
|
package/runtime.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { TTL_MS, STATUS_KEY, snapshot, statusText, unavailable } from './core.mjs';
|
|
2
|
+
import { resolveCredential } from './auth.mjs';
|
|
3
|
+
import { QuotaCache } from './quota.mjs';
|
|
4
|
+
|
|
5
|
+
export { STATUS_KEY };
|
|
6
|
+
const modelIdentity = (ctx) => {
|
|
7
|
+
const model = ctx.model;
|
|
8
|
+
return JSON.stringify([model?.provider, model?.id, model?.baseUrl]);
|
|
9
|
+
};
|
|
10
|
+
export class Dashboard {
|
|
11
|
+
constructor(authDeps, { cache = new QuotaCache(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, now = Date.now, createFooter } = {}) {
|
|
12
|
+
Object.assign(this, { authDeps, cache, setIntervalFn, clearIntervalFn, now, createFooter });
|
|
13
|
+
this.generation = 0;
|
|
14
|
+
this.refreshId = 0;
|
|
15
|
+
this.quota = unavailable(null, 'unavailable', 'session_not_started');
|
|
16
|
+
this.authControllers = new Set();
|
|
17
|
+
}
|
|
18
|
+
start(ctx) {
|
|
19
|
+
this.stop();
|
|
20
|
+
this.ctx = ctx;
|
|
21
|
+
this.quota = unavailable(ctx.model?.provider, 'loading', null);
|
|
22
|
+
this.local(ctx);
|
|
23
|
+
if (ctx.hasUI) {
|
|
24
|
+
// 接管 footer 只为把扩展状态行并进统计行;状态仍照常经 setStatus 发布,
|
|
25
|
+
// 这样别的扩展接管 footer 时本插件依然显示。footer 由接线层注入,
|
|
26
|
+
// 使本模块不依赖 pi-tui。
|
|
27
|
+
if (this.createFooter && ctx.ui.setFooter) {
|
|
28
|
+
ctx.ui.setFooter((_tui, theme, footerData) =>
|
|
29
|
+
this.createFooter(theme, footerData, () => this.ctx, this.authDeps.readCredential));
|
|
30
|
+
this.footerOwned = true;
|
|
31
|
+
}
|
|
32
|
+
this.timer = this.setIntervalFn(() => { void this.refresh(); }, TTL_MS);
|
|
33
|
+
this.timer?.unref?.();
|
|
34
|
+
void this.refresh();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
select(ctx) {
|
|
38
|
+
this.local(ctx);
|
|
39
|
+
if (ctx.hasUI) void this.refresh();
|
|
40
|
+
}
|
|
41
|
+
local(ctx = this.ctx) {
|
|
42
|
+
if (!this.ctx || !ctx) return;
|
|
43
|
+
this.ctx = ctx;
|
|
44
|
+
const identity = modelIdentity(ctx);
|
|
45
|
+
// ctx.model 是实时 getter,thinking 事件可能先于 model_select;每个发布入口独立检查。
|
|
46
|
+
if (identity !== this.modelIdentity) {
|
|
47
|
+
this.modelIdentity = identity;
|
|
48
|
+
this.generation++;
|
|
49
|
+
this.refreshId++;
|
|
50
|
+
this.cache.cancelPending();
|
|
51
|
+
for (const controller of this.authControllers) controller.abort();
|
|
52
|
+
this.key = undefined;
|
|
53
|
+
this.quota = unavailable(ctx.model?.provider, 'loading', 'model_changed');
|
|
54
|
+
}
|
|
55
|
+
// 仅发布已重读核对存储/环境凭据的数据,不表示验证了活动请求账户。
|
|
56
|
+
// cache 内的新响应可能仍在等待这次重读,不能直接取来显示。
|
|
57
|
+
if (this.quota.fetchedAt !== undefined && this.now() - this.quota.fetchedAt >= TTL_MS)
|
|
58
|
+
this.quota = { ...this.quota, state: 'stale' };
|
|
59
|
+
const s = snapshot(ctx, this.quota);
|
|
60
|
+
// 当前 provider 查不到额度时整条撤掉,不在底栏留一个空位。
|
|
61
|
+
if (ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, statusText(s) || undefined);
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
async refresh(force = false) {
|
|
65
|
+
const ctx = this.ctx;
|
|
66
|
+
if (!ctx) return;
|
|
67
|
+
this.local(ctx);
|
|
68
|
+
const identity = this.modelIdentity;
|
|
69
|
+
const generation = this.generation;
|
|
70
|
+
const refreshId = ++this.refreshId;
|
|
71
|
+
const current = () => {
|
|
72
|
+
if (!this.ctx) return false;
|
|
73
|
+
// 即便没有任何事件先到,也不能让实时 getter 的新模型接收旧请求结果。
|
|
74
|
+
if (modelIdentity(this.ctx) !== identity) { this.local(); return false; }
|
|
75
|
+
return generation === this.generation && refreshId === this.refreshId;
|
|
76
|
+
};
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
this.authControllers.add(controller);
|
|
79
|
+
// Bound each auth derivation, separately from the HTTP deadline.
|
|
80
|
+
const resolve = async () => {
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), 8000);
|
|
82
|
+
let onAbort;
|
|
83
|
+
try {
|
|
84
|
+
return await Promise.race([
|
|
85
|
+
resolveCredential(ctx, this.authDeps, controller.signal, this.now()),
|
|
86
|
+
new Promise((done) => {
|
|
87
|
+
onAbort = () => done({ result: unavailable(ctx.model?.provider, 'error', 'auth_timeout_or_cancelled') });
|
|
88
|
+
if (controller.signal.aborted) onAbort();
|
|
89
|
+
else controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
try {
|
|
98
|
+
const auth = await resolve();
|
|
99
|
+
if (!current()) return;
|
|
100
|
+
if (auth.result) {
|
|
101
|
+
this.key = undefined;
|
|
102
|
+
this.cache.cancelPending();
|
|
103
|
+
this.quota = auth.result;
|
|
104
|
+
return this.local();
|
|
105
|
+
}
|
|
106
|
+
if (this.key !== auth.key) {
|
|
107
|
+
this.key = auth.key;
|
|
108
|
+
this.quota = { ...unavailable(auth.provider, 'loading', null), credentialSource: auth.credentialSource };
|
|
109
|
+
}
|
|
110
|
+
this.local();
|
|
111
|
+
const result = await this.cache.get(auth, force);
|
|
112
|
+
if (!current()) return;
|
|
113
|
+
// 请求期间存储/环境凭据可能改变;重读只核对该来源,不执行或推断请求 header hook。
|
|
114
|
+
const confirmed = await resolve();
|
|
115
|
+
if (!current()) return;
|
|
116
|
+
if (confirmed.result || confirmed.key !== auth.key || confirmed.credentialSource !== auth.credentialSource) {
|
|
117
|
+
this.key = undefined;
|
|
118
|
+
this.cache.cancelPending();
|
|
119
|
+
this.quota = confirmed.result ?? unavailable(auth.provider, 'unavailable', 'account_changed_refresh_required');
|
|
120
|
+
} else this.quota = result;
|
|
121
|
+
return this.local();
|
|
122
|
+
} catch {
|
|
123
|
+
if (current()) {
|
|
124
|
+
this.key = undefined;
|
|
125
|
+
this.quota = unavailable(ctx.model?.provider, 'error', 'dashboard_refresh_failed');
|
|
126
|
+
return this.local();
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
this.authControllers.delete(controller);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
stop() {
|
|
133
|
+
this.generation++;
|
|
134
|
+
this.refreshId++;
|
|
135
|
+
if (this.timer !== undefined) this.clearIntervalFn(this.timer);
|
|
136
|
+
this.timer = undefined;
|
|
137
|
+
for (const controller of this.authControllers) controller.abort();
|
|
138
|
+
this.authControllers.clear();
|
|
139
|
+
this.cache.clear();
|
|
140
|
+
if (this.ctx?.hasUI) {
|
|
141
|
+
this.ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
142
|
+
if (this.footerOwned) this.ctx.ui.setFooter?.(undefined); // 交还内置 footer
|
|
143
|
+
}
|
|
144
|
+
this.footerOwned = false;
|
|
145
|
+
this.ctx = undefined;
|
|
146
|
+
this.modelIdentity = undefined;
|
|
147
|
+
this.key = undefined;
|
|
148
|
+
this.quota = unavailable(null, 'unavailable', 'session_stopped');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
export function registerDashboard(pi, dashboard) {
|
|
152
|
+
pi.on('session_start', (_event, ctx) => { dashboard.start(ctx); });
|
|
153
|
+
pi.on('session_shutdown', () => { dashboard.stop(); });
|
|
154
|
+
pi.on('model_select', (_event, ctx) => { dashboard.select(ctx); });
|
|
155
|
+
// ponytail: O(session entries) aggregation; throttle streaming to 4Hz. Use
|
|
156
|
+
// incremental accounting only if very large sessions make this measurable.
|
|
157
|
+
let lastStreamUpdate = 0;
|
|
158
|
+
pi.on('message_update', (_event, ctx) => {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
if (now - lastStreamUpdate >= 250) { lastStreamUpdate = now; dashboard.local(ctx); }
|
|
161
|
+
});
|
|
162
|
+
for (const event of ['thinking_level_select', 'message_end', 'turn_end', 'agent_settled', 'session_compact', 'session_tree']) {
|
|
163
|
+
pi.on(event, (_event, ctx) => { dashboard.local(ctx); });
|
|
164
|
+
}
|
|
165
|
+
pi.registerCommand('dashboard', {
|
|
166
|
+
description: 'Read-only unified snapshot; /dashboard refresh bypasses TTL (not backoff)',
|
|
167
|
+
handler: async (args, ctx) => {
|
|
168
|
+
if (!ctx.hasUI) return;
|
|
169
|
+
if (args.trim() && args.trim() !== 'refresh') {
|
|
170
|
+
ctx.ui.notify('Usage: /dashboard [refresh]', 'info');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// No sendMessage/appendEntry: snapshots never enter model context or disk.
|
|
174
|
+
dashboard.local(ctx);
|
|
175
|
+
const s = await dashboard.refresh(args.trim() === 'refresh');
|
|
176
|
+
if (s && dashboard.ctx) dashboard.ctx.ui.notify(JSON.stringify(s, null, 2), 'info');
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|