pi-multi-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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wyouwd1
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,199 @@
1
+ # pi-multi-quota
2
+
3
+ 在 pi 的 footer 里实时显示**当前模型所属供应商**的额度 —— 纯 HTTP、零子进程、零运行时依赖。
4
+
5
+ 为「一个 pi 里同时挂着多个供应商、且其中一家有多个账号」的场景设计。
6
+
7
+ ---
8
+
9
+ ## 支持范围
10
+
11
+ | 供应商 | 显示内容 | 凭据来源 |
12
+ |---|---|---|
13
+ | **火山方舟 Ark**(Coding Plan) | 5h / 周 / 月 已用百分比 | 控制台 cookie(手动维护,见下) |
14
+ | **OpenCode Go**(Zen) | rolling(footer 显示为 5h)/ weekly / monthly 百分比 | 复用 pi 已配置的 API key |
15
+ | **DeepSeek** | 账户余额(CNY / USD 分别显示) | 复用 pi 已配置的 API key |
16
+
17
+ footer 跟随当前模型:用 Ark 的模型时显示 Ark 的**全部账号**,切到 OpenCode 就整段换成 OpenCode。
18
+
19
+ ---
20
+
21
+ ## 安装
22
+
23
+ 从 npm 安装(推荐,已收录进 [pi.dev/packages](https://pi.dev/packages) 包目录):
24
+
25
+ ```bash
26
+ pi install npm:pi-multi-quota
27
+ ```
28
+
29
+ 从 GitHub 安装:
30
+
31
+ ```bash
32
+ pi install https://github.com/wyouwd1/pi-multi-quota
33
+ ```
34
+
35
+ 本地克隆安装(开发用,在仓库根目录执行):
36
+
37
+ ```bash
38
+ git clone https://github.com/wyouwd1/pi-multi-quota
39
+ cd pi-multi-quota
40
+ pi install .
41
+ ```
42
+
43
+ 装完重启 pi 或 `/reload`。
44
+
45
+ > footer 只在当前模型属于上表三家供应商之一时出现;切到其它 provider 时 footer 段会清空,这是预期行为。
46
+
47
+ 卸载时用**与安装时相同的 source 标识**:
48
+
49
+ ```bash
50
+ pi remove npm:pi-multi-quota # 从 npm 安装的
51
+ pi remove https://github.com/wyouwd1/pi-multi-quota # 从 GitHub 安装的
52
+ pi remove . # 从克隆目录安装的(在仓库根目录执行)
53
+ ```
54
+
55
+ ---
56
+
57
+ ## 命令
58
+
59
+ | 命令 | 作用 |
60
+ |---|---|
61
+ | `/quota` | 展开**当前供应商**明细(各窗口百分比 + 重置倒计时 / 余额明细) |
62
+ | `/quota all` | 一次查询三家(Ark 每个账号 + Zen + DeepSeek)。仅**当前 provider** 对应的数据源能取到凭据,其余两家显示 `✗ 未配置` |
63
+ | `/quota set <账号>` | 粘贴并**当场校验** Ark cookie(校验失败绝不保存) |
64
+ | `/quota list` | 列出已配置账号及其 cookie 剩余有效期 |
65
+ | `/quota close` | 关闭详情面板 |
66
+
67
+ ---
68
+
69
+ ## 配置 Ark cookie(重点)
70
+
71
+ Ark 的额度只能通过**控制台接口**读取,需要一份浏览器 cookie。这是唯一需要手动维护的东西。
72
+
73
+ ### 首次配置
74
+
75
+ 1. 浏览器打开并登录:`https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan`
76
+ 2. `F12` → Console → 输入 `document.cookie` → 回车 → **复制整段结果**
77
+ 3. **先创建账号槽位**:编辑 `~/.pi/agent/multi-quota.json`(不存在则新建;若 `~/.pi/agent/` 目录也不存在,先 `mkdir -p ~/.pi/agent`),内容为:
78
+
79
+ ```json
80
+ {"ark":{"accounts":[{"id":"ark-a","provider":"<你在 pi 里配置的 provider id>","cookie":""}]}}
81
+ ```
82
+
83
+ `/quota set` 要求槽位**已存在**,所以必须先建槽位、再用 `/quota set ark-a` 粘贴 cookie;
84
+ 此时 `cookie` 留空字符串即可(footer 会显示该槽位「未配置」而不是报错)。
85
+ `provider` 填你在 pi 里配置的 provider id(可在 pi 的 provider / 模型配置里查到)。它只用于识别「当前模型对应哪个 Ark 账号」,决定 footer 在窄终端下优先保住哪个账号的完整显示;数据源由模型 baseUrl 的 host 决定,填错不会报错、额度照常显示,但 footer 会退化为单窗口形态。
86
+ 建好文件后建议 `chmod 600 ~/.pi/agent/multi-quota.json`(手工创建时权限取决于 umask;扩展自己写入时会自动收紧到 600)。
87
+ 4. 回到 pi 运行 `/quota set ark-a` → 粘贴 → 回车
88
+ 5. 扩展会**立刻发一次真实请求**校验:
89
+ - 通过 → 保存(文件权限 `600`)→ footer 立即刷新,并回显该账号当前额度
90
+ - 失败 → 告诉你具体原因(`cookie 过期` / `cookie 不完整`),**不会写入任何东西**
91
+
92
+ ### 两个账号(或更多)
93
+
94
+ ⚠️ **火山 SSO 的登录态是浏览器级共享的** —— 在同一个浏览器里登录账号 B,会把账号 A 的 cookie 顶掉。
95
+
96
+ 所以要同时持有两份有效 cookie,需要:
97
+
98
+ - 一个用正常窗口,另一个用**隐身窗口 / 另一个浏览器 / 另一台设备**
99
+ - 先按「首次配置」第 3 步在 `~/.pi/agent/multi-quota.json` 的 `accounts` 数组里追加 `ark-b` 槽位(`id` 为 `ark-b`、`provider` 填账号 B 对应的 pi provider id、`cookie` 留空)
100
+ - 分别登录 → 各自复制一次 cookie → 分别 `/quota set ark-a`、`/quota set ark-b`
101
+
102
+ **防呆**:扩展会读取 cookie 里的 `AccountID`,如果新 cookie 与另一个槽位是**同一个火山账号**,会拒绝保存并提示 —— 避免两个槽位贴成同一份。
103
+
104
+ ### cookie 会过期
105
+
106
+ cookie 里的 `digest` 是 SSO access token,**约 24 小时失效**。
107
+
108
+ - 剩余 < 2 小时:开会话时 **toast 提示一次**(footer 不额外标记)
109
+ - 已失效:footer 显示 `Ark-A ✗ cookie 过期`(**不影响** Ark-B 等其他账号)
110
+ - 处理:重新走一遍上面的步骤,`/quota set ark-a` 覆盖即可
111
+
112
+ 用 `/quota list` 可以随时查看各账号还剩多久。
113
+
114
+ ---
115
+
116
+ ## 配置文件
117
+
118
+ 路径:`~/.pi/agent/multi-quota.json`(写入时自动设为 `600`)
119
+
120
+ ```json
121
+ {
122
+ "ark": {
123
+ "accounts": [
124
+ { "id": "ark-a", "provider": "volcengine", "cookie": "<粘贴的整段 cookie>" },
125
+ { "id": "ark-b", "provider": "volcengine-2", "cookie": "<粘贴的整段 cookie>" }
126
+ ]
127
+ }
128
+ }
129
+ ```
130
+
131
+ - `id`:账号标识,决定 footer 里的显示名(`ark-a` → `Ark-A`)
132
+ - `provider`:你在 pi 里的 provider id,仅用于把「当前模型」对应到 Ark 账号(footer 裁剪优先级);数据源按 baseUrl host 判定,与它无关
133
+ - `cookie`:留空字符串表示「尚未配置」,footer 会显示该槽位未配置而不是报错
134
+
135
+ > OpenCode 与 DeepSeek 的密钥**不需要写进这个文件** —— 扩展通过 pi 的凭据系统读取,不重复存储。
136
+
137
+ ---
138
+
139
+ ## 状态与错误对照
140
+
141
+ | footer 显示 | 含义 | 怎么办 |
142
+ |---|---|---|
143
+ | `Ark-A 5h 13% wk 37% mo 100%` | 正常 | — |
144
+ | `~Ark-A 5h 13% …` | 本次刷新失败,显示的是**上次成功数据** | 等待自动退避重试 |
145
+ | `Ark-A ✗ 未配置` / `Zen ✗ 未配置` | Ark:该槽位 cookie 为空;Zen / DeepSeek:当前 provider 的 key 取不到(如 `/quota all` 里的非当前供应商) | Ark 用 `/quota set ark-a`;Zen / DeepSeek 切到对应 provider 即恢复 |
146
+ | `Ark-A ✗ cookie 过期` | `digest` 已失效(约 24h) | `/quota set ark-a` |
147
+ | `Ark-A ✗ cookie 不完整` | 缺 `csrfToken`,多半是没复制全 | 重新复制**整段** cookie |
148
+ | `✗ 接口变更` | 响应结构不认识(供应商改了接口) | 需要更新本扩展 |
149
+ | `✗ 网络错误` / `✗ 超时` | 网络问题 | 自动退避(5min → 30min 封顶) |
150
+
151
+ ---
152
+
153
+ ## 安全边界
154
+
155
+ - cookie 只存在本地 `~/.pi/agent/multi-quota.json`,权限 `600`
156
+ - **cookie 原文不会出现在**日志、错误信息、footer、session 文件里 —— 错误信息只保留错误码
157
+ - 只向固定的供应商 host 发送凭据:`console.volcengine.com`(Ark)/ `opencode.ai`(OpenCode)/ `api.deepseek.com`(DeepSeek)。三家 URL 都是源码常量;Ark 另在发起前复核目标 host,白名单外直接拒绝
158
+ - 三个供应商的请求都带 `redirect: "error"`:拒绝跟随重定向,防止凭据被转发到其他 host
159
+ - **不**读取浏览器 cookie 数据库、**不**启动浏览器、**不**调用任何外部 CLI、**不**起子进程
160
+ - 额度数据不写入 session,也不发送给模型
161
+
162
+ ---
163
+
164
+ ## 开发
165
+
166
+ 前置要求:Node **>= 20**(见 `package.json` 的 `engines`),且已安装 pi 编码助手。
167
+ 仓库不含 `node_modules`,先装依赖再跑测试:
168
+
169
+ ```bash
170
+ npm install # 安装 tsx / typescript 等开发依赖
171
+ npm test # node --import tsx --test test/*.test.ts
172
+ npm run typecheck
173
+ pi -e ./src/index.ts # 临时加载(不写入 settings)
174
+ ```
175
+
176
+ ### 结构
177
+
178
+ ```
179
+ src/
180
+ ├── index.ts # 扩展入口:事件订阅 + /quota 命令族
181
+ ├── types.ts # 跨模块类型契约
182
+ ├── registry.ts # baseUrl host → 数据源分派 + 凭据解析
183
+ ├── cookie.ts # Ark cookie 解析(csrfToken / AccountID / digest exp)
184
+ ├── config.ts # 配置文件读写(原子写入 + 权限 600 + 脱敏)
185
+ ├── cache.ts # TTL 缓存 + stale 保留 + 并发去重 + 指数退避
186
+ ├── footer.ts # footer 渲染 + 宽度裁剪 / 详情渲染
187
+ └── sources/
188
+ ├── ark.ts # 控制台 GetCodingPlanUsage(POST + csrf)
189
+ ├── opencode.ts # /zen/go/v1/usage
190
+ └── deepseek.ts # /user/balance
191
+ ```
192
+
193
+ 设计与决策记录见 [`SPEC.md`](./SPEC.md)。
194
+
195
+ ### 已知限制
196
+
197
+ - Ark cookie **必须手动更新**(约每 24 小时一次);未实现自动续期 —— 控制台接口无公开刷新链路
198
+ - 只支持上述三个供应商,新增供应商需要写一个 `src/sources/*.ts` 适配器并在 `registry.ts` 注册
199
+ - 不做历史用量统计与图表
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "pi-multi-quota",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Pi extension: 纯 HTTP 查询 Ark 双账号 / OpenCode Go / DeepSeek 额度,footer 跟随当前模型展示",
6
+ "license": "MIT",
7
+ "author": "wyouwd1",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/wyouwd1/pi-multi-quota.git"
11
+ },
12
+ "files": ["src", "README.md", "LICENSE"],
13
+ "homepage": "https://github.com/wyouwd1/pi-multi-quota#readme",
14
+ "bugs": { "url": "https://github.com/wyouwd1/pi-multi-quota/issues" },
15
+ "keywords": ["pi-package", "pi-extension", "quota", "usage"],
16
+ "pi": { "extensions": ["./src/index.ts"] },
17
+ "scripts": {
18
+ "test": "node --import tsx --test test/*.test.ts",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "peerDependencies": { "@earendil-works/pi-coding-agent": "*" },
22
+ "devDependencies": {
23
+ "@earendil-works/pi-coding-agent": "*",
24
+ "@types/node": "^26.0.0",
25
+ "tsx": "^4.19.0",
26
+ "typescript": "^7.0.0"
27
+ },
28
+ "engines": { "node": ">=20" }
29
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * 额度缓存层:TTL、stale 标记、失败退避与并发去重。
3
+ *
4
+ * 设计要点:
5
+ * - 所有时间点通过 `now` 参数注入,测试不依赖真实计时器。
6
+ * - 缓存内部持有一份浅拷贝,`noteFailure` 不去修改调用方传入的对象。
7
+ * - 库文件静默,不打印任何日志;错误信息不落缓存(由调用方负责脱敏)。
8
+ */
9
+ import type { AccountReport } from "./types.js";
10
+
11
+ export interface QuotaCacheOptions {
12
+ /** 数据新鲜期,默认 5 分钟。 */
13
+ ttlMs?: number;
14
+ /** 退避初值,默认 5 分钟。 */
15
+ backoffBaseMs?: number;
16
+ /** 退避上限,默认 30 分钟。 */
17
+ backoffMaxMs?: number;
18
+ }
19
+
20
+ /**
21
+ * 缓存句柄。仅作为不透明标记类型对外暴露,
22
+ * 内部状态通过 CacheState 在模块内访问。
23
+ */
24
+ export interface QuotaCache {
25
+ readonly __brand: "QuotaCache";
26
+ }
27
+
28
+ const DEFAULT_TTL_MS = 5 * 60 * 1000;
29
+ const DEFAULT_BACKOFF_BASE_MS = 5 * 60 * 1000;
30
+ const DEFAULT_BACKOFF_MAX_MS = 30 * 60 * 1000;
31
+
32
+ /** 连续失败计数与下次允许查询的时刻。 */
33
+ interface FailureState {
34
+ count: number;
35
+ nextAllowedAt: number;
36
+ }
37
+
38
+ interface CacheState {
39
+ readonly __brand: "QuotaCache";
40
+ readonly ttlMs: number;
41
+ readonly backoffBaseMs: number;
42
+ readonly backoffMaxMs: number;
43
+ /** 最近一次数据(可能已过期、可能已标 stale)。 */
44
+ readonly reports: Map<string, AccountReport>;
45
+ readonly failures: Map<string, FailureState>;
46
+ /** 在途 Promise,key 由 dedupe 调用方决定。 */
47
+ readonly inflight: Map<string, Promise<unknown>>;
48
+ }
49
+
50
+ export function createCache(opts: QuotaCacheOptions = {}): QuotaCache {
51
+ const cache: CacheState = {
52
+ __brand: "QuotaCache",
53
+ ttlMs: opts.ttlMs ?? DEFAULT_TTL_MS,
54
+ backoffBaseMs: opts.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS,
55
+ backoffMaxMs: opts.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS,
56
+ reports: new Map(),
57
+ failures: new Map(),
58
+ inflight: new Map(),
59
+ };
60
+ return cache;
61
+ }
62
+
63
+ /** 把不透明句柄还原为内部状态。 */
64
+ function stateOf(cache: QuotaCache): CacheState {
65
+ return cache as CacheState;
66
+ }
67
+
68
+ /** 第 n 次连续失败(n ≥ 1)的退避时长,以 backoffMaxMs 封顶。 */
69
+ function backoffDelayMs(state: CacheState, failureCount: number): number {
70
+ const exponent = failureCount > 1 ? failureCount - 1 : 0;
71
+ const delay = state.backoffBaseMs * 2 ** exponent;
72
+ return Math.min(delay, state.backoffMaxMs);
73
+ }
74
+
75
+ /** 未过期(now - fetchedAt < ttlMs)才返回。 */
76
+ export function getFresh(cache: QuotaCache, accountId: string, now?: number): AccountReport | undefined {
77
+ const state = stateOf(cache);
78
+ const report = state.reports.get(accountId);
79
+ if (!report) return undefined;
80
+ const at = now ?? Date.now();
81
+ return at - report.fetchedAt < state.ttlMs ? report : undefined;
82
+ }
83
+
84
+ /** 即使已过期也返回,调用方据 `stale` / `fetchedAt` 判断展示方式。 */
85
+ export function getLastKnown(cache: QuotaCache, accountId: string): AccountReport | undefined {
86
+ return stateOf(cache).reports.get(accountId);
87
+ }
88
+
89
+ /** 记录一次成功数据;report.fetchedAt 有效时以它为准,否则用 now。 */
90
+ export function put(cache: QuotaCache, report: AccountReport, now?: number): void {
91
+ const state = stateOf(cache);
92
+ const fetchedAt = Number.isFinite(report.fetchedAt) ? report.fetchedAt : (now ?? Date.now());
93
+ state.reports.set(report.accountId, { ...report, fetchedAt });
94
+ }
95
+
96
+ /** 查询失败:已有数据标记为 stale,并累计失败计数用于退避。 */
97
+ export function noteFailure(cache: QuotaCache, accountId: string, now?: number): void {
98
+ const state = stateOf(cache);
99
+ const at = now ?? Date.now();
100
+
101
+ const existing = state.reports.get(accountId);
102
+ if (existing && existing.stale !== true) {
103
+ state.reports.set(accountId, { ...existing, stale: true });
104
+ }
105
+
106
+ const count = (state.failures.get(accountId)?.count ?? 0) + 1;
107
+ state.failures.set(accountId, { count, nextAllowedAt: at + backoffDelayMs(state, count) });
108
+ }
109
+
110
+ /** 查询成功:清空失败计数与退避窗口。 */
111
+ export function noteSuccess(cache: QuotaCache, accountId: string): void {
112
+ stateOf(cache).failures.delete(accountId);
113
+ }
114
+
115
+ /**
116
+ * 是否应当发起网络查询:
117
+ * 成功数据仍新鲜 → false;失败后尚在退避窗口内 → false;其余 → true。
118
+ */
119
+ export function shouldQuery(cache: QuotaCache, accountId: string, now?: number): boolean {
120
+ const state = stateOf(cache);
121
+ const at = now ?? Date.now();
122
+ if (getFresh(cache, accountId, at)) return false;
123
+
124
+ const failure = state.failures.get(accountId);
125
+ if (failure && at < failure.nextAllowedAt) return false;
126
+
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * 并发去重:同一 key 的在途 Promise 直接复用。
132
+ * 无论成功或失败都在 settle 后清除,避免永久缓存 rejected promise;
133
+ * fn 同步抛错时不入表(否则会留下永不 settle 的条目)。
134
+ */
135
+ export function dedupe<T>(cache: QuotaCache, key: string, fn: () => Promise<T>): Promise<T> {
136
+ const state = stateOf(cache);
137
+ const existing = state.inflight.get(key);
138
+ if (existing) return existing as Promise<T>;
139
+
140
+ let inner: Promise<T>;
141
+ try {
142
+ inner = fn();
143
+ } catch (err) {
144
+ return Promise.reject(err);
145
+ }
146
+
147
+ const tracked = inner.finally(() => {
148
+ if (state.inflight.get(key) === tracked) state.inflight.delete(key);
149
+ });
150
+ state.inflight.set(key, tracked);
151
+ return tracked;
152
+ }
package/src/config.ts ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 配置存储:读写 ~/.pi/agent/multi-quota.json,含权限加固、损坏保护与脱敏工具。
3
+ *
4
+ * 约束(SPEC.md §6 安全边界 · §12 Boundaries):
5
+ * - 文件损坏时只报错,**绝不覆盖**用户文件;
6
+ * - 写入走「临时文件 + rename」原子替换,权限固定 600;
7
+ * - redact() 供一切日志/错误信息使用,禁止 cookie / key 原文外泄。
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ /** 单个 Ark 账号的配置。 */
14
+ export interface ArkAccountConfig {
15
+ /** 账号 id,例如 "ark-a"。 */
16
+ id: string;
17
+ /** 绑定的 pi provider id,例如 "volcengine"。 */
18
+ provider: string;
19
+ /** 控制台 cookie 原文。 */
20
+ cookie: string;
21
+ }
22
+
23
+ /** 持久化配置结构。 */
24
+ export interface QuotaConfig {
25
+ ark: { accounts: ArkAccountConfig[] };
26
+ }
27
+
28
+ /** 测试用覆盖变量:指向隔离的配置文件,避免测试污染真实 ~/.pi/agent/。 */
29
+ const CONFIG_PATH_ENV = "PI_MULTI_QUOTA_CONFIG";
30
+
31
+ /** 配置文件绝对路径:~/.pi/agent/multi-quota.json。 */
32
+ export function configPath(): string {
33
+ const override = process.env[CONFIG_PATH_ENV];
34
+ if (override !== undefined && override.trim() !== "") return path.resolve(override);
35
+ return path.join(os.homedir(), ".pi", "agent", "multi-quota.json");
36
+ }
37
+
38
+ /** 默认空结构。每次返回新对象,避免调用方改到共享引用。 */
39
+ function defaultConfig(): QuotaConfig {
40
+ return { ark: { accounts: [] } };
41
+ }
42
+
43
+ function asObject(value: unknown): Record<string, unknown> | undefined {
44
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
45
+ return value as Record<string, unknown>;
46
+ }
47
+
48
+ /** 错误码,用于拼接不含敏感内容的错误信息。 */
49
+ function errorCode(err: unknown): string {
50
+ const code = asObject(err)?.["code"];
51
+ return typeof code === "string" ? code : "unknown";
52
+ }
53
+
54
+ /** 把磁盘上的任意 JSON 规范化成合法 QuotaConfig:缺字段补默认值,非法账号条目丢弃。 */
55
+ function normalizeConfig(raw: unknown): QuotaConfig {
56
+ const root = asObject(raw);
57
+ const ark = root === undefined ? undefined : asObject(root["ark"]);
58
+ const rawAccounts = ark === undefined || !Array.isArray(ark["accounts"]) ? [] : ark["accounts"];
59
+ const accounts: ArkAccountConfig[] = [];
60
+ for (const item of rawAccounts) {
61
+ const entry = asObject(item);
62
+ if (entry === undefined) continue;
63
+ const { id, provider, cookie } = entry;
64
+ if (typeof id !== "string" || typeof provider !== "string" || typeof cookie !== "string") continue;
65
+ accounts.push({ id, provider, cookie });
66
+ }
67
+ return { ark: { accounts } };
68
+ }
69
+
70
+ /** 文件不存在 → 返回默认空结构;JSON 损坏 → 抛错,绝不覆盖用户文件。 */
71
+ export function loadConfig(): QuotaConfig {
72
+ const file = configPath();
73
+ let text: string;
74
+ try {
75
+ text = fs.readFileSync(file, "utf8");
76
+ } catch (err) {
77
+ if (errorCode(err) === "ENOENT") return defaultConfig();
78
+ throw new Error(`读取配置失败:${file}(${errorCode(err)})`);
79
+ }
80
+ let parsed: unknown;
81
+ try {
82
+ parsed = JSON.parse(text);
83
+ } catch {
84
+ // 不覆盖用户文件:损坏时只报错,把修复权交回用户。
85
+ throw new Error(`配置文件不是合法 JSON,已保留原文件未做修改:${file}`);
86
+ }
87
+ return normalizeConfig(parsed);
88
+ }
89
+
90
+ /** 同进程内的临时文件序号,保证同毫秒多次写入不撞名。 */
91
+ let tmpCounter = 0;
92
+
93
+ /** 写入并将权限设为 600。先写临时文件再 rename,避免中断留下半截配置。 */
94
+ export function saveConfig(cfg: QuotaConfig): void {
95
+ const file = configPath();
96
+ const dir = path.dirname(file);
97
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
98
+
99
+ tmpCounter += 1;
100
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}-${tmpCounter}.tmp`);
101
+ const data = `${JSON.stringify(cfg, null, 2)}\n`;
102
+ try {
103
+ // flag "wx":同名临时文件已存在时直接失败,避免跟随符号链接写入他人文件
104
+ fs.writeFileSync(tmp, data, { encoding: "utf8", mode: 0o600, flag: "wx" });
105
+ fs.chmodSync(tmp, 0o600); // umask 可能放宽 mode,显式收紧
106
+ fs.renameSync(tmp, file);
107
+ fs.chmodSync(file, 0o600);
108
+ } catch (err) {
109
+ try {
110
+ fs.rmSync(tmp, { force: true });
111
+ } catch {
112
+ // 清理失败不掩盖原始写入错误
113
+ }
114
+ throw err;
115
+ }
116
+ }
117
+
118
+ const REDACTED = "<redacted>";
119
+
120
+ /**
121
+ * 敏感键值对:`digest=…` / `"csrfToken": "…"` / `CSRF-Token: …` 等形态,值一律替换。
122
+ * 键名列表是脱敏的唯一来源,新增键名只需改这里。
123
+ */
124
+ const KEY_VALUE_PATTERN =
125
+ /(["']?)(digest|csrf_?token|csrf-token|x-csrf-token|cookie|set-cookie|token|access_?token|refresh_?token|sessionid|session_id|jsessionid|session|sso_?token|passport|passwd|password|secret|api[_-]?key)(["']?\s*[=:]\s*)(["']?)([^\s"',;]+)/gi;
126
+
127
+ /** 形如 `k=v; k=v; ...` 的 cookie 串(≥2 对键值)。 */
128
+ const COOKIE_SHAPE_PATTERN = /[A-Za-z0-9_%.-]+=[^;\s"']*(?:\s*;\s*[A-Za-z0-9_%.-]+=[^;\s"']*)+/g;
129
+
130
+ /** `authorization` 行通常整段都是凭据,值一律抹掉。 */
131
+ const AUTHORIZATION_PATTERN = /(\bauthorization\b["']?\s*[=:]\s*)(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\r\n]+)/gi;
132
+
133
+ const BEARER_PATTERN = /(\bBearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
134
+
135
+ /** `sk-` / `ark-` 前缀的 API key。 */
136
+ const API_KEY_PATTERN = /\b(?:sk|ark)-[A-Za-z0-9_-]{6,}/g;
137
+
138
+ /** 只有含敏感键名的 cookie 串才整段替换,普通 `a=1; b=2` 文本保持原样。 */
139
+ function containsSensitiveKey(cookieText: string): boolean {
140
+ KEY_VALUE_PATTERN.lastIndex = 0; // 全局正则带状态,进出都复位
141
+ const found = KEY_VALUE_PATTERN.test(cookieText);
142
+ KEY_VALUE_PATTERN.lastIndex = 0;
143
+ return found;
144
+ }
145
+
146
+ /** 把文本中的 cookie/key 值替换为 <redacted>,供任何日志/错误信息使用。 */
147
+ export function redact(text: string): string {
148
+ return text
149
+ .replace(COOKIE_SHAPE_PATTERN, (match) => (containsSensitiveKey(match) ? REDACTED : match))
150
+ .replace(AUTHORIZATION_PATTERN, `$1${REDACTED}`)
151
+ .replace(KEY_VALUE_PATTERN, (_match, q1: string, key: string, sep: string, q2: string) => `${q1}${key}${sep}${q2}${REDACTED}`)
152
+ .replace(BEARER_PATTERN, `$1${REDACTED}`)
153
+ .replace(API_KEY_PATTERN, REDACTED);
154
+ }
155
+
156
+ export function findArkAccountByProvider(cfg: QuotaConfig, providerId: string): ArkAccountConfig | undefined {
157
+ return cfg.ark.accounts.find((account) => account.provider === providerId);
158
+ }
159
+
160
+ export function findArkAccountById(cfg: QuotaConfig, id: string): ArkAccountConfig | undefined {
161
+ return cfg.ark.accounts.find((account) => account.id === id);
162
+ }
163
+
164
+ /** 按 id upsert;若同 id 已存在则替换其 provider/cookie。入参与其中的账号对象都不被修改。 */
165
+ export function upsertArkAccount(cfg: QuotaConfig, account: ArkAccountConfig): QuotaConfig {
166
+ const next: ArkAccountConfig = { id: account.id, provider: account.provider, cookie: account.cookie };
167
+ const index = cfg.ark.accounts.findIndex((existing) => existing.id === account.id);
168
+ if (index === -1) return { ark: { accounts: [...cfg.ark.accounts, next] } };
169
+ return { ark: { accounts: cfg.ark.accounts.map((existing, i) => (i === index ? next : existing)) } };
170
+ }