dsh-commandcode-quota 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +108 -2
- package/README.zh.md +109 -0
- package/cordis.patch.yml +21 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
- package/scripts/sync-version.cjs +33 -0
- package/src/client.js +761 -0
- package/src/index.js +504 -0
- package/test/client.test.cjs +160 -0
- package/test/host.test.cjs +259 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-commandcode-quota —— Command Code 余额额度仪表盘(单账号宿主端)
|
|
3
|
+
*
|
|
4
|
+
* 通过 Command Code 官方 API 读取余额与限额数据, 在 DSH Web 界面提供
|
|
5
|
+
* 一个与 OpenCode 余额查询类似的单账号仪表盘。
|
|
6
|
+
*
|
|
7
|
+
* GET /__dsh-commandcode-quota/dashboard → 余额 + 限额 + 用量
|
|
8
|
+
* GET /__dsh-commandcode-quota/dashboard?scope=quick → 余额优先,其余字段用缓存回填
|
|
9
|
+
* GET /__dsh-commandcode-quota/credentials → 候选凭据条目名 + 当前选择
|
|
10
|
+
* POST /__dsh-commandcode-quota/credentials → 保存选择的凭据条目名
|
|
11
|
+
*
|
|
12
|
+
* 数据源 (均为 Authorization: Bearer <key> 的 GET 请求):
|
|
13
|
+
* GET {base}/alpha/billing/credits 余额 & 窗口限额
|
|
14
|
+
* GET {base}/alpha/usage/summary 用量汇总
|
|
15
|
+
* GET {base}/alpha/billing/subscriptions 订阅信息
|
|
16
|
+
* GET {base}/alpha/whoami 用户信息
|
|
17
|
+
*
|
|
18
|
+
* 凭据通过 ctx.credentials 服务解析,不向客户端返回 API key。
|
|
19
|
+
*
|
|
20
|
+
* 配置 (cordis.patch.yml):
|
|
21
|
+
* apiKeyEnv: COMMANDCODE_API_KEY
|
|
22
|
+
* fallbackEnv: COMMANDCODE2_API_KEY # 可选回退凭据
|
|
23
|
+
* baseURL: https://api.commandcode.ai
|
|
24
|
+
*
|
|
25
|
+
* ESM module format (cordis bundle rule): named exports apply/inject/name.
|
|
26
|
+
*/
|
|
27
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
28
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { dirname, join } from "node:path";
|
|
31
|
+
|
|
32
|
+
const name = "dsh-commandcode-quota";
|
|
33
|
+
const inject = ["credentials"];
|
|
34
|
+
|
|
35
|
+
const DEFAULT_BASE_URL = "https://api.commandcode.ai";
|
|
36
|
+
const DEFAULT_API_KEY_ENV = "COMMANDCODE_API_KEY";
|
|
37
|
+
// Command Code 的 usage / subscriptions / whoami 实测要 8~20s, 8s 超时必然误杀。
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
39
|
+
|
|
40
|
+
// --- HTTP helpers ------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function sendJson(res, status, obj) {
|
|
43
|
+
const body = JSON.stringify(obj);
|
|
44
|
+
res.writeHead(status, {
|
|
45
|
+
"content-type": "application/json; charset=utf-8",
|
|
46
|
+
"content-length": Buffer.byteLength(body),
|
|
47
|
+
"cache-control": "no-store"
|
|
48
|
+
});
|
|
49
|
+
res.end(body);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readQuery(req, key) {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
55
|
+
return url.searchParams.get(key);
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Command Code API client -------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async function ccFetch(baseURL, path, apiKey, timeoutMs) {
|
|
64
|
+
const url = `${baseURL}${path}`;
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
let timedOut = false;
|
|
67
|
+
const started = Date.now();
|
|
68
|
+
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers: {
|
|
73
|
+
authorization: `Bearer ${apiKey}`,
|
|
74
|
+
accept: "application/json",
|
|
75
|
+
"user-agent": "dsh-commandcode-quota/0.2.1"
|
|
76
|
+
},
|
|
77
|
+
signal: controller.signal
|
|
78
|
+
});
|
|
79
|
+
let json = null;
|
|
80
|
+
try { json = await res.json(); } catch { /* 非 JSON */ }
|
|
81
|
+
return { status: res.status, json, tookMs: Date.now() - started };
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const detail = timedOut
|
|
84
|
+
? `本插件超时 ${timeoutMs}ms (${Date.now() - started}ms)`
|
|
85
|
+
: `${error?.name ?? "Error"}: ${error?.message ?? String(error)} (${Date.now() - started}ms)`;
|
|
86
|
+
const wrapped = new Error(detail);
|
|
87
|
+
wrapped.cause = error;
|
|
88
|
+
wrapped.timedOut = timedOut;
|
|
89
|
+
throw wrapped;
|
|
90
|
+
} finally {
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 连接级偶发失败 (TypeError: fetch failed / socket reset) 重试一次;
|
|
96
|
+
* 本插件主动超时不重试 —— 服务器本来就慢, 再等一轮更慢。 */
|
|
97
|
+
async function ccFetchRetry(baseURL, path, apiKey, timeoutMs) {
|
|
98
|
+
try {
|
|
99
|
+
return await ccFetch(baseURL, path, apiKey, timeoutMs);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error?.timedOut) throw error;
|
|
102
|
+
return await ccFetch(baseURL, path, apiKey, timeoutMs);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 各接口的写入 / 快照 / 回填: 便于统一处理"成功则写 + 缓存, 失败则回退上次值"。
|
|
108
|
+
*/
|
|
109
|
+
const ENDPOINTS = [
|
|
110
|
+
["credits", "/alpha/billing/credits"],
|
|
111
|
+
["usage", "/alpha/usage/summary"],
|
|
112
|
+
["subscriptions", "/alpha/billing/subscriptions"],
|
|
113
|
+
["whoami", "/alpha/whoami"]
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const HANDLERS = {
|
|
117
|
+
credits: {
|
|
118
|
+
apply(json, out) { out.balance = json.credits; out.windowLimits = json.windowLimits; },
|
|
119
|
+
snapshot(out) { return out.balance ? { balance: out.balance, windowLimits: out.windowLimits } : null; },
|
|
120
|
+
restore(snap, out) { out.balance = snap.balance; out.windowLimits = snap.windowLimits; }
|
|
121
|
+
},
|
|
122
|
+
usage: {
|
|
123
|
+
apply(json, out) { out.usage = json; },
|
|
124
|
+
snapshot(out) { return out.usage ?? null; },
|
|
125
|
+
restore(snap, out) { out.usage = snap; }
|
|
126
|
+
},
|
|
127
|
+
subscriptions: {
|
|
128
|
+
apply(json, out) { if (json.data) out.subscription = json.data; },
|
|
129
|
+
snapshot(out) { return out.subscription ?? null; },
|
|
130
|
+
restore(snap, out) { out.subscription = snap; }
|
|
131
|
+
},
|
|
132
|
+
whoami: {
|
|
133
|
+
apply(json, out) { if (json.user) out.user = json.user; },
|
|
134
|
+
snapshot(out) { return out.user ?? null; },
|
|
135
|
+
restore(snap, out) { out.user = snap; }
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 上次成功值的进程内缓存。
|
|
141
|
+
* Command Code 的 usage / subscriptions / whoami 实测响应 8~20s 且偶发 500,
|
|
142
|
+
* 超时或报错时用上次成功值兜底, 避免面板闪出"订阅信息暂不可用"。
|
|
143
|
+
*/
|
|
144
|
+
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
|
145
|
+
const lastGood = new Map();
|
|
146
|
+
|
|
147
|
+
function cacheGet(key) {
|
|
148
|
+
const hit = lastGood.get(key);
|
|
149
|
+
if (!hit) return null;
|
|
150
|
+
if (Date.now() - hit.at > CACHE_TTL_MS) return null;
|
|
151
|
+
return hit;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cacheSet(key, value) {
|
|
155
|
+
if (value === undefined || value === null) return;
|
|
156
|
+
lastGood.set(key, { value, at: Date.now() });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 请求一个账号的完整仪表盘数据。
|
|
161
|
+
* 四个接口并行发出 (总耗时 = 最慢的一个, 而不是四个之和), 单接口失败不影响其他;
|
|
162
|
+
* 失败且曾有成功值时用缓存兜底, 并在 stale 里说明。
|
|
163
|
+
* onlyKeys 非空时只查这些接口 (scope=quick: 只查快的 credits),
|
|
164
|
+
* 其余字段用上次缓存回填, 并在 cached 里列出。
|
|
165
|
+
*/
|
|
166
|
+
async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
|
|
167
|
+
const errors = [];
|
|
168
|
+
const stale = [];
|
|
169
|
+
const out = { ok: true, fetchedAt: Date.now(), errors, stale };
|
|
170
|
+
|
|
171
|
+
const list = Array.isArray(onlyKeys) && onlyKeys.length > 0
|
|
172
|
+
? ENDPOINTS.filter(([key]) => onlyKeys.includes(key))
|
|
173
|
+
: ENDPOINTS;
|
|
174
|
+
|
|
175
|
+
const results = await Promise.all(list.map(async ([key, path]) => {
|
|
176
|
+
try {
|
|
177
|
+
const res = await ccFetchRetry(cfg.baseURL, path, cfg.apiKey, timeoutMs);
|
|
178
|
+
return { key, status: res.status, json: res.json };
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { key, error: String(error) };
|
|
181
|
+
}
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
for (const r of results) {
|
|
185
|
+
const handler = HANDLERS[r.key];
|
|
186
|
+
if (!r.error && r.status === 200 && r.json) {
|
|
187
|
+
handler.apply(r.json, out);
|
|
188
|
+
cacheSet(r.key, handler.snapshot(out));
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const message = r.error
|
|
192
|
+
? `请求失败: ${r.error}`
|
|
193
|
+
: (r.status === 401 ? "API key 无效或无权限 (HTTP 401)" : `HTTP ${r.status}`);
|
|
194
|
+
// 401 是凭据问题, 不能拿旧数据掩盖; 其余失败回退上次成功值。
|
|
195
|
+
const hit = r.status === 401 ? null : cacheGet(r.key);
|
|
196
|
+
if (hit) {
|
|
197
|
+
handler.restore(hit.value, out);
|
|
198
|
+
stale.push({ endpoint: r.key, message, at: hit.at });
|
|
199
|
+
} else {
|
|
200
|
+
errors.push({ endpoint: r.key, message });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// quick 模式跳过的接口: 用缓存回填 (不算 stale, 本次根本没查)
|
|
205
|
+
const skipped = ENDPOINTS.filter(([key]) => !list.some(([k]) => k === key));
|
|
206
|
+
if (skipped.length > 0) {
|
|
207
|
+
out.cached = [];
|
|
208
|
+
for (const [key] of skipped) {
|
|
209
|
+
const hit = cacheGet(key);
|
|
210
|
+
if (!hit) continue;
|
|
211
|
+
HANDLERS[key].restore(hit.value, out);
|
|
212
|
+
out.cached.push(key);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (errors.length >= list.length) out.ok = false;
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// --- 配置解析 ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/** 单账号配置;fallbackEnv 仅在首选凭据缺失时使用,不做账号切换。 */
|
|
223
|
+
function normalizeAccount(config) {
|
|
224
|
+
return {
|
|
225
|
+
apiKeyEnv: typeof config?.apiKeyEnv === "string" && config.apiKeyEnv.length > 0 ? config.apiKeyEnv : DEFAULT_API_KEY_ENV,
|
|
226
|
+
fallbackEnv: typeof config?.fallbackEnv === "string" && config.fallbackEnv.length > 0 ? config.fallbackEnv : null,
|
|
227
|
+
baseURL: typeof config?.baseURL === "string" && config.baseURL.length > 0 ? config.baseURL : DEFAULT_BASE_URL
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 解析单账号请求选项;每次现读凭据,缺失时尝试回退条目。 */
|
|
232
|
+
async function resolveAccountOptions(ctx, account) {
|
|
233
|
+
const credentials = ctx.get("credentials");
|
|
234
|
+
const tried = [];
|
|
235
|
+
const candidates = [account.apiKeyEnv, account.fallbackEnv].filter((v) => typeof v === "string" && v.length > 0);
|
|
236
|
+
let apiKey = "";
|
|
237
|
+
|
|
238
|
+
for (const envName of candidates) {
|
|
239
|
+
tried.push(envName);
|
|
240
|
+
if (!credentials) break;
|
|
241
|
+
try {
|
|
242
|
+
const resolved = await credentials.resolve(credentialRef(envName));
|
|
243
|
+
if (resolved && typeof resolved.value === "string" && resolved.value.length > 0) {
|
|
244
|
+
apiKey = resolved.value;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
} catch { /* 试下一个 */ }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
apiKey,
|
|
252
|
+
triedEnvs: tried,
|
|
253
|
+
baseURL: account.baseURL
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- 凭据条目选择(面板里可切换)----------------------------------------------
|
|
258
|
+
//
|
|
259
|
+
// 两个约束决定了这里的写法:
|
|
260
|
+
// 1. ctx.credentials 只有 resolve/describe/set/unset,没有「列出条目」的能力,
|
|
261
|
+
// 所以候选列表只能自己从 .credentials.yaml 里取 —— 且【只取键名,绝不读值】。
|
|
262
|
+
// 2. 选择要持久化才能跨重启生效;写进 $DSH_HOME 下的一个小 JSON,优先级高于
|
|
263
|
+
// profile patch 里的 apiKeyEnv(面板里的选择是最新意图)。
|
|
264
|
+
|
|
265
|
+
/** 合法凭据条目名:POSIX shell 标识符,与 dsh-credentials 的 REF_PATTERN 一致。 */
|
|
266
|
+
const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
267
|
+
|
|
268
|
+
function dshHome() {
|
|
269
|
+
const fromEnv = process.env.DSH_HOME;
|
|
270
|
+
return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : join(homedir(), ".dsh");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function choiceFile() {
|
|
274
|
+
return join(dshHome(), "dsh-commandcode-quota.json");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 面板里保存过的选择;没有或损坏时返回全 null(回落到 patch 配置)。 */
|
|
278
|
+
function readChoice() {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(readFileSync(choiceFile(), "utf8"));
|
|
281
|
+
const pick = (v) => (typeof v === "string" && REF_PATTERN.test(v) ? v : null);
|
|
282
|
+
return { apiKeyEnv: pick(parsed?.apiKeyEnv), fallbackEnv: pick(parsed?.fallbackEnv) };
|
|
283
|
+
} catch {
|
|
284
|
+
return { apiKeyEnv: null, fallbackEnv: null };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function writeChoice(choice) {
|
|
289
|
+
const file = choiceFile();
|
|
290
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
291
|
+
writeFileSync(file, JSON.stringify(choice, null, 2) + "\n", "utf8");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 列出 .credentials.yaml 里 refs: 段下的条目名。
|
|
296
|
+
* 只解析键、跳过一切值 —— 返回的是名字,不是密钥。
|
|
297
|
+
*/
|
|
298
|
+
function readCredentialEntries() {
|
|
299
|
+
let text;
|
|
300
|
+
try {
|
|
301
|
+
text = readFileSync(join(dshHome(), ".credentials.yaml"), "utf8");
|
|
302
|
+
} catch {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
const names = [];
|
|
306
|
+
let inRefs = false;
|
|
307
|
+
let indent = -1;
|
|
308
|
+
for (const line of text.split(/\r?\n/)) {
|
|
309
|
+
if (/^refs:\s*$/.test(line)) { inRefs = true; continue; }
|
|
310
|
+
if (!inRefs) continue;
|
|
311
|
+
if (line.trim().length === 0) continue;
|
|
312
|
+
const m = line.match(/^(\s+)([A-Za-z_][A-Za-z0-9_]*):/);
|
|
313
|
+
if (!m) {
|
|
314
|
+
if (/^\S/.test(line)) break; // 回到顶层键,refs 段结束
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (indent < 0) indent = m[1].length;
|
|
318
|
+
if (m[1].length === indent) names.push(m[2]);
|
|
319
|
+
}
|
|
320
|
+
return names;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 读取请求体(上限 8KB,够放两个条目名)。 */
|
|
324
|
+
function readBody(req, limit = 8192) {
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
let body = "";
|
|
327
|
+
req.on("data", (chunk) => {
|
|
328
|
+
body += chunk;
|
|
329
|
+
if (body.length > limit) {
|
|
330
|
+
reject(new Error("请求体过大"));
|
|
331
|
+
req.destroy?.();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
req.on("end", () => resolve(body));
|
|
335
|
+
req.on("error", reject);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// --- plugin ------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* 挑出要展示的凭据条目。
|
|
343
|
+
*
|
|
344
|
+
* 口径:对不上模型供应商的条目说明没在用它,不必占位置。
|
|
345
|
+
* 但如果一个都对不上(凭据库还没和任何 provider 关联),就退回全部 ——
|
|
346
|
+
* 否则下拉是空的,反而没法选。
|
|
347
|
+
*/
|
|
348
|
+
function pickEntries(refs, labels) {
|
|
349
|
+
const all = refs.map((ref) => ({ ref, provider: labels[ref] ?? null }));
|
|
350
|
+
const matched = all.filter((entry) => entry.provider);
|
|
351
|
+
return matched.length > 0 ? matched : all;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 凭据条目名 → 模型供应商显示名。
|
|
356
|
+
*
|
|
357
|
+
* 这层对应本来就存在于模型配置里(provider.apiKeyEnv 指向条目名),所以读
|
|
358
|
+
* llm-pi-ai 的 providers 就能把 "COMMANDCODE2_API_KEY" 翻译成用户在模型设置页
|
|
359
|
+
* 看到的 "commandcode2"。读不到就返回空表,调用方回落到只显示条目名。
|
|
360
|
+
*
|
|
361
|
+
* 用 ctx.get("settings") 而不是 inject —— 读不到时降级即可,
|
|
362
|
+
* 不必因为缺少这个服务就让整个插件不激活。
|
|
363
|
+
*/
|
|
364
|
+
function readProviderLabels(ctx) {
|
|
365
|
+
// 只能用 ctx.get:cordis 里访问未注入的服务属性(ctx.settings)会直接抛异常,
|
|
366
|
+
// 而这里的原则是「读不到就降级」,不该让整个端点跟着失败。
|
|
367
|
+
if (typeof ctx.get !== "function") return {};
|
|
368
|
+
let service;
|
|
369
|
+
try {
|
|
370
|
+
service = ctx.get("settings");
|
|
371
|
+
} catch {
|
|
372
|
+
return {};
|
|
373
|
+
}
|
|
374
|
+
if (typeof service?.describe !== "function") return {};
|
|
375
|
+
let rows;
|
|
376
|
+
try {
|
|
377
|
+
rows = service.describe();
|
|
378
|
+
} catch {
|
|
379
|
+
return {};
|
|
380
|
+
}
|
|
381
|
+
const row = (Array.isArray(rows) ? rows : []).find((r) => r?.ns === "llm-pi-ai");
|
|
382
|
+
const providers = row?.user?.providers;
|
|
383
|
+
if (!providers || typeof providers !== "object") return {};
|
|
384
|
+
const labels = {};
|
|
385
|
+
for (const [id, cfg] of Object.entries(providers)) {
|
|
386
|
+
const ref = cfg?.apiKeyEnv;
|
|
387
|
+
if (typeof ref !== "string" || ref.length === 0) continue;
|
|
388
|
+
const label = typeof cfg?.displayName === "string" && cfg.displayName.length > 0 ? cfg.displayName : id;
|
|
389
|
+
if (!(ref in labels)) labels[ref] = label;
|
|
390
|
+
}
|
|
391
|
+
return labels;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function apply(ctx, config) {
|
|
395
|
+
const base = normalizeAccount(config);
|
|
396
|
+
const timeoutMs = Number.isFinite(config?.timeoutMs) && config.timeoutMs > 0
|
|
397
|
+
? config.timeoutMs
|
|
398
|
+
: DEFAULT_TIMEOUT_MS;
|
|
399
|
+
|
|
400
|
+
// 每次请求重新算:面板里刚改过选择就立刻生效,不必重载插件。
|
|
401
|
+
function currentAccount() {
|
|
402
|
+
const saved = readChoice();
|
|
403
|
+
return {
|
|
404
|
+
...base,
|
|
405
|
+
apiKeyEnv: saved.apiKeyEnv ?? base.apiKeyEnv,
|
|
406
|
+
fallbackEnv: saved.fallbackEnv ?? base.fallbackEnv
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function registerHttp(host, targetCtx) {
|
|
411
|
+
targetCtx.effect(() => host.register({
|
|
412
|
+
kind: "exact",
|
|
413
|
+
path: "/__dsh-commandcode-quota/dashboard",
|
|
414
|
+
handler: async (req, res) => {
|
|
415
|
+
if (req.method !== "GET") {
|
|
416
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
const t0 = Date.now();
|
|
421
|
+
// scope=quick: 只查响应快的 credits, 用量/订阅用缓存回填 (客户端先渲染再补全)
|
|
422
|
+
const scope = readQuery(req, "scope");
|
|
423
|
+
|
|
424
|
+
const options = await resolveAccountOptions(ctx, currentAccount());
|
|
425
|
+
const meta = { ok: true };
|
|
426
|
+
if (options.apiKey.length === 0) {
|
|
427
|
+
sendJson(res, 200, {
|
|
428
|
+
...meta,
|
|
429
|
+
ok: false,
|
|
430
|
+
error: `未配置 API key (尝试过: ${options.triedEnvs.join(", ") || "无"})。用面板上方的「凭据条目」选一个已有条目,或在 DSH 设置 → 凭据里添加`,
|
|
431
|
+
keyConfigured: false
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const dashboard = await fetchDashboard(
|
|
436
|
+
options,
|
|
437
|
+
timeoutMs,
|
|
438
|
+
scope === "quick" ? ["credits"] : null
|
|
439
|
+
);
|
|
440
|
+
sendJson(res, 200, { ...meta, ...dashboard, keyConfigured: true, debug: { timeoutMs, scope: scope ?? "full", handlerMs: Date.now() - t0 } });
|
|
441
|
+
} catch (error) {
|
|
442
|
+
sendJson(res, 500, { ok: false, error: `仪表盘请求失败: ${String(error)}` });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}));
|
|
446
|
+
|
|
447
|
+
// 凭据条目:GET 列出候选 + 当前选择;POST 保存选择。
|
|
448
|
+
// 无论如何都可用 —— 面板没配 key 时正是最需要它的时候。
|
|
449
|
+
targetCtx.effect(() => host.register({
|
|
450
|
+
kind: "exact",
|
|
451
|
+
path: "/__dsh-commandcode-quota/credentials",
|
|
452
|
+
handler: async (req, res) => {
|
|
453
|
+
if (req.method === "GET") {
|
|
454
|
+
const account = currentAccount();
|
|
455
|
+
const labels = readProviderLabels(ctx);
|
|
456
|
+
sendJson(res, 200, {
|
|
457
|
+
ok: true,
|
|
458
|
+
// 只列能对上模型供应商的条目(口径见 pickEntries)
|
|
459
|
+
entries: pickEntries(readCredentialEntries(), labels),
|
|
460
|
+
apiKeyEnv: account.apiKeyEnv,
|
|
461
|
+
fallbackEnv: account.fallbackEnv,
|
|
462
|
+
chosen: readChoice()
|
|
463
|
+
});
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (req.method === "POST") {
|
|
467
|
+
try {
|
|
468
|
+
const parsed = JSON.parse((await readBody(req)) || "{}");
|
|
469
|
+
const next = { apiKeyEnv: null, fallbackEnv: null };
|
|
470
|
+
for (const key of ["apiKeyEnv", "fallbackEnv"]) {
|
|
471
|
+
const value = parsed?.[key];
|
|
472
|
+
if (value === null || value === undefined || value === "") continue;
|
|
473
|
+
if (typeof value !== "string" || !REF_PATTERN.test(value)) {
|
|
474
|
+
sendJson(res, 400, { ok: false, error: `凭据条目名不合法: ${JSON.stringify(value)}` });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
next[key] = value;
|
|
478
|
+
}
|
|
479
|
+
writeChoice(next);
|
|
480
|
+
sendJson(res, 200, { ok: true, ...next });
|
|
481
|
+
} catch (error) {
|
|
482
|
+
sendJson(res, 400, { ok: false, error: `保存失败: ${String(error)}` });
|
|
483
|
+
}
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
487
|
+
}
|
|
488
|
+
}));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const ws = ctx.get("webServer");
|
|
492
|
+
if (ws !== undefined) {
|
|
493
|
+
registerHttp(ws, ctx);
|
|
494
|
+
} else {
|
|
495
|
+
ctx.inject(["webServer"], (sub) => {
|
|
496
|
+
registerHttp(sub.webServer, sub);
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
ctx.logger.info("[dsh-commandcode-quota] Command Code 单账号余额仪表盘已注册");
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export { apply, fetchDashboard, inject, name };
|
|
504
|
+
export default { apply, inject, name };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// 用轻量 React/请求替身测试前端;不访问网络,不操作 DSH 进程。
|
|
2
|
+
const fs = require("node:fs");
|
|
3
|
+
const vm = require("node:vm");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const { join } = require("node:path");
|
|
6
|
+
const plugin = join(__dirname, "..");
|
|
7
|
+
const fixture = {
|
|
8
|
+
ok: true, providerId: "cc2",
|
|
9
|
+
providers: [{ id: "cc1", label: "账号 1" }, { id: "cc2", label: "账号 2" }],
|
|
10
|
+
balance: { monthlyCredits: 70 },
|
|
11
|
+
usage: { totalMonthlyCredits: 30, totalTokens: 100, totalCost: 5, successRate: 98 },
|
|
12
|
+
windowLimits: { fiveHour: { used: 10, cap: 100 }, weekly: { used: 20, cap: 100 } },
|
|
13
|
+
// 月度窗口的重置时间取自订阅周期末,是 ISO 字符串而非毫秒数
|
|
14
|
+
subscription: { status: "active", currentPeriodEnd: new Date(Date.now() + 20 * 86400000).toISOString() },
|
|
15
|
+
};
|
|
16
|
+
function mount({ open = true, data = fixture, failed = false, creds = null } = {}) {
|
|
17
|
+
let registration, component, slot, stateIndex = 0;
|
|
18
|
+
const values = [open, data, false, null, creds || { entries: [], apiKeyEnv: null, saving: false, error: null }];
|
|
19
|
+
const updates = [], callbacks = [], effects = [], requests = [], timers = [];
|
|
20
|
+
const React = {
|
|
21
|
+
Fragment: "fragment",
|
|
22
|
+
createElement(type, props, ...children) { return { type, props: props || {}, children }; },
|
|
23
|
+
useState(initial) {
|
|
24
|
+
const index = stateIndex++;
|
|
25
|
+
return [index < values.length ? values[index] : initial, value => updates.push({ index, value })];
|
|
26
|
+
},
|
|
27
|
+
useCallback(fn, deps) { callbacks.push({ fn, deps }); return fn; },
|
|
28
|
+
useEffect(fn) { effects.push(fn); },
|
|
29
|
+
useRef(value) { return { current: value }; },
|
|
30
|
+
};
|
|
31
|
+
vm.runInNewContext(fs.readFileSync(join(plugin, "src/client.js"), "utf8"), {
|
|
32
|
+
window: { __ModuleLoader__: { load(row) { registration = row; } } },
|
|
33
|
+
fetch: async (url, options) => {
|
|
34
|
+
requests.push({ url, options });
|
|
35
|
+
return { ok: !failed, status: failed ? 503 : 200, json: async () => fixture };
|
|
36
|
+
},
|
|
37
|
+
setInterval(fn, ms) { timers.push({ fn, ms }); return 1; },
|
|
38
|
+
clearInterval(id) { timers.push({ cleared: id }); },
|
|
39
|
+
});
|
|
40
|
+
assert.equal(registration.id, "dsh-commandcode-quota");
|
|
41
|
+
const client = registration.factory(() => React);
|
|
42
|
+
assert.deepEqual(Array.from(client.inject), ["slots"]);
|
|
43
|
+
client.apply({ slots: {
|
|
44
|
+
inject(name, fn) { assert.equal(name, "conversation.composer.dock"); fn(); },
|
|
45
|
+
register(row, fn) { slot = row; component = fn; },
|
|
46
|
+
} });
|
|
47
|
+
assert.equal(slot.id, "dsh-commandcode-quota-dashboard");
|
|
48
|
+
assert.equal(slot.order, -1);
|
|
49
|
+
const tree = component(), nodes = [], texts = [];
|
|
50
|
+
function visit(node) {
|
|
51
|
+
if (Array.isArray(node)) return node.forEach(visit);
|
|
52
|
+
if (node == null || typeof node === "boolean") return;
|
|
53
|
+
if (typeof node !== "object") return texts.push(String(node));
|
|
54
|
+
if (typeof node.type === "function") return visit(node.type(node.props));
|
|
55
|
+
nodes.push(node);
|
|
56
|
+
visit(node.children);
|
|
57
|
+
}
|
|
58
|
+
visit(tree);
|
|
59
|
+
return { nodes, texts, updates, callbacks, effects, requests, timers, stateIndex };
|
|
60
|
+
}
|
|
61
|
+
async function main() {
|
|
62
|
+
const view = mount();
|
|
63
|
+
const buttons = view.nodes.filter(node => node.type === "button");
|
|
64
|
+
assert.equal(buttons.length, 3, "只保留状态栏、刷新和关闭按钮,不应存在账号切换按钮");
|
|
65
|
+
assert.equal(view.stateIndex, 5, "状态:面板开合 / 数据 / 加载 / 错误 / 凭据条目");
|
|
66
|
+
assert.equal(view.callbacks[0].deps.length, 0);
|
|
67
|
+
for (const label of ["5h 10%", "周 20%", "月 30%", "$70.00", "5 小时窗口", "每周窗口", "每月窗口"]) {
|
|
68
|
+
assert.ok(view.texts.includes(label), `保留额度展示:${label}`);
|
|
69
|
+
}
|
|
70
|
+
// 回归:月度重置时间来自 ISO 字符串,未经 toMs 归一化会渲染成 "NaN分NaN秒"
|
|
71
|
+
const resetTexts = view.texts.filter((t) => t.includes("重置"));
|
|
72
|
+
assert.equal(resetTexts.length, 3, "三个窗口都应显示重置信息");
|
|
73
|
+
for (const t of resetTexts) assert.ok(!t.includes("NaN"), `重置文本不应出现 NaN:${t}`);
|
|
74
|
+
assert.ok(
|
|
75
|
+
resetTexts.some((t) => /\d+天\d+小时(\d{4}\/\d{2}\/\d{2})/.test(t)),
|
|
76
|
+
`月度应显示天级倒计时与到期日,实际:${resetTexts.join(" | ")}`,
|
|
77
|
+
);
|
|
78
|
+
await buttons.find(node => node.props.title === "刷新").props.onClick();
|
|
79
|
+
assert.deepEqual(view.requests.filter((r) => r.url.includes("/dashboard")).map((r) => r.url), [
|
|
80
|
+
"/__dsh-commandcode-quota/dashboard?scope=quick",
|
|
81
|
+
"/__dsh-commandcode-quota/dashboard",
|
|
82
|
+
]);
|
|
83
|
+
assert.ok(view.requests.every(req => req.options.cache === "no-store"));
|
|
84
|
+
assert.ok(!view.updates.some(update => update.index > 3));
|
|
85
|
+
|
|
86
|
+
// 这个 mock 的 useEffect 只记录不执行,所以在这里手动触发挂载逻辑:
|
|
87
|
+
// 挂载时必须顺带拉一次凭据条目,下拉才会有候选
|
|
88
|
+
view.effects[0]();
|
|
89
|
+
assert.ok(
|
|
90
|
+
view.requests.some((r) => r.url === "/__dsh-commandcode-quota/credentials"),
|
|
91
|
+
"挂载时应拉取凭据条目列表",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// 凭据条目下拉存在的意义就是「没配 key 时也能选」,所以每种面板状态都必须能看到它
|
|
95
|
+
const pickerState = {
|
|
96
|
+
entries: [
|
|
97
|
+
{ ref: "BAILIAN_API_KEY", provider: "bailian" },
|
|
98
|
+
{ ref: "COMMANDCODE2_API_KEY", provider: "commandcode2" },
|
|
99
|
+
{ ref: "CC_ACCOUNT2_API_KEY", provider: null },
|
|
100
|
+
],
|
|
101
|
+
apiKeyEnv: "COMMANDCODE2_API_KEY",
|
|
102
|
+
saving: false,
|
|
103
|
+
error: null,
|
|
104
|
+
};
|
|
105
|
+
for (const [label, options] of [
|
|
106
|
+
["正常态", {}],
|
|
107
|
+
["报错态", { failed: true }],
|
|
108
|
+
["无数据态", { data: null }],
|
|
109
|
+
]) {
|
|
110
|
+
const view2 = mount({ ...options, creds: pickerState });
|
|
111
|
+
const selects = view2.nodes.filter((n) => n.type === "select");
|
|
112
|
+
assert.equal(selects.length, 1, `${label}下应能看到凭据条目下拉`);
|
|
113
|
+
const opts = view2.nodes.filter((n) => n.type === "option");
|
|
114
|
+
assert.deepEqual(
|
|
115
|
+
opts.map((o) => o.props.value),
|
|
116
|
+
["", "BAILIAN_API_KEY", "COMMANDCODE2_API_KEY", "CC_ACCOUNT2_API_KEY"],
|
|
117
|
+
`${label}下的候选项不正确`,
|
|
118
|
+
);
|
|
119
|
+
// 能对上模型供应商的显示「供应商(条目名)」,对不上的只显示条目名
|
|
120
|
+
assert.deepEqual(opts.map((o) => o.children[0]), [
|
|
121
|
+
"(未设置)",
|
|
122
|
+
"bailian(BAILIAN_API_KEY)",
|
|
123
|
+
"commandcode2(COMMANDCODE2_API_KEY)",
|
|
124
|
+
"CC_ACCOUNT2_API_KEY",
|
|
125
|
+
], `${label}下的显示文案不正确`);
|
|
126
|
+
assert.equal(selects[0].props.value, "COMMANDCODE2_API_KEY", `${label}下应选中当前条目`);
|
|
127
|
+
}
|
|
128
|
+
// 宿主端读不到供应商时会退化成纯字符串数组,也必须照常工作
|
|
129
|
+
const plain = mount({ creds: { entries: ["BAILIAN_API_KEY"], apiKeyEnv: null, saving: false, error: null } });
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
plain.nodes.filter((n) => n.type === "option").map((o) => o.children[0]),
|
|
132
|
+
["(未设置)", "BAILIAN_API_KEY"],
|
|
133
|
+
);
|
|
134
|
+
// 配置指向一个尚未添加的条目时也要出现在候选里,否则下拉会显示成空白
|
|
135
|
+
const orphan = mount({ creds: { entries: [{ ref: "OTHER_KEY", provider: null }], apiKeyEnv: "MISSING_KEY", saving: false, error: null } });
|
|
136
|
+
assert.deepEqual(
|
|
137
|
+
orphan.nodes.filter((n) => n.type === "option").map((o) => o.props.value),
|
|
138
|
+
["", "MISSING_KEY", "OTHER_KEY"],
|
|
139
|
+
);
|
|
140
|
+
const timerView = mount({ open: false, data: null });
|
|
141
|
+
assert.equal(timerView.nodes.filter(node => node.type === "button").length, 1);
|
|
142
|
+
const cleanup = timerView.effects[0]();
|
|
143
|
+
assert.equal(timerView.timers[0].ms, 3 * 60 * 1000);
|
|
144
|
+
cleanup();
|
|
145
|
+
assert.equal(timerView.timers[1].cleared, 1);
|
|
146
|
+
const failure = mount({ failed: true });
|
|
147
|
+
await failure.callbacks[0].fn();
|
|
148
|
+
assert.ok(failure.updates.some(update => update.index === 3 && update.value.includes("HTTP 503")));
|
|
149
|
+
assert.ok(failure.updates.some(update => update.index === 2 && update.value === false));
|
|
150
|
+
const manifest = JSON.parse(fs.readFileSync(join(plugin, "package.json"), "utf8"));
|
|
151
|
+
assert.equal(manifest.name, "dsh-commandcode-quota", "客户端注册 id 必须等于包名");
|
|
152
|
+
assert.equal(manifest.exports["./client"], "./src/client.js", "必须导出 ./client 供客户端模块加载");
|
|
153
|
+
for (const file of ["locale/en.json", "locale/zh.json"]) {
|
|
154
|
+
const meta = JSON.parse(fs.readFileSync(join(plugin, file), "utf8"));
|
|
155
|
+
assert.ok(meta.meta?.title && meta.meta?.description, `${file} 必须提供标题与描述`);
|
|
156
|
+
}
|
|
157
|
+
console.log("通过:无账号切换;额度展示、手动/定时刷新、错误处理、槽位注册保持可用。");
|
|
158
|
+
console.log("通过:包名与客户端注册 id 一致,locale 为合法 JSON。");
|
|
159
|
+
}
|
|
160
|
+
main().catch(error => { console.error(error.message); process.exitCode = 1; });
|