dsh-subusage 0.8.3 → 0.10.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.
@@ -0,0 +1,208 @@
1
+ // 火山方舟(Volcengine Ark)订阅额度适配:管控面 OpenAPI 的 AK/SK 签名与套餐窗口归一化。
2
+ //
3
+ // 额度接口只在管控面 OpenAPI 暴露,鉴权是 IAM Access Key 的 HMAC-SHA256 签名(V4 变体),
4
+ // 与推理用的方舟 API Key(Bearer / UUID 形态)是**两套完全不同的凭据**:
5
+ // 拿推理 Key 去签名只会得到 401/403,与接口可用性无关。
6
+ //
7
+ // 套餐额度分散在多个 Action 上,且官方 API 概览只公开了其中一部分(Team 系):
8
+ // - GetCodingPlanUsage 个人版 Coding Plan(Level + Percent,按次数计)
9
+ // - GetAFPUsage 个人版 Agent Plan(AFP 绝对值,5 小时/日/周/月四个滚动窗口)
10
+ // - GetSeatInfoUsage / ListSeatInfoUsages Coding Plan 企业版席位(需 SeatID)
11
+ // - GetSeatAFPUsage Agent Plan 企业版席位(需 SeatIDs)
12
+ // 本插件按 provider 固定调用其中一个 Action:路由由官方插件 ark-plan-api 注册,
13
+ // ark-coding-plan-* 走 Coding Plan,ark-agent-plan-* 走 Agent Plan。
14
+ import { createHash, createHmac } from "node:crypto";
15
+
16
+ export const VOLC_HOST = "open.volcengineapi.com";
17
+ export const VOLC_REGION = "cn-beijing";
18
+ export const VOLC_SERVICE = "ark";
19
+ export const VOLC_VERSION = "2024-01-01";
20
+
21
+ const sha256Hex = value => createHash("sha256").update(value ?? "").digest("hex");
22
+ const hmac = (key, value, encoding) => createHmac("sha256", key).update(value).digest(encoding);
23
+
24
+ /**
25
+ * RFC3986 严格转义。encodeURIComponent 会放过 ! ' ( ) * ,而签名要求把它们
26
+ * 也转成 %XX —— 少转一个字符,服务端算出的签名就不同,表现为 InvalidSignature。
27
+ */
28
+ export function volcEscape(value) {
29
+ return encodeURIComponent(String(value)).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
30
+ }
31
+
32
+ /** X-Date 格式:yyyyMMdd'T'HHmmss'Z'(UTC,无分隔符、无毫秒)。 */
33
+ export function volcDateFormat(ms) {
34
+ return new Date(ms).toISOString().replace(/[-:]|\.\d{3}/g, "");
35
+ }
36
+
37
+ /** 查询串按参数名升序,用严格转义拼接(Action 与 Version 走 query,不在 body 里)。 */
38
+ export function volcCanonicalQuery(params) {
39
+ return Object.keys(params).sort().map(key => `${volcEscape(key)}=${volcEscape(params[key])}`).join("&");
40
+ }
41
+
42
+ /**
43
+ * 生成一次调用的 URL 与签名头。签名密钥派生链:
44
+ * kDate = HMAC(SK, yyyyMMdd) → kRegion = HMAC(kDate, region)
45
+ * → kService = HMAC(kRegion, service) → kSigning = HMAC(kService, "request")
46
+ * 待签串与 AWS SigV4 同形,但算法名写 HMAC-SHA256,scope 末段固定为 request。
47
+ */
48
+ export function volcSignature({ accessKeyId, secretAccessKey, action, version = VOLC_VERSION, body = "{}", date = Date.now(), host = VOLC_HOST, region = VOLC_REGION, service = VOLC_SERVICE }) {
49
+ const xDate = volcDateFormat(date);
50
+ const shortDate = xDate.slice(0, 8);
51
+ const payloadHash = sha256Hex(body);
52
+ const query = volcCanonicalQuery({ Action: action, Version: version });
53
+ const signedHeaders = "content-type;host;x-content-sha256;x-date";
54
+ const canonicalHeaders = [
55
+ "content-type:application/json",
56
+ `host:${host}`,
57
+ `x-content-sha256:${payloadHash}`,
58
+ `x-date:${xDate}`
59
+ ].join("\n") + "\n";
60
+ const credentialScope = `${shortDate}/${region}/${service}/request`;
61
+ const canonicalRequest = ["POST", "/", query, canonicalHeaders, signedHeaders, payloadHash].join("\n");
62
+ const stringToSign = ["HMAC-SHA256", xDate, credentialScope, sha256Hex(canonicalRequest)].join("\n");
63
+ const signingKey = hmac(hmac(hmac(hmac(secretAccessKey, shortDate), region), service), "request");
64
+ return {
65
+ url: `https://${host}/?${query}`,
66
+ headers: {
67
+ "content-type": "application/json",
68
+ "x-content-sha256": payloadHash,
69
+ "x-date": xDate,
70
+ authorization: `HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${hmac(signingKey, stringToSign, "hex")}`
71
+ }
72
+ };
73
+ }
74
+
75
+ /** 业务信封错误:ResponseMetadata.Error 比 HTTP 状态码更能说明拒绝原因。 */
76
+ export function volcError(body) {
77
+ const error = body?.ResponseMetadata?.Error;
78
+ if (!error || typeof error !== "object") return undefined;
79
+ const code = typeof error.Code === "string" && error.Code.trim() ? error.Code.trim() : "UnknownError";
80
+ const message = typeof error.Message === "string" && error.Message.trim() ? error.Message.trim() : "";
81
+ return { code, message };
82
+ }
83
+
84
+ function finite(value) {
85
+ return typeof value === "number" && Number.isFinite(value);
86
+ }
87
+ /** 额度字段是字符串数字("50.0"),只接受有限非负数;非法值不当作 0。 */
88
+ function amount(value) {
89
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
90
+ if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) {
91
+ const parsed = Number(value);
92
+ return parsed >= 0 ? parsed : undefined;
93
+ }
94
+ return undefined;
95
+ }
96
+ /** 重置时间戳单位不统一:Coding Plan 是秒,Agent Plan 是毫秒。 */
97
+ function fromEpoch(value, unit) {
98
+ if (!Number.isSafeInteger(value) && !Number.isFinite(value)) return undefined;
99
+ const ms = unit === "seconds" ? value * 1000 : value;
100
+ return Number.isFinite(ms) && ms > 0 && Number.isFinite(new Date(ms).getTime()) ? new Date(ms).toISOString() : undefined;
101
+ }
102
+
103
+ // Level 是后端下发的窗口名;未登记的取值不产出窗口,不猜语义。
104
+ const CODING_LEVELS = {
105
+ session: "5h", "5h": "5h", five_hour: "5h", fivehour: "5h",
106
+ weekly: "week", week: "week", "7d": "week", seven_day: "week",
107
+ monthly: "month", month: "month", thirty_day: "month"
108
+ };
109
+
110
+ /**
111
+ * 个人版 Coding Plan:Result.QuotaUsage[] 每项一个窗口。
112
+ * Percent 是**已用百分数**(0–100,实测常见 0.39 这类小值,不能按「≤1 即小数」放大)。
113
+ * 接口只给百分比,不返回绝对量,因此不产出 detail(Cap 字段未在任何来源证实存在,不编造)。
114
+ */
115
+ export function parseVolcCodingPlan(body) {
116
+ // 正常走 OpenAPI 的 ResponseMetadata + Result 信封;个别抓包形态把字段放在顶层,两者都接受。
117
+ const result = body?.Result && typeof body.Result === "object" && !Array.isArray(body.Result) ? body.Result : body;
118
+ const rows = Array.isArray(result?.QuotaUsage) ? result.QuotaUsage : [];
119
+ const windows = [];
120
+ for (const row of rows) {
121
+ const kind = CODING_LEVELS[String(row?.Level ?? "").trim().toLowerCase()];
122
+ if (!kind) continue;
123
+ const percent = amount(row?.Percent);
124
+ if (percent === undefined) throw new Error("Volcengine Coding Plan percent is invalid");
125
+ windows.push({ kind, percent, resetsAt: fromEpoch(row?.ResetTimestamp, "seconds") });
126
+ }
127
+ return { subscribed: rows.length > 0, windows };
128
+ }
129
+
130
+ // Agent Plan 的 AFP 窗口:Quota / Used 是绝对值,比例由两者算出。
131
+ const AFP_WINDOWS = [["AFPFiveHour", "5h"], ["AFPDaily", "day"], ["AFPWeekly", "week"], ["AFPMonthly", "month"]];
132
+
133
+ /**
134
+ * 个人版 Agent Plan:Result 下四个滚动窗口。
135
+ * Quota 为 0/缺失表示该窗口不适用(例如视觉模型不占 5 小时额度),不产出行、更不当成 0%。
136
+ */
137
+ export function parseVolcAgentPlan(body) {
138
+ const result = body?.Result;
139
+ if (!result || typeof result !== "object") return { subscribed: false, windows: [], plan: undefined };
140
+ const windows = [];
141
+ let reported = false;
142
+ for (const [field, kind] of AFP_WINDOWS) {
143
+ const row = result[field];
144
+ if (!row || typeof row !== "object") continue;
145
+ reported = true;
146
+ const quota = amount(row.Quota), used = amount(row.Used);
147
+ if (quota === undefined || used === undefined) throw new Error("Volcengine AFP window is invalid");
148
+ if (quota <= 0) continue;
149
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(row.ResetTime, "milliseconds"), detail: { used, limit: quota, unit: "AFP" } });
150
+ }
151
+ const plan = typeof result.PlanType === "string" && result.PlanType.trim() ? result.PlanType.trim() : undefined;
152
+ return { subscribed: windows.length > 0 || reported, windows, plan };
153
+ }
154
+
155
+ // ── 企业版/团队版:额度挂在「席位」上,要先 ListSeatInfos 取 SeatID ────────────────
156
+
157
+ /** 从 ListSeatInfos 的响应里取席位 ID 列表(路径为 Result.Data[].SeatID)。 */
158
+ export function parseVolcSeatIds(body) {
159
+ const data = body?.Result?.Data;
160
+ if (!Array.isArray(data)) return [];
161
+ return data.map(row => typeof row?.SeatID === "string" && row.SeatID.trim() ? row.SeatID.trim() : undefined).filter(Boolean);
162
+ }
163
+
164
+ /** Agent Plan 企业版单个席位:GetSeatAFPUsage 的响应(Result.SeatAFPUsages[],取第一条)。 */
165
+ export function parseVolcSeatAfp(body) {
166
+ const rows = body?.Result?.SeatAFPUsages;
167
+ if (!Array.isArray(rows) || !rows.length) return { subscribed: false, windows: [] };
168
+ const row = rows[0] && typeof rows[0] === "object" ? rows[0] : {};
169
+ const windows = [];
170
+ let reported = false;
171
+ for (const [field, kind] of AFP_WINDOWS) {
172
+ const window = row[field];
173
+ if (!window || typeof window !== "object") continue;
174
+ reported = true;
175
+ const quota = amount(window.Quota), used = amount(window.Used);
176
+ if (quota === undefined || used === undefined) throw new Error("Volcengine seat AFP window is invalid");
177
+ if (quota <= 0) continue;
178
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(window.ResetTime, "milliseconds"), detail: { used, limit: quota, unit: "AFP" } });
179
+ }
180
+ const plan = typeof row.PlanType === "string" && row.PlanType.trim() ? row.PlanType.trim() : undefined;
181
+ const seatId = typeof row.SeatID === "string" && row.SeatID.trim() ? row.SeatID.trim() : undefined;
182
+ return { subscribed: windows.length > 0 || reported, windows, plan, seatId };
183
+ }
184
+
185
+ /**
186
+ * Coding Plan 企业版单个席位:GetSeatInfoUsage 的响应(Result.SeatInfoUsage)。
187
+ * 三个字段都是**已用百分比**(字符串),缺字段不产出行、不当作 0。
188
+ * 窗口语义:WeeklyUsage→周、MonthlyUsage→月;ShortTermUsage 国内文档写「近 5 分钟用量」、
189
+ * 国际站英文文档写「5 小时百分比」,两站冲突——按短周期窗口处理,存疑见 docs/development.md。
190
+ */
191
+ export function parseVolcSeatCoding(body) {
192
+ // **站点间结构不同**(已核实):国际站是 `Result.SeatInfoUsage.*`,中国站契约是 `Result.*` 直挂。
193
+ // 只认一种会让另一站点静默读不到数据,所以两条路径都试。
194
+ const result = body?.Result && typeof body.Result === "object" && !Array.isArray(body.Result) ? body.Result : undefined;
195
+ const nested = result?.SeatInfoUsage;
196
+ const info = nested && typeof nested === "object" && !Array.isArray(nested) ? nested : result;
197
+ if (!info || typeof info !== "object" || Array.isArray(info)) return { subscribed: false, windows: [] };
198
+ const windows = [];
199
+ // 三个 usage 字段各带一个同族的重置时刻(毫秒):只给百分比不给重置会让它和别家不一致。
200
+ // 注意 5 小时用量为 0 时 ShortTermResetMilestone 返回 -1,fromEpoch 的 ms>0 判定会挡掉。
201
+ for (const [field, kind, resetField] of [["ShortTermUsage", "5h", "ShortTermResetMilestone"], ["WeeklyUsage", "week", "WeeklyResetMilestone"], ["MonthlyUsage", "month", "MonthlyResetMilestone"]]) {
202
+ const value = amount(info[field]);
203
+ if (value === undefined) continue;
204
+ windows.push({ kind, percent: Math.min(100, value), resetsAt: fromEpoch(info[resetField], "milliseconds") });
205
+ }
206
+ const seatId = typeof info.SeatID === "string" && info.SeatID.trim() ? info.SeatID.trim() : undefined;
207
+ return { subscribed: windows.length > 0, windows, seatId };
208
+ }
package/locale/en.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "name": "Subscription usage",
3
- "description": "Shows subscription quota for Z.ai, Kimi, MiMo, OpenCode Go, Command Code, MiniMax, Synthetic and NanoGPT, with new providers disabled by default, shared refresh and credential management."
3
+ "description": "Shows subscription quota and balance for 30 AI providers (DeepSeek, Volcengine Ark, Z.ai, Kimi, MiMo, Codex, MiniMax, OpenRouter, SiliconFlow, LiteLLM, ...), with one settings page for detection switches and credentials plus a read-only quota API."
4
4
  }
package/locale/zh.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
2
  "name": "订阅用量显示",
3
- "description": "显示 Z.ai / Kimi / MiMo / OpenCode Go / Command Code / MiniMax / Synthetic / NanoGPT 订阅余量;新增提供商默认关闭、共享刷新与凭据管理。"
3
+ "description": "显示 30 家 AI 提供商(DeepSeek、火山方舟 Ark、Z.ai、Kimi、MiMo、Codex、MiniMax、OpenRouter、SiliconFlow、LiteLLM 等)的订阅余量与余额;统一管理检测开关与凭据,并提供只读额度查询接口。"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
3
  "displayName": "订阅用量显示",
4
- "version": "0.8.3",
4
+ "version": "0.10.0",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "registry": "https://registry.npmjs.org/"
@@ -10,7 +10,7 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/KouzakiUmi/dsh-subusage.git"
12
12
  },
13
- "description": "显示 Z.ai(中国与国际)/ Kimi / MiMo / OpenCode Go / Command Code / MiniMax / Synthetic / NanoGPT 订阅余量;新增提供商默认关闭,共享刷新与凭据管理。",
13
+ "description": "显示 DeepSeek 官方余额、火山方舟(arkcli 官方 CLI 路由与旧插件路由、Agent/Coding Plan 个人版与企业版席位)、Z.ai(中国与国际)、Kimi、MiMo、OpenCode Go、Command Code、SuperGrok、Codex(ChatGPT 订阅)、MiniMax(中国)、Synthetic、NanoGPT、SiliconFlow、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM 的订阅余量与余额;共享刷新与凭据管理,默认关闭 19 家。",
14
14
  "keywords": [
15
15
  "dsh",
16
16
  "dsh-plugin",
@@ -24,6 +24,7 @@
24
24
  "@deepseek-ai/dsh": ">=0.2.0-rc.1 <0.3.0-0",
25
25
  "@deepseek-ai/dsh-credentials": ">=0.2.0-rc.1 <0.3.0-0",
26
26
  "@deepseek-ai/dsh-launch-environment": ">=0.2.0-rc.1 <0.3.0-0",
27
+ "@deepseek-ai/dsh-tools": ">=0.2.0-rc.1 <0.3.0-0",
27
28
  "@deepseek-ai/dsh-typert-protocol": ">=0.2.0-rc.1 <0.3.0-0"
28
29
  },
29
30
  "type": "module",