dsh-commandcode-quota 0.0.0-stage → 0.2.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/src/index.js ADDED
@@ -0,0 +1,311 @@
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
+ *
10
+ * 数据源 (均为 Authorization: Bearer <key> 的 GET 请求):
11
+ * GET {base}/alpha/billing/credits 余额 & 窗口限额
12
+ * GET {base}/alpha/usage/summary 用量汇总
13
+ * GET {base}/alpha/billing/subscriptions 订阅信息
14
+ * GET {base}/alpha/whoami 用户信息
15
+ *
16
+ * 凭据通过 ctx.credentials 服务解析,不向客户端返回 API key。
17
+ *
18
+ * 配置 (cordis.patch.yml):
19
+ * apiKeyEnv: COMMANDCODE_API_KEY
20
+ * fallbackEnv: COMMANDCODE2_API_KEY # 可选回退凭据
21
+ * baseURL: https://api.commandcode.ai
22
+ *
23
+ * ESM module format (cordis bundle rule): named exports apply/inject/name.
24
+ */
25
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
26
+
27
+ const name = "dsh-commandcode-quota";
28
+ const inject = ["credentials"];
29
+
30
+ const DEFAULT_BASE_URL = "https://api.commandcode.ai";
31
+ const DEFAULT_API_KEY_ENV = "COMMANDCODE_API_KEY";
32
+ // Command Code 的 usage / subscriptions / whoami 实测要 8~20s, 8s 超时必然误杀。
33
+ const DEFAULT_TIMEOUT_MS = 30000;
34
+
35
+ // --- HTTP helpers ------------------------------------------------------------
36
+
37
+ function sendJson(res, status, obj) {
38
+ const body = JSON.stringify(obj);
39
+ res.writeHead(status, {
40
+ "content-type": "application/json; charset=utf-8",
41
+ "content-length": Buffer.byteLength(body),
42
+ "cache-control": "no-store"
43
+ });
44
+ res.end(body);
45
+ }
46
+
47
+ function readQuery(req, key) {
48
+ try {
49
+ const url = new URL(req.url ?? "/", "http://x");
50
+ return url.searchParams.get(key);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ // --- Command Code API client -------------------------------------------------
57
+
58
+ async function ccFetch(baseURL, path, apiKey, timeoutMs) {
59
+ const url = `${baseURL}${path}`;
60
+ const controller = new AbortController();
61
+ let timedOut = false;
62
+ const started = Date.now();
63
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
64
+ try {
65
+ const res = await fetch(url, {
66
+ method: "GET",
67
+ headers: {
68
+ authorization: `Bearer ${apiKey}`,
69
+ accept: "application/json",
70
+ "user-agent": "dsh-commandcode-quota/0.2.0"
71
+ },
72
+ signal: controller.signal
73
+ });
74
+ let json = null;
75
+ try { json = await res.json(); } catch { /* 非 JSON */ }
76
+ return { status: res.status, json, tookMs: Date.now() - started };
77
+ } catch (error) {
78
+ const detail = timedOut
79
+ ? `本插件超时 ${timeoutMs}ms (${Date.now() - started}ms)`
80
+ : `${error?.name ?? "Error"}: ${error?.message ?? String(error)} (${Date.now() - started}ms)`;
81
+ const wrapped = new Error(detail);
82
+ wrapped.cause = error;
83
+ wrapped.timedOut = timedOut;
84
+ throw wrapped;
85
+ } finally {
86
+ clearTimeout(timer);
87
+ }
88
+ }
89
+
90
+ /** 连接级偶发失败 (TypeError: fetch failed / socket reset) 重试一次;
91
+ * 本插件主动超时不重试 —— 服务器本来就慢, 再等一轮更慢。 */
92
+ async function ccFetchRetry(baseURL, path, apiKey, timeoutMs) {
93
+ try {
94
+ return await ccFetch(baseURL, path, apiKey, timeoutMs);
95
+ } catch (error) {
96
+ if (error?.timedOut) throw error;
97
+ return await ccFetch(baseURL, path, apiKey, timeoutMs);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * 各接口的写入 / 快照 / 回填: 便于统一处理"成功则写 + 缓存, 失败则回退上次值"。
103
+ */
104
+ const ENDPOINTS = [
105
+ ["credits", "/alpha/billing/credits"],
106
+ ["usage", "/alpha/usage/summary"],
107
+ ["subscriptions", "/alpha/billing/subscriptions"],
108
+ ["whoami", "/alpha/whoami"]
109
+ ];
110
+
111
+ const HANDLERS = {
112
+ credits: {
113
+ apply(json, out) { out.balance = json.credits; out.windowLimits = json.windowLimits; },
114
+ snapshot(out) { return out.balance ? { balance: out.balance, windowLimits: out.windowLimits } : null; },
115
+ restore(snap, out) { out.balance = snap.balance; out.windowLimits = snap.windowLimits; }
116
+ },
117
+ usage: {
118
+ apply(json, out) { out.usage = json; },
119
+ snapshot(out) { return out.usage ?? null; },
120
+ restore(snap, out) { out.usage = snap; }
121
+ },
122
+ subscriptions: {
123
+ apply(json, out) { if (json.data) out.subscription = json.data; },
124
+ snapshot(out) { return out.subscription ?? null; },
125
+ restore(snap, out) { out.subscription = snap; }
126
+ },
127
+ whoami: {
128
+ apply(json, out) { if (json.user) out.user = json.user; },
129
+ snapshot(out) { return out.user ?? null; },
130
+ restore(snap, out) { out.user = snap; }
131
+ }
132
+ };
133
+
134
+ /**
135
+ * 上次成功值的进程内缓存。
136
+ * Command Code 的 usage / subscriptions / whoami 实测响应 8~20s 且偶发 500,
137
+ * 超时或报错时用上次成功值兜底, 避免面板闪出"订阅信息暂不可用"。
138
+ */
139
+ const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
140
+ const lastGood = new Map();
141
+
142
+ function cacheGet(key) {
143
+ const hit = lastGood.get(key);
144
+ if (!hit) return null;
145
+ if (Date.now() - hit.at > CACHE_TTL_MS) return null;
146
+ return hit;
147
+ }
148
+
149
+ function cacheSet(key, value) {
150
+ if (value === undefined || value === null) return;
151
+ lastGood.set(key, { value, at: Date.now() });
152
+ }
153
+
154
+ /**
155
+ * 请求一个账号的完整仪表盘数据。
156
+ * 四个接口并行发出 (总耗时 = 最慢的一个, 而不是四个之和), 单接口失败不影响其他;
157
+ * 失败且曾有成功值时用缓存兜底, 并在 stale 里说明。
158
+ * onlyKeys 非空时只查这些接口 (scope=quick: 只查快的 credits),
159
+ * 其余字段用上次缓存回填, 并在 cached 里列出。
160
+ */
161
+ async function fetchDashboard(cfg, timeoutMs, onlyKeys) {
162
+ const errors = [];
163
+ const stale = [];
164
+ const out = { ok: true, fetchedAt: Date.now(), errors, stale };
165
+
166
+ const list = Array.isArray(onlyKeys) && onlyKeys.length > 0
167
+ ? ENDPOINTS.filter(([key]) => onlyKeys.includes(key))
168
+ : ENDPOINTS;
169
+
170
+ const results = await Promise.all(list.map(async ([key, path]) => {
171
+ try {
172
+ const res = await ccFetchRetry(cfg.baseURL, path, cfg.apiKey, timeoutMs);
173
+ return { key, status: res.status, json: res.json };
174
+ } catch (error) {
175
+ return { key, error: String(error) };
176
+ }
177
+ }));
178
+
179
+ for (const r of results) {
180
+ const handler = HANDLERS[r.key];
181
+ if (!r.error && r.status === 200 && r.json) {
182
+ handler.apply(r.json, out);
183
+ cacheSet(r.key, handler.snapshot(out));
184
+ continue;
185
+ }
186
+ const message = r.error
187
+ ? `请求失败: ${r.error}`
188
+ : (r.status === 401 ? "API key 无效或无权限 (HTTP 401)" : `HTTP ${r.status}`);
189
+ // 401 是凭据问题, 不能拿旧数据掩盖; 其余失败回退上次成功值。
190
+ const hit = r.status === 401 ? null : cacheGet(r.key);
191
+ if (hit) {
192
+ handler.restore(hit.value, out);
193
+ stale.push({ endpoint: r.key, message, at: hit.at });
194
+ } else {
195
+ errors.push({ endpoint: r.key, message });
196
+ }
197
+ }
198
+
199
+ // quick 模式跳过的接口: 用缓存回填 (不算 stale, 本次根本没查)
200
+ const skipped = ENDPOINTS.filter(([key]) => !list.some(([k]) => k === key));
201
+ if (skipped.length > 0) {
202
+ out.cached = [];
203
+ for (const [key] of skipped) {
204
+ const hit = cacheGet(key);
205
+ if (!hit) continue;
206
+ HANDLERS[key].restore(hit.value, out);
207
+ out.cached.push(key);
208
+ }
209
+ }
210
+
211
+ if (errors.length >= list.length) out.ok = false;
212
+ return out;
213
+ }
214
+
215
+ // --- 配置解析 ------------------------------------------------------------------
216
+
217
+ /** 单账号配置;fallbackEnv 仅在首选凭据缺失时使用,不做账号切换。 */
218
+ function normalizeAccount(config) {
219
+ return {
220
+ apiKeyEnv: typeof config?.apiKeyEnv === "string" && config.apiKeyEnv.length > 0 ? config.apiKeyEnv : DEFAULT_API_KEY_ENV,
221
+ fallbackEnv: typeof config?.fallbackEnv === "string" && config.fallbackEnv.length > 0 ? config.fallbackEnv : null,
222
+ baseURL: typeof config?.baseURL === "string" && config.baseURL.length > 0 ? config.baseURL : DEFAULT_BASE_URL
223
+ };
224
+ }
225
+
226
+ /** 解析单账号请求选项;每次现读凭据,缺失时尝试回退条目。 */
227
+ async function resolveAccountOptions(ctx, account) {
228
+ const credentials = ctx.get("credentials");
229
+ const tried = [];
230
+ const candidates = [account.apiKeyEnv, account.fallbackEnv].filter((v) => typeof v === "string" && v.length > 0);
231
+ let apiKey = "";
232
+
233
+ for (const envName of candidates) {
234
+ tried.push(envName);
235
+ if (!credentials) break;
236
+ try {
237
+ const resolved = await credentials.resolve(credentialRef(envName));
238
+ if (resolved && typeof resolved.value === "string" && resolved.value.length > 0) {
239
+ apiKey = resolved.value;
240
+ break;
241
+ }
242
+ } catch { /* 试下一个 */ }
243
+ }
244
+
245
+ return {
246
+ apiKey,
247
+ triedEnvs: tried,
248
+ baseURL: account.baseURL
249
+ };
250
+ }
251
+
252
+ // --- plugin ------------------------------------------------------------------
253
+
254
+ function apply(ctx, config) {
255
+ const account = normalizeAccount(config);
256
+ const timeoutMs = Number.isFinite(config?.timeoutMs) && config.timeoutMs > 0
257
+ ? config.timeoutMs
258
+ : DEFAULT_TIMEOUT_MS;
259
+
260
+ function registerHttp(host, targetCtx) {
261
+ targetCtx.effect(() => host.register({
262
+ kind: "exact",
263
+ path: "/__dsh-commandcode-quota/dashboard",
264
+ handler: async (req, res) => {
265
+ if (req.method !== "GET") {
266
+ sendJson(res, 405, { error: "method not allowed" });
267
+ return;
268
+ }
269
+ try {
270
+ const t0 = Date.now();
271
+ // scope=quick: 只查响应快的 credits, 用量/订阅用缓存回填 (客户端先渲染再补全)
272
+ const scope = readQuery(req, "scope");
273
+
274
+ const options = await resolveAccountOptions(ctx, account);
275
+ const meta = { ok: true };
276
+ if (options.apiKey.length === 0) {
277
+ sendJson(res, 200, {
278
+ ...meta,
279
+ ok: false,
280
+ error: `未配置 API key (尝试过: ${options.triedEnvs.join(", ") || "无"}); 请在 ~/.dsh/.credentials.yaml 中配置`,
281
+ keyConfigured: false
282
+ });
283
+ return;
284
+ }
285
+ const dashboard = await fetchDashboard(
286
+ options,
287
+ timeoutMs,
288
+ scope === "quick" ? ["credits"] : null
289
+ );
290
+ sendJson(res, 200, { ...meta, ...dashboard, keyConfigured: true, debug: { timeoutMs, scope: scope ?? "full", handlerMs: Date.now() - t0 } });
291
+ } catch (error) {
292
+ sendJson(res, 500, { ok: false, error: `仪表盘请求失败: ${String(error)}` });
293
+ }
294
+ }
295
+ }));
296
+ }
297
+
298
+ const ws = ctx.get("webServer");
299
+ if (ws !== undefined) {
300
+ registerHttp(ws, ctx);
301
+ } else {
302
+ ctx.inject(["webServer"], (sub) => {
303
+ registerHttp(sub.webServer, sub);
304
+ });
305
+ }
306
+
307
+ ctx.logger.info("[dsh-commandcode-quota] Command Code 单账号余额仪表盘已注册");
308
+ }
309
+
310
+ export { apply, fetchDashboard, inject, name };
311
+ export default { apply, inject, name };
@@ -0,0 +1,102 @@
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 } = {}) {
17
+ let registration, component, slot, stateIndex = 0;
18
+ const values = [open, data, false, 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, 4, "不再维护选中账号状态");
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.map(req => req.url), ["/__dsh-commandcode-quota/dashboard?scope=quick", "/__dsh-commandcode-quota/dashboard"]);
80
+ assert.ok(view.requests.every(req => req.options.cache === "no-store"));
81
+ assert.ok(!view.updates.some(update => update.index > 3));
82
+ const timerView = mount({ open: false, data: null });
83
+ assert.equal(timerView.nodes.filter(node => node.type === "button").length, 1);
84
+ const cleanup = timerView.effects[0]();
85
+ assert.equal(timerView.timers[0].ms, 3 * 60 * 1000);
86
+ cleanup();
87
+ assert.equal(timerView.timers[1].cleared, 1);
88
+ const failure = mount({ failed: true });
89
+ await failure.callbacks[0].fn();
90
+ assert.ok(failure.updates.some(update => update.index === 3 && update.value.includes("HTTP 503")));
91
+ assert.ok(failure.updates.some(update => update.index === 2 && update.value === false));
92
+ const manifest = JSON.parse(fs.readFileSync(join(plugin, "package.json"), "utf8"));
93
+ assert.equal(manifest.name, "dsh-commandcode-quota", "客户端注册 id 必须等于包名");
94
+ assert.equal(manifest.exports["./client"], "./src/client.js", "必须导出 ./client 供客户端模块加载");
95
+ for (const file of ["locale/en.json", "locale/zh.json"]) {
96
+ const meta = JSON.parse(fs.readFileSync(join(plugin, file), "utf8"));
97
+ assert.ok(meta.meta?.title && meta.meta?.description, `${file} 必须提供标题与描述`);
98
+ }
99
+ console.log("通过:无账号切换;额度展示、手动/定时刷新、错误处理、槽位注册保持可用。");
100
+ console.log("通过:包名与客户端注册 id 一致,locale 为合法 JSON。");
101
+ }
102
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });
@@ -0,0 +1,134 @@
1
+ // 模拟宿主、凭据与 API,不读取真实凭据,也不访问网络。
2
+ const fs = require("node:fs");
3
+ const vm = require("node:vm");
4
+ const assert = require("node:assert/strict");
5
+ const { createHash } = require("node:crypto");
6
+ const { join } = require("node:path");
7
+ const plugin = join(__dirname, "..");
8
+ const source = fs.readFileSync(join(plugin, "src/index.js"), "utf8");
9
+ const responses = {
10
+ "/alpha/billing/credits": { credits: { monthlyCredits: 70 }, windowLimits: { fiveHour: { used: 10, cap: 100 } } },
11
+ "/alpha/usage/summary": { totalMonthlyCredits: 30, totalTokens: 100, totalCost: 5, successRate: 98 },
12
+ "/alpha/billing/subscriptions": { data: { status: "active", currentPeriodEnd: "2026-11-01T00:00:00Z" } },
13
+ "/alpha/whoami": { user: { name: "测试账号" } },
14
+ };
15
+ function load() {
16
+ const requests = [];
17
+ let reply = async (url) => ({ ok: true, status: 200, json: async () => responses[new URL(url).pathname] });
18
+ const context = vm.createContext({
19
+ Buffer, URL, AbortController, setTimeout, clearTimeout, createHash,
20
+ credentialRef: (ref) => ref,
21
+ fetch: async (url, options) => { requests.push({ url, options }); return reply(url, options); },
22
+ });
23
+ vm.runInContext(source.replace(/^import .*;\r?$/gm, "").replace(/^export .*;\r?$/gm, "") +
24
+ "\nglobalThis.host = { apply, fetchDashboard, inject, name };", context);
25
+ return { host: context.host, requests, respond(fn) { reply = fn; } };
26
+ }
27
+ function mount(runtime, config = {}, keys = {}, lazy = false) {
28
+ let route;
29
+ const refs = [];
30
+ const web = { register(row) { route = row; return () => {}; } };
31
+ const credentials = { async resolve(ref) { refs.push(ref); return { value: keys[ref] ?? "" }; } };
32
+ const ctx = {
33
+ get(name) { return name === "credentials" ? credentials : name === "webServer" && !lazy ? web : undefined; },
34
+ effect(fn) { return fn(); },
35
+ inject(names, fn) { assert.deepEqual(Array.from(names), ["webServer"]); fn({ webServer: web, effect: ctx.effect }); },
36
+ logger: { info() {} },
37
+ };
38
+ runtime.host.apply(ctx, config);
39
+ assert.equal(runtime.host.name, "dsh-commandcode-quota");
40
+ assert.deepEqual(Array.from(runtime.host.inject), ["credentials"]);
41
+ assert.equal(route.kind, "exact");
42
+ assert.equal(route.path, "/__dsh-commandcode-quota/dashboard");
43
+ return {
44
+ refs,
45
+ async request(url = route.path, method = "GET") {
46
+ let status, headers, raw;
47
+ await route.handler({ method, url }, {
48
+ writeHead(code, values) { status = code; headers = values; },
49
+ end(body) { raw = body; },
50
+ });
51
+ assert.equal(headers["cache-control"], "no-store");
52
+ assert.equal(headers["content-length"], Buffer.byteLength(raw));
53
+ return { status, body: JSON.parse(raw), raw };
54
+ },
55
+ };
56
+ }
57
+ const config = { apiKeyEnv: "PRIMARY_TEST_KEY", fallbackEnv: "FALLBACK_TEST_KEY", baseURL: "https://mock.invalid" };
58
+ async function main() {
59
+ const runtime = load();
60
+ const app = mount(runtime, config, { PRIMARY_TEST_KEY: "fixture-primary" });
61
+ const full = await app.request();
62
+ assert.equal(full.status, 200);
63
+ assert.equal(full.body.ok, true);
64
+ assert.equal(full.body.balance.monthlyCredits, 70);
65
+ assert.equal(full.body.usage.totalMonthlyCredits, 30);
66
+ assert.equal(full.body.subscription.status, "active");
67
+ assert.equal(full.body.user.name, "测试账号");
68
+ for (const field of ["providers", "providerId", "providerLabel", "keyInfo", "warnings"]) {
69
+ assert.ok(!(field in full.body), `单账号响应不应包含 ${field}`);
70
+ }
71
+ assert.ok(!full.raw.includes("fixture-primary"));
72
+ assert.equal(runtime.requests.length, 4);
73
+ assert.ok(runtime.requests.every(req => req.options.headers.authorization === "Bearer fixture-primary"));
74
+ assert.deepEqual(app.refs, ["PRIMARY_TEST_KEY"]);
75
+ runtime.requests.length = 0;
76
+ const quick = await app.request("/__dsh-commandcode-quota/dashboard?scope=quick");
77
+ assert.equal(runtime.requests.length, 1);
78
+ assert.equal(new URL(runtime.requests[0].url).pathname, "/alpha/billing/credits");
79
+ assert.equal(quick.body.usage.totalMonthlyCredits, 30);
80
+ assert.deepEqual(quick.body.cached, ["usage", "subscriptions", "whoami"]);
81
+ runtime.respond(async () => ({ status: 503, json: async () => ({}) }));
82
+ const stale = await app.request();
83
+ assert.equal(stale.body.balance.monthlyCredits, 70);
84
+ assert.equal(stale.body.stale.length, 4);
85
+ runtime.respond(async () => ({ status: 401, json: async () => ({}) }));
86
+ const unauthorized = await app.request();
87
+ assert.equal(unauthorized.body.ok, false);
88
+ assert.equal(unauthorized.body.balance, undefined);
89
+ assert.equal(unauthorized.body.errors.length, 4);
90
+ runtime.requests.length = 0;
91
+ assert.equal((await app.request(undefined, "POST")).status, 405);
92
+ assert.equal(runtime.requests.length, 0);
93
+ const fallbackRuntime = load();
94
+ const fallback = mount(fallbackRuntime, config, { FALLBACK_TEST_KEY: "fixture-fallback" }, true);
95
+ assert.equal((await fallback.request("/__dsh-commandcode-quota/dashboard?scope=quick")).body.keyConfigured, true);
96
+ assert.deepEqual(fallback.refs, ["PRIMARY_TEST_KEY", "FALLBACK_TEST_KEY"]);
97
+ assert.equal(fallbackRuntime.requests[0].options.headers.authorization, "Bearer fixture-fallback");
98
+ const emptyRuntime = load();
99
+ const missing = await mount(emptyRuntime, config).request();
100
+ assert.equal(missing.body.keyConfigured, false);
101
+ assert.equal(missing.body.ok, false);
102
+ assert.equal(emptyRuntime.requests.length, 0);
103
+ const defaultRuntime = load();
104
+ assert.equal((await mount(defaultRuntime, {}, { COMMANDCODE_API_KEY: "fixture-default" }).request()).body.ok, true);
105
+ let attempts = 0;
106
+ const retryRuntime = load();
107
+ retryRuntime.respond(async (url) => {
108
+ if (++attempts === 1) throw new TypeError("模拟连接失败");
109
+ return { status: 200, json: async () => responses[new URL(url).pathname] };
110
+ });
111
+ assert.equal((await mount(retryRuntime, config, { PRIMARY_TEST_KEY: "fixture-primary" }).request("/__dsh-commandcode-quota/dashboard?scope=quick")).body.ok, true);
112
+ assert.equal(attempts, 2);
113
+ const timeoutRuntime = load();
114
+ timeoutRuntime.respond((url, options) => new Promise((resolve, reject) => {
115
+ options.signal.addEventListener("abort", () => reject(new Error("模拟超时")), { once: true });
116
+ }));
117
+ assert.equal((await mount(timeoutRuntime, { ...config, timeoutMs: 5 }, { PRIMARY_TEST_KEY: "fixture-primary" }).request("/__dsh-commandcode-quota/dashboard?scope=quick")).body.ok, false);
118
+ assert.equal(timeoutRuntime.requests.length, 1, "主动超时不重试");
119
+ // 包自检:声明、注册 id 与 bundle 补丁层三者必须一致。
120
+ const manifest = JSON.parse(fs.readFileSync(join(plugin, "package.json"), "utf8"));
121
+ assert.equal(manifest.name, "dsh-commandcode-quota", "package.json 的 name 必须等于宿主端导出 name");
122
+ assert.equal(manifest.dsh.bundle.patch, "./cordis.patch.yml", "必须声明 bundle 补丁层");
123
+ const patch = fs.readFileSync(join(plugin, "cordis.patch.yml"), "utf8");
124
+ assert.ok(
125
+ patch.includes(`id: ${manifest.name}`) && patch.includes(`name: '${manifest.name}'`),
126
+ "bundle 补丁的注册 id 与包名必须一致",
127
+ );
128
+ // 发版时最容易漏的一处:user-agent 里的版本号要与 package.json 同步。
129
+ const uaVersion = source.match(/"user-agent":\s*"[^"]*\/([^"]+)"/)?.[1];
130
+ assert.equal(uaVersion, manifest.version, "user-agent 版本必须与 package.json 的 version 一致");
131
+ console.log("通过:单账号响应、主/回退凭据、完整/快速查询、缓存、401、405、重试和超时。");
132
+ console.log("通过:宿主端导出、路由、包名与 bundle 补丁层一致。");
133
+ }
134
+ main().catch(error => { console.error(error.message); process.exitCode = 1; });