pi-glm-quota 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 +128 -0
- package/config.example.json +6 -0
- package/index.ts +241 -0
- package/mock.test.ts +171 -0
- package/package.json +40 -0
- package/pure.test.ts +148 -0
- package/pure.ts +198 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 focksor
|
|
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,128 @@
|
|
|
1
|
+
# pi-glm-quota
|
|
2
|
+
|
|
3
|
+
pi coding agent 的 GLM Coding Plan(智谱积分套餐)配额显示扩展。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
GLM 5h █░░░░░░░░░ 12% ⏳1h22m · W █████░░░░░ 48% ⏳4d3h
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- **5h / W**:5 小时滚动窗口 / 周窗口,各显示用量百分比与重置倒计时
|
|
10
|
+
- 颜色分档:<50% 绿 / 50-79% 黄 / ≥80% 红;数据过期时末尾标 `~`
|
|
11
|
+
- `/glm-quota` 命令查看明细(积分数字、重置时间、套餐档位)
|
|
12
|
+
- `/glm-quota-setup` 命令交互式配置(无需手改文件)
|
|
13
|
+
- 仅当前 provider 为 `zai*`(如 `zai-coding-cn`)时显示,切走自动隐藏
|
|
14
|
+
- 每轮对话后节流刷新(90s)+ 每 5 分钟后台刷新 + 每 30s 本地倒计时重绘
|
|
15
|
+
|
|
16
|
+
## 为什么需要这个扩展(踩坑记录)
|
|
17
|
+
|
|
18
|
+
智谱 **2026-07-30 套餐改版**后的积分制套餐(含团队套餐),其用量查询接口
|
|
19
|
+
`/api/monitor/usage/quota/limit` 的调用方式与旧版不同:
|
|
20
|
+
|
|
21
|
+
| 要素 | 说明 |
|
|
22
|
+
| --- | --- |
|
|
23
|
+
| `Authorization: <API key>` | raw 形式(不带 Bearer),取自 auth.json 的 `zai-coding-cn` key 或 `ZAI_CODING_CN_API_KEY` 环境变量 |
|
|
24
|
+
| `?type=2` | query 参数;实测团队套餐为 2,个人新版疑似为 1(未验证) |
|
|
25
|
+
| `bigmodel-organization: org-xxx` | **组织上下文头,必需** |
|
|
26
|
+
| `bigmodel-project: proj_xxx` | **项目上下文头,必需** |
|
|
27
|
+
|
|
28
|
+
缺任何一个的表现:
|
|
29
|
+
|
|
30
|
+
- 缺 `type` → `当前用户不存在coding plan`(所有社区插件都死在这里)
|
|
31
|
+
- 缺 org/project 头 → 返回空 `data:{}`
|
|
32
|
+
|
|
33
|
+
这就是 `@zhaoji-wang/pi-glm-usage`、`@bacnh85/pi-sub`、`pi-glm-usage`
|
|
34
|
+
等社区插件全部报错的原因——它们只发 API key。
|
|
35
|
+
|
|
36
|
+
**org/project/type 的获取方法**:浏览器访问 https://bigmodel.cn/coding-plan/ 并登录,
|
|
37
|
+
在 DevTools 的 Network 面板中找到 `api/monitor/usage/quota/limit` 请求:请求头
|
|
38
|
+
`bigmodel-organization` 和 `bigmodel-project` 即 org 与 project;URL query 参数
|
|
39
|
+
`type` 即套餐类型(团队=2,个人=1)。
|
|
40
|
+
(`/glm-quota-setup` 向导首个输入框的标题会展示同样的指引)
|
|
41
|
+
|
|
42
|
+
## 配置
|
|
43
|
+
|
|
44
|
+
配置包含三项:`organization`(组织 ID)、`project`(项目 ID)、`type`
|
|
45
|
+
(套餐类型,团队=2 / 个人=1)。三种配置方式,优先级从高到低:
|
|
46
|
+
|
|
47
|
+
### 方式一:环境变量(临时/CI 场景)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
export GLM_QUOTA_ORGANIZATION="org-xxxx"
|
|
51
|
+
export GLM_QUOTA_PROJECT="proj_xxxx"
|
|
52
|
+
export GLM_QUOTA_TYPE=2 # 可选,默认 2
|
|
53
|
+
export GLM_QUOTA_INTERVAL_SECONDS=300 # 可选,刷新间隔(秒)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 方式二:交互式向导(推荐)
|
|
57
|
+
|
|
58
|
+
在 pi 中运行:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
/glm-quota-setup
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
依次输入 org、project、type,保存后立即拉取验证。已配置时标题会显示
|
|
65
|
+
当前值,输入框留空提交表示该项保持不变(可只改其中一项);按 Esc 取消
|
|
66
|
+
则整体中止不写入。
|
|
67
|
+
|
|
68
|
+
### 方式三:配置文件
|
|
69
|
+
|
|
70
|
+
复制 `config.example.json` 为 `~/.pi/agent/glm-quota.json` 并填入真实值:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"organization": "org-xxxxxxxx",
|
|
75
|
+
"project": "proj_xxxxxxxxxxxx",
|
|
76
|
+
"type": 2,
|
|
77
|
+
"intervalSeconds": 300
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
> 配置文件含账号上下文标识,请勿提交到公共仓库。
|
|
82
|
+
|
|
83
|
+
API key 的解析优先级:`ZAI_CODING_CN_API_KEY` 环境变量 >
|
|
84
|
+
`~/.pi/agent/auth.json` 的 `zai-coding-cn` 条目(与 pi 本身的凭证一致)。
|
|
85
|
+
|
|
86
|
+
## 安装方式
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pi install npm:pi-glm-quota
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
用 `pi update --extensions` 更新;`pi remove npm:pi-glm-quota` 卸载。
|
|
93
|
+
|
|
94
|
+
开发模式(本地路径加载):在 `~/.pi/agent/settings.json` 的 `extensions`
|
|
95
|
+
数组中加入本目录的绝对路径:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"extensions": ["/path/to/pi-glm-quota"]
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
pi 加载 `package.json` 中 `pi` 清单声明的 `index.ts`,`/reload` 可热重载。
|
|
104
|
+
|
|
105
|
+
## 文件结构
|
|
106
|
+
|
|
107
|
+
| 文件 | 职责 |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `index.ts` | 扩展入口:事件接线、fetch、状态栏、命令与配置向导 |
|
|
110
|
+
| `pure.ts` | 纯逻辑:配置读取(env/文件)、key 解析、响应归一化、渲染 |
|
|
111
|
+
| `pure.test.ts` | 纯逻辑单测(离线,合成数据),`node pure.test.ts` |
|
|
112
|
+
| `mock.test.ts` | mock pi 上下文的接线测试(真实网络请求),`node mock.test.ts` |
|
|
113
|
+
| `config.example.json` | 配置文件模板 |
|
|
114
|
+
|
|
115
|
+
## 响应语义备注
|
|
116
|
+
|
|
117
|
+
`limits[]` 中 `CREDIT_LIMIT` 条目的 `unit` 字段(实测):
|
|
118
|
+
|
|
119
|
+
- `unit: 3` → 小时窗口,`number: 5` 即 5 小时滚动窗
|
|
120
|
+
- `unit: 6` → 周窗口,`number: 1` 即 1 周
|
|
121
|
+
|
|
122
|
+
`percentage` 为已用百分比(0-100);`usage` 为总额度、`currentValue` 为已用、
|
|
123
|
+
`remaining` 为剩余(积分);`nextResetTime` 为 epoch 毫秒。
|
|
124
|
+
|
|
125
|
+
## 已知局限
|
|
126
|
+
|
|
127
|
+
- 直接读服务端百分比,不受各模型积分系数差异影响
|
|
128
|
+
- 套餐类型变更(团队 ↔ 个人)需同步更新 `type` 与 org/project
|
package/index.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GLM Coding Plan(智谱积分套餐)配额显示扩展 — 入口
|
|
3
|
+
*
|
|
4
|
+
* 适用场景:智谱 2026-07-30 改版后的新版积分套餐(含团队套餐)。
|
|
5
|
+
* 这类套餐的用量查询接口需要携带组织/项目上下文头,社区插件均未支持,
|
|
6
|
+
* 因此本扩展直接按实测配方调用官方 monitor 端点:
|
|
7
|
+
*
|
|
8
|
+
* GET https://bigmodel.cn/api/monitor/usage/quota/limit?type=<type>
|
|
9
|
+
* Authorization: <API key> (auth.json 的 zai-coding-cn key,raw 形式)
|
|
10
|
+
* bigmodel-organization: <org id> (组织上下文)
|
|
11
|
+
* bigmodel-project: <project id> (项目上下文)
|
|
12
|
+
*
|
|
13
|
+
* 配置(三选一,优先级从高到低):
|
|
14
|
+
* 1. 环境变量 GLM_QUOTA_ORGANIZATION / GLM_QUOTA_PROJECT / GLM_QUOTA_TYPE
|
|
15
|
+
* 2. ~/.pi/agent/glm-quota.json(可用 /glm-quota-setup 向导生成)
|
|
16
|
+
* 3. 均未配置时状态栏提示运行向导
|
|
17
|
+
*
|
|
18
|
+
* 显示:footer 状态栏,如 GLM 5h ██░░░░░░░░ 10% ⏳1h5m · W █████░░░░░ 48% ⏳4d3h
|
|
19
|
+
* 命令:/glm-quota 查看明细;/glm-quota-setup 交互式配置
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import {
|
|
24
|
+
loadConfig,
|
|
25
|
+
renderFooter,
|
|
26
|
+
renderReport,
|
|
27
|
+
resolveApiKey,
|
|
28
|
+
saveConfig,
|
|
29
|
+
QUOTA_URL,
|
|
30
|
+
classifyLimits,
|
|
31
|
+
type GlmQuotaConfig,
|
|
32
|
+
type QuotaResponse,
|
|
33
|
+
type Snapshot,
|
|
34
|
+
} from "./pure.ts";
|
|
35
|
+
|
|
36
|
+
/** 状态栏槽位标识 */
|
|
37
|
+
const STATUS_KEY = "glm-quota";
|
|
38
|
+
/** 拉取超时(毫秒) */
|
|
39
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
40
|
+
/** 默认后台刷新间隔(秒) */
|
|
41
|
+
const DEFAULT_INTERVAL_S = 300;
|
|
42
|
+
/** 对话后触发刷新的最小间隔(毫秒),避免高频请求 */
|
|
43
|
+
const AFTER_TURN_THROTTLE_MS = 90_000;
|
|
44
|
+
/** 倒计时本地重绘间隔(毫秒),不发起网络请求 */
|
|
45
|
+
const TICK_MS = 30_000;
|
|
46
|
+
|
|
47
|
+
type Ui = ExtensionContext["ui"];
|
|
48
|
+
|
|
49
|
+
/** 拉取配额快照 */
|
|
50
|
+
async function fetchQuota(org: string, project: string, typeQ: number, key: string): Promise<Snapshot> {
|
|
51
|
+
const res = await fetch(`${QUOTA_URL}?type=${typeQ}`, {
|
|
52
|
+
headers: {
|
|
53
|
+
accept: "application/json",
|
|
54
|
+
authorization: key,
|
|
55
|
+
"bigmodel-organization": org,
|
|
56
|
+
"bigmodel-project": project,
|
|
57
|
+
},
|
|
58
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
59
|
+
});
|
|
60
|
+
const body = (await res.json()) as QuotaResponse;
|
|
61
|
+
if (!res.ok || !body.success || !body.data) {
|
|
62
|
+
throw new Error(`quota API error: HTTP ${res.status} ${body.msg ?? ""}`.trim());
|
|
63
|
+
}
|
|
64
|
+
const windows = classifyLimits(body.data.limits ?? []);
|
|
65
|
+
if (windows.length === 0) throw new Error("quota API returned no CREDIT_LIMIT windows");
|
|
66
|
+
return { windows, level: body.data.level ?? "?", fetchedAt: Date.now() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function (pi: ExtensionAPI) {
|
|
70
|
+
let snapshot: Snapshot | null = null;
|
|
71
|
+
let stale = false;
|
|
72
|
+
let lastFetchAt = 0;
|
|
73
|
+
let inFlight = false;
|
|
74
|
+
let fetchTimer: ReturnType<typeof setInterval> | undefined;
|
|
75
|
+
let tickTimer: ReturnType<typeof setInterval> | undefined;
|
|
76
|
+
let currentProviderIsGlm = false;
|
|
77
|
+
|
|
78
|
+
const paint = (ui: Ui) => {
|
|
79
|
+
if (!currentProviderIsGlm || !snapshot) return;
|
|
80
|
+
ui.setStatus(STATUS_KEY, renderFooter(snapshot, stale, (c, s) => ui.theme.fg(c, s)));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** 刷新配额;失败时保留旧值并标记 stale */
|
|
84
|
+
const refresh = async (ui: Ui, force = false) => {
|
|
85
|
+
if (inFlight) return;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
if (!force && now - lastFetchAt < AFTER_TURN_THROTTLE_MS) return;
|
|
88
|
+
const cfg = loadConfig();
|
|
89
|
+
const key = resolveApiKey();
|
|
90
|
+
if (!cfg || !key) {
|
|
91
|
+
ui.setStatus(STATUS_KEY, ui.theme.fg("dim", "GLM quota: 未配置,运行 /glm-quota-setup 向导"));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
inFlight = true;
|
|
95
|
+
try {
|
|
96
|
+
snapshot = await fetchQuota(cfg.organization, cfg.project, cfg.type, key);
|
|
97
|
+
stale = false;
|
|
98
|
+
lastFetchAt = Date.now();
|
|
99
|
+
paint(ui);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
stale = true;
|
|
102
|
+
if (!snapshot) {
|
|
103
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
104
|
+
ui.setStatus(STATUS_KEY, ui.theme.fg("error", `GLM quota: ${msg.slice(0, 60)}`));
|
|
105
|
+
} else {
|
|
106
|
+
paint(ui);
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
inFlight = false;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
114
|
+
// 无界面模式不启动刷新循环,避免无谓的网络请求
|
|
115
|
+
if (ctx.mode === "print" || ctx.mode === "json") return;
|
|
116
|
+
currentProviderIsGlm = (ctx.model?.provider ?? "").startsWith("zai");
|
|
117
|
+
const interval = (loadConfig()?.intervalSeconds ?? DEFAULT_INTERVAL_S) * 1000;
|
|
118
|
+
await refresh(ctx.ui, true);
|
|
119
|
+
fetchTimer = setInterval(() => void refresh(ctx.ui), interval);
|
|
120
|
+
tickTimer = setInterval(() => paint(ctx.ui), TICK_MS);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
124
|
+
if (fetchTimer) clearInterval(fetchTimer);
|
|
125
|
+
if (tickTimer) clearInterval(tickTimer);
|
|
126
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// 模型切换:非 GLM provider 清除状态,GLM provider 触发刷新
|
|
130
|
+
pi.on("model_select", async (event, ctx) => {
|
|
131
|
+
currentProviderIsGlm = event.model.provider.startsWith("zai");
|
|
132
|
+
if (!currentProviderIsGlm) {
|
|
133
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
await refresh(ctx.ui, true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// 每轮对话结束后节流刷新
|
|
140
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
141
|
+
if (!currentProviderIsGlm) return;
|
|
142
|
+
await refresh(ctx.ui);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
pi.registerCommand("glm-quota", {
|
|
146
|
+
description: "查看 GLM Coding Plan 积分配额明细",
|
|
147
|
+
handler: async (_args, ctx) => {
|
|
148
|
+
const cfg = loadConfig();
|
|
149
|
+
if (!cfg) {
|
|
150
|
+
ctx.ui.notify(
|
|
151
|
+
"未配置组织/项目上下文。运行 /glm-quota-setup 向导配置,\n" +
|
|
152
|
+
"或设置环境变量 GLM_QUOTA_ORGANIZATION / GLM_QUOTA_PROJECT,\n" +
|
|
153
|
+
"或编辑 ~/.pi/agent/glm-quota.json(格式见项目内 config.example.json)",
|
|
154
|
+
"error",
|
|
155
|
+
);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
await refresh(ctx.ui, true);
|
|
159
|
+
if (!snapshot) {
|
|
160
|
+
ctx.ui.notify("GLM quota: 暂无数据(拉取失败,稍后自动重试)", "error");
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
ctx.ui.notify(renderReport(snapshot, stale), "info");
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
pi.registerCommand("glm-quota-setup", {
|
|
168
|
+
description: "配置 GLM 配额查询的组织/项目上下文(交互式向导,已配置项留空保持不变)",
|
|
169
|
+
handler: async (_args, ctx) => {
|
|
170
|
+
const current = loadConfig();
|
|
171
|
+
// 帮助文案嵌入首个输入框标题(多行渲染),随弹窗持久可见;
|
|
172
|
+
// 不用 notify —— 阻塞式输入弹窗会立即盖住聊天区 toast
|
|
173
|
+
const help =
|
|
174
|
+
"获取 org/project/type:浏览器访问 https://bigmodel.cn/coding-plan/ 并登录,\n" +
|
|
175
|
+
"在 DevTools Network 中找到 api/monitor/usage/quota/limit 请求,\n" +
|
|
176
|
+
"请求头 bigmodel-organization / bigmodel-project 即 org 与 project,\n" +
|
|
177
|
+
"URL query 参数 type 即套餐类型(团队=2,个人=1)\n\n";
|
|
178
|
+
// 已配置时标题展示当前值,输入留空表示保持不变;取消(Esc)则整体中止
|
|
179
|
+
const org = await ctx.ui.input(
|
|
180
|
+
help +
|
|
181
|
+
(current
|
|
182
|
+
? `bigmodel-organization(当前:${current.organization},留空保持不变)`
|
|
183
|
+
: "bigmodel-organization(组织 ID,org- 开头,必填)"),
|
|
184
|
+
current?.organization ?? "org-xxxxxxxx",
|
|
185
|
+
);
|
|
186
|
+
if (org === undefined) return;
|
|
187
|
+
const project = await ctx.ui.input(
|
|
188
|
+
current
|
|
189
|
+
? `bigmodel-project(当前:${current.project},留空保持不变)`
|
|
190
|
+
: "bigmodel-project(项目 ID,proj_ 开头,必填)",
|
|
191
|
+
current?.project ?? "proj_xxxxxxxx",
|
|
192
|
+
);
|
|
193
|
+
if (project === undefined) return;
|
|
194
|
+
const typeRaw = await ctx.ui.input(
|
|
195
|
+
current
|
|
196
|
+
? `套餐类型 type(当前:${current.type};团队=2,个人=1,留空保持不变)`
|
|
197
|
+
: "套餐类型 type(团队套餐=2,个人套餐=1,回车默认 2)",
|
|
198
|
+
current ? String(current.type) : "2",
|
|
199
|
+
);
|
|
200
|
+
if (typeRaw === undefined) return;
|
|
201
|
+
|
|
202
|
+
// 合并:留空取现值
|
|
203
|
+
const organization = org.trim() || current?.organization || "";
|
|
204
|
+
const projectVal = project.trim() || current?.project || "";
|
|
205
|
+
if (!organization || !projectVal) {
|
|
206
|
+
ctx.ui.notify("org 与 project 均为必填,本次未保存。重新运行 /glm-quota-setup", "error");
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const type = typeRaw.trim() && /^\d+$/.test(typeRaw.trim()) ? Number(typeRaw.trim()) : current?.type ?? 2;
|
|
210
|
+
|
|
211
|
+
const changed =
|
|
212
|
+
!current ||
|
|
213
|
+
current.organization !== organization ||
|
|
214
|
+
current.project !== projectVal ||
|
|
215
|
+
current.type !== type;
|
|
216
|
+
if (!changed) {
|
|
217
|
+
ctx.ui.notify("配置未变化", "info");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 保留原有 intervalSeconds 等未涉及的配置项
|
|
222
|
+
const cfg: GlmQuotaConfig = {
|
|
223
|
+
organization,
|
|
224
|
+
project: projectVal,
|
|
225
|
+
type,
|
|
226
|
+
...(current?.intervalSeconds !== undefined ? { intervalSeconds: current.intervalSeconds } : {}),
|
|
227
|
+
};
|
|
228
|
+
saveConfig(cfg);
|
|
229
|
+
await refresh(ctx.ui, true);
|
|
230
|
+
if (snapshot) {
|
|
231
|
+
ctx.ui.notify(`配置已保存并生效:\n${renderReport(snapshot, stale)}`, "info");
|
|
232
|
+
} else {
|
|
233
|
+
ctx.ui.notify(
|
|
234
|
+
"配置已写入 ~/.pi/agent/glm-quota.json,但拉取未成功。\n" +
|
|
235
|
+
"请检查 org/project 是否正确(获取方法见项目 README)",
|
|
236
|
+
"warning",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
package/mock.test.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展接线的端到端测试:mock pi 上下文,触发真实事件流,
|
|
3
|
+
* 验证 fetch → 解析 → setStatus 全链路(发起真实网络请求)。
|
|
4
|
+
*
|
|
5
|
+
* 需要本机已配置 ~/.pi/agent/glm-quota.json 与可用 API key,
|
|
6
|
+
* 缺失时自动跳过(便于其他用户在无凭证环境跑测试)。
|
|
7
|
+
*
|
|
8
|
+
* 运行:node mock.test.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import factory from "./index.ts";
|
|
12
|
+
import { loadConfig, resolveApiKey } from "./pure.ts";
|
|
13
|
+
|
|
14
|
+
type Handler = (event: unknown, ctx: unknown) => unknown;
|
|
15
|
+
|
|
16
|
+
const handlers = new Map<string, Handler>();
|
|
17
|
+
const commands = new Map<string, { description: string; handler: Handler }>();
|
|
18
|
+
const statuses = new Map<string, string | undefined>();
|
|
19
|
+
const notifications: Array<{ msg: string; level: string }> = [];
|
|
20
|
+
|
|
21
|
+
const inputTitles: string[] = [];
|
|
22
|
+
const fakeUi = (inputAnswers: string[] = []) => ({
|
|
23
|
+
setStatus: (key: string, text?: string) => {
|
|
24
|
+
statuses.set(key, text);
|
|
25
|
+
console.log(`[status:${key}] ${text ?? "(cleared)"}`);
|
|
26
|
+
},
|
|
27
|
+
theme: { fg: (_c: string, s: string) => s },
|
|
28
|
+
notify: (msg: string, level: string) => {
|
|
29
|
+
notifications.push({ msg, level });
|
|
30
|
+
console.log(`[notify:${level}]\n${msg}`);
|
|
31
|
+
},
|
|
32
|
+
input: async (title: string, _placeholder: string) => {
|
|
33
|
+
inputTitles.push(title);
|
|
34
|
+
return inputAnswers.shift();
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const fakePi = {
|
|
39
|
+
on: (event: string, handler: Handler) => handlers.set(event, handler),
|
|
40
|
+
registerCommand: (name: string, def: { description: string; handler: Handler }) => commands.set(name, def),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
async function fire(event: string, ev: unknown, ctx: unknown) {
|
|
44
|
+
const h = handlers.get(event);
|
|
45
|
+
if (!h) throw new Error(`no handler for ${event}`);
|
|
46
|
+
await h(ev, ctx);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
const configured = loadConfig() !== null && resolveApiKey() !== null;
|
|
51
|
+
|
|
52
|
+
factory(fakePi as never);
|
|
53
|
+
|
|
54
|
+
const tuiCtx = {
|
|
55
|
+
mode: "tui",
|
|
56
|
+
model: { provider: "zai-coding-cn", id: "glm-5.3" },
|
|
57
|
+
ui: fakeUi(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (!configured) {
|
|
61
|
+
await fire("session_start", { reason: "startup" }, tuiCtx);
|
|
62
|
+
const hint = statuses.get("glm-quota") ?? "";
|
|
63
|
+
if (!hint.includes("glm-quota-setup")) {
|
|
64
|
+
console.error("FAIL 未配置时应提示运行 /glm-quota-setup,实际:", hint);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
console.log("本机无配置/key,未配置提示路径验证通过,跳过后续真实请求测试 ✅");
|
|
68
|
+
process.exit(0);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log("== session_start ==");
|
|
72
|
+
await fire("session_start", { reason: "startup" }, tuiCtx);
|
|
73
|
+
|
|
74
|
+
const footer = statuses.get("glm-quota") ?? "";
|
|
75
|
+
if (!footer.includes("5h") || !footer.includes("W")) {
|
|
76
|
+
console.error("FAIL footer 未包含窗口信息:", footer);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
console.log("\nfooter 验证通过 ✓");
|
|
80
|
+
|
|
81
|
+
console.log("\n== /glm-quota 命令 ==");
|
|
82
|
+
const cmd = commands.get("glm-quota");
|
|
83
|
+
if (!cmd) {
|
|
84
|
+
console.error("FAIL 未注册 glm-quota 命令");
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
await cmd.handler("", tuiCtx);
|
|
88
|
+
if (notifications.length === 0) {
|
|
89
|
+
console.error("FAIL 命令未产生 notify");
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log("\n== model_select 切到非 GLM provider ==");
|
|
94
|
+
await fire(
|
|
95
|
+
"model_select",
|
|
96
|
+
{ model: { provider: "anthropic", id: "x" }, previousModel: undefined, source: "set" },
|
|
97
|
+
tuiCtx,
|
|
98
|
+
);
|
|
99
|
+
if (statuses.get("glm-quota") !== undefined) {
|
|
100
|
+
console.error("FAIL 切走后状态栏未清除");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
console.log("切走清除 ✓");
|
|
104
|
+
|
|
105
|
+
console.log("\n== model_select 切回 GLM ==");
|
|
106
|
+
await fire(
|
|
107
|
+
"model_select",
|
|
108
|
+
{ model: { provider: "zai-coding-cn", id: "glm-5.3" }, previousModel: undefined, source: "set" },
|
|
109
|
+
tuiCtx,
|
|
110
|
+
);
|
|
111
|
+
if (!(statuses.get("glm-quota") ?? "").includes("5h")) {
|
|
112
|
+
console.error("FAIL 切回后未恢复显示");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
console.log("切回恢复 ✓");
|
|
116
|
+
|
|
117
|
+
console.log("\n== /glm-quota-setup:留空保持现有值 ==");
|
|
118
|
+
const setup = commands.get("glm-quota-setup");
|
|
119
|
+
if (!setup) {
|
|
120
|
+
console.error("FAIL 未注册 glm-quota-setup 命令");
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
const before = JSON.stringify(loadConfig());
|
|
124
|
+
const keepCtx = { ...tuiCtx, ui: fakeUi(["", "", ""]) };
|
|
125
|
+
await setup.handler("", keepCtx);
|
|
126
|
+
if (
|
|
127
|
+
!inputTitles[0]?.includes("bigmodel.cn/coding-plan/") ||
|
|
128
|
+
!inputTitles[0]?.includes("bigmodel-project") ||
|
|
129
|
+
!inputTitles[0]?.includes("type")
|
|
130
|
+
) {
|
|
131
|
+
console.error("FAIL 首个输入框标题应包含获取帮助:", inputTitles[0]);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
console.log("帮助嵌入标题 ✓");
|
|
135
|
+
if (JSON.stringify(loadConfig()) !== before) {
|
|
136
|
+
console.error("FAIL 留空时应保持原配置");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
console.log("留空保持 ✓");
|
|
140
|
+
|
|
141
|
+
console.log("\n== /glm-quota-setup:取消(Esc)中止 ==");
|
|
142
|
+
const cancelCtx = { ...tuiCtx, ui: fakeUi([undefined as unknown as string]) };
|
|
143
|
+
await setup.handler("", cancelCtx);
|
|
144
|
+
if (JSON.stringify(loadConfig()) !== before) {
|
|
145
|
+
console.error("FAIL 取消后不应写配置");
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
console.log("取消中止 ✓");
|
|
149
|
+
|
|
150
|
+
console.log("\n== /glm-quota-setup:留空 org/project + 修改 type ==");
|
|
151
|
+
const modCtx = { ...tuiCtx, ui: fakeUi(["", "", "9"]) };
|
|
152
|
+
await setup.handler("", modCtx);
|
|
153
|
+
const after = loadConfig();
|
|
154
|
+
const beforeCfg = JSON.parse(before) as NonNullable<ReturnType<typeof loadConfig>>;
|
|
155
|
+
if (after?.type !== 9 || after.organization !== beforeCfg.organization || after.project !== beforeCfg.project) {
|
|
156
|
+
console.error("FAIL 部分修改结果不对:", JSON.stringify(after));
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
console.log("部分修改 ✓(org/project 保持,type=9)");
|
|
160
|
+
|
|
161
|
+
// 回滚为测试前的真实配置
|
|
162
|
+
const { saveConfig } = await import("./pure.ts");
|
|
163
|
+
saveConfig(beforeCfg);
|
|
164
|
+
|
|
165
|
+
console.log("\n== session_shutdown ==");
|
|
166
|
+
await fire("session_shutdown", { reason: "quit" }, tuiCtx);
|
|
167
|
+
console.log("全部通过 ✅");
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
void main();
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-glm-quota",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GLM Coding Plan (Zhipu BigModel credit-based plans, incl. team) quota usage in the pi footer — org/project context headers, /glm-quota report, /glm-quota-setup wizard",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"glm",
|
|
10
|
+
"zhipu",
|
|
11
|
+
"bigmodel",
|
|
12
|
+
"zai",
|
|
13
|
+
"coding-plan",
|
|
14
|
+
"quota",
|
|
15
|
+
"usage"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "focksor",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"index.ts",
|
|
28
|
+
"pure.ts",
|
|
29
|
+
"pure.test.ts",
|
|
30
|
+
"mock.test.ts",
|
|
31
|
+
"config.example.json",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE"
|
|
34
|
+
],
|
|
35
|
+
"pi": {
|
|
36
|
+
"extensions": [
|
|
37
|
+
"./index.ts"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
}
|
package/pure.test.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pure.ts 的独立单元测试(node 直接运行,无需 pi 运行时)
|
|
3
|
+
* 运行:node pure.test.ts
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import {
|
|
10
|
+
bar,
|
|
11
|
+
classifyLimits,
|
|
12
|
+
fmtCountdown,
|
|
13
|
+
fmtNum,
|
|
14
|
+
loadConfig,
|
|
15
|
+
pctColor,
|
|
16
|
+
renderFooter,
|
|
17
|
+
renderReport,
|
|
18
|
+
resolveApiKey,
|
|
19
|
+
saveConfig,
|
|
20
|
+
type LimitItem,
|
|
21
|
+
type Snapshot,
|
|
22
|
+
} from "./pure.ts";
|
|
23
|
+
|
|
24
|
+
let failed = 0;
|
|
25
|
+
function check(name: string, cond: boolean, detail?: string) {
|
|
26
|
+
if (cond) {
|
|
27
|
+
console.log(`ok ${name}`);
|
|
28
|
+
} else {
|
|
29
|
+
failed++;
|
|
30
|
+
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ""}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// —— 响应样本(结构来自真实抓包,数值为合成数据)——
|
|
35
|
+
const sampleLimits: LimitItem[] = [
|
|
36
|
+
{
|
|
37
|
+
type: "CREDIT_LIMIT",
|
|
38
|
+
unit: 3,
|
|
39
|
+
number: 5,
|
|
40
|
+
usage: 15000,
|
|
41
|
+
currentValue: 1500,
|
|
42
|
+
remaining: 13500,
|
|
43
|
+
percentage: 10,
|
|
44
|
+
nextResetTime: 1_789_000_000_000,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "CREDIT_LIMIT",
|
|
48
|
+
unit: 6,
|
|
49
|
+
number: 1,
|
|
50
|
+
usage: 66000,
|
|
51
|
+
currentValue: 33000,
|
|
52
|
+
remaining: 33000,
|
|
53
|
+
percentage: 50,
|
|
54
|
+
nextResetTime: 1_789_300_000_000,
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const windows = classifyLimits(sampleLimits);
|
|
59
|
+
check("classifyLimits 返回 2 个窗口", windows.length === 2, `got ${windows.length}`);
|
|
60
|
+
check("第一个窗口是 5h", windows[0]?.label === "5h", windows[0]?.label);
|
|
61
|
+
check("第二个窗口是 W", windows[1]?.label === "W", windows[1]?.label);
|
|
62
|
+
check("5h 已用量", windows[0]?.used === 1500);
|
|
63
|
+
check("W 百分比取整", windows[1]?.pct === 50);
|
|
64
|
+
check("忽略非 CREDIT_LIMIT 条目", classifyLimits([{ ...sampleLimits[0]!, type: "TIME_LIMIT" }]).length === 0);
|
|
65
|
+
|
|
66
|
+
const snap: Snapshot = { windows, level: "pro", fetchedAt: 1_788_950_000_000 };
|
|
67
|
+
const footer = renderFooter(snap, false, null, 1_788_950_000_000);
|
|
68
|
+
console.log("footer:", footer);
|
|
69
|
+
check("footer 含 5h", footer.includes("5h"));
|
|
70
|
+
check("footer 含 W", footer.includes("W"));
|
|
71
|
+
check("footer 含百分比", footer.includes("10%") && footer.includes("50%"));
|
|
72
|
+
check("footer 含沙漏图标", footer.includes("⏳"));
|
|
73
|
+
const staleFooter = renderFooter(snap, true, null, 1_788_950_000_000);
|
|
74
|
+
check("stale 标记为 ~", staleFooter.endsWith("~"), staleFooter);
|
|
75
|
+
|
|
76
|
+
check("bar 10 格", bar(10).length === 10);
|
|
77
|
+
check("bar 填充数", bar(48).startsWith("█████") && !bar(48).startsWith("██████"));
|
|
78
|
+
check("pctColor 分档", pctColor(10) === "success" && pctColor(60) === "warning" && pctColor(85) === "error");
|
|
79
|
+
check("fmtNum 千分位", fmtNum(33000) === "33,000");
|
|
80
|
+
check(
|
|
81
|
+
"fmtCountdown 分钟级",
|
|
82
|
+
fmtCountdown(1_788_950_000_000 + 42 * 60_000, 1_788_950_000_000) === "42m",
|
|
83
|
+
fmtCountdown(1_788_950_000_000 + 42 * 60_000, 1_788_950_000_000),
|
|
84
|
+
);
|
|
85
|
+
check(
|
|
86
|
+
"fmtCountdown 小时级",
|
|
87
|
+
fmtCountdown(1_788_950_000_000 + (3 * 3600 + 5 * 60) * 1000, 1_788_950_000_000) === "3h5m",
|
|
88
|
+
);
|
|
89
|
+
check(
|
|
90
|
+
"fmtCountdown 天级",
|
|
91
|
+
fmtCountdown(1_788_950_000_000 + (4 * 86400 + 3 * 3600) * 1000, 1_788_950_000_000) === "4d3h",
|
|
92
|
+
);
|
|
93
|
+
check("fmtCountdown 已过期归零", fmtCountdown(0, 1_788_950_000_000) === "0m");
|
|
94
|
+
|
|
95
|
+
const report = renderReport(snap, false);
|
|
96
|
+
console.log("--- report ---");
|
|
97
|
+
console.log(report);
|
|
98
|
+
check("报告含档位", report.includes("pro"));
|
|
99
|
+
check("报告含积分数字", report.includes("1,500") && report.includes("15,000"));
|
|
100
|
+
check("报告含重置时间", report.includes("重置于"));
|
|
101
|
+
|
|
102
|
+
// —— 配置读取 / 环境变量覆盖 / 写入 ——
|
|
103
|
+
const dir = mkdtempSync(join(tmpdir(), "glm-quota-test-"));
|
|
104
|
+
const cfgPath = join(dir, "config.json");
|
|
105
|
+
const authPath = join(dir, "auth.json");
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(cfgPath, JSON.stringify({ organization: "org-file", project: "proj-file", type: 1 }), "utf-8");
|
|
108
|
+
|
|
109
|
+
const fromFile = loadConfig(cfgPath, {});
|
|
110
|
+
check("从文件读取配置", fromFile?.organization === "org-file" && fromFile?.project === "proj-file");
|
|
111
|
+
check("文件 type 生效", fromFile?.type === 1);
|
|
112
|
+
|
|
113
|
+
const envOverride = loadConfig(cfgPath, { GLM_QUOTA_ORGANIZATION: "org-env", GLM_QUOTA_TYPE: "2" });
|
|
114
|
+
check("环境变量覆盖 org", envOverride?.organization === "org-env");
|
|
115
|
+
check("环境变量保留文件 project", envOverride?.project === "proj-file");
|
|
116
|
+
check("环境变量覆盖 type", envOverride?.type === 2);
|
|
117
|
+
|
|
118
|
+
const partialPath = join(dir, "partial.json");
|
|
119
|
+
writeFileSync(partialPath, JSON.stringify({ organization: "org-only" }), "utf-8");
|
|
120
|
+
check("缺 project 时返回 null", loadConfig(partialPath, {}) === null);
|
|
121
|
+
check("配置文件不存在返回 null", loadConfig(join(dir, "none.json"), {}) === null);
|
|
122
|
+
|
|
123
|
+
const envOnly = loadConfig(join(dir, "none.json"), {
|
|
124
|
+
GLM_QUOTA_ORGANIZATION: "org-env",
|
|
125
|
+
GLM_QUOTA_PROJECT: "proj-env",
|
|
126
|
+
GLM_QUOTA_INTERVAL_SECONDS: "120",
|
|
127
|
+
});
|
|
128
|
+
check("仅环境变量即可成配置", envOnly?.organization === "org-env" && envOnly?.project === "proj-env");
|
|
129
|
+
check("环境变量 intervalSeconds", envOnly?.intervalSeconds === 120);
|
|
130
|
+
check("非法 type 回退默认 2", loadConfig(cfgPath, { GLM_QUOTA_TYPE: "abc" })?.type === 2);
|
|
131
|
+
|
|
132
|
+
saveConfig({ organization: "org-new", project: "proj-new", type: 2 }, cfgPath);
|
|
133
|
+
const reread = loadConfig(cfgPath, {});
|
|
134
|
+
check("saveConfig 写入后可读回", reread?.organization === "org-new" && reread?.type === 2);
|
|
135
|
+
|
|
136
|
+
writeFileSync(authPath, JSON.stringify({ "zai-coding-cn": { type: "api_key", key: "testkey123" } }), "utf-8");
|
|
137
|
+
check("resolveApiKey 读 auth.json", resolveApiKey({}, authPath) === "testkey123");
|
|
138
|
+
check("resolveApiKey 环境变量优先", resolveApiKey({ ZAI_CODING_CN_API_KEY: "envkey" }, authPath) === "envkey");
|
|
139
|
+
check("resolveApiKey 缺失返回 null", resolveApiKey({}, join(dir, "none.json")) === null);
|
|
140
|
+
} finally {
|
|
141
|
+
rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (failed > 0) {
|
|
145
|
+
console.error(`\n${failed} 项失败`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
console.log("\n全部通过");
|
package/pure.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GLM 配额显示的纯逻辑部分:配置读取、凭证解析、响应归一化、渲染。
|
|
3
|
+
* 不依赖 pi 运行时,可独立单元测试。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
/** 配置文件路径 */
|
|
11
|
+
export const CONFIG_PATH = join(homedir(), ".pi", "agent", "glm-quota.json");
|
|
12
|
+
/** auth.json 路径 */
|
|
13
|
+
export const AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
|
|
14
|
+
/** 用量查询端点(不带 query) */
|
|
15
|
+
export const QUOTA_URL = "https://bigmodel.cn/api/monitor/usage/quota/limit";
|
|
16
|
+
|
|
17
|
+
export interface GlmQuotaConfig {
|
|
18
|
+
organization: string;
|
|
19
|
+
project: string;
|
|
20
|
+
type: number;
|
|
21
|
+
intervalSeconds?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 服务端返回的限额条目 */
|
|
25
|
+
export interface LimitItem {
|
|
26
|
+
type: string;
|
|
27
|
+
unit: number;
|
|
28
|
+
number: number;
|
|
29
|
+
usage: number;
|
|
30
|
+
currentValue: number;
|
|
31
|
+
remaining: number;
|
|
32
|
+
percentage: number;
|
|
33
|
+
nextResetTime: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface QuotaResponse {
|
|
37
|
+
code: number;
|
|
38
|
+
msg: string;
|
|
39
|
+
data?: { limits?: LimitItem[]; level?: string };
|
|
40
|
+
success?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 归一化后的窗口视图 */
|
|
44
|
+
export interface QuotaWindow {
|
|
45
|
+
label: string;
|
|
46
|
+
used: number;
|
|
47
|
+
total: number;
|
|
48
|
+
remaining: number;
|
|
49
|
+
pct: number;
|
|
50
|
+
resetAt: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Snapshot {
|
|
54
|
+
windows: QuotaWindow[];
|
|
55
|
+
level: string;
|
|
56
|
+
fetchedAt: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 主题着色函数的最小接口(与 pi 的 theme.fg 兼容) */
|
|
60
|
+
export type FgFn = (c: "success" | "warning" | "error" | "dim", s: string) => string;
|
|
61
|
+
|
|
62
|
+
/** 支持的环境变量(优先级高于配置文件) */
|
|
63
|
+
const ENV_ORG = "GLM_QUOTA_ORGANIZATION";
|
|
64
|
+
const ENV_PROJECT = "GLM_QUOTA_PROJECT";
|
|
65
|
+
const ENV_TYPE = "GLM_QUOTA_TYPE";
|
|
66
|
+
const ENV_INTERVAL = "GLM_QUOTA_INTERVAL_SECONDS";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 读取配置。优先级:环境变量 > 配置文件;org/project 任一缺失返回 null。
|
|
70
|
+
* path/env 参数仅供测试注入,默认使用真实路径与环境。
|
|
71
|
+
*/
|
|
72
|
+
export function loadConfig(path = CONFIG_PATH, env: Record<string, string | undefined> = process.env): GlmQuotaConfig | null {
|
|
73
|
+
let file: Partial<GlmQuotaConfig> = {};
|
|
74
|
+
try {
|
|
75
|
+
file = JSON.parse(readFileSync(path, "utf-8")) as Partial<GlmQuotaConfig>;
|
|
76
|
+
} catch {
|
|
77
|
+
// 文件不存在或非法都按空配置处理,交给环境变量兜底
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const organization = env[ENV_ORG]?.trim() || (typeof file.organization === "string" ? file.organization.trim() : "");
|
|
81
|
+
const project = env[ENV_PROJECT]?.trim() || (typeof file.project === "string" ? file.project.trim() : "");
|
|
82
|
+
if (organization.length === 0 || project.length === 0) return null;
|
|
83
|
+
|
|
84
|
+
const typeRaw = env[ENV_TYPE]?.trim() ?? (typeof file.type === "number" ? String(file.type) : "");
|
|
85
|
+
const type = /^\d+$/.test(typeRaw ?? "") ? Number(typeRaw) : 2;
|
|
86
|
+
|
|
87
|
+
const intervalRaw = env[ENV_INTERVAL]?.trim() ?? (typeof file.intervalSeconds === "number" ? String(file.intervalSeconds) : "");
|
|
88
|
+
const intervalSeconds = /^\d+$/.test(intervalRaw ?? "") && Number(intervalRaw) > 0 ? Number(intervalRaw) : undefined;
|
|
89
|
+
|
|
90
|
+
return { organization, project, type, intervalSeconds };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 保存配置(供 /glm-quota-setup 向导写入),自动创建父目录 */
|
|
94
|
+
export function saveConfig(cfg: GlmQuotaConfig, path = CONFIG_PATH): void {
|
|
95
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
96
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 解析 API key:env 优先(ZAI_CODING_CN_API_KEY),其次 auth.json 的
|
|
101
|
+
* zai-coding-cn 条目。path/env 参数仅供测试注入。
|
|
102
|
+
*/
|
|
103
|
+
export function resolveApiKey(
|
|
104
|
+
env: Record<string, string | undefined> = process.env,
|
|
105
|
+
path = AUTH_PATH,
|
|
106
|
+
): string | null {
|
|
107
|
+
const envKey = env.ZAI_CODING_CN_API_KEY?.trim();
|
|
108
|
+
if (envKey && envKey.length > 0) return envKey;
|
|
109
|
+
try {
|
|
110
|
+
const auth = JSON.parse(readFileSync(path, "utf-8")) as Record<string, { key?: string }>;
|
|
111
|
+
return auth["zai-coding-cn"]?.key ?? null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 把服务端 limits 归一化为显示窗口。
|
|
119
|
+
* 实测语义(新版积分套餐):unit 3 = 小时窗口(number 5 → 5 小时滚动窗),
|
|
120
|
+
* unit 6 = 周窗口(number 1 → 1 周);其余类型以通用标签保留。
|
|
121
|
+
* 最多返回 2 个窗口,按重置时间升序(5h 在前、周窗口在后)。
|
|
122
|
+
*/
|
|
123
|
+
export function classifyLimits(limits: LimitItem[]): QuotaWindow[] {
|
|
124
|
+
const windows: QuotaWindow[] = [];
|
|
125
|
+
for (const l of limits) {
|
|
126
|
+
if (l.type !== "CREDIT_LIMIT") continue;
|
|
127
|
+
let label: string;
|
|
128
|
+
if (l.unit === 3) label = `${l.number}h`;
|
|
129
|
+
else if (l.unit === 6) label = l.number === 1 ? "W" : `${l.number}w`;
|
|
130
|
+
else label = `u${l.unit}x${l.number}`;
|
|
131
|
+
windows.push({
|
|
132
|
+
label,
|
|
133
|
+
used: l.currentValue,
|
|
134
|
+
total: l.usage,
|
|
135
|
+
remaining: l.remaining,
|
|
136
|
+
pct: Math.max(0, Math.min(100, Math.round(l.percentage))),
|
|
137
|
+
resetAt: l.nextResetTime,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
windows.sort((a, b) => a.resetAt - b.resetAt);
|
|
141
|
+
return windows.slice(0, 2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 千分位格式化 */
|
|
145
|
+
export function fmtNum(n: number): string {
|
|
146
|
+
return Math.round(n).toLocaleString("en-US");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 重置倒计时:1h5m / 4d3h / 42m */
|
|
150
|
+
export function fmtCountdown(resetAt: number, now = Date.now()): string {
|
|
151
|
+
let sec = Math.max(0, Math.floor((resetAt - now) / 1000));
|
|
152
|
+
const d = Math.floor(sec / 86400);
|
|
153
|
+
sec -= d * 86400;
|
|
154
|
+
const h = Math.floor(sec / 3600);
|
|
155
|
+
const m = Math.floor((sec - h * 3600) / 60);
|
|
156
|
+
if (d > 0) return `${d}d${h}h`;
|
|
157
|
+
if (h > 0) return `${h}h${m}m`;
|
|
158
|
+
return `${m}m`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 10 格用量条 */
|
|
162
|
+
export function bar(pct: number): string {
|
|
163
|
+
const filled = Math.min(10, Math.round(pct / 10));
|
|
164
|
+
return "█".repeat(filled) + "░".repeat(10 - filled);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 按使用率选颜色 */
|
|
168
|
+
export function pctColor(pct: number): "success" | "warning" | "error" {
|
|
169
|
+
if (pct >= 80) return "error";
|
|
170
|
+
if (pct >= 50) return "warning";
|
|
171
|
+
return "success";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** footer 渲染;fg 传 null 时输出无色纯文本(测试 / notify 场景) */
|
|
175
|
+
export function renderFooter(snap: Snapshot, stale: boolean, fg: FgFn | null, now = Date.now()): string {
|
|
176
|
+
const parts = snap.windows.map((w) => {
|
|
177
|
+
const seg = `${w.label} ${bar(w.pct)} ${w.pct}% ⏳${fmtCountdown(w.resetAt, now)}${stale ? "~" : ""}`;
|
|
178
|
+
return fg ? fg(pctColor(w.pct), seg) : seg;
|
|
179
|
+
});
|
|
180
|
+
const joiner = fg ? fg("dim", " · ") : " · ";
|
|
181
|
+
return (fg ? fg("dim", "GLM ") : "GLM ") + parts.join(joiner);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 报告渲染(/glm-quota 用),多行纯文本 */
|
|
185
|
+
export function renderReport(snap: Snapshot, stale: boolean): string {
|
|
186
|
+
const lines = snap.windows.map((w) => {
|
|
187
|
+
const resetLocal = new Date(w.resetAt).toLocaleString("zh-CN", { hour12: false });
|
|
188
|
+
return (
|
|
189
|
+
`${w.label} 窗口 ${fmtNum(w.used)} / ${fmtNum(w.total)} 积分已用(${w.pct}%)\n` +
|
|
190
|
+
` 剩余 ${fmtNum(w.remaining)} · 重置于 ${resetLocal}(⏳${fmtCountdown(w.resetAt)})`
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
const staleMark = stale ? "(数据过期,最近一次拉取失败)" : "";
|
|
194
|
+
return (
|
|
195
|
+
`GLM Coding Plan — ${snap.level} 档${staleMark}\n${lines.join("\n")}\n` +
|
|
196
|
+
`最近刷新:${new Date(snap.fetchedAt).toLocaleTimeString("zh-CN", { hour12: false })}`
|
|
197
|
+
);
|
|
198
|
+
}
|