dsh-subusage 0.7.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/README.md +187 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +1096 -0
- package/lib/index.js +534 -0
- package/lib/mimo-login.js +122 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +66 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
// dsh-subusage Host:秘密仅保存在 Host,远端只暴露 provider 补丁和公开设置。
|
|
2
|
+
import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
3
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
4
|
+
import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
|
|
5
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { MimoLogin } from "./mimo-login.js";
|
|
10
|
+
|
|
11
|
+
const name = "dsh-subusage";
|
|
12
|
+
const inject = ["llm"];
|
|
13
|
+
const PROVIDERS = {
|
|
14
|
+
"zai-coding-cn": { label: "Z.ai", envName: "ZAI_CODING_CN_API_KEY" },
|
|
15
|
+
"zai-coding": { label: "Z.ai International", envName: "ZAI_CODING_API_KEY", defaultEnabled: false },
|
|
16
|
+
"synthetic": { label: "Synthetic", envName: "SYNTHETIC_API_KEY", defaultEnabled: false },
|
|
17
|
+
"nanogpt": { label: "NanoGPT", envName: "NANOGPT_API_KEY", defaultEnabled: false },
|
|
18
|
+
"kimi-coding": { label: "Kimi", envName: "KIMI_CODING_API_KEY" },
|
|
19
|
+
"xiaomi-token-plan-cn": { label: "MiMo", envName: "XIAOMI_TOKEN_PLAN_CN_API_KEY" },
|
|
20
|
+
"opencode-go": { label: "OpenCode Go", envName: "OPENCODE_API_KEY" },
|
|
21
|
+
"minimax": { label: "MiniMax", envName: "MINIMAX_API_KEY", usageUrl: "https://api.minimax.io/v1/token_plan/remains" },
|
|
22
|
+
"minimax-cn": { label: "MiniMax CN", envName: "MINIMAX_CN_API_KEY", usageUrl: "https://api.minimaxi.com/v1/token_plan/remains" },
|
|
23
|
+
// Commandcode 路由由另一个插件(@mars-sea/dsh-commandcode-provider)注册:凭据、多账户与
|
|
24
|
+
// API 地址都在那边管理。本插件不保存其凭据,只沿用同一凭据来源链自取 Key 后直连计费接口。
|
|
25
|
+
"commandcode": { label: "Command Code", envName: "COMMANDCODE_API_KEY", managedByPlugin: true }
|
|
26
|
+
};
|
|
27
|
+
const IDS = Object.keys(PROVIDERS);
|
|
28
|
+
// 与 @mars-sea/dsh-commandcode-provider 一致的 CLI 版本标识(其常量 COMMAND_CODE_CLI_VERSION)。
|
|
29
|
+
const COMMAND_CODE_CLI_VERSION = "1.73.0";
|
|
30
|
+
const COMMAND_CODE_API_BASE = "https://api.commandcode.ai";
|
|
31
|
+
const COMMAND_CODE_AUTH_FILE = join(homedir(), ".commandcode", "auth.json");
|
|
32
|
+
// 月总额快照与提供方插件 KNOWN_SUBSCRIPTION_PLANS 对齐(而非月剩余余额)。
|
|
33
|
+
// 来源:https://github.com/Mars-Sea/dsh-commandcode-provider/blob/main/src/capabilities.ts
|
|
34
|
+
const COMMAND_CODE_MONTHLY_CAPS = { "individual-go": 10, "individual-go-v1": 10, "individual-goat": 70, "individual-pro": 30, "individual-pro-v1": 80, "individual-provider": 15, "individual-max": 150, "individual-ultra": 300, "teams-pro": 40 };
|
|
35
|
+
function commandCodeMonthlyCap(planId) {
|
|
36
|
+
const normalized = planId.toLowerCase().replace(/_/g, "-");
|
|
37
|
+
const prefix = Object.keys(COMMAND_CODE_MONTHLY_CAPS).sort((a, b) => b.length - a.length).find(key => normalized === key || normalized.startsWith(`${key}-`));
|
|
38
|
+
return prefix ? COMMAND_CODE_MONTHLY_CAPS[prefix] : undefined;
|
|
39
|
+
}
|
|
40
|
+
// 兜底读取官方 CLI 登录文件(cmd login 写入),解析方式对齐提供方插件的 resolveAuthFileApiKey。
|
|
41
|
+
function commandCodeAuthFileKey(io) {
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(io.readFileSync(COMMAND_CODE_AUTH_FILE, "utf8"));
|
|
44
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
45
|
+
const str = v => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
46
|
+
const fromRecord = v => {
|
|
47
|
+
const r = v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
48
|
+
if (!r) return undefined;
|
|
49
|
+
if (r.type === "api") return str(r.key);
|
|
50
|
+
if (r.type === "oauth") return str(r.access);
|
|
51
|
+
return str(r.key) ?? str(r.access);
|
|
52
|
+
};
|
|
53
|
+
return str(parsed.apiKey) ?? str(parsed.commandcode) ?? fromRecord(parsed.commandcode) ?? fromRecord(parsed["command-code"]);
|
|
54
|
+
} catch { return undefined; }
|
|
55
|
+
}
|
|
56
|
+
const CONFIG_PATH = join(homedir(), ".dsh", "dsh-subusage.json");
|
|
57
|
+
const MAX_BYTES = 1024 * 1024;
|
|
58
|
+
const TTL = 60000;
|
|
59
|
+
const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
60
|
+
function redactMimo(value, cookie) {
|
|
61
|
+
const sensitive = [cookie, ...cookie.split(";").map(part => { const trimmed = part.trim(); return trimmed.slice(trimmed.indexOf("=") + 1).replace(/^\"|\"$/g, ""); }).filter(Boolean)].filter(Boolean);
|
|
62
|
+
// 套餐名是未经结构约束的展示文本;余额/明细已严格限制为数值和货币代码。
|
|
63
|
+
// 不对子串碰巧匹配的日期、自建枚举或单位做替换,避免短 Cookie 破坏语义。
|
|
64
|
+
const sanitize = text => sensitive.reduce((out, secret) => out.split(secret).join("[redacted]"), text);
|
|
65
|
+
return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
|
|
66
|
+
}
|
|
67
|
+
function defaultSettings() {
|
|
68
|
+
return { zai: { type: 1, organization: "", project: "" }, xiaomi: { cookie: "" }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
|
|
69
|
+
}
|
|
70
|
+
function parseStored(value) {
|
|
71
|
+
const s = defaultSettings();
|
|
72
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw failure("subusage/config", "设置文件结构无效");
|
|
73
|
+
if (value.zai && typeof value.zai === "object") s.zai = { type: value.zai.type === 2 ? 2 : 1, organization: typeof value.zai.organization === "string" ? value.zai.organization : "", project: typeof value.zai.project === "string" ? value.zai.project : "" };
|
|
74
|
+
if (typeof value.xiaomi?.cookie === "string") s.xiaomi.cookie = value.xiaomi.cookie;
|
|
75
|
+
if (typeof value.visibility?.hideWithoutApi === "boolean") s.visibility.hideWithoutApi = value.visibility.hideWithoutApi;
|
|
76
|
+
for (const id of IDS) {
|
|
77
|
+
if (typeof value.visibility?.providers?.[id] === "boolean") s.visibility.providers[id] = value.visibility.providers[id];
|
|
78
|
+
if (PROVIDERS[id].managedByPlugin) continue;
|
|
79
|
+
if (typeof value.keys?.[id] === "string") s.keys[id] = value.keys[id];
|
|
80
|
+
if (value.keyModes?.[id] === "manual") s.keyModes[id] = "manual";
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
function publicSettings(s) {
|
|
85
|
+
return { revision: hash(s), zai: { ...s.zai }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim() }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
|
|
86
|
+
}
|
|
87
|
+
function invalid(message) { return new RemoteError("subusage/invalid-request", message, { retryable: false, retainPrevious: false }); }
|
|
88
|
+
function parseUpdate(update) {
|
|
89
|
+
if (!update || typeof update !== "object" || !["keep", "replace", "clear"].includes(update.action)) throw invalid("Invalid secret update");
|
|
90
|
+
if (update.action === "replace" && (typeof update.value !== "string" || !update.value.trim())) throw invalid("Replacement must be a nonempty string");
|
|
91
|
+
return { action: update.action, ...(update.action === "replace" ? { value: update.value.trim() } : {}) };
|
|
92
|
+
}
|
|
93
|
+
function parseSettings(value) {
|
|
94
|
+
if (value?.visibility !== undefined) {
|
|
95
|
+
const v = value.visibility;
|
|
96
|
+
if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(value).some(k => !["visibility", "expectedRevision"].includes(k)) || typeof value.expectedRevision !== "string" || !value.expectedRevision) throw invalid("Invalid visibility patch");
|
|
97
|
+
if (Object.keys(v).some(k => !["providers", "hideWithoutApi"].includes(k)) || !Object.keys(v).length) throw invalid("Invalid visibility fields");
|
|
98
|
+
const visibility = {};
|
|
99
|
+
if (v.hideWithoutApi !== undefined) { if (typeof v.hideWithoutApi !== "boolean") throw invalid("Invalid auto-hide switch"); visibility.hideWithoutApi = v.hideWithoutApi; }
|
|
100
|
+
if (v.providers !== undefined) {
|
|
101
|
+
if (!v.providers || typeof v.providers !== "object" || Array.isArray(v.providers) || !Object.keys(v.providers).length || Object.entries(v.providers).some(([id, enabled]) => !IDS.includes(id) || typeof enabled !== "boolean")) throw invalid("Invalid provider switches");
|
|
102
|
+
visibility.providers = { ...v.providers };
|
|
103
|
+
}
|
|
104
|
+
return { expectedRevision: value.expectedRevision, visibility };
|
|
105
|
+
}
|
|
106
|
+
if (!value || typeof value !== "object" || !IDS.includes(value.providerId)) throw invalid("Invalid provider patch");
|
|
107
|
+
const out = { providerId: value.providerId };
|
|
108
|
+
if (value.expectedRevision !== undefined) { if (typeof value.expectedRevision !== "string") throw invalid("Invalid revision"); out.expectedRevision = value.expectedRevision; }
|
|
109
|
+
if (value.keyMode !== undefined) { if (!["inherit", "manual"].includes(value.keyMode)) throw invalid("Invalid key mode"); out.keyMode = value.keyMode; }
|
|
110
|
+
for (const field of ["keyUpdate", "cookieUpdate"]) if (value[field] !== undefined) out[field] = parseUpdate(value[field]);
|
|
111
|
+
if (PROVIDERS[value.providerId]?.managedByPlugin && (out.keyMode !== undefined || out.keyUpdate !== undefined || out.cookieUpdate !== undefined)) throw invalid("Credentials are managed by the provider plugin");
|
|
112
|
+
if (out.keyUpdate?.action === "replace" && /[\x00-\x1f\x7f]/.test(value.keyUpdate.value)) throw invalid("Invalid API key");
|
|
113
|
+
if (out.cookieUpdate && out.providerId !== "xiaomi-token-plan-cn") throw invalid("Cookie patch is MiMo-only");
|
|
114
|
+
if (out.cookieUpdate?.action === "replace") {
|
|
115
|
+
const cookie = out.cookieUpdate.value;
|
|
116
|
+
if (/[\x00-\x1f\x7f]/.test(value.cookieUpdate.value)) throw invalid("Invalid Cookie");
|
|
117
|
+
const names = new Map();
|
|
118
|
+
for (const item of cookie.split(";")) {
|
|
119
|
+
const match = item.trim().match(/^([!#$%&'*+.^_`|~0-9A-Za-z-]+)=(\"[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*\"|[\x21\x23-\x2b\x2d-\x3a\x3c-\x5b\x5d-\x7e]*)$/);
|
|
120
|
+
if (!match || names.has(match[1])) throw invalid("Invalid Cookie");
|
|
121
|
+
names.set(match[1], match[2].replace(/^\"|\"$/g, ""));
|
|
122
|
+
}
|
|
123
|
+
if (!names.get("api-platform_serviceToken") || !names.get("userId")) throw invalid("Cookie is missing required names");
|
|
124
|
+
}
|
|
125
|
+
if (value.zai !== undefined) {
|
|
126
|
+
if (out.providerId !== "zai-coding-cn" || !value.zai || ![1, 2].includes(value.zai.type) || typeof value.zai.organization !== "string" || typeof value.zai.project !== "string") throw invalid("Invalid Z.ai patch");
|
|
127
|
+
out.zai = { type: value.zai.type, organization: value.zai.organization.trim(), project: value.zai.project.trim() };
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
function parseQuery(value) {
|
|
132
|
+
if (!value || !Array.isArray(value.providerIds) || value.providerIds.some(id => !IDS.includes(id)) || typeof value.force !== "boolean") throw invalid("Invalid refresh request");
|
|
133
|
+
return { providerIds: [...new Set(value.providerIds)], force: value.force };
|
|
134
|
+
}
|
|
135
|
+
function failure(code, message, retryable = false, retryAfterMs) {
|
|
136
|
+
return new RemoteError(code, message, { retryable, retainPrevious: retryable, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
|
|
137
|
+
}
|
|
138
|
+
async function fetchJson(fetcher, url, headers, lifetimeSignal) {
|
|
139
|
+
let response;
|
|
140
|
+
try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.7.0", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
|
|
141
|
+
catch { throw failure("subusage/network", "网络请求失败或超时", true); }
|
|
142
|
+
if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
|
|
143
|
+
if (!response.ok) {
|
|
144
|
+
const retry = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
145
|
+
const raw = response.headers?.get?.("retry-after");
|
|
146
|
+
const retryAfterMs = raw ? (/^\d+(\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Math.max(0, Date.parse(raw) - Date.now())) : undefined;
|
|
147
|
+
throw failure(response.status === 429 ? "subusage/rate-limit" : "subusage/http", `HTTP ${response.status}`, retry, Number.isFinite(retryAfterMs) ? retryAfterMs : undefined);
|
|
148
|
+
}
|
|
149
|
+
if (Number(response.headers?.get?.("content-length")) > MAX_BYTES) throw failure("subusage/response", "响应超过 1MiB");
|
|
150
|
+
let text = "";
|
|
151
|
+
try {
|
|
152
|
+
if (response.body?.getReader) {
|
|
153
|
+
const reader = response.body.getReader(); const decoder = new TextDecoder(); let size = 0;
|
|
154
|
+
try { for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_BYTES) { await reader.cancel(); throw failure("subusage/response", "响应超过 1MiB"); } text += decoder.decode(value, { stream: true }); } text += decoder.decode(); } finally { reader.releaseLock(); }
|
|
155
|
+
} else { text = await response.text(); if (Buffer.byteLength(text) > MAX_BYTES) throw failure("subusage/response", "响应超过 1MiB"); }
|
|
156
|
+
} catch (e) { if (e instanceof RemoteError) throw e; throw failure("subusage/network", "读取响应失败", true); }
|
|
157
|
+
let body;
|
|
158
|
+
try { body = JSON.parse(text); } catch { throw failure("subusage/response", "响应不是合法 JSON"); }
|
|
159
|
+
if ([401, 403, "401", "403"].includes(body?.code)) throw failure("subusage/auth", "登录或凭据已失效");
|
|
160
|
+
return body;
|
|
161
|
+
}
|
|
162
|
+
function numeric(v) { return typeof v === "number" && Number.isFinite(v) && v >= 0; }
|
|
163
|
+
function windowRow(kind, raw, resetsAt, status, detail) {
|
|
164
|
+
if (!numeric(raw)) throw failure("subusage/response", "用量百分比缺失或无效");
|
|
165
|
+
const percent = raw < 100 ? Math.min(99.9, Math.round(raw * 10) / 10) : Math.round(raw * 10) / 10;
|
|
166
|
+
return { kind, percent, status: status === "rate-limited" || raw >= 100 ? "rate-limited" : "ok", ...(typeof resetsAt === "string" && Number.isFinite(Date.parse(resetsAt)) ? { resetsAt: new Date(resetsAt).toISOString() } : {}), ...(detail ? { detail } : {}) };
|
|
167
|
+
}
|
|
168
|
+
const RANK = { month: 0, sub: 0, period: 0, week: 1, "7d": 1, day: 2, "5h": 2, rolling: 2 };
|
|
169
|
+
function cascadeRateLimited(windows) {
|
|
170
|
+
const roots = windows.filter(w => w.status === "rate-limited");
|
|
171
|
+
for (const w of windows) {
|
|
172
|
+
const blockers = roots.filter(root => (RANK[root.kind] ?? 3) < (RANK[w.kind] ?? 3));
|
|
173
|
+
if (blockers.length) { if (w.status !== "rate-limited") w.cascade = true; w.status = "rate-limited"; w.detail = { ...w.detail, blockedBy: [...new Set(blockers.map(root => root.kind))] }; }
|
|
174
|
+
}
|
|
175
|
+
return windows;
|
|
176
|
+
}
|
|
177
|
+
export function normalizeKimi(body) {
|
|
178
|
+
// 已知旧版 usages 和新版 usage/limits 的显式命名窗口;不推断未知字段。
|
|
179
|
+
const u = body?.usages ?? body?.usage ?? body?.limits;
|
|
180
|
+
if (!u || !u.limit_5h || !u.limit_7d) throw failure("subusage/response", "Invalid Kimi usage response");
|
|
181
|
+
return cascadeRateLimited(["5h", "7d"].map(kind => { const row = u[`limit_${kind}`]; if (!numeric(row.used_ratio)) throw failure("subusage/response", "Invalid Kimi used_ratio"); return windowRow(kind, row.used_ratio * 100, row.reset_time); }));
|
|
182
|
+
}
|
|
183
|
+
export function normalizeZai(body) {
|
|
184
|
+
const data = body?.data;
|
|
185
|
+
if (!Array.isArray(data?.limits)) throw failure("subusage/response", "Invalid Z.ai usage response");
|
|
186
|
+
const windows = [];
|
|
187
|
+
for (const l of data.limits) {
|
|
188
|
+
const unit = l?.type === "TOKENS_LIMIT" && l.unit === undefined ? 3 : l?.unit;
|
|
189
|
+
if (!["CREDIT_LIMIT", "TOKENS_LIMIT"].includes(l?.type) || ![3, 6].includes(unit)) continue;
|
|
190
|
+
const reset = l.nextResetTime && Number.isFinite(new Date(l.nextResetTime).getTime()) ? new Date(l.nextResetTime).toISOString() : undefined;
|
|
191
|
+
const detail = numeric(l.currentValue) && numeric(l.usage) ? { used: l.currentValue, limit: l.usage, unit: l.type === "TOKENS_LIMIT" ? "tokens" : "credits" } : undefined;
|
|
192
|
+
windows.push(windowRow(unit === 3 ? "5h" : "week", l.percentage, reset, undefined, detail));
|
|
193
|
+
}
|
|
194
|
+
if (!windows.length) throw failure("subusage/response", "Z.ai response carries no coding-plan windows");
|
|
195
|
+
return { windows: cascadeRateLimited(windows), level: typeof data.level === "string" ? data.level : undefined };
|
|
196
|
+
}
|
|
197
|
+
export function normalizeMimo(balBody, detailBody, usageBody) {
|
|
198
|
+
const bal = balBody?.data;
|
|
199
|
+
if (!bal || typeof bal.balance !== "string" || !/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(bal.balance) || !Number.isFinite(Number(bal.balance))) throw failure("subusage/response", "Invalid MiMo balance response");
|
|
200
|
+
const data = usageBody?.data ?? {}; const detail = detailBody?.data ?? {};
|
|
201
|
+
const pick = (group, name) => Array.isArray(group?.items) ? group.items.find(i => i?.name === name) : undefined;
|
|
202
|
+
const pool = pick(data.usage, "plan_total_token") ?? pick(data.monthUsage, "month_total_token");
|
|
203
|
+
const poolDetail = numeric(pool?.used) && numeric(pool?.limit) ? { used: pool.used, limit: pool.limit, unit: "credits" } : undefined;
|
|
204
|
+
const windows = pool && numeric(pool.percent) ? [windowRow("sub", pool.percent * 100, detail.currentPeriodEnd, undefined, poolDetail)] : [];
|
|
205
|
+
const currency = typeof bal.currency === "string" && /^[A-Z]{3}$/.test(bal.currency) ? bal.currency : "";
|
|
206
|
+
const extras = [{ kind: "balance", value: `${bal.balance} ${currency}`.trim() }];
|
|
207
|
+
const plan = detail.planName || detail.planCode;
|
|
208
|
+
if (typeof plan === "string") extras.push({ kind: "plan", value: plan });
|
|
209
|
+
return { windows, extras, balanceOk: true, coverage: windows.length ? "complete" : "partial" };
|
|
210
|
+
}
|
|
211
|
+
export function normalizeOpencodeGo(body) {
|
|
212
|
+
const src = body?.usage ?? body;
|
|
213
|
+
if (!src || typeof src !== "object") throw failure("subusage/response", "Invalid OpenCode Go usage response");
|
|
214
|
+
const windows = [];
|
|
215
|
+
for (const [key, kind] of Object.entries({ rolling: "rolling", weekly: "week", monthly: "month" })) if (src[key]) windows.push(windowRow(kind, src[key].percent, src[key].resetsAt, src[key].status));
|
|
216
|
+
if (!windows.length) throw failure("subusage/response", "OpenCode Go response carries no usage windows");
|
|
217
|
+
const plan = src.source ?? body.source;
|
|
218
|
+
return { windows: cascadeRateLimited(windows), extras: typeof plan === "string" ? [{ kind: "plan", value: plan }] : [], coverage: windows.length === 3 ? "complete" : "partial" };
|
|
219
|
+
}
|
|
220
|
+
// Official quota contracts; never include unrelated search/image pools in the model badge.
|
|
221
|
+
// https://dev.synthetic.new/docs/synthetic/quotas
|
|
222
|
+
export function normalizeSynthetic(body) {
|
|
223
|
+
const row = body?.subscription;
|
|
224
|
+
if (!numeric(row?.requests) || !numeric(row?.limit) || row.limit <= 0) throw failure("subusage/response", "Invalid Synthetic subscription quota");
|
|
225
|
+
return { windows: [windowRow("rolling", row.requests / row.limit * 100, row.renewsAt, undefined, { used: row.requests, limit: row.limit, unit: "requests" })], extras: [], coverage: "complete" };
|
|
226
|
+
}
|
|
227
|
+
// https://docs.nano-gpt.com/api-reference/endpoint/subscription-usage
|
|
228
|
+
export function normalizeNanoGpt(body) {
|
|
229
|
+
if (typeof body?.active !== "boolean" || !["active", "grace", "inactive"].includes(body.state)) throw failure("subusage/response", "Invalid NanoGPT subscription response");
|
|
230
|
+
const windows = []; let partial = false;
|
|
231
|
+
if (body.active && body.state !== "inactive") for (const [field, kind, limit] of [["dailyInputTokens", "day", body.limits?.dailyInputTokens], ["weeklyInputTokens", "week", body.limits?.weeklyInputTokens], ["tokens", "period", body.tokenLimits?.total]]) {
|
|
232
|
+
const row = body[field];
|
|
233
|
+
if (row == null && limit == null) continue;
|
|
234
|
+
if (row?.degraded === true || !numeric(row?.percentUsed) || !numeric(limit) || limit <= 0 || !numeric(row?.used)) { partial = true; continue; }
|
|
235
|
+
const reset = numeric(row.resetAt) && Number.isFinite(new Date(row.resetAt).getTime()) ? new Date(row.resetAt).toISOString() : undefined;
|
|
236
|
+
windows.push(windowRow(kind, row.percentUsed * 100, reset, undefined, { used: row.used, limit, unit: "tokens" }));
|
|
237
|
+
}
|
|
238
|
+
return { windows: cascadeRateLimited(windows), extras: [{ kind: "plan", value: body.state }], coverage: windows.length && !partial ? "complete" : "partial" };
|
|
239
|
+
}
|
|
240
|
+
// 官方 CLI 的计数消歧规则:有 remaining_percent 时按它校准;旧响应 usage_count 是剩余。
|
|
241
|
+
// https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts
|
|
242
|
+
export function normalizeMinimax(body) {
|
|
243
|
+
const code = body?.base_resp?.status_code;
|
|
244
|
+
if (code !== 0) {
|
|
245
|
+
if ([1004, 2049].includes(code)) throw failure("subusage/auth", "MiniMax 订阅 Key 无效或已失效");
|
|
246
|
+
if ([1001, 1002].includes(code)) throw failure("subusage/http", "MiniMax 用量接口暂时不可用", true);
|
|
247
|
+
throw failure("subusage/response", "MiniMax 用量接口未返回成功状态");
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(body.model_remains)) throw failure("subusage/response", "Invalid MiniMax usage response");
|
|
250
|
+
// 不能将视频/图像池混到当前编程模型的药丸,也不能累加共享模型池。
|
|
251
|
+
const general = body.model_remains.filter(row => row?.model_name === "general");
|
|
252
|
+
const candidates = general.length ? general : body.model_remains.filter(row => typeof row?.model_name === "string" && /^MiniMax-M/i.test(row.model_name));
|
|
253
|
+
if (candidates.length !== 1) throw failure("subusage/response", "MiniMax response carries no unambiguous coding quota");
|
|
254
|
+
const row = candidates[0], windows = [];
|
|
255
|
+
for (const [prefix, kind, end] of [["current_interval", "5h", "end_time"], ["current_weekly", "week", "weekly_end_time"]]) {
|
|
256
|
+
const status = row[`${prefix}_status`];
|
|
257
|
+
// 无上限或未开通池不画虚假的 0% 用量;零总计也不证明额度耗尽。
|
|
258
|
+
if (status === 3) continue;
|
|
259
|
+
if (status !== undefined && ![1, 2].includes(status)) throw failure("subusage/response", "Invalid MiniMax quota status");
|
|
260
|
+
const total = row[`${prefix}_total_count`], reported = row[`${prefix}_usage_count`], percent = row[`${prefix}_remaining_percent`];
|
|
261
|
+
if (percent !== undefined && !numeric(percent)) throw failure("subusage/response", "Invalid MiniMax remaining percentage");
|
|
262
|
+
let remaining, detail;
|
|
263
|
+
if (numeric(total) && total > 0 && numeric(reported) && reported <= total) {
|
|
264
|
+
remaining = reported;
|
|
265
|
+
if (percent !== undefined) {
|
|
266
|
+
const asRemaining = Math.abs(reported / total * 100 - percent);
|
|
267
|
+
const asUsed = Math.abs((total - reported) / total * 100 - percent);
|
|
268
|
+
if (Math.min(asRemaining, asUsed) > 1) remaining = undefined;
|
|
269
|
+
else if (asUsed < asRemaining) remaining = total - reported;
|
|
270
|
+
}
|
|
271
|
+
if (remaining !== undefined) detail = { used: total - remaining, limit: total, remaining, unit: "quota" };
|
|
272
|
+
}
|
|
273
|
+
if (percent === undefined && remaining === undefined && status !== 2) {
|
|
274
|
+
if ((total === undefined && reported === undefined) || (total === 0 && reported === 0)) continue;
|
|
275
|
+
throw failure("subusage/response", "Invalid MiniMax quota counts");
|
|
276
|
+
}
|
|
277
|
+
const usedPercent = status === 2 ? 100 : Math.max(0, 100 - (percent ?? remaining / total * 100));
|
|
278
|
+
const endTime = row[end];
|
|
279
|
+
const reset = numeric(endTime) && endTime > 0 && Number.isFinite(new Date(endTime).getTime()) ? new Date(endTime).toISOString() : undefined;
|
|
280
|
+
windows.push(windowRow(kind, usedPercent, reset, undefined, detail));
|
|
281
|
+
}
|
|
282
|
+
return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length === 2 ? "complete" : "partial" };
|
|
283
|
+
}
|
|
284
|
+
export function normalizeCommandCode(body, subscriptionBody) {
|
|
285
|
+
// 输入是 Command Code 计费接口(/alpha/billing/credits)的原始响应体。
|
|
286
|
+
// absent 窗口 ≠ 零额度:未报告的窗口不画额度行;cap:0 是报告过的无上限,按不受限展示。
|
|
287
|
+
// 已出现的窗口块缺 used/cap 属非法(不当作零用量);exceeded 缺省视为未超限,resetAt 无值不显示。
|
|
288
|
+
const credits = body?.credits && typeof body.credits === "object" && !Array.isArray(body.credits) ? body.credits : {};
|
|
289
|
+
const limits = body?.windowLimits && typeof body.windowLimits === "object" && !Array.isArray(body.windowLimits) ? body.windowLimits : {};
|
|
290
|
+
if (!Object.keys(credits).length && !Object.keys(limits).length) throw failure("subusage/response", "Invalid Command Code usage response");
|
|
291
|
+
const windows = [];
|
|
292
|
+
for (const [key, kind] of [["fiveHour", "5h"], ["weekly", "week"]]) {
|
|
293
|
+
const w = limits[key];
|
|
294
|
+
if (w === undefined || w === null) continue;
|
|
295
|
+
if (typeof w !== "object" || Array.isArray(w)) throw failure("subusage/response", "Invalid Command Code usage window");
|
|
296
|
+
const cap = w.cap, used = w.used;
|
|
297
|
+
if (!numeric(cap) || !numeric(used)) throw failure("subusage/response", "Invalid Command Code usage window");
|
|
298
|
+
const resetAt = typeof w.resetAt === "number" && w.resetAt > 0 && Number.isFinite(new Date(w.resetAt).getTime()) ? new Date(w.resetAt).toISOString() : undefined;
|
|
299
|
+
windows.push(windowRow(kind, cap > 0 ? used / cap * 100 : 0, resetAt, w.exceeded === true ? "rate-limited" : undefined, cap > 0 ? { used, limit: cap, unit: "credits" } : undefined));
|
|
300
|
+
}
|
|
301
|
+
const extras = [];
|
|
302
|
+
const subscription = subscriptionBody?.data && typeof subscriptionBody.data === "object" && !Array.isArray(subscriptionBody.data) ? subscriptionBody.data : {};
|
|
303
|
+
const plan = typeof subscription.planId === "string" && subscription.planId.trim() ? subscription.planId.trim() : typeof credits.planId === "string" ? credits.planId.trim() : "";
|
|
304
|
+
if (plan) extras.push({ kind: "plan", value: plan });
|
|
305
|
+
const remaining = credits.monthlyCredits;
|
|
306
|
+
const monthlyCap = commandCodeMonthlyCap(plan);
|
|
307
|
+
const hasMonthlyBalance = typeof remaining === "number" && Number.isFinite(remaining);
|
|
308
|
+
if (hasMonthlyBalance) extras.push({ kind: "monthly-balance", value: `${Math.round(remaining * 100) / 100} credits` });
|
|
309
|
+
if (hasMonthlyBalance && monthlyCap !== undefined) {
|
|
310
|
+
const used = Math.max(0, monthlyCap - remaining);
|
|
311
|
+
// 这是套餐月池,不含购买/赠送余额;余额大于快照总额时不显示负用量。
|
|
312
|
+
const end = subscription.currentPeriodEnd;
|
|
313
|
+
const date = typeof end === "string" ? new Date(end) : typeof end === "number" && end > 0 ? new Date(end) : null;
|
|
314
|
+
const resetsAt = date && Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
|
315
|
+
windows.push(windowRow("month", used / monthlyCap * 100, resetsAt, undefined, { used: Math.round(used * 1000000) / 1000000, limit: monthlyCap, remaining, unit: "credits", limitSource: "plan-snapshot" }));
|
|
316
|
+
}
|
|
317
|
+
// 余额只合并真正报告过的已购/赠送额度;未报告的字段不冒充 0。
|
|
318
|
+
const reported = ["purchasedCredits", "freeCredits"].map(field => credits[field]).filter(v => typeof v === "number" && Number.isFinite(v));
|
|
319
|
+
if (reported.length) extras.push({ kind: "balance", value: `${Math.round(reported.reduce((sum, v) => sum + Math.max(0, v), 0) * 100) / 100} credits` });
|
|
320
|
+
// 月池耗尽并不证明已购/赠送池或短窗口也不可用;只级联接口的周/5小时限制。
|
|
321
|
+
cascadeRateLimited(windows.filter(w => w.kind !== "month"));
|
|
322
|
+
return { windows, extras, coverage: hasMonthlyBalance && monthlyCap !== undefined ? "complete" : "partial" };
|
|
323
|
+
}
|
|
324
|
+
function detectConfigured(ctx) {
|
|
325
|
+
try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id)])); }
|
|
326
|
+
catch { return Object.fromEntries(IDS.map(id => [id, false])); }
|
|
327
|
+
}
|
|
328
|
+
const codec = (type, parse) => ({ mode: "strict", typeSymbol: `dsh-subusage#${type}`, schema: { parse }, create: () => ({ parse }) });
|
|
329
|
+
const resultCodec = codec("SubUsageResult", value => { if (!value || !Array.isArray(value.entries) || !value.settings || !value.configured || typeof value.updatedAt !== "string") throw invalid("Invalid result"); return value; });
|
|
330
|
+
function parseLoginStart(value) { if (!value || typeof value.expectedRevision !== "string" || !value.expectedRevision) throw invalid("Invalid login revision"); return { expectedRevision: value.expectedRevision }; }
|
|
331
|
+
function parseLoginCancel(value) { if (!value || typeof value.jobId !== "string" || !value.jobId) throw invalid("Invalid login job"); return { jobId: value.jobId }; }
|
|
332
|
+
const loginCodec = codec("SubUsageLoginState", value => {
|
|
333
|
+
if (!value || !(value.jobId === null || typeof value.jobId === "string") || !["idle", "launching", "waiting", "verifying", "success", "cancelled", "error"].includes(value.state)) throw invalid("Invalid login state");
|
|
334
|
+
if (value.result) resultCodec.schema.parse(value.result);
|
|
335
|
+
return value;
|
|
336
|
+
});
|
|
337
|
+
export const subUsageRemote = {
|
|
338
|
+
package: name,
|
|
339
|
+
descriptors: [
|
|
340
|
+
{ id: `${name}#subUsage/read`, service: "subUsage", namespace: "subUsage", method: "read", invocation: { kind: "direct" }, parameters: [], result: resultCodec },
|
|
341
|
+
...[["refresh", "request", "SubUsageQuery", parseQuery], ["save", "settings", "SubUsageSettings", parseSettings]].map(([method, param, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: param, wire: param, source: "json", codec: codec(type, parse) }], result: resultCodec })),
|
|
342
|
+
{ id: `${name}#subUsage/getMimoLoginStatus`, service: "subUsage", namespace: "subUsage", method: "getMimoLoginStatus", invocation: { kind: "direct" }, parameters: [], result: loginCodec },
|
|
343
|
+
...[["startMimoLogin", "MimoLoginStart", parseLoginStart], ["cancelMimoLogin", "MimoLoginCancel", parseLoginCancel]].map(([method, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: "request", wire: "request", source: "json", codec: codec(type, parse) }], result: loginCodec }))
|
|
344
|
+
]
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// 可注入仅供隔离测试;生产仍由当前 manifest 的 Cordis ctx.plugin 注册。
|
|
348
|
+
export class SubUsageService extends TypertRemoteService {
|
|
349
|
+
constructor(ctx, options = {}) {
|
|
350
|
+
super(ctx, "subUsage"); this.ctx = ctx;
|
|
351
|
+
this.io = options.io ?? { readFileSync, mkdirSync, writeFileSync, renameSync, chmodSync };
|
|
352
|
+
this.path = options.configPath ?? CONFIG_PATH; this.fetcher = options.fetch ?? globalThis.fetch;
|
|
353
|
+
this.now = options.now ?? Date.now;
|
|
354
|
+
this.credentials = options.resolveCredentials ?? (async id => await ctx.get("credentials")?.resolve(credentialRef(PROVIDERS[id].envName)));
|
|
355
|
+
this.environment = options.resolveEnvironment ?? (id => launchEnvironmentOf(ctx).get(PROVIDERS[id].envName));
|
|
356
|
+
this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
|
|
357
|
+
this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
|
|
358
|
+
this.login = new MimoLogin({
|
|
359
|
+
error: failure,
|
|
360
|
+
capture: revision => { const s = this.load(); if (revision !== hash(s)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取"); return this.mimoMaterial(s); },
|
|
361
|
+
verify: (cookie, signal) => { parseSettings({ providerId: "xiaomi-token-plan-cn", cookieUpdate: { action: "replace", value: cookie } }); return this.fetchProvider("xiaomi-token-plan-cn", { key: cookie, source: "cookie" }, this.load(), signal); },
|
|
362
|
+
commit: (cookie, captured, verified) => this.commitMimoLogin(cookie, captured, verified),
|
|
363
|
+
reconcileResult: result => {
|
|
364
|
+
const latest = this.load();
|
|
365
|
+
if (this.disposed || this.mimoMaterial(latest) !== this.loginResultMaterial) return null;
|
|
366
|
+
// 同一身份也只回放当前缓存,旧额度已失效时不返回旧 entry。
|
|
367
|
+
const entry = this.cache.get("xiaomi-token-plan-cn")?.entry;
|
|
368
|
+
return this.result(latest, entry ? [redactMimo(entry, latest.xiaomi.cookie)] : []);
|
|
369
|
+
}
|
|
370
|
+
}, { now: this.now, ...options.mimoLogin });
|
|
371
|
+
ctx.effect?.(() => () => this.dispose());
|
|
372
|
+
}
|
|
373
|
+
load() {
|
|
374
|
+
try { return parseStored(JSON.parse(this.io.readFileSync(this.path, "utf8"))); }
|
|
375
|
+
catch (e) { if (e?.code === "ENOENT") return defaultSettings(); throw failure("subusage/config", "无法读取订阅设置"); }
|
|
376
|
+
}
|
|
377
|
+
result(settings, entries) { return { updatedAt: new Date(this.now()).toISOString(), settings: publicSettings(settings), configured: detectConfigured(this.ctx), entries }; }
|
|
378
|
+
async identity(id, s) {
|
|
379
|
+
if (id === "xiaomi-token-plan-cn") return { key: s.xiaomi.cookie.trim(), source: s.xiaomi.cookie.trim() ? "cookie" : "none" };
|
|
380
|
+
if (!PROVIDERS[id].managedByPlugin && s.keyModes[id] === "manual") return { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
|
|
381
|
+
const hit = await this.credentials(id);
|
|
382
|
+
if (typeof hit?.value === "string" && hit.value.trim()) return { key: hit.value.trim(), source: "credentials" };
|
|
383
|
+
const env = await this.environment(id);
|
|
384
|
+
if (typeof env?.value === "string" && env.value.trim()) return { key: env.value.trim(), source: "env" };
|
|
385
|
+
// 凭据由提供方插件管理的模型商:与它同一兜底读取官方 CLI 登录文件(cmd login)。
|
|
386
|
+
if (PROVIDERS[id].managedByPlugin) {
|
|
387
|
+
const fileKey = await this.authFile();
|
|
388
|
+
if (typeof fileKey === "string" && fileKey.trim()) return { key: fileKey.trim(), source: "auth-file" };
|
|
389
|
+
}
|
|
390
|
+
return PROVIDERS[id].managedByPlugin ? { key: undefined, source: "none" } : { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
|
|
391
|
+
}
|
|
392
|
+
async fetchProvider(id, identity, settings, signal = this.controller.signal) {
|
|
393
|
+
const get = (url, headers) => fetchJson(this.fetcher, url, headers, signal);
|
|
394
|
+
const headers = { authorization: `Bearer ${identity.key}` };
|
|
395
|
+
if (id === "synthetic") return normalizeSynthetic(await get("https://api.synthetic.new/v2/quotas", headers));
|
|
396
|
+
if (id === "nanogpt") return normalizeNanoGpt(await get(identity.key.startsWith("sk-nano-mgmt-") ? "https://nano-gpt.com/api/management/v1/subscription/usage" : "https://api.nano-gpt.com/api/subscription/v1/usage", headers));
|
|
397
|
+
if (id === "zai-coding") {
|
|
398
|
+
const out = normalizeZai(await get("https://api.z.ai/api/monitor/usage/quota/limit", { authorization: identity.key }));
|
|
399
|
+
return { windows: out.windows, extras: out.level ? [{ kind: "plan", value: out.level }] : [] };
|
|
400
|
+
}
|
|
401
|
+
if (id === "zai-coding-cn") {
|
|
402
|
+
if (settings.zai.type === 2) { if (settings.zai.organization) headers["bigmodel-organization"] = settings.zai.organization; if (settings.zai.project) headers["bigmodel-project"] = settings.zai.project; }
|
|
403
|
+
const out = normalizeZai(await get(`https://open.bigmodel.cn/api/monitor/usage/quota/limit?type=${settings.zai.type}`, headers));
|
|
404
|
+
return { windows: out.windows, extras: out.level ? [{ kind: "plan", value: out.level }] : [] };
|
|
405
|
+
}
|
|
406
|
+
if (id === "kimi-coding") {
|
|
407
|
+
const [body, me] = await Promise.all([get("https://api.kimi.com/coding/v1/usages", headers), get("https://api.kimi.com/coding/v1/me", headers).catch(() => undefined)]);
|
|
408
|
+
return { windows: normalizeKimi(body), extras: typeof me?.user_level_name === "string" ? [{ kind: "plan", value: me.user_level_name }] : [] };
|
|
409
|
+
}
|
|
410
|
+
if (id === "opencode-go") return normalizeOpencodeGo(await get("https://opencode.ai/zen/go/v1/usage", headers));
|
|
411
|
+
if (id === "minimax" || id === "minimax-cn") return normalizeMinimax(await get(PROVIDERS[id].usageUrl, headers));
|
|
412
|
+
if (id === "commandcode") {
|
|
413
|
+
// 与提供方插件同一凭据来源,但直连计费接口并行拉取:其 report() 会对每个账户
|
|
414
|
+
// 串行 whoami + 三个端点(60 秒预算),作为药丸刷新来源太慢。仅并行读取余额与套餐/账期;请求头对齐其 accountHeaders。
|
|
415
|
+
const billingHeaders = { ...headers, "accept-encoding": "identity", "x-command-code-version": COMMAND_CODE_CLI_VERSION, "x-cli-environment": "production" };
|
|
416
|
+
const [credits, subscription] = await Promise.all([
|
|
417
|
+
get(`${COMMAND_CODE_API_BASE}/alpha/billing/credits`, billingHeaders),
|
|
418
|
+
get(`${COMMAND_CODE_API_BASE}/alpha/billing/subscriptions`, billingHeaders).catch(() => undefined)
|
|
419
|
+
]);
|
|
420
|
+
return normalizeCommandCode(credits, subscription);
|
|
421
|
+
}
|
|
422
|
+
const base = "https://platform.xiaomimimo.com/api/v1";
|
|
423
|
+
const h = { cookie: identity.key, origin: "https://platform.xiaomimimo.com", referer: "https://platform.xiaomimimo.com/#/console/balance", "x-timezone": "UTC+08:00" };
|
|
424
|
+
let usageError;
|
|
425
|
+
const [bal, detail, usage] = await Promise.all([get(`${base}/balance`, h), get(`${base}/tokenPlan/detail`, h).catch(e => { if (e.code === "subusage/auth") throw e; return undefined; }), get(`${base}/tokenPlan/usage`, h).catch(e => { if (e.code === "subusage/auth") throw e; usageError = e; return undefined; })]);
|
|
426
|
+
// 普通刷新与登录验证都经过这里:服务端字段不可信,缓存前去除 Cookie 回显。
|
|
427
|
+
const data = redactMimo(normalizeMimo(bal, detail, usage), identity.key);
|
|
428
|
+
if (usageError) data.usageError = usageError;
|
|
429
|
+
return data;
|
|
430
|
+
}
|
|
431
|
+
base(id, source) { return { providerId: id, label: PROVIDERS[id].label, envName: PROVIDERS[id].envName, keySource: source, apiDetected: source === "none" ? null : true, coverage: "partial", freshness: "unknown", lastSuccessAt: null, lastAttemptAt: new Date(this.now()).toISOString(), retryable: false, retainPrevious: false }; }
|
|
432
|
+
async provider(id, settings, force) {
|
|
433
|
+
const startedGeneration = this.generations.get(id) ?? 0;
|
|
434
|
+
let identity;
|
|
435
|
+
try { identity = await this.identity(id, settings); }
|
|
436
|
+
catch { this.cache.delete(id); return { ...this.base(id, "none"), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
|
|
437
|
+
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
438
|
+
if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force);
|
|
439
|
+
if (!settings.visibility.providers[id]) {
|
|
440
|
+
this.cache.delete(id); this.inflight.delete(id);
|
|
441
|
+
return { ...this.base(id, identity.source), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
|
|
442
|
+
}
|
|
443
|
+
const fingerprint = hash([identity.key ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null]);
|
|
444
|
+
if (this.fingerprints.has(id) && this.fingerprints.get(id) !== fingerprint) this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
|
|
445
|
+
this.fingerprints.set(id, fingerprint);
|
|
446
|
+
const generation = this.generations.get(id) ?? 0;
|
|
447
|
+
const cached = this.cache.get(id);
|
|
448
|
+
if (cached && cached.fingerprint !== fingerprint) this.cache.delete(id);
|
|
449
|
+
const previous = cached?.fingerprint === fingerprint ? cached : undefined;
|
|
450
|
+
const active = this.inflight.get(id);
|
|
451
|
+
if (active?.fingerprint === fingerprint && active.generation === generation) return active.promise;
|
|
452
|
+
const now = this.now();
|
|
453
|
+
const resetDue = previous?.entry.windows?.some(w => Number.isFinite(Date.parse(w.resetsAt)) && Date.parse(w.resetsAt) > previous.at && Date.parse(w.resetsAt) <= now);
|
|
454
|
+
if (previous && (now < previous.retryAt || !resetDue && !force && now - previous.at < TTL)) return previous.entry;
|
|
455
|
+
const promise = (async () => {
|
|
456
|
+
const base = this.base(id, identity.source); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
|
|
457
|
+
if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)" : "未配置 API Key", errorCode: "subusage/no-key" };
|
|
458
|
+
else try {
|
|
459
|
+
const data = await this.fetchProvider(id, identity, settings);
|
|
460
|
+
const usageError = data.usageError; delete data.usageError;
|
|
461
|
+
if (usageError?.details?.retryable && previous?.entry.lastSuccessAt) throw usageError;
|
|
462
|
+
entry = { ...base, ...data, state: "ok", coverage: data.coverage ?? "complete", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString() }; failures = 0;
|
|
463
|
+
if (usageError) { entry.errorCode = usageError.code; entry.error = usageError.message; entry.retryable = usageError.details?.retryable === true; if (entry.retryable) retryAt = this.now() + Math.max(2000, usageError.details?.retryAfterMs ?? 0); }
|
|
464
|
+
} catch (e) {
|
|
465
|
+
const temporary = e instanceof RemoteError && e.details?.retryable === true;
|
|
466
|
+
const retain = temporary && !!previous?.entry.lastSuccessAt;
|
|
467
|
+
entry = { ...base, state: "error", error: e instanceof RemoteError ? e.message : "用量响应无效", errorCode: e instanceof RemoteError ? e.code : "subusage/response", retryable: temporary, retainPrevious: retain };
|
|
468
|
+
if (retain) Object.assign(entry, { windows: previous.entry.windows, extras: previous.entry.extras, coverage: previous.entry.coverage, freshness: "stale", lastSuccessAt: previous.entry.lastSuccessAt });
|
|
469
|
+
failures++;
|
|
470
|
+
if (temporary) retryAt = this.now() + Math.max(e.details?.retryAfterMs ?? 0, Math.min(300000, 1000 * 2 ** Math.min(failures, 8)));
|
|
471
|
+
}
|
|
472
|
+
if (!this.disposed && generation === (this.generations.get(id) ?? 0)) this.cache.set(id, { fingerprint, entry, at: this.now(), retryAt, failures });
|
|
473
|
+
if (this.disposed || generation !== (this.generations.get(id) ?? 0)) return { ...this.base(id, "none"), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
|
|
474
|
+
return entry;
|
|
475
|
+
})();
|
|
476
|
+
this.inflight.set(id, { fingerprint, generation, promise });
|
|
477
|
+
try { return await promise; } finally { if (this.inflight.get(id)?.promise === promise) this.inflight.delete(id); }
|
|
478
|
+
}
|
|
479
|
+
async read() { return this.refresh({ providerIds: IDS, force: false }); }
|
|
480
|
+
async refresh(request) {
|
|
481
|
+
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
482
|
+
const query = parseQuery(request); const settings = this.load();
|
|
483
|
+
const entries = await Promise.all(query.providerIds.map(id => this.provider(id, settings, query.force).catch(() => ({ ...this.base(id, "none"), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
|
|
484
|
+
const latest = this.load();
|
|
485
|
+
if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none"), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
|
|
486
|
+
return this.result(latest, entries);
|
|
487
|
+
}
|
|
488
|
+
startMimoLogin(request) { return this.login.start(parseLoginStart(request)); }
|
|
489
|
+
getMimoLoginStatus() { return this.login.status(); }
|
|
490
|
+
cancelMimoLogin(request) { return this.login.cancel(parseLoginCancel(request)); }
|
|
491
|
+
mimoMaterial(s) { const id = "xiaomi-token-plan-cn"; return hash([s.xiaomi.cookie, s.keys[id] ?? "", s.keyModes[id]]); }
|
|
492
|
+
commitMimoLogin(cookie, captured, verified) {
|
|
493
|
+
const id = "xiaomi-token-plan-cn", s = this.load();
|
|
494
|
+
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
495
|
+
if (this.mimoMaterial(s) !== captured) throw failure("subusage/revision-conflict", "MiMo 设置已被修改,拒绝覆盖");
|
|
496
|
+
this.persist({ providerId: id, expectedRevision: hash(s), cookieUpdate: { action: "replace", value: cookie } });
|
|
497
|
+
const stored = this.load(); const data = { ...verified }; const usageError = data.usageError; delete data.usageError;
|
|
498
|
+
const entry = { ...this.base(id, "cookie"), ...data, state: "ok", coverage: data.coverage ?? "partial", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString(), ...(usageError ? { errorCode: usageError.code, error: "额度暂时无法读取", retryable: usageError.details?.retryable === true } : {}) };
|
|
499
|
+
// 响应内容也不得回显本次 Cookie(服务端字段属于不可信输入)。
|
|
500
|
+
const safeEntry = redactMimo(entry, cookie);
|
|
501
|
+
const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null]);
|
|
502
|
+
this.fingerprints.set(id, fingerprint); this.cache.set(id, { fingerprint, entry: safeEntry, at: this.now(), retryAt: usageError ? this.now() + 15000 : 0, failures: 0 });
|
|
503
|
+
this.loginResultMaterial = this.mimoMaterial(stored);
|
|
504
|
+
return this.result(stored, [safeEntry]);
|
|
505
|
+
}
|
|
506
|
+
async save(settings) { return this.persist(settings); }
|
|
507
|
+
persist(settings) {
|
|
508
|
+
if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
|
|
509
|
+
const patch = parseSettings(settings); const stored = this.load();
|
|
510
|
+
if (patch.expectedRevision !== undefined && patch.expectedRevision !== hash(stored)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取");
|
|
511
|
+
const id = patch.providerId;
|
|
512
|
+
const affected = patch.visibility ? Object.keys(patch.visibility.providers ?? {}).filter(key => stored.visibility.providers[key] !== patch.visibility.providers[key]) : [id];
|
|
513
|
+
if (patch.visibility) {
|
|
514
|
+
if (patch.visibility.hideWithoutApi !== undefined) stored.visibility.hideWithoutApi = patch.visibility.hideWithoutApi;
|
|
515
|
+
Object.assign(stored.visibility.providers, patch.visibility.providers);
|
|
516
|
+
}
|
|
517
|
+
if (patch.keyMode !== undefined) stored.keyModes[id] = patch.keyMode;
|
|
518
|
+
const update = (obj, key, op) => { if (op?.action === "replace") obj[key] = op.value; else if (op?.action === "clear") delete obj[key]; };
|
|
519
|
+
update(stored.keys, id, patch.keyUpdate);
|
|
520
|
+
if (patch.cookieUpdate?.action === "clear") stored.xiaomi.cookie = ""; else update(stored.xiaomi, "cookie", patch.cookieUpdate);
|
|
521
|
+
if (patch.zai) stored.zai = patch.zai;
|
|
522
|
+
try { this.io.mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 }); this.io.writeFileSync(`${this.path}.tmp`, JSON.stringify(stored, null, 2), { encoding: "utf8", mode: 0o600 }); this.io.chmodSync(`${this.path}.tmp`, 0o600); this.io.renameSync(`${this.path}.tmp`, this.path); }
|
|
523
|
+
catch { throw failure("subusage/config", "无法保存订阅设置"); }
|
|
524
|
+
for (const key of affected) { this.generations.set(key, (this.generations.get(key) ?? 0) + 1); this.cache.delete(key); this.inflight.delete(key); }
|
|
525
|
+
return this.result(stored, []);
|
|
526
|
+
}
|
|
527
|
+
dispose() { this.login.dispose(); this.disposed = true; this.controller.abort(); this.cache.clear(); this.inflight.clear(); this.fingerprints.clear(); for (const id of IDS) this.generations.set(id, (this.generations.get(id) ?? 0) + 1); }
|
|
528
|
+
}
|
|
529
|
+
function registerRemotes(ctx) {
|
|
530
|
+
ctx.inject(["typert"], scope => { scope.effect(() => scope.typert.register({ package: subUsageRemote.package, face: "host", schemas: [], model: { services: [], events: [], objects: [] }, invocations: subUsageRemote.descriptors })); });
|
|
531
|
+
}
|
|
532
|
+
function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); ctx.logger.info("dsh-subusage: usage service ready"); }
|
|
533
|
+
export { apply, inject, name };
|
|
534
|
+
export default { name, inject, apply };
|