dsh-plugin-weapp-gateway 0.8.13 → 0.8.23

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,1668 @@
1
+ /**
2
+ * Coding Plan 额度查询 adapter 框架(多厂商)。
3
+ *
4
+ * 每家厂商一个 adapter:固定官方端点白名单 + Key 发现线索(env/CLI 文件)+
5
+ * 响应解析器。解析器为纯函数(可单测);网络与凭据解析在宿主侧(index.js)。
6
+ *
7
+ * 归一化输出:windows = { [name]: { percent: 0-100, resetsAt: ISO 字符串 } }。
8
+ * 凭证安全:默认使用官方域名;MiniMax 可显式指定 HTTPS origin,仅向该 origin 发 Key,拒绝重定向。
9
+ *
10
+ * 实测确认(2026-08):
11
+ * - Anthropic OAuth usage 端点存活(未授权返回限流/401);
12
+ * - Z.ai / 智谱 Coding Plan usage 端点存活(401「token expired or incorrect」);
13
+ * - MiniMax Token Plan remains 端点存活(1004 需 Authorization);
14
+ * - Kimi PAYG 余额端点 api.moonshot.cn/v1/users/me/balance 存活(401 incorrect_api_key,官方文档明确);
15
+ * Kimi Code 订阅周窗/5小时窗经 api.kimi.com/coding/v1/usages 可查(issue #53,Kimi CLI 同款接口:
16
+ * 需 UA KimiCLI/1.6 与订阅 Key sk-kimi-*,与开放平台 PAYG Key 不通用;404 回退 /v1/usage;
17
+ * 订阅端点不可用时降级回 PAYG 余额,行为与旧版一致);
18
+ * - OpenRouter credits 端点 openrouter.ai/api/v1/credits 存活(401,官方文档明确);
19
+ * - SiliconFlow 用户信息端点 api.siliconflow.cn/v1/user/info 存活(30014 Token is invalid);
20
+ * - CommandCode(commandcode.ai)billing credits 端点存活(401 unauthorized;issue #30):
21
+ * GET api.commandcode.ai/alpha/billing/credits,窗口(fiveHour/weekly)+ 月度 Credits 余额;
22
+ * - Cline Pass(cline.bot)额度端点存活(401 unauthorized;2026-10 实测):
23
+ * GET api.cline.bot/api/v1/users/me/plan/usage-limits,5 小时/周/月三窗口 percentUsed(已用%)
24
+ * 与 resetsAt;网关只报已用百分比,没有绝对额度字段;
25
+ * - SCNet(超算互联网)Token Plan 仅有 sk-tp- 专属推理端点(api.scnet.cn),额度用量只在控制台
26
+ * 「模型服务 → Token Plan → 我的订阅/Token 用量」可见,无 API-Key 化查询端点——以本地
27
+ * Credits 计量接入(官方抵扣表折算,见 SCNET_CREDIT_RATES;issue #26);
28
+ * - 火山方舟 Volcano Ark Coding Plan:管控面 API(open.volcengineapi.com/?Action=GetUsageDetails
29
+ * / GetAFPUsage / GetPersonalPlan,Version=2024-01-01,service=ark,region=cn-beijing,AK/SK+HMAC 签名,
30
+ * 需控制台创建 IAM 子用户并授予 ArkReadOnlyAccess + BillingCenterReadOnlyAccess,窗口五小时/周/月三档,
31
+ * 参考 https://www.volcengine.com/docs/82379/1298459 与 CCswitch 已实现的签名要点,issue #60);
32
+ * - 百炼无 API-Key 化用量端点,但官方百炼 CLI(bl)可查 Token/Coding Plan——经千问卡片 quotaSource:'bailian'
33
+ * 接入(lib/bailian-cli.js 子进程桥),本 adapter 不接;Codex 额度另由客户端读取 dsh-openai-codex 的本地状态。
34
+ * Gemini Code Assist / GitHub Copilot 个人版未接入。
35
+ */
36
+
37
+ import { createHash, createHmac } from 'node:crypto'
38
+
39
+ import { fetchWithRetry, readJsonBounded } from './net.mjs'
40
+ import { queryFactoryQuota, FACTORY_QUOTA_URL } from './factory-quota.mjs'
41
+ import { queryOpenDesignQuota, OPENDESIGN_API } from './opendesign.mjs'
42
+ import { fetchWorkBuddyConnectCredits, fetchWorkBuddyCredits, isWorkBuddyTokenExpired, resolveWorkBuddyCredential, workbuddyBillingBase } from './workbuddy.mjs'
43
+ import { fetchWorkBuddyAccounts } from './workbuddy-accounts.mjs'
44
+
45
+ export const CODING_PLAN_PROVIDERS = {
46
+ opendesign: {
47
+ label: 'OpenDesign', credentialEnvs: ['OPENDESIGN_CONTROL_KEY', 'VELA_CONTROL_KEY'],
48
+ keyHint: 'OpenDesign Control Key (quota only; not the inference API key)',
49
+ },
50
+ factory: {
51
+ label: 'Factory (Droid)',
52
+ credentialEnvs: ['FACTORY_API_KEY'],
53
+ keyHint: 'Factory 额度查询凭据(DSH 凭据库 / FACTORY_API_KEY)',
54
+ },
55
+ anthropic: {
56
+ label: 'Anthropic (Claude Pro/Max)',
57
+ credentialEnvs: ['ANTHROPIC_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'],
58
+ keyHint: 'Claude Code OAuth access token(~/.claude/.credentials.json)',
59
+ },
60
+ zai: {
61
+ label: 'Z.ai / 智谱 GLM Coding Plan',
62
+ credentialEnvs: ['ZAI_API_KEY', 'BIGMODEL_API_KEY'],
63
+ keyHint: 'Coding Plan 专属 API Key(z.ai / bigmodel.cn 控制台)',
64
+ },
65
+ minimax: {
66
+ label: 'MiniMax Token Plan',
67
+ credentialEnvs: ['MINIMAX_API_KEY'],
68
+ keyHint: 'MiniMax API Key(sk-* / sk-cp-*)',
69
+ },
70
+ kimi: {
71
+ label: 'Kimi / Moonshot',
72
+ // KIMI_CODING_API_KEY = Kimi Code 订阅 Key(sk-kimi-*,即 coding 推理端点用的 Key),
73
+ // 与开放平台 PAYG Key(sk-*)不通用:优先取订阅 Key 查配额,无订阅时回落
74
+ // MOONSHOT/KIMI Key 走 PAYG 余额端点(两类查询的凭据发现需区分,issue #53)。
75
+ credentialEnvs: ['KIMI_CODING_API_KEY', 'MOONSHOT_API_KEY', 'KIMI_API_KEY'],
76
+ keyHint: 'Kimi Code 订阅 Key(sk-kimi-*,即 coding 推理端点用的 Key)显示本周/5 小时配额;仅配开放平台 Key 时显示 PAYG 余额',
77
+ },
78
+ openrouter: {
79
+ label: 'OpenRouter',
80
+ credentialEnvs: ['OPENROUTER_API_KEY'],
81
+ keyHint: 'OpenRouter API Key(sk-or-*;显示预付 credits 已用%)',
82
+ },
83
+ siliconflow: {
84
+ label: 'SiliconFlow 硅基流动',
85
+ credentialEnvs: ['SILICONFLOW_API_KEY'],
86
+ keyHint: 'SiliconFlow API Key(sk-*;显示账户余额)',
87
+ },
88
+ commandcode: {
89
+ label: 'CommandCode',
90
+ credentialEnvs: ['COMMANDCODE_API_KEY'],
91
+ keyHint: 'commandcode.ai API Key(user_*;显示 5 小时/周窗口用量% 与月度 Credits 余额)',
92
+ },
93
+ clinepass: {
94
+ label: 'Cline Pass',
95
+ credentialEnvs: ['CLINE_PASS_API_KEY'],
96
+ keyHint: 'Cline Pass API Key(sk_*;显示 5 小时/周/月三窗口用量%)',
97
+ },
98
+ scnet: {
99
+ label: 'SCNet 超算互联网 Token Plan',
100
+ credentialEnvs: [],
101
+ // SCNet 未提供 API-Key 化的额度查询端点(仅控制台可见),不走网络:
102
+ // 按官方 Credits 抵扣表(2026-08-11)由本地账本估算,无需任何凭据。
103
+ keyHint: '无需凭据:按官方 Credits 抵扣表(2026-08-11 生效)由本地账本估算月度用量',
104
+ },
105
+ volcengine: {
106
+ label: '火山方舟 Volcano Ark Coding Plan',
107
+ // 管控面 AK/SK(非 ARK_API_KEY Bearer):需在火山引擎控制台创建 IAM 子用户并授予
108
+ // ArkReadOnlyAccess + BillingCenterReadOnlyAccess,得到 AccessKeyID/SecretAccessKey;
109
+ // SDK 约定的环境变量名为 VOLC_ACCESSKEY/VOLC_SECRETKEY,此处同时兼容
110
+ // VOLCENGINE_* 与 ARK_* 变体,配置里以 accessKeyId / secretAccessKey 两个字段承载(issue #60)。
111
+ credentialEnvs: ['VOLC_ACCESSKEY', 'VOLCENGINE_ACCESS_KEY_ID', 'VOLCENGINE_ACCESS_KEY', 'ARK_ACCESS_KEY_ID'],
112
+ credentialEnvsSecret: ['VOLC_SECRETKEY', 'VOLCENGINE_SECRET_ACCESS_KEY', 'VOLCENGINE_SECRET_KEY', 'ARK_SECRET_ACCESS_KEY'],
113
+ keyHint: '火山引擎访问密钥 AccessKeyID + SecretAccessKey(控制台 IAM→用户→密钥,需 ArkReadOnlyAccess + BillingCenterReadOnlyAccess;或导出 VOLC_ACCESSKEY / VOLC_SECRETKEY)',
114
+ },
115
+ qwen: {
116
+ label: '千问 Qwen Token Plan',
117
+ credentialEnvs: [],
118
+ // 本地估算保持默认;可选择已登录的官方 qianwen CLI 查询订阅快照(#146)。
119
+ keyHint: '本地估算无需凭据;官方 CLI 模式需在 DSH 主机运行 qianwen auth login',
120
+ },
121
+ mimo: {
122
+ label: '小米 MiMo Token Plan',
123
+ // 额度查询走小米开放平台控制台 API(仅小米账号 SSO Cookie 鉴权,tp-*/ttp-* 专用
124
+ // Key 无用量端点),凭据为整段 Cookie 头(必需 api-platform_serviceToken(或
125
+ // serviceToken)与 userId 字段,过期后需重新复制)。
126
+ credentialEnvs: ['MIMO_COOKIE', 'XIAOMI_MIMO_COOKIE'],
127
+ keyHint: '小米开放平台控制台 Cookie(登录 platform.xiaomimimo.com 后 F12→网络→balanceAlertConfig→请求头 cookie 整段复制)',
128
+ },
129
+ workbuddy: {
130
+ label: 'WorkBuddy 积分',
131
+ // 优先读取连接插件状态;旧版明文文件仅用于未注册状态接口的环境。
132
+ credentialEnvs: [],
133
+ keyHint: '无需复制凭据;通过 dsh-workbuddy-connect 查询积分,兼容旧版明文登录文件',
134
+ },
135
+ }
136
+
137
+ export const CODING_PLAN_PROVIDER_IDS = Object.keys(CODING_PLAN_PROVIDERS)
138
+
139
+ /**
140
+ * WorkBuddy 余额数值文本(整数积分;千位分隔)。
141
+ *
142
+ * 只返回数值本身,不带「余额」前缀:额度页的窗口行左侧已经有「余额」标签
143
+ * (codingPlanWindowLabel 的 balance 分支),数值再自带前缀就会渲染成
144
+ * 「余额 余额: 1,117」。这与其它文本窗口(MiMo/Qwen 的余额行)一致——
145
+ * 文本窗口只装数值,标签由窗口名给出,侧栏标题行同理。
146
+ */
147
+ export function formatWorkBuddyBalance(total) {
148
+ return Number.isFinite(total) ? String(Math.round(total)).replace(/\B(?=(\d{3})+(?!\d))/g, ',') : String(total)
149
+ }
150
+
151
+ // ── 小米 MiMo Token Plan ─────────────────────────────────────────────
152
+ //
153
+ // 额度查询走控制台 API(https://platform.xiaomimimo.com/api/v1,Cookie 鉴权;
154
+ // 端点与响应结构由 PR #166 贡献者实测,并参照 CodexBar 的 MiMoUsageFetcher):
155
+ // GET /tokenPlan/usage → data.monthUsage.items[{ name, used, limit, percent }]
156
+ // GET /tokenPlan/detail → data.{ planCode, currentPeriodEnd, expired }(周期截止=重置时刻)
157
+ // GET /balance → data.{ balance, currency, cashBalance, giftBalance, ... }
158
+ // 服务编排(tp-*/ttp-* 专用 Key)在 token-plan-{cn,sgp,ams}.xiaomimimo.com 上只有推理
159
+ // 端点,无任何用量端点(实测 404),故不支持 Key 鉴权。
160
+ export const MIMO_API_BASE = 'https://platform.xiaomimimo.com/api/v1'
161
+ export const MIMO_ENDPOINTS = {
162
+ usage: `${MIMO_API_BASE}/tokenPlan/usage`,
163
+ detail: `${MIMO_API_BASE}/tokenPlan/detail`,
164
+ balance: `${MIMO_API_BASE}/balance`,
165
+ }
166
+ /** 与 lib/plan-billing.js 的 plan 轨分类联动:DSH 侧常见 provider id 都归到 mimo。 */
167
+ export const MIMO_TOKEN_PLAN_PROVIDER_IDS = [
168
+ 'mimo', 'xiaomimimo', 'mimo-token-plan', 'xiaomi-token-plan',
169
+ 'xiaomi-token-plan-cn', 'xiaomi-token-plan-sgp', 'xiaomi-token-plan-ams',
170
+ ]
171
+
172
+ /** 归一化百分比:0-1 视为小数,>=1 视为已是百分数;非法 → null。 */
173
+ export function normalizePercent(value) {
174
+ const n = Number(value)
175
+ if (!Number.isFinite(n) || n < 0) return null
176
+ const pct = n <= 1 ? n * 100 : n
177
+ return Math.min(100, Math.round(pct * 10) / 10)
178
+ }
179
+
180
+ /** 归一化重置时刻:unix 秒 / unix 毫秒 / ISO 字符串 → ISO 字符串;非法 → ''。 */
181
+ export function normalizeResetAt(value) {
182
+ if (typeof value === 'string' && value.length > 0) {
183
+ const ms = Date.parse(value)
184
+ if (Number.isFinite(ms)) return new Date(ms).toISOString()
185
+ const asNum = Number(value)
186
+ if (Number.isFinite(asNum) && asNum > 0) return new Date(asNum > 1e12 ? asNum : asNum * 1000).toISOString()
187
+ return ''
188
+ }
189
+ const n = Number(value)
190
+ if (!Number.isFinite(n) || n <= 0) return ''
191
+ return new Date(n > 1e12 ? n : n * 1000).toISOString()
192
+ }
193
+
194
+ /** 组装单个百分比窗口;percent 非法时返回 null。 */
195
+ function windowOf(percent, resetsAt) {
196
+ const pct = normalizePercent(percent)
197
+ if (pct === null) return null
198
+ return { percent: pct, resetsAt: normalizeResetAt(resetsAt) }
199
+ }
200
+
201
+ /** 组装文本窗口(余额等无百分比的量):text 空 → null。 */
202
+ export function textWindowOf(text) {
203
+ const s = typeof text === 'string' ? text.trim() : String(text ?? '').trim()
204
+ return s.length > 0 ? { resetsAt: '', text: s } : null
205
+ }
206
+
207
+ /**
208
+ * 解析 Anthropic OAuth 用量响应(GET https://api.anthropic.com/api/oauth/usage)。
209
+ * 形如 { five_hour: { utilization, resets_at }, seven_day: {...}, seven_day_sonnet: {...}, extra_usage: {...} }。
210
+ * utilization 为 0-100 百分数,resets_at 为 unix 秒。
211
+ */
212
+ export function parseAnthropicUsage(data) {
213
+ if (data === null || typeof data !== 'object') return null
214
+ const windows = {}
215
+ for (const [name, raw] of Object.entries(data)) {
216
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
217
+ // 子配额窗口(seven_day_sonnet / five_hour_opus 等)只描述单一模型系列的
218
+ // 限额:canonicalWindowKey 会把它与同名主窗口归到同一键,后解析的子配额
219
+ // 覆盖主窗采样列与面板百分比(seven_day: 12% 显示成 sonnet 的 3%)。
220
+ // 只保留主窗口,子配额整体丢弃。
221
+ if (/^(five[_ -]?hour|seven[_ -]?day)[_ -]/i.test(name)) continue
222
+ const win = windowOf(raw.utilization ?? raw.used_percentage, raw.resets_at ?? raw.reset_at)
223
+ if (win !== null) windows[name] = win
224
+ }
225
+ return Object.keys(windows).length > 0 ? windows : null
226
+ }
227
+
228
+ /**
229
+ * 解析 Z.ai / 智谱 GLM Coding Plan 用量响应。
230
+ * 兼容三种已见形态(按优先级):
231
+ * - { data: { limits: [{ type, unit, number, percentage, nextResetTime, usage,
232
+ * currentValue, ... }], level } }(2026-08 起的监控端点
233
+ * /api/monitor/usage/quota/limit,issue #42;TOKENS_LIMIT 为 token 窗口(Pro/Max),
234
+ * CREDIT_LIMIT 为 Credit 计费窗口(Lite,percentage/currentValue 语义与
235
+ * TOKENS_LIMIT 一致,issue #44),两者均按 unit 3=小时档、6=周档映射;
236
+ * TIME_LIMIT 为 MCP/工具调用月度额度、量纲不同不纳入;unit 缺失时按
237
+ * nextResetTime 排序兜底——0% 用量的滚动窗口不返回重置时间,排最前)
238
+ * - { plans: [{ status, total_units, used_units, available_units, period_end, capabilities }] }
239
+ * (旧计费端点;period_end 语义按数值大小推断:重置跨度 >1 天视为周档,否则为 5 小时档)
240
+ * - { five_hour: { utilization|percent, resets_at }, weekly|week|seven_day: {...} }
241
+ */
242
+ export function parseZaiUsage(data) {
243
+ if (data === null || typeof data !== 'object') return null
244
+ const windows = {}
245
+ // 形态零:监控端点 quota/limit(issue #42)。
246
+ {
247
+ const limits = data.data !== null && typeof data.data === 'object' && Array.isArray(data.data.limits)
248
+ ? data.data.limits : null
249
+ if (limits !== null) {
250
+ // 已按 unit 明确分配的窗口;unit 缺失/未知的条目留待重置时间排序兜底。
251
+ const monWindows = {}
252
+ const rest = []
253
+ for (const limit of limits) {
254
+ // TOKENS_LIMIT(Pro/Max token 窗口)与 CREDIT_LIMIT(Lite Credit 窗口,issue #44)
255
+ // 的 percentage/currentValue/usage 与 unit 语义完全一致,一并接受。
256
+ if (limit === null || typeof limit !== 'object' || (limit.type !== 'TOKENS_LIMIT' && limit.type !== 'CREDIT_LIMIT')) continue
257
+ // percentage 已是 0-100 百分数;缺失时用 currentValue/usage 反推。
258
+ const pct = limit.percentage !== undefined ? clampPct(Number(limit.percentage))
259
+ : Number.isFinite(Number(limit.usage)) && Number(limit.usage) > 0 && Number.isFinite(Number(limit.currentValue))
260
+ ? clampPct((Number(limit.currentValue) / Number(limit.usage)) * 100)
261
+ : null
262
+ if (pct === null) continue
263
+ const resetsAt = normalizeResetAt(limit.nextResetTime)
264
+ const unit = Number(limit.unit)
265
+ if (unit === 3 && monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: pct, resetsAt }
266
+ else if (unit === 6 && monWindows.weekly === undefined) monWindows.weekly = { percent: pct, resetsAt }
267
+ else if (!Number.isFinite(unit)) rest.push({ pct, resetsAt, resetMs: Number(limit.nextResetTime) })
268
+ }
269
+ if (rest.length > 0) {
270
+ // 无 unit 条目:重置时间升序(0% 滚动窗口无重置时间视为最近,排最前),
271
+ // 依次补位到 5 小时档 → 周档(老套餐仅一条时只出 5 小时档)。
272
+ rest.sort((a, b) => {
273
+ const av = Number.isFinite(a.resetMs) && a.resetMs > 0 ? a.resetMs : 0
274
+ const bv = Number.isFinite(b.resetMs) && b.resetMs > 0 ? b.resetMs : 0
275
+ return av - bv
276
+ })
277
+ for (const item of rest) {
278
+ if (monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: item.pct, resetsAt: item.resetsAt }
279
+ else if (monWindows.weekly === undefined) monWindows.weekly = { percent: item.pct, resetsAt: item.resetsAt }
280
+ }
281
+ }
282
+ if (Object.keys(monWindows).length > 0) return monWindows
283
+ // limits 中无可解析 token 窗口:落回后续形态,最终由调用方透传错误信封。
284
+ }
285
+ }
286
+ // 形态一:plans 数组(zcode 逆向确认的计费 API 形状)。
287
+ if (Array.isArray(data.plans)) {
288
+ for (const plan of data.plans) {
289
+ if (plan === null || typeof plan !== 'object') continue
290
+ const total = Number(plan.total_units)
291
+ const used = Number(plan.used_units)
292
+ let pct = null
293
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) {
294
+ pct = clampPct((used / total) * 100)
295
+ } else {
296
+ pct = normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage)
297
+ }
298
+ if (pct === null) continue
299
+ const spanMs = Number(plan.period_end) * 1000 - Date.now()
300
+ // 5 小时档重置跨度必 <1 天;周档最长 7 天——以 1 天为界区分两档。
301
+ const key = Number.isFinite(spanMs) && spanMs > 24 * 3600_000 ? 'weekly' : 'fiveHour'
302
+ windows[key] = { percent: Math.round(pct * 10) / 10, resetsAt: normalizeResetAt(plan.period_end) }
303
+ }
304
+ }
305
+ // 形态二:与 Anthropic 相同的扁平窗口对象。
306
+ for (const [name, raw] of Object.entries(data)) {
307
+ if (name === 'plans' || raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
308
+ const win = windowOf(raw.utilization ?? raw.percent ?? raw.used_percentage, raw.resets_at ?? raw.reset_at ?? raw.resetsAt)
309
+ if (win !== null) windows[name] = win
310
+ }
311
+ return Object.keys(windows).length > 0 ? windows : null
312
+ }
313
+
314
+ /** 0-100 已用百分比,保留 1 位。 */
315
+ function clampPct(p) {
316
+ return Math.max(0, Math.min(100, Math.round(p * 10) / 10))
317
+ }
318
+
319
+ /**
320
+ * MiniMax 窗口剩余百分比。优先 *_remaining_percent(现行 Token Plan,total 常为 0);
321
+ * 否则用 total/remain 或 total/used 反推。非法 → null。
322
+ */
323
+ function remainingPercentOf(row, remainPctKey, totalKey, usedKey, remainKey) {
324
+ if (row === null || typeof row !== 'object') return null
325
+ const numeric = key => row[key] == null || row[key] === '' ? NaN : Number(row[key])
326
+ const rp = numeric(remainPctKey)
327
+ if (Number.isFinite(rp)) return Math.min(100, Math.max(0, rp <= 1 ? rp * 100 : rp))
328
+ const total = numeric(totalKey)
329
+ const used = numeric(usedKey)
330
+ const remain = numeric(remainKey)
331
+ if (Number.isFinite(total) && total > 0) {
332
+ if (Number.isFinite(remain)) return (remain / total) * 100
333
+ if (Number.isFinite(used)) return ((total - used) / total) * 100
334
+ }
335
+ return null
336
+ }
337
+
338
+ /**
339
+ * 从单条 MiniMax 记录抽出 5h / 7d 窗口。percent 存已用%(与其它厂商一致);
340
+ * status=3 表示不限量,保留显式标记;未知窗口不推断为无限量。
341
+ */
342
+ function windowsFromMiniMaxRecord(row) {
343
+ if (row === null || typeof row !== 'object') return {}
344
+ const windows = {}
345
+ if (Number(row.current_interval_status) === 3) {
346
+ windows['5h'] = { unlimited: true, text: '∞', resetsAt: '' }
347
+ } else {
348
+ const remain = remainingPercentOf(
349
+ row,
350
+ 'current_interval_remaining_percent',
351
+ 'current_interval_total_count',
352
+ 'current_interval_usage_count',
353
+ 'current_interval_remain_count',
354
+ )
355
+ if (remain !== null) {
356
+ windows['5h'] = {
357
+ percent: clampPct(100 - remain),
358
+ resetsAt: normalizeResetAt(row.end_time ?? row.reset_time ?? row.next_reset_time),
359
+ }
360
+ }
361
+ }
362
+ if (Number(row.current_weekly_status) === 3) {
363
+ windows['7d'] = { unlimited: true, text: '∞', resetsAt: '' }
364
+ } else {
365
+ const remain = remainingPercentOf(
366
+ row,
367
+ 'current_weekly_remaining_percent',
368
+ 'current_weekly_total_count',
369
+ 'current_weekly_usage_count',
370
+ 'current_weekly_remain_count',
371
+ )
372
+ if (remain !== null) {
373
+ windows['7d'] = {
374
+ percent: clampPct(100 - remain),
375
+ resetsAt: normalizeResetAt(row.weekly_end_time),
376
+ }
377
+ }
378
+ }
379
+ return windows
380
+ }
381
+
382
+ /** 选 chat/通用额度行:general → MiniMax-M* → 第一条能解析出窗口的记录。跳过仅 video/speech 的无限量行。 */
383
+ function pickMiniMaxModelRow(rows) {
384
+ const list = rows.filter(row => row !== null && typeof row === 'object' && !/^(video|speech|audio|image)(?:$|[-_])/i.test(String(row.model_name ?? '')))
385
+ const byName = name => list.find(row => String(row.model_name ?? '').toLowerCase() === name)
386
+ return byName('general')
387
+ ?? list.find(row => /^minimax-m/i.test(String(row.model_name ?? '')))
388
+ ?? list.find(row => Object.keys(windowsFromMiniMaxRecord(row)).length > 0)
389
+ ?? list[0]
390
+ ?? null
391
+ }
392
+
393
+ /**
394
+ * 解析 MiniMax 用量响应。兼容四种官方形态:
395
+ * - Token Plan(2026-08 现行,model_remains):GET https://www.minimaxi.com|io/v1/token_plan/remains
396
+ * { model_remains: [{ model_name, current_interval_remaining_percent, current_weekly_remaining_percent, ... }] }
397
+ * total_count 常为 0,以 remaining_percent 为准;取 general(或 MiniMax-M*)一行抽出 5h/7d,不按模型拆条;
398
+ * - Token Plan 平铺结构(issue #20):根节点(或 data.data)直含 current_interval_* 与 current_weekly_*;
399
+ * - Token Plan 旧数组形态:窗口数组字段,条目含 total/used/remain 与 interval 标签;
400
+ * - Coding Plan 旧计数制:model_remains 仅有 total/used、无 remaining_percent 时汇总。
401
+ */
402
+ export function parseMiniMaxRemains(data) {
403
+ if (data === null || typeof data !== 'object') return null
404
+ const payload = data.data !== null && typeof data.data === 'object' && !Array.isArray(data.data) ? data.data : data
405
+ const pickArray = (...keys) => {
406
+ for (const key of keys) {
407
+ const direct = Array.isArray(data?.[key]) ? data[key] : null
408
+ const nested = Array.isArray(data?.data?.[key]) ? data.data[key] : null
409
+ if (direct !== null) return direct
410
+ if (nested !== null) return nested
411
+ }
412
+ return null
413
+ }
414
+
415
+ // 现行 Token Plan:model_remains + remaining_percent(total 可为 0)。优先于平铺,避免根对象空字段误判。
416
+ const modelRows = pickArray('model_remains')
417
+ if (modelRows !== null) {
418
+ const row = pickMiniMaxModelRow(modelRows)
419
+ const fromRow = windowsFromMiniMaxRecord(row)
420
+ if (Object.keys(fromRow).length > 0) return fromRow
421
+ // 旧计数制:无 remaining_percent、靠 total>0 汇总(忽略零额度行)。
422
+ let total = 0
423
+ let used = 0
424
+ let found = false
425
+ for (const item of modelRows) {
426
+ if (item === null || typeof item !== 'object') continue
427
+ const t = Number(item.current_interval_total_count ?? item.total)
428
+ const u = Number(item.current_interval_usage_count ?? item.used)
429
+ if (!Number.isFinite(t) || t <= 0) continue
430
+ found = true
431
+ total += t
432
+ used += Number.isFinite(u) ? u : 0
433
+ }
434
+ if (found && total > 0) {
435
+ return { current: { percent: clampPct((used / total) * 100), resetsAt: '' } }
436
+ }
437
+ }
438
+
439
+ const flat = windowsFromMiniMaxRecord(payload)
440
+ if (Object.keys(flat).length > 0) return flat
441
+
442
+ // Token Plan:窗口数组(字段名容错)。
443
+ const windows = {}
444
+ const planRows = pickArray('token_plan_remains', 'plan_remains', 'remains', 'windows')
445
+ if (planRows !== null) {
446
+ planRows.forEach((row, index) => {
447
+ if (row === null || typeof row !== 'object') return
448
+ const total = Number(row.current_interval_total_count ?? row.total_count ?? row.total ?? row.limit)
449
+ const used = Number(row.current_interval_usage_count ?? row.used_count ?? row.usage_count ?? row.used)
450
+ const remain = Number(row.current_interval_remain_count ?? row.remain_count ?? row.remain ?? row.remaining)
451
+ let pct = null
452
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
453
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
454
+ else pct = normalizePercent(row.utilization ?? row.percent ?? row.used_percentage)
455
+ if (pct === null) return
456
+ const labelRaw = row.interval ?? row.interval_type ?? row.window_type ?? row.type ?? row.name
457
+ const label = typeof labelRaw === 'string' && labelRaw.length > 0 ? labelRaw : 'window' + String(index + 1)
458
+ windows[label] = {
459
+ percent: Math.max(0, Math.min(100, Math.round(pct * 10) / 10)),
460
+ resetsAt: normalizeResetAt(row.reset_time ?? row.resets_at ?? row.next_reset_time ?? row.reset_at),
461
+ }
462
+ })
463
+ }
464
+ return Object.keys(windows).length > 0 ? windows : null
465
+ }
466
+
467
+ /**
468
+ * 解析 Kimi / Moonshot 余额响应(GET https://api.moonshot.cn/v1/users/me/balance)。
469
+ * 官方返回形如 { available_balance: <分> }(人民币分),兼容 cached/total 变体与元单位形态。
470
+ * 输出文本窗口(余额无总量,不适合百分比进度条)。
471
+ */
472
+ export function parseKimiBalance(data) {
473
+ if (data === null || typeof data !== 'object') return null
474
+ const raw = data.available_balance ?? data.balance ?? data.cash_balance ?? data.data?.available_balance
475
+ const n = Number(raw)
476
+ if (!Number.isFinite(n) || n < 0) return null
477
+ // 官方单位为人民币分;数值 <100 视为已是元(兼容变体)。
478
+ const cny = n >= 100 ? n / 100 : n
479
+ const text = '余额 ¥' + (Math.round(cny * 100) / 100).toFixed(2)
480
+ const win = textWindowOf(text)
481
+ return win === null ? null : { balance: win }
482
+ }
483
+
484
+ /**
485
+ * 解析 Kimi Code 订阅配额响应(issue #53,
486
+ * GET https://api.kimi.com/coding/v1/usages,404 回退 /v1/usage;Kimi CLI 同款接口)。
487
+ * 形如 { usage: { used, limit, remaining, resetTime }(本周配额),
488
+ * limits: [{ window: { duration, timeUnit }, detail: { used, limit, remaining, resetTime } }](滚动窗口),
489
+ * parallel/user 为并发上限与订阅等级,不进窗口。端点非公开文档化,结构可能随
490
+ * 客户端版本变化:解析失败时 queryCodingPlan 会继续尝试下一端点(最终回落 PAYG 余额)。
491
+ */
492
+ export function parseKimiCodingUsage(data) {
493
+ if (data === null || typeof data !== 'object') return null
494
+ const windows = {}
495
+ // 本周配额(顶层 usage):used/limit → 已用%,remaining 兜底反推。
496
+ const usage = data.usage
497
+ if (usage !== null && typeof usage === 'object' && !Array.isArray(usage)) {
498
+ const total = Number(usage.limit)
499
+ const used = Number(usage.used)
500
+ const remain = Number(usage.remaining)
501
+ let pct = null
502
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
503
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
504
+ if (pct !== null) windows.weekly = { percent: clampPct(pct), resetsAt: normalizeResetAt(usage.resetTime) }
505
+ }
506
+ // 滚动窗口(limits[]):按 window.duration/timeUnit 命名(5 小时 → 5h)。
507
+ const limits = Array.isArray(data.limits) ? data.limits : []
508
+ limits.forEach((row, index) => {
509
+ if (row === null || typeof row !== 'object') return
510
+ const detail = row.detail
511
+ if (detail === null || typeof detail !== 'object' || Array.isArray(detail)) return
512
+ const total = Number(detail.limit)
513
+ const used = Number(detail.used)
514
+ const remain = Number(detail.remaining)
515
+ let pct = null
516
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
517
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
518
+ if (pct === null) return
519
+ const duration = Number(row.window?.duration)
520
+ const unitRaw = String(row.window?.timeUnit ?? '').toLowerCase()
521
+ const unit = unitRaw.startsWith('hour') ? 'h' : unitRaw.startsWith('day') ? 'd'
522
+ : unitRaw.startsWith('week') ? 'w' : unitRaw.startsWith('minute') ? 'm'
523
+ : unitRaw.startsWith('month') ? 'mo' : unitRaw.slice(0, 4)
524
+ const name = Number.isFinite(duration) && duration > 0 && unit.length > 0 ? duration + unit : 'window' + String(index + 1)
525
+ windows[name] = {
526
+ percent: clampPct(pct),
527
+ resetsAt: normalizeResetAt(detail.resetTime ?? detail.reset_at ?? detail.resetsAt),
528
+ }
529
+ })
530
+ return Object.keys(windows).length > 0 ? windows : null
531
+ }
532
+
533
+ /**
534
+ * 解析 OpenRouter 额度响应(GET https://openrouter.ai/api/v1/credits)。
535
+ * 官方返回 { data: { total_credits, total_usage } }(美元);输出已用% 窗口。
536
+ */
537
+ export function parseOpenRouterCredits(data) {
538
+ if (data === null || typeof data !== 'object') return null
539
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
540
+ const total = Number(d.total_credits ?? d.credits)
541
+ const used = Number(d.total_usage ?? d.usage)
542
+ if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(used)) return null
543
+ const pct = Math.max(0, Math.min(100, Math.round((used / total) * 1000) / 10))
544
+ return { credits: { percent: pct, resetsAt: normalizeResetAt(d.resets_at ?? d.next_reset_time) } }
545
+ }
546
+
547
+ /**
548
+ * 解析 SiliconFlow 用户信息响应(GET https://api.siliconflow.cn/v1/user/info)。
549
+ * 余额字段容错(balance/amount/remain),输出文本窗口(人民币)。
550
+ */
551
+ export function parseSiliconFlowInfo(data) {
552
+ if (data === null || typeof data !== 'object') return null
553
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
554
+ const raw = d.balance ?? d.amount ?? d.remain ?? d.remaining
555
+ const n = Number(raw)
556
+ if (!Number.isFinite(n) || n < 0) return null
557
+ const win = textWindowOf('余额 ¥' + (Math.round(n * 100) / 100).toFixed(2))
558
+ return win === null ? null : { balance: win }
559
+ }
560
+
561
+ /**
562
+ * 解析 CommandCode(commandcode.ai)额度响应(issue #30,
563
+ * GET https://api.commandcode.ai/alpha/billing/credits)。
564
+ * 官方返回 { credits: { monthlyCredits, ... }, windowLimits: { fiveHour: { used, cap, resetAt },
565
+ * weekly: {...} } }:窗口按 used/cap 输出已用%(resetAt 为 epoch 毫秒);月度 Credits 为
566
+ * 余额池(1 credit ≈ $1 用量,无总量字段),以文本窗口展示(与 Kimi/SiliconFlow 余额同形态)。
567
+ */
568
+ export function parseCommandCodeMonthly(creditsBody, summary, subscription) {
569
+ const numeric = v => (typeof v === 'number' || typeof v === 'string' && v.trim()) && Number.isFinite(Number(v)) && Number(v) >= 0 ? Number(v) : null
570
+ const credits = creditsBody?.credits
571
+ if (!credits || typeof credits !== 'object' || Array.isArray(credits)) return null
572
+ const values = ['monthlyCredits', 'purchasedCredits', 'freeCredits'].map(key => numeric(credits[key]))
573
+ const spent = numeric(summary?.totalCost)
574
+ if (values.some(value => value === null) || spent === null || summary?.periodBasis !== 'billing-period') return null
575
+ const start = subscription?.data?.currentPeriodStart
576
+ // Never combine a previous billing period's usage with the current wallet.
577
+ if (start && (!Number.isFinite(Date.parse(start)) || Date.parse(start) !== Date.parse(summary.periodStart))) return null
578
+ const pool = spent + values.reduce((sum, value) => sum + value, 0)
579
+ if (!Number.isFinite(pool) || pool <= 0) return null
580
+ return { percent: clampPct(spent / pool * 100), resetsAt: normalizeResetAt(subscription?.data?.currentPeriodEnd) }
581
+ }
582
+
583
+ async function enhanceCommandCodeMonthly(windows, credits, key, signal) {
584
+ const read = async path => {
585
+ const response = await fetchWithRetry('https://api.commandcode.ai/alpha/' + path, {
586
+ signal, redirect: 'error', headers: { authorization: `Bearer ${key.trim()}`, accept: 'application/json',
587
+ 'user-agent': 'dsh-cost-meter/1.4 (DeepSeek Harness plugin)' },
588
+ }, { timeoutMs: 8000, attempts: 1 })
589
+ if (!response.ok) { await response.body?.cancel?.(); throw Error('CommandCode monthly quota unavailable') }
590
+ return readJsonBounded(response)
591
+ }
592
+ const subscription = await read('billing/subscriptions')
593
+ const start = subscription?.data?.currentPeriodStart
594
+ const summary = await read('usage/summary' + (typeof start === 'string' && Number.isFinite(Date.parse(start)) ? '?since=' + encodeURIComponent(start) : ''))
595
+ const monthly = parseCommandCodeMonthly(credits, summary, subscription)
596
+ if (monthly) windows.monthly = monthly
597
+ }
598
+
599
+ export function parseCommandCodeCredits(data) {
600
+ if (data === null || typeof data !== 'object') return null
601
+ const windows = {}
602
+ const limits = data.windowLimits
603
+ if (limits !== null && typeof limits === 'object' && !Array.isArray(limits)) {
604
+ for (const [name, raw] of Object.entries(limits)) {
605
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
606
+ const used = Number(raw.used)
607
+ const cap = Number(raw.cap)
608
+ if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0 || used < 0) continue
609
+ windows[name] = { percent: clampPct((used / cap) * 100), resetsAt: normalizeResetAt(raw.resetAt) }
610
+ }
611
+ }
612
+ const credits = data.credits
613
+ const monthly = Number(credits !== null && typeof credits === 'object' ? credits.monthlyCredits : undefined)
614
+ if (Number.isFinite(monthly) && monthly >= 0) {
615
+ const win = textWindowOf('余额 $' + (Math.round(monthly * 100) / 100).toFixed(2))
616
+ if (win !== null) windows.monthly = win
617
+ }
618
+ return Object.keys(windows).length > 0 ? windows : null
619
+ }
620
+
621
+ /**
622
+ * 解析 Cline Pass(cline.bot)额度响应(2026-10 实测,
623
+ * GET https://api.cline.bot/api/v1/users/me/plan/usage-limits)。
624
+ * 官方返回 { data: { limits: [{ type: 'five_hour' | 'weekly' | 'monthly', percentUsed, resetsAt }] } }:
625
+ * percentUsed 已是 0-100 的**已用**百分比,直接 clampPct,不走 normalizePercent 的
626
+ * 0-1 猜测(否则 1% 会被放大成 100%);type 未知或百分比非法的窗口跳过,至少一个可用窗口才返回。
627
+ */
628
+ export function parseClinePassUsage(data) {
629
+ if (data === null || typeof data !== 'object') return null
630
+ const body = data.data
631
+ const limits = body !== null && typeof body === 'object' ? body.limits : undefined
632
+ if (!Array.isArray(limits)) return null
633
+ const names = new Map([['five_hour', 'fiveHour'], ['weekly', 'weekly'], ['monthly', 'monthly']])
634
+ const windows = {}
635
+ for (const entry of limits) {
636
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
637
+ const name = names.get(entry.type)
638
+ // 只接受有限数字或非空数字字符串:Number(null)/Number('')/Number(false)/Number([]) 都是 0,
639
+ // Number(true) 是 1,直接转换会把「缺失/未知」伪装成「已用 0% / 1%」。
640
+ const raw = entry.percentUsed
641
+ const percent = typeof raw === 'number'
642
+ ? raw
643
+ : typeof raw === 'string' && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim())
644
+ ? Number(raw)
645
+ : Number.NaN
646
+ if (name === undefined || !Number.isFinite(percent) || percent < 0) continue
647
+ windows[name] = { percent: clampPct(percent), resetsAt: normalizeResetAt(entry.resetsAt) }
648
+ }
649
+ return Object.keys(windows).length > 0 ? windows : null
650
+ }
651
+
652
+ // ── 小米 MiMo Token Plan ─────────────────────────────────────────────
653
+
654
+ /**
655
+ * 归一化 MiMo 控制台 Cookie(整段 Cookie 头粘贴)。容忍前缀 "Cookie:"、整体引号与
656
+ * 换行;必需 userId 与 serviceToken(api-platform_serviceToken 或 serviceToken)两族
657
+ * 字段,缺一即视为无效(与 CodexBar 同口径)。返回归一化 Cookie 串;无效 → null。
658
+ */
659
+ export function normalizeMimoCookie(value) {
660
+ let s = typeof value === 'string' ? value.trim() : ''
661
+ if (s.length === 0) return null
662
+ s = s.replace(/^(?:cookie|set-cookie)\s*:\s*/i, '').replace(/[\r\n]+/g, ' ').trim()
663
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) s = s.slice(1, -1).trim()
664
+ if (s.length === 0) return null
665
+ const parts = s.split(';').map(part => part.trim()).filter(Boolean)
666
+ if (parts.some(part => !/^[^=\s]+=[^;]*$/.test(part))) return null
667
+ const populated = parts.filter(part => part.slice(part.indexOf('=') + 1).replace(/^["']|["']$/g, '').trim())
668
+ const hasToken = populated.some(part => /^(?:api-platform_)?serviceToken=/.test(part))
669
+ const hasUser = populated.some(part => /^userId=/.test(part))
670
+ return hasToken && hasUser ? s : null
671
+ }
672
+
673
+ // Reject JSON null/booleans/empty strings rather than showing a fabricated zero.
674
+ const mimoNumber = value => {
675
+ if (typeof value !== 'number' && (typeof value !== 'string' || !value.trim())) return null
676
+ const n = Number(value)
677
+ return Number.isFinite(n) ? n : null
678
+ }
679
+
680
+ /**
681
+ * 解析 MiMo Token Plan 用量响应(GET https://platform.xiaomimimo.com/api/v1/tokenPlan/usage)。
682
+ * 官方形态 { code:0, data:{ monthUsage:{ percent, items:[{ name, used, limit, percent }] } } };
683
+ * 兼容社区文档的 data.usage.items 形态与顶层 items。items.name 已知值映射为语义窗口
684
+ * (month_total_token/plan_total_token → plan 套餐积分,compensation_total_token →
685
+ * compensation 补偿积分),未知 name 原样作窗口名。percent 为已用口径(percent 字段是
686
+ * used/limit 小数,优先用 used/limit 直算,规避 1% 边界的归一歧义)。重置时刻在
687
+ * tokenPlan/detail 里,由 queryCodingPlan 回填。
688
+ */
689
+ export function parseMimoTokenPlanUsage(data) {
690
+ if (data === null || typeof data !== 'object') return null
691
+ const root = data.data !== null && typeof data.data === 'object' ? data.data : data
692
+ let items = null
693
+ for (const bucket of [root.monthUsage, root.usage, root]) {
694
+ if (bucket !== null && typeof bucket === 'object' && Array.isArray(bucket.items)) { items = bucket.items; break }
695
+ }
696
+ if (items === null) return null
697
+ const windows = {}
698
+ items.forEach((row, index) => {
699
+ if (row === null || typeof row !== 'object') return
700
+ const nameRaw = typeof row.name === 'string' && row.name.length > 0 ? row.name : ''
701
+ const key = nameRaw === 'month_total_token' || nameRaw === 'plan_total_token' ? 'plan'
702
+ : nameRaw === 'compensation_total_token' ? 'compensation'
703
+ : nameRaw.length > 0 ? nameRaw : 'window' + String(index + 1)
704
+ if (['__proto__', 'constructor', 'prototype'].includes(key)) return
705
+ const total = mimoNumber(row.limit ?? row.total)
706
+ const used = mimoNumber(row.used)
707
+ let pct = null
708
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used) && used >= 0) pct = (used / total) * 100
709
+ else {
710
+ const fraction = mimoNumber(row.percent)
711
+ if (fraction !== null && fraction >= 0) pct = fraction * 100
712
+ }
713
+ if (pct === null || !Number.isFinite(pct)) return
714
+ windows[key] = {
715
+ percent: clampPct(pct),
716
+ resetsAt: normalizeResetAt(row.reset_at ?? row.resetsAt ?? row.resetAt ?? ''),
717
+ }
718
+ })
719
+ return Object.keys(windows).length > 0 ? windows : null
720
+ }
721
+
722
+ /**
723
+ * 解析 MiMo 套餐详情响应(GET https://platform.xiaomimimo.com/api/v1/tokenPlan/detail)。
724
+ * 形如 { code:0, data:{ planCode:'standard', currentPeriodEnd:'2026-05-04 23:59:59', expired:false } }。
725
+ * currentPeriodEnd 为「yyyy-MM-dd HH:mm:ss」裸串:控制台按北京时间展示,23:59:59 即自然
726
+ * 日界,故按 Asia/Shanghai(+08:00)解释(CodexBar 按 UTC 解析与此不一致,以控制台口径为准)。
727
+ * 返回 { planCode, expired, resetsAt };非法 → null。
728
+ */
729
+ export function parseMimoTokenPlanDetail(data) {
730
+ if (data === null || typeof data !== 'object') return null
731
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
732
+ const planCode = typeof d.planCode === 'string' ? d.planCode.trim() : ''
733
+ const expired = d.expired === true
734
+ const raw = typeof d.currentPeriodEnd === 'string' ? d.currentPeriodEnd.trim() : ''
735
+ let resetsAt = ''
736
+ if (raw.length > 0) {
737
+ const m = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(?::\d{2})?)$/.exec(raw)
738
+ const isoish = m === null ? raw : `${m[1]}T${m[2].length === 5 ? m[2] + ':00' : m[2]}+08:00`
739
+ resetsAt = normalizeResetAt(isoish)
740
+ }
741
+ return { planCode, expired, resetsAt }
742
+ }
743
+
744
+ /**
745
+ * 解析 MiMo 余额响应(GET https://platform.xiaomimimo.com/api/v1/balance)。
746
+ * 形如 { code:0, data:{ balance:'25.51', currency:'USD', cashBalance:'20', giftBalance:'5.51' } }
747
+ * (数值为字符串)。输出「带币种符号的余额」文本;非法 → null。
748
+ */
749
+ export function parseMimoBalance(data) {
750
+ if (data === null || typeof data !== 'object') return null
751
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
752
+ const n = mimoNumber(d.balance ?? d.cashBalance)
753
+ if (n === null || n < 0) return null
754
+ const cur = String(d.currency ?? '').trim().toUpperCase()
755
+ const symbol = cur === 'CNY' || cur === 'RMB' ? '¥' : cur === 'USD' ? '$' : cur.length > 0 ? cur + ' ' : ''
756
+ return symbol + n.toFixed(2)
757
+ }
758
+
759
+ // ── 火山方舟 Volcano Ark Coding Plan(issue #60)──────────────────────────
760
+
761
+ export const VOLCENGINE_HOST = 'open.volcengineapi.com'
762
+ export const VOLCENGINE_SERVICE = 'ark'
763
+ export const VOLCENGINE_REGION = 'cn-beijing'
764
+ export const VOLCENGINE_VERSION = '2024-01-01'
765
+ // 管控面 Action 白名单(按序尝试;需 AK/SK+HMAC 签名,非 Bearer)。
766
+ // GetCodingPlanUsage 为 CodingPlan 官方用量接口(issue #71 实测 by @suyukun):
767
+ // 无参即可返回 Result.QuotaUsage[] 三窗(session/weekly/monthly,只含 Percent),
768
+ // 置于首位;GetAFPUsage 实为 AgentPlan 接口(issue #60 评论 by @sanqiPanax,
769
+ // v1.5.46),CodingPlan 用户拿到全 0/空,GetUsageDetails 裸调 400(缺
770
+ // Filter.StartTime),GetPersonalPlan 亦需额外参数,保留作兜底变体。
771
+ export const VOLCENGINE_ACTIONS = ['GetCodingPlanUsage', 'GetAFPUsage', 'GetUsageDetails', 'GetPersonalPlan']
772
+
773
+ function hmacSha256(key, data) {
774
+ return createHmac('sha256', key).update(data, 'utf8').digest()
775
+ }
776
+
777
+ function hashHex(data) {
778
+ return createHash('sha256').update(data, 'utf8').digest('hex')
779
+ }
780
+
781
+ function uriEscape(str) {
782
+ return encodeURIComponent(str).replace(/[^A-Za-z0-9_.~%-]+/g, escape).replace(/\*/g, ch => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`)
783
+ }
784
+
785
+ function queryParamsToString(params) {
786
+ return Object.keys(params).sort().map(key => {
787
+ const val = params[key]
788
+ if (val === undefined || val === null) return undefined
789
+ const ek = uriEscape(key)
790
+ if (!ek) return undefined
791
+ if (Array.isArray(val)) return `${ek}=${val.map(uriEscape).sort().join(`&${ek}=`)}`
792
+ return `${ek}=${uriEscape(String(val))}`
793
+ }).filter(Boolean).join('&')
794
+ }
795
+
796
+ function getVolcengineDateTimeNow() {
797
+ return new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
798
+ }
799
+
800
+ /**
801
+ * 生成火山引擎 OpenAPI HMAC-SHA256 签名 Authorization 头(参考 volc-openapi-demos/signature/nodejs/sign.js)。
802
+ * 仅对 host/x-date/x-content-sha256 三头加签(GET 无 body 时 bodySha=hash(''))。
803
+ * 返回 { 'X-Date', 'X-Content-Sha256', Host, Authorization } 供 fetch 使用。
804
+ */
805
+ export function volcengineAuthorization({ accessKeyId, secretAccessKey, method = 'GET', host = VOLCENGINE_HOST, path = '/', query = {}, body = '', region = VOLCENGINE_REGION, service = VOLCENGINE_SERVICE, datetime }) {
806
+ const xDate = datetime ?? getVolcengineDateTimeNow()
807
+ const date = xDate.slice(0, 8)
808
+ const bodySha = hashHex(body)
809
+ // 需参与签名的头:仅三者(与 Python/Java 的四头变体均兼容——服务侧按 SignedHeaders 校验实际发送的头)
810
+ const signedHeaders = 'host;x-content-sha256;x-date'
811
+ const canonicalHeaders = `host:${host}\nx-content-sha256:${bodySha}\nx-date:${xDate}`
812
+ const qs = queryParamsToString(query)
813
+ const canonicalRequest = [method.toUpperCase(), path, qs, `${canonicalHeaders}\n`, signedHeaders, bodySha].join('\n')
814
+ const credentialScope = [date, region, service, 'request'].join('/')
815
+ const stringToSign = ['HMAC-SHA256', xDate, credentialScope, hashHex(canonicalRequest)].join('\n')
816
+ const kDate = hmacSha256(secretAccessKey, date)
817
+ const kRegion = hmacSha256(kDate, region)
818
+ const kService = hmacSha256(kRegion, service)
819
+ const kSigning = hmacSha256(kService, 'request')
820
+ const signature = hmacSha256(kSigning, stringToSign).toString('hex')
821
+ const authorization = `HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
822
+ return { 'X-Date': xDate, 'X-Content-Sha256': bodySha, Host: host, Authorization: authorization }
823
+ }
824
+
825
+ /** 归一化火山方舟窗口名:五小时/周/月/日。 */
826
+ function normalizeVolcWindowName(raw) {
827
+ const s = String(raw ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
828
+ if (['5h', 'fivehour', 'fiveh', 'session', 'rolling', 'fivehourwindow', 'hour5'].includes(s) || s.includes('5h') || s.includes('fivehour')) return 'fiveHour'
829
+ if (s.includes('daily') || s === 'daily' || s === 'day' || s === '1d' || s === 'afpdaily') return 'daily'
830
+ if (['weekly', 'week', '7d', 'seven', 'weeklywindow'].includes(s) || s.includes('week')) return 'weekly'
831
+ if (['monthly', 'month', '30d', 'monthlywindow'].includes(s) || s.includes('month')) return 'monthly'
832
+ // 中文标签兼容
833
+ if (String(raw).includes('小时') || String(raw).includes('5')) return 'fiveHour'
834
+ if (String(raw).includes('日')) return 'daily'
835
+ if (String(raw).includes('周')) return 'weekly'
836
+ if (String(raw).includes('月')) return 'monthly'
837
+ // 未识别的保留原名(供调用方透传,如 AFPDaily 等),由上层按原名展示而非丢弃
838
+ const trimmed = String(raw ?? '').trim()
839
+ if (trimmed.length > 0 && trimmed.length < 32) return trimmed.replace(/\s+/g, '_')
840
+ return null
841
+ }
842
+
843
+ /**
844
+ * 解析火山方舟用量响应(管控面 GetCodingPlanUsage / GetUsageDetails / GetAFPUsage / GetPersonalPlan 等)。
845
+ * CodingPlan 官方形态 { Result: { QuotaUsage:[{Level:'session'|'weekly'|'monthly', Percent, ResetTimestamp, Cap}], ... } }(issue #71,
846
+ * 只返 Percent,无 used/total);或 { ResponseMetadata:..., Result: { UsageDetails:[{QuotaType,Total,Used,Remaining,ResetTime}], ... } }
847
+ * 或 arkcli usage plan 的 { items:[{product:"coding-plan",periods:[{label:"session"/"weekly"/"monthly",percent,reset_at}]}] }
848
+ * 或扁平窗口对象 { fiveHour:{used,limit,reset}, weekly:{...}, monthly:{...} }。
849
+ * 容忍多种字段命名与大小写,百分比由 used/total 推导(或直接取 percent),非法窗口忽略。
850
+ */
851
+ export function parseVolcengineUsage(data) {
852
+ if (data === null || typeof data !== 'object') return null
853
+ // arkcli 形态:items→coding-plan periods
854
+ if (Array.isArray(data.items)) {
855
+ const item = data.items.find(i => i !== null && typeof i === 'object' && String(i.product ?? '').toLowerCase().includes('coding'))
856
+ ?? data.items.find(i => i !== null && typeof i === 'object' && Array.isArray(i.periods))
857
+ if (item !== null && typeof item === 'object' && Array.isArray(item.periods)) {
858
+ const windows = {}
859
+ for (const p of item.periods) {
860
+ if (p === null || typeof p !== 'object') continue
861
+ const nameRaw = p.label ?? p.name ?? p.type ?? p.quotaType ?? p.window
862
+ const name = normalizeVolcWindowName(nameRaw) ?? String(nameRaw ?? '').trim()
863
+ if (!name) continue
864
+ let pct = null
865
+ if (Number.isFinite(Number(p.percent))) pct = clampPct(Number(p.percent))
866
+ else {
867
+ const total = Number(p.total ?? p.limit ?? p.quota ?? p.max ?? p.capacity)
868
+ const used = Number(p.used ?? p.consumed ?? p.usage ?? p.currentValue)
869
+ const remain = Number(p.remaining ?? p.remain ?? p.available)
870
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = clampPct((used / total) * 100)
871
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = clampPct(((total - remain) / total) * 100)
872
+ }
873
+ if (pct === null) continue
874
+ const resetsAt = normalizeResetAt(p.reset_at ?? p.resetAt ?? p.resetTime ?? p.nextResetTime ?? p.expiresAt ?? p.EndTime ?? p.endTime)
875
+ // arkcli reset_at 为 unix 秒
876
+ const resetsAtNorm = normalizeResetAt(p.reset_at) || resetsAt
877
+ windows[name] = { percent: pct, resetsAt: resetsAtNorm }
878
+ }
879
+ return Object.keys(windows).length > 0 ? windows : null
880
+ }
881
+ }
882
+ // 通用 Result 提取
883
+ const candidates = []
884
+ const pushCandidate = v => { if (v !== null && typeof v === 'object') candidates.push(v) }
885
+ pushCandidate(data.Result)
886
+ pushCandidate(data.result)
887
+ pushCandidate(data.data?.Result)
888
+ pushCandidate(data.data?.result)
889
+ pushCandidate(data.data)
890
+ pushCandidate(data.Result?.UsageDetails)
891
+ pushCandidate(data.result?.UsageDetails)
892
+ // 也尝试顶层 Result 本身为数组的情形
893
+ const root = candidates.length > 0 ? candidates[0] : data
894
+ // 尝试定位配额数组字段
895
+ let quotaList = null
896
+ if (root !== null && typeof root === 'object') {
897
+ quotaList = root.QuotaUsage ?? root.quotaUsage ?? root.UsageDetails ?? root.usageDetails ?? root.usages ?? root.limits ?? root.quotas ?? root.windows ?? root.periods ?? root.details ?? root.items ?? null
898
+ if (!Array.isArray(quotaList) && Array.isArray(root)) quotaList = root
899
+ // 单个Result包装对象里可能直接含窗口字段
900
+ if (quotaList === null && typeof root === 'object' && !Array.isArray(root)) {
901
+ // 检查是否有直接的窗口对象(如 fiveHour/weekly/monthly)
902
+ const directWindows = {}
903
+ for (const [k, v] of Object.entries(root)) {
904
+ if (k === 'ResponseMetadata' || k === 'Result' || k === 'result') continue
905
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) continue
906
+ // 尝试当作窗口条目
907
+ const probe = parseVolcEngineWindowEntry(k, v)
908
+ if (probe !== null) directWindows[probe.name] = probe.win
909
+ }
910
+ if (Object.keys(directWindows).length > 0) return directWindows
911
+ }
912
+ }
913
+ if (Array.isArray(quotaList)) {
914
+ const windows = {}
915
+ for (const entry of quotaList) {
916
+ if (entry === null || typeof entry !== 'object') continue
917
+ // GetCodingPlanUsage 的窗口名字段为 Level(session/weekly/monthly,issue #71),
918
+ // 置于候选最前;其余 Action 沿用 QuotaType 等既有字段。
919
+ const nameRaw = entry.Level ?? entry.level ?? entry.QuotaType ?? entry.quotaType ?? entry.Type ?? entry.type ?? entry.Label ?? entry.label ?? entry.Period ?? entry.period ?? entry.Name ?? entry.name ?? entry.Window ?? entry.window ?? entry.QuotaName ?? entry.quotaName
920
+ let name = normalizeVolcWindowName(nameRaw)
921
+ if (name === null) continue
922
+ let pct = null
923
+ if (Number.isFinite(Number(entry.Percent ?? entry.percent ?? entry.percentage ?? entry.Percentage))) {
924
+ pct = clampPct(Number(entry.Percent ?? entry.percent ?? entry.percentage ?? entry.Percentage))
925
+ } else {
926
+ const total = Number(entry.Total ?? entry.total ?? entry.Limit ?? entry.limit ?? entry.Quota ?? entry.quota ?? entry.Capacity ?? entry.capacity ?? entry.Max ?? entry.max ?? entry.TotalQuota ?? entry.totalQuota)
927
+ const used = Number(entry.Used ?? entry.used ?? entry.Usage ?? entry.usage ?? entry.Consumed ?? entry.consumed ?? entry.CurrentValue ?? entry.currentValue ?? entry.UsedQuota ?? entry.usedQuota)
928
+ const remain = Number(entry.Remaining ?? entry.remaining ?? entry.Remain ?? entry.remain ?? entry.Available ?? entry.available ?? entry.RemainQuota ?? entry.remainQuota)
929
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = clampPct((used / total) * 100)
930
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = clampPct(((total - remain) / total) * 100)
931
+ else if (Number.isFinite(Number(entry.UsedPercent ?? entry.usedPercent))) pct = clampPct(Number(entry.UsedPercent))
932
+ }
933
+ if (pct === null) continue
934
+ const resetsAt = normalizeResetAt(entry.ResetTime ?? entry.resetTime ?? entry.ResetAt ?? entry.resetAt ?? entry.NextResetTime ?? entry.nextResetTime ?? entry.ExpiresAt ?? entry.expiresAt ?? entry.EndTime ?? entry.endTime ?? entry.ResetTimestamp ?? entry.resetTimestamp)
935
+ if (windows[name] === undefined) windows[name] = { percent: pct, resetsAt }
936
+ }
937
+ if (Object.keys(windows).length > 0) return windows
938
+ }
939
+ // 兜底:扁平窗口对象(与 Anthropic 同形)
940
+ const windows = {}
941
+ for (const [name, raw] of Object.entries(root)) {
942
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
943
+ const win = windowOf(raw.utilization ?? raw.percent ?? raw.used_percentage ?? raw.Percent, raw.resets_at ?? raw.reset_at ?? raw.resetsAt ?? raw.resetAt ?? raw.resetTime)
944
+ if (win !== null) {
945
+ const norm = normalizeVolcWindowName(name) ?? name
946
+ windows[norm] = win
947
+ }
948
+ }
949
+ return Object.keys(windows).length > 0 ? windows : null
950
+ }
951
+
952
+ function parseVolcEngineWindowEntry(name, raw) {
953
+ if (raw === null || typeof raw !== 'object') return null
954
+ let pct = null
955
+ if (Number.isFinite(Number(raw.percent ?? raw.Percent ?? raw.percentage))) pct = clampPct(Number(raw.percent ?? raw.Percent ?? raw.percentage))
956
+ else {
957
+ const total = Number(raw.total ?? raw.Total ?? raw.limit ?? raw.Limit ?? raw.quota ?? raw.Quota)
958
+ const used = Number(raw.used ?? raw.Used ?? raw.usage ?? raw.Usage)
959
+ const remain = Number(raw.remaining ?? raw.Remaining ?? raw.remain ?? raw.Remain)
960
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = clampPct((used / total) * 100)
961
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = clampPct(((total - remain) / total) * 100)
962
+ }
963
+ if (pct === null) return null
964
+ const resetsAt = normalizeResetAt(raw.resets_at ?? raw.reset_at ?? raw.resetsAt ?? raw.resetAt ?? raw.resetTime ?? raw.ResetTime)
965
+ const norm = normalizeVolcWindowName(name) ?? name
966
+ return { name: norm, win: { percent: pct, resetsAt } }
967
+ }
968
+
969
+ // ── SCNet(超算互联网)Token Plan 本地 Credits 计量(issue #26)──────────────
970
+ //
971
+ // SCNet Token Plan 为 Credits 包月订阅(基础 60,000 / 标准 240,000 / 高级 600,000),
972
+ // 输入(缓存命中+未命中)与输出 Token 按官方抵扣表折算 Credits 从月度额度抵扣;套餐自开通日
973
+ // 起算,有效期至次月对应日 23:59:59(UTC+8),到期清零。平台无 API-Key 化用量端点,故按
974
+ // 抵扣表对本地账本当前计费周期的用量做估算(实际消耗以控制台账单为准)。
975
+
976
+ /**
977
+ * 官方 Credits 抵扣表(2026-08-11 起生效;来源:
978
+ * https://ax.ac.sugon.com/ac/openapi/doc/2.0/moduleapi/plans/token-plan.html)。
979
+ * 单位:Credits / 百万 tokens;input=未命中缓存输入,cachedInput=命中缓存输入,output=输出。
980
+ */
981
+ export const SCNET_CREDIT_RATES = {
982
+ 'GLM-5.2': { input: 7543, cachedInput: 189, output: 26400 },
983
+ 'GLM-5.1': { input: 8743, cachedInput: 175, output: 32057 },
984
+ 'GLM-5': { input: 8743, cachedInput: 175, output: 32057 },
985
+ 'DeepSeek-V4-Pro': { input: 10286, cachedInput: 86, output: 20571 },
986
+ 'DeepSeek-V4-Flash': { input: 1200, cachedInput: 24, output: 2400 },
987
+ 'DeepSeek-V4-Flash-0731': { input: 1543, cachedInput: 31, output: 3086 },
988
+ 'Kimi-K3': { input: 34286, cachedInput: 343, output: 171429 },
989
+ 'Kimi-K2.7-Code': { input: 8357, cachedInput: 167, output: 34714 },
990
+ 'Kimi-K2.6': { input: 8357, cachedInput: 167, output: 34714 },
991
+ 'Kimi-K2.5': { input: 5143, cachedInput: 103, output: 27000 },
992
+ 'MiniMax-M3': { input: 3600, cachedInput: 72, output: 14400 },
993
+ 'MiniMax-M2.7': { input: 3600, cachedInput: 72, output: 14400 },
994
+ 'MiniMax-M2.5': { input: 2520, cachedInput: 50, output: 10080 },
995
+ 'Qwen3.8-max': { input: 18514, cachedInput: 231, output: 49371 },
996
+ }
997
+
998
+ /** SCNet 模型名归一:小写并剔除非字母数字(GLM-5.2 → glm52;大小写/连接符差异等价)。 */
999
+ export function scnetCanonModelId(modelId) {
1000
+ return String(modelId ?? '').toLowerCase().replace(/[^a-z0-9]/g, '')
1001
+ }
1002
+
1003
+ const SCNET_RATE_BY_CANON = Object.fromEntries(
1004
+ Object.entries(SCNET_CREDIT_RATES).map(([id, rate]) => [scnetCanonModelId(id), rate]),
1005
+ )
1006
+
1007
+ /** 按抵扣表折算一组 token 桶的 Credits(cacheWrite 计入未命中输入;reasoning 已含于 output 不重复计)。 */
1008
+ export function scnetModelCredits(tokens, rate) {
1009
+ const num = value => {
1010
+ const n = Number(value)
1011
+ return Number.isFinite(n) && n > 0 ? n : 0
1012
+ }
1013
+ const missInput = num(tokens?.input) + num(tokens?.cacheWrite)
1014
+ return (missInput * rate.input + num(tokens?.cacheRead) * rate.cachedInput + num(tokens?.output) * rate.output) / 1_000_000
1015
+ }
1016
+
1017
+ /**
1018
+ * 计算当前计费周期(本地时区):套餐自 planStart(YYYY-MM-DD)起算、每月重置,有效期至
1019
+ * 次月对应日 23:59:59;planStart 缺省时按自然月。返回 { fromKey, toKeyInclusive, resetsAt }。
1020
+ */
1021
+ export function scnetPlanPeriod(nowMs, planStart) {
1022
+ const now = new Date(Number.isFinite(Number(nowMs)) ? nowMs : Date.now())
1023
+ const pad = n => String(n).padStart(2, '0')
1024
+ const keyOf = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
1025
+ let start = null
1026
+ let explicitStart = false
1027
+ if (typeof planStart === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(planStart)) {
1028
+ const parsed = new Date(`${planStart}T00:00:00`)
1029
+ if (!Number.isNaN(parsed.getTime())) {
1030
+ start = parsed
1031
+ explicitStart = true
1032
+ }
1033
+ }
1034
+ // 自然月缺省分支:end 已是排他上界(次月 1 日 00:00),末日 = end - 1ms 即自然月末终刻。
1035
+ if (start === null) start = new Date(now.getFullYear(), now.getMonth(), 1)
1036
+ const addMonth = d => {
1037
+ const next = new Date(d.getFullYear(), d.getMonth() + 1, 1)
1038
+ const day = Math.min(d.getDate(), new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate())
1039
+ next.setDate(day)
1040
+ return next
1041
+ }
1042
+ if (!explicitStart) {
1043
+ let end = addMonth(start)
1044
+ let guard = 0
1045
+ while (now.getTime() >= end.getTime() && guard < 1200) {
1046
+ start = end
1047
+ end = addMonth(start)
1048
+ guard += 1
1049
+ }
1050
+ const last = new Date(end.getTime() - 1)
1051
+ return { fromKey: keyOf(start), toKeyInclusive: keyOf(last), resetsAt: last.toISOString() }
1052
+ }
1053
+ // 显式 planStart 分支:「有效期至次月对应日 23:59:59」——排他上界取对应日的次日
1054
+ // 00:00(endEx),末日 = endEx - 1ms,即含次月对应日全天(23:59:59.999);
1055
+ // 推进时 start 恒取上一「对应日」锚点,保持每月同日重置、不逐日漂移。guard 防死循环。
1056
+ const nextDayExclusive = d => {
1057
+ const e = new Date(d)
1058
+ e.setDate(e.getDate() + 1)
1059
+ return e
1060
+ }
1061
+ let end = addMonth(start)
1062
+ let endEx = nextDayExclusive(end)
1063
+ let guard = 0
1064
+ while (now.getTime() >= endEx.getTime() && guard < 1200) {
1065
+ start = end
1066
+ end = addMonth(start)
1067
+ endEx = nextDayExclusive(end)
1068
+ guard += 1
1069
+ }
1070
+ const last = new Date(endEx.getTime() - 1)
1071
+ return { fromKey: keyOf(start), toKeyInclusive: keyOf(last), resetsAt: last.toISOString() }
1072
+ }
1073
+
1074
+ export const SCNET_TOKEN_PLAN_PROVIDER_IDS = ['scnet', 'scnet-tokenplan', 'scnet-token-plan']
1075
+
1076
+ export function isScnetTokenPlanProvider(provider) {
1077
+ const name = String(provider ?? '').trim().toLowerCase().replace(/^llm-/, '')
1078
+ return SCNET_TOKEN_PLAN_PROVIDER_IDS.includes(name)
1079
+ }
1080
+
1081
+ /**
1082
+ * 汇总本地账本当前计费周期的 SCNet Credits 用量,仅计 SCNet 订阅渠道的收录模型。
1083
+ * entry 为 codingPlans.scnet 配置(planCredits 必填、planStart 可选)。
1084
+ * includeCall 应用 Plan/API 分类;belongsToPlan 可指定自定义渠道的套餐归属。
1085
+ * 返回 null(planCredits 非法)或 { used, total, percent, resetsAt, byModel, windows }。
1086
+ */
1087
+ export function scnetTokenPlanWindows(days, entry, nowMs, includeCall, belongsToPlan) {
1088
+ const total = Number(entry?.planCredits)
1089
+ if (!Number.isFinite(total) || total <= 0) return null
1090
+ const period = scnetPlanPeriod(nowMs, entry?.planStart)
1091
+ const byModel = {}
1092
+ let used = 0
1093
+ for (const [date, day] of Object.entries(days ?? {})) {
1094
+ if (typeof date !== 'string' || date < period.fromKey || date > period.toKeyInclusive) continue
1095
+ for (const [pmKey, buckets] of Object.entries(day?.byProviderModel ?? {})) {
1096
+ const sep = pmKey.indexOf(':')
1097
+ const provider = sep > 0 ? pmKey.slice(0, sep) : ''
1098
+ const model = sep > 0 ? pmKey.slice(sep + 1) : pmKey
1099
+ if (!(belongsToPlan ? belongsToPlan(provider, model) : isScnetTokenPlanProvider(provider)) || (includeCall && !includeCall(provider, model))) continue
1100
+ const canon = scnetCanonModelId(model)
1101
+ const rate = SCNET_RATE_BY_CANON[canon]
1102
+ if (rate === undefined || buckets === null || typeof buckets !== 'object') continue
1103
+ const credits = scnetModelCredits(buckets, rate)
1104
+ used += credits
1105
+ byModel[canon] = (byModel[canon] ?? 0) + credits
1106
+ }
1107
+ }
1108
+ const percent = Math.min(100, Math.round((used / total) * 1000) / 10)
1109
+ const fmt = n => Math.round(n).toLocaleString('en-US')
1110
+ return {
1111
+ used,
1112
+ total,
1113
+ percent,
1114
+ resetsAt: period.resetsAt,
1115
+ byModel,
1116
+ windows: {
1117
+ monthly: { percent, resetsAt: period.resetsAt },
1118
+ credits: { resetsAt: '', text: `${fmt(used)} / ${fmt(total)} Credits (est.)` },
1119
+ },
1120
+ }
1121
+ }
1122
+
1123
+ /**
1124
+ * 千问 Token Plan(个人版)Credits 抵扣率(issue #78)。
1125
+ *
1126
+ * 平台未提供 API-Key 化的额度查询端点(额度仅控制台可见,需 cookie+sec_token
1127
+ * 网关会话),与 SCNet 同模式走本地账本估算。抵扣率取自平台定价页
1128
+ * (platform.qianwenai.com,单位:每百万 token 抵扣的 Credits;个人版为 Credits
1129
+ * 计费,5 小时 + 7 天滚动窗口,本表按官方页 2026-08 数值内置,可在设置中覆盖)。
1130
+ * cachedInput 为缓存读抵扣率;cacheWrite 计入未命中输入。
1131
+ */
1132
+ export const QWEN_CREDIT_RATES = {
1133
+ 'qwen3.8-max-preview': { input: 60, cachedInput: 6, output: 60 },
1134
+ 'qwen3.7-max': { input: 60, cachedInput: 6, output: 60 },
1135
+ 'qwen3.7-plus': { input: 15, cachedInput: 1.5, output: 60 },
1136
+ 'qwen3.6-flash': { input: 3, cachedInput: 0.3, output: 12 },
1137
+ 'glm-5.2': { input: 45, cachedInput: 4.5, output: 120 },
1138
+ 'deepseek-v4-pro': { input: 45, cachedInput: 4.5, output: 90 },
1139
+ 'deepseek-v4-flash': { input: 3, cachedInput: 0.3, output: 12 },
1140
+ 'kimi-k2.7-code': { input: 30, cachedInput: 3, output: 60 },
1141
+ 'kimi-k2.6': { input: 30, cachedInput: 3, output: 60 },
1142
+ 'minimax-m2.5': { input: 30, cachedInput: 3, output: 60 },
1143
+ }
1144
+
1145
+ const QWEN_CREDIT_RATES_BY_CANON = Object.fromEntries(
1146
+ Object.entries(QWEN_CREDIT_RATES).map(([id, rate]) => [scnetCanonModelId(id), rate]),
1147
+ )
1148
+
1149
+ // 只识别明确的订阅渠道;qianwen/dashscope 等按量渠道不能仅凭同名模型归入。
1150
+ export const QWEN_TOKEN_PLAN_PROVIDER_IDS = ['qwen', 'qwen-tokenplan', 'qianwen-tokenplan', 'qwen-token-plan', 'qianwen-token-plan']
1151
+
1152
+ export function isQwenTokenPlanProvider(provider) {
1153
+ const name = String(provider ?? '').trim().toLowerCase().replace(/^llm-/, '')
1154
+ return QWEN_TOKEN_PLAN_PROVIDER_IDS.includes(name)
1155
+ }
1156
+
1157
+ /**
1158
+ * 汇总本地账本当前计费周期(自然月,与 SCNet 同款 scnetPlanPeriod)的千问
1159
+ * Credits 用量。entry 为 codingPlans.qwen 配置:{ planCredits(必填,月度总额),
1160
+ * planStart(可选,计费周期锚日), rates(可选,模型名 → 抵扣率覆盖,键为归一模型名) }。
1161
+ * 仅汇总千问订阅渠道;includeCall 应用 Plan/API 分类,belongsToPlan 支持自定义归属。
1162
+ * 返回 null(planCredits 非法)或 { used, total, percent, resetsAt, byModel, windows }。
1163
+ */
1164
+ export function qwenTokenPlanWindows(days, entry, nowMs, includeCall, belongsToPlan) {
1165
+ const total = Number(entry?.planCredits)
1166
+ if (!Number.isFinite(total) || total <= 0) return null
1167
+ // 用户覆盖的抵扣率优先,内置表兜底;键与模型名均做字母数字归一比较。
1168
+ const rawOverrides = entry?.rates !== null && typeof entry?.rates === 'object' && !Array.isArray(entry?.rates) ? entry.rates : {}
1169
+ const overrides = Object.fromEntries(Object.entries(rawOverrides).map(([model, rate]) => [scnetCanonModelId(model), rate]))
1170
+ const rateOf = canon => {
1171
+ if (overrides[canon] !== undefined && overrides[canon] !== null && typeof overrides[canon] === 'object') return overrides[canon]
1172
+ return QWEN_CREDIT_RATES_BY_CANON[canon]
1173
+ }
1174
+ const period = scnetPlanPeriod(nowMs, entry?.planStart)
1175
+ const byModel = {}
1176
+ let used = 0
1177
+ for (const [date, day] of Object.entries(days ?? {})) {
1178
+ if (typeof date !== 'string' || date < period.fromKey || date > period.toKeyInclusive) continue
1179
+ for (const [pmKey, buckets] of Object.entries(day?.byProviderModel ?? {})) {
1180
+ const sep = pmKey.indexOf(':')
1181
+ const provider = sep > 0 ? pmKey.slice(0, sep) : ''
1182
+ const model = sep > 0 ? pmKey.slice(sep + 1) : pmKey
1183
+ if (!(belongsToPlan ? belongsToPlan(provider, model) : isQwenTokenPlanProvider(provider)) || (includeCall && !includeCall(provider, model))) continue
1184
+ const canon = scnetCanonModelId(model)
1185
+ const rate = rateOf(canon)
1186
+ if (rate === undefined || buckets === null || typeof buckets !== 'object') continue
1187
+ const credits = scnetModelCredits(buckets, rate)
1188
+ used += credits
1189
+ byModel[canon] = (byModel[canon] ?? 0) + credits
1190
+ }
1191
+ }
1192
+ const percent = Math.min(100, Math.round((used / total) * 1000) / 10)
1193
+ const fmt = n => Math.round(n).toLocaleString('en-US')
1194
+ return {
1195
+ used,
1196
+ total,
1197
+ percent,
1198
+ resetsAt: period.resetsAt,
1199
+ byModel,
1200
+ windows: {
1201
+ monthly: { percent, resetsAt: period.resetsAt },
1202
+ credits: { resetsAt: '', text: `${fmt(used)} / ${fmt(total)} Credits (est.)` },
1203
+ },
1204
+ }
1205
+ }
1206
+
1207
+ /** 默认官方端点;MiniMax 可用显式 HTTPS origin 覆盖此列表。 */
1208
+ export const CODING_PLAN_ENDPOINTS = {
1209
+ opendesign: [OPENDESIGN_API + '/workspaces/{workspaceId}/billing/preflight', OPENDESIGN_API + '/workspaces/{workspaceId}/wallet/balance'],
1210
+ factory: [FACTORY_QUOTA_URL],
1211
+ anthropic: ['https://api.anthropic.com/api/oauth/usage'],
1212
+ zai: [
1213
+ // 2026-08 接口变更(issue #42):额度查询迁移到监控端点 /api/monitor/usage/quota/limit(国内 Key 对应
1214
+ // bigmodel.cn、国际 Key 对应 z.ai,两域 Key 不互通——401 时继续换域尝试,见 queryCodingPlan)。
1215
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
1216
+ 'https://api.z.ai/api/monitor/usage/quota/limit',
1217
+ // 旧计费端点兜底:v3 存活(issue #17)、v4 历史;monitor 端点不可达时仍可出数。
1218
+ 'https://api.z.ai/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1219
+ 'https://open.bigmodel.cn/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1220
+ 'https://api.z.ai/api/coding/paas/v4/dashboard/billing/coding_plan/usage',
1221
+ 'https://open.bigmodel.cn/api/coding/paas/v4/dashboard/billing/coding_plan/usage',
1222
+ ],
1223
+ minimax: [
1224
+ 'https://www.minimax.cn/v1/token_plan/remains',
1225
+ 'https://www.minimax.io/v1/token_plan/remains',
1226
+ 'https://www.minimaxi.com/v1/token_plan/remains',
1227
+ 'https://www.minimax.cn/v1/api/openplatform/coding_plan/remains',
1228
+ 'https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains',
1229
+ ],
1230
+ kimi: [
1231
+ // Kimi Code 订阅配额(issue #53):Kimi CLI 实际调用的接口,需 UA KimiCLI/1.6 与
1232
+ // 订阅 Key(sk-kimi-*);404 时回退单数形式。订阅端点失败/无订阅 Key 时降级到
1233
+ // 末尾的 PAYG 余额端点,行为与旧版一致。
1234
+ 'https://api.kimi.com/coding/v1/usages',
1235
+ 'https://api.kimi.com/coding/v1/usage',
1236
+ 'https://api.moonshot.cn/v1/users/me/balance',
1237
+ ],
1238
+ openrouter: ['https://openrouter.ai/api/v1/credits'],
1239
+ siliconflow: ['https://api.siliconflow.cn/v1/user/info'],
1240
+ commandcode: ['https://api.commandcode.ai/alpha/billing/credits'],
1241
+ clinepass: ['https://api.cline.bot/api/v1/users/me/plan/usage-limits'],
1242
+ // SCNet 无 API-Key 化额度端点:本地 Credits 计量(见 scnetTokenPlanWindows),不走网络。
1243
+ scnet: [],
1244
+ // 千问由本地估算或 qwen-cli.js 调用官方 CLI;插件不托管 CLI 登录凭据。
1245
+ qwen: [],
1246
+ // 火山方舟:管控面 OpenAPI(需 AK/SK+HMAC,非 Bearer),按 Action 变体尝试(见 VOLCENGINE_ACTIONS)
1247
+ volcengine: VOLCENGINE_ACTIONS.map(action => `https://${VOLCENGINE_HOST}/?Action=${action}&Version=${VOLCENGINE_VERSION}`),
1248
+ // MiMo 主端点即用量;detail/balance 由 queryCodingPlan 的 mimo 分支作为增强行尽力拉取。
1249
+ mimo: [MIMO_ENDPOINTS.usage],
1250
+ // WorkBuddy 无静态端点:billing 域名随凭据 domain 变(个人 cn/global 两域,企业固定
1251
+ // codebuddy.cn),由 lib/workbuddy.js 的 workbuddyBillingBase 按凭据决定(见 workbuddy 分支)。
1252
+ workbuddy: [],
1253
+ }
1254
+
1255
+ const CODING_PLAN_PARSERS = {
1256
+ anthropic: parseAnthropicUsage,
1257
+ minimax: parseMiniMaxRemains,
1258
+ zai: parseZaiUsage,
1259
+ kimi: parseKimiBalance,
1260
+ openrouter: parseOpenRouterCredits,
1261
+ siliconflow: parseSiliconFlowInfo,
1262
+ commandcode: parseCommandCodeCredits,
1263
+ clinepass: parseClinePassUsage,
1264
+ volcengine: parseVolcengineUsage,
1265
+ mimo: parseMimoTokenPlanUsage,
1266
+ }
1267
+
1268
+ /** 空串为自动选择;自定义地址只接受 HTTPS origin,避免路径、凭据和重定向改变 Key 的目的地。 */
1269
+ export function normalizeMiniMaxBaseUrl(value) {
1270
+ if (value === undefined || value === '') return ''
1271
+ if (typeof value !== 'string' || value.length > 2048) return null
1272
+ const text = value.trim()
1273
+ if (!text) return ''
1274
+ if (!/^https:\/\/[^/?#]+\/?$/i.test(text) || /[\s\\@?#]/.test(text)) return null
1275
+ try {
1276
+ const url = new URL(text)
1277
+ if (!url.hostname || url.username || url.password || url.pathname !== '/') return null
1278
+ return url.origin
1279
+ } catch { return null }
1280
+ }
1281
+
1282
+ export function miniMaxEndpoints(baseUrl) {
1283
+ const origin = normalizeMiniMaxBaseUrl(baseUrl)
1284
+ if (origin === null) throw new Error('MiniMax: invalid HTTPS origin')
1285
+ return origin ? ['/v1/token_plan/remains', '/v1/api/openplatform/coding_plan/remains'].map(path => origin + path) : CODING_PLAN_ENDPOINTS.minimax
1286
+ }
1287
+
1288
+ /**
1289
+ * 解析火山方舟双凭据(AK/SK):支持
1290
+ * - 对象 { accessKeyId, secretAccessKey } (首选),
1291
+ * - 字符串 "AKID:SK" 或 "AKID: SK"(冒号/空白分隔),
1292
+ * - 仅 AKID 时尝试从 Secret 环境变量补齐(调用方已做)。
1293
+ * 返回 { accessKeyId, secretAccessKey } 或 null。
1294
+ */
1295
+ export function normalizeVolcengineKey(key) {
1296
+ if (key !== null && typeof key === 'object' && !Array.isArray(key)) {
1297
+ const id = String(key.accessKeyId ?? key.accessKeyID ?? key.ak ?? key.apiKey ?? '').trim()
1298
+ const secret = String(key.secretAccessKey ?? key.secretKey ?? key.sk ?? key.apiSecret ?? '').trim()
1299
+ if (id.length > 0 && secret.length > 0) return { accessKeyId: id, secretAccessKey: secret }
1300
+ // 兼容 apiKey 承载 AK 的场景:若对象里仅有 apiKey 且 secret 另在字段
1301
+ if (id.length > 0 && secret.length === 0 && typeof key.secretAccessKey === 'string') {
1302
+ const s = String(key.secretAccessKey).trim()
1303
+ if (s.length > 0) return { accessKeyId: id, secretAccessKey: s }
1304
+ }
1305
+ return null
1306
+ }
1307
+ if (typeof key === 'string') {
1308
+ const trimmed = key.trim()
1309
+ if (trimmed.length === 0) return null
1310
+ // 冒号分隔(AK:SK)
1311
+ const colonIdx = trimmed.indexOf(':')
1312
+ if (colonIdx > 0) {
1313
+ const id = trimmed.slice(0, colonIdx).trim()
1314
+ const secret = trimmed.slice(colonIdx + 1).trim()
1315
+ if (id.length > 0 && secret.length > 0) return { accessKeyId: id, secretAccessKey: secret }
1316
+ }
1317
+ // 空白分隔亦兼容
1318
+ const parts = trimmed.split(/\s+/)
1319
+ if (parts.length === 2 && parts[0].length > 0 && parts[1].length > 0 && parts[0].startsWith('AK')) {
1320
+ return { accessKeyId: parts[0], secretAccessKey: parts[1] }
1321
+ }
1322
+ // 仅 AK 本身,无法确定 Secret
1323
+ return null
1324
+ }
1325
+ return null
1326
+ }
1327
+
1328
+ /**
1329
+ * 查询单家 coding plan 额度。按 CODING_PLAN_ENDPOINTS 顺序尝试官方端点:
1330
+ * 认证失败(401/403)与解析成功立即返回;其余错误尝试下一个端点。
1331
+ * 预期场景(未找到 Key / 无订阅)抛出 error.soft = true 的软错误。
1332
+ * @param provider - anthropic | zai | minimax | kimi | openrouter | siliconflow | volcengine。
1333
+ * @param key - 已解析出的 API Key / OAuth token;火山方舟可为 { accessKeyId, secretAccessKey } 或 "AK:SK" 字符串;null 表示未找到。
1334
+ * @param locale - 消息语言(zh/en)。
1335
+ * @param t - 服务端文案函数 tmsg(locale, code, vars)。
1336
+ * @param options - MiniMax 可指定 baseUrl 和取消 signal;留空保留官方回退链。
1337
+ * @returns {Promise<{ windows: object, endpoint: string }>}
1338
+ */
1339
+ export async function queryCodingPlan(provider, key, locale, t, { baseUrl, orgId, workspaceId, factoryPool, workbuddyConfig, signal, onAttempt } = {}) {
1340
+ const meta = CODING_PLAN_PROVIDERS[provider]
1341
+ if (meta === undefined) throw new Error(t(locale, 'codingPlanUnknown', { provider: String(provider) }))
1342
+ if (provider === 'opendesign') {
1343
+ // Issue #274: usage and wallet are read independently in lib/opendesign.js. A partial answer
1344
+ // (wallet-only after a usage 401, or usage-only after a wallet failure) is still a successful
1345
+ // refresh and must reach the card; `partial` names the degraded side for diagnostics.
1346
+ const result = await queryOpenDesignQuota(key, locale, t, { workspaceId, signal })
1347
+ if (result.partial !== undefined) {
1348
+ try {
1349
+ onAttempt?.({ provider, endpoint: result.endpoint, status: 200, stage: 'partial',
1350
+ code: Object.entries(result.partial).map(([side, kind]) => side + ':' + kind).join(',') })
1351
+ } catch { /* Logging cannot change query results. */ }
1352
+ }
1353
+ return result
1354
+ }
1355
+ if (provider === 'factory') return queryFactoryQuota(key, locale, t, { orgId, factoryPool, signal })
1356
+ // 火山方舟:双凭据 AK/SK + HMAC 签名(非 Bearer),单独校验
1357
+ if (provider === 'volcengine') {
1358
+ const creds = normalizeVolcengineKey(key)
1359
+ if (creds === null) {
1360
+ const error = new Error(t(locale, 'codingPlanKeyMissing', { provider: meta.label }))
1361
+ error.soft = true
1362
+ throw error
1363
+ }
1364
+ const urls = CODING_PLAN_ENDPOINTS[provider]
1365
+ let lastError = null
1366
+ let parseError = null
1367
+ for (const url of urls) {
1368
+ let action = 'GetUsageDetails'
1369
+ let version = VOLCENGINE_VERSION
1370
+ try {
1371
+ const u = new URL(url)
1372
+ action = u.searchParams.get('Action') ?? action
1373
+ version = u.searchParams.get('Version') ?? version
1374
+ } catch {}
1375
+ const query = { Action: action, Version: version }
1376
+ let response
1377
+ try {
1378
+ const auth = volcengineAuthorization({
1379
+ accessKeyId: creds.accessKeyId,
1380
+ secretAccessKey: creds.secretAccessKey,
1381
+ method: 'GET',
1382
+ host: VOLCENGINE_HOST,
1383
+ path: '/',
1384
+ query,
1385
+ body: '',
1386
+ })
1387
+ const headers = {
1388
+ 'x-date': auth['X-Date'],
1389
+ 'x-content-sha256': auth['X-Content-Sha256'],
1390
+ authorization: auth.Authorization,
1391
+ host: auth.Host,
1392
+ 'user-agent': 'dsh-cost-meter/1.5 (DeepSeek Harness plugin)',
1393
+ }
1394
+ response = await fetchWithRetry(`https://${VOLCENGINE_HOST}/?${queryParamsToString(query)}`, { headers }, { timeoutMs: 15000, attempts: 2 })
1395
+ } catch (error) {
1396
+ lastError = error
1397
+ continue
1398
+ }
1399
+ if (response.status === 401 || response.status === 403) {
1400
+ const error = new Error(t(locale, 'codingPlanUnauthorized', { provider: meta.label, code: String(response.status) }))
1401
+ error.soft = true
1402
+ lastError = error
1403
+ continue // 多 Action 变体间 401 仅视为当前 Action 不可用,继续尝试下一 Action
1404
+ }
1405
+ if (!response.ok) {
1406
+ lastError = new Error(t(locale, 'codingPlanHttp', { provider: meta.label, code: String(response.status), url }))
1407
+ continue
1408
+ }
1409
+ let data
1410
+ try { data = await response.json() } catch { data = null }
1411
+ // Volcengine 业务信封:ResponseMetadata.Error.Code/Message 非零即失败
1412
+ const apiError = data?.ResponseMetadata?.Error?.Code ?? data?.ResponseMetadata?.Error?.Message ?? null
1413
+ if (apiError !== null && typeof apiError === 'string' && apiError.length > 0) {
1414
+ const msg = data?.ResponseMetadata?.Error?.Message ?? apiError
1415
+ const error = new Error(`${meta.label}: ${msg}`)
1416
+ parseError ??= error
1417
+ lastError = error
1418
+ continue
1419
+ }
1420
+ const windows = parseVolcengineUsage(data)
1421
+ if (windows === null) {
1422
+ const envelope = data !== null && typeof data === 'object' && typeof data.code === 'number' && data.code !== 0
1423
+ && typeof (data.msg ?? data.message) === 'string' ? (data.msg ?? data.message) : null
1424
+ const error = envelope !== null
1425
+ ? new Error(`${meta.label}: ${envelope}`)
1426
+ : new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
1427
+ parseError ??= error
1428
+ lastError = error
1429
+ continue
1430
+ }
1431
+ return { windows, endpoint: url }
1432
+ }
1433
+ throw parseError ?? lastError ?? new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
1434
+ }
1435
+ // 小米 MiMo Token Plan:控制台 Cookie 鉴权(非 Bearer)。用量窗口为主,套餐详情
1436
+ // (重置时刻)与余额为尽力而为的增强行——失败不阻塞主窗口。
1437
+ if (provider === 'mimo') {
1438
+ const cookie = normalizeMimoCookie(key)
1439
+ if (cookie === null) {
1440
+ const missing = key === null || typeof key !== 'string' || key.trim().length === 0
1441
+ const error = new Error(t(locale, missing ? 'mimoCookieMissing' : 'mimoCookieInvalid', { provider: meta.label }))
1442
+ error.soft = true
1443
+ throw error
1444
+ }
1445
+ const headers = {
1446
+ cookie,
1447
+ accept: 'application/json',
1448
+ 'user-agent': 'dsh-cost-meter/1.7 (DeepSeek Harness plugin)',
1449
+ }
1450
+ /** 单端点抓取:登录态过期(401/403 或业务信封 code=401)报专属文案,其余非 200 报 HTTP 文案。 */
1451
+ const readJson = async url => {
1452
+ signal?.throwIfAborted()
1453
+ const response = await fetchWithRetry(url, { headers, redirect: 'manual', signal }, { timeoutMs: 15000, attempts: 2 })
1454
+ if ((response.status >= 300 && response.status < 400) || response.status === 401 || response.status === 403) {
1455
+ try { await response.body?.cancel?.() } catch {}
1456
+ const error = new Error(t(locale, 'mimoLoginExpired', { provider: meta.label }))
1457
+ error.soft = true
1458
+ throw error
1459
+ }
1460
+ if (!response.ok) {
1461
+ try { await response.body?.cancel?.() } catch {}
1462
+ throw new Error(t(locale, 'codingPlanHttp', { provider: meta.label, code: String(response.status), url }))
1463
+ }
1464
+ const body = await readJsonBounded(response)
1465
+ signal?.throwIfAborted()
1466
+ if (body !== null && typeof body === 'object' && body.code !== undefined && String(body.code) !== '0') {
1467
+ if (['401', '403'].includes(String(body.code))) {
1468
+ const error = new Error(t(locale, 'mimoLoginExpired', { provider: meta.label }))
1469
+ error.soft = true
1470
+ throw error
1471
+ }
1472
+ throw new Error(t(locale, 'codingPlanHttp', { provider: meta.label, code: String(body.code), url }))
1473
+ }
1474
+ return body
1475
+ }
1476
+ const windows = parseMimoTokenPlanUsage(await readJson(MIMO_ENDPOINTS.usage))
1477
+ if (windows === null) throw new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
1478
+ // 套餐详情:周期截止 = 套餐积分窗口的重置时刻(失败忽略,保留无重置窗口)。
1479
+ try {
1480
+ const detail = parseMimoTokenPlanDetail(await readJson(MIMO_ENDPOINTS.detail))
1481
+ if (detail !== null && detail.resetsAt.length > 0 && windows.plan !== undefined) windows.plan.resetsAt = detail.resetsAt
1482
+ } catch { signal?.throwIfAborted() }
1483
+ // 余额行:按币种符号输出文本窗口(与 Kimi/SiliconFlow 余额同形态;失败忽略)。
1484
+ try {
1485
+ const win = textWindowOf(parseMimoBalance(await readJson(MIMO_ENDPOINTS.balance)))
1486
+ if (win !== null) windows.balance = win
1487
+ } catch { signal?.throwIfAborted() }
1488
+ return { windows, endpoint: MIMO_ENDPOINTS.usage }
1489
+ }
1490
+ // WorkBuddy 积分(port 自 corrinehu/dsh-workbuddy-connect (MIT) src/upstream.ts):
1491
+ // key 携带当前宿主的本地 origin;无连接插件接口时回退旧版明文文件。
1492
+ // 余额为主窗口,企业版 cycleResetTime 为重置窗口。
1493
+ if (provider === 'workbuddy') {
1494
+ let credential, credits
1495
+ try {
1496
+ const options = key !== null && typeof key === 'object' ? key : {}
1497
+ const accounts = await fetchWorkBuddyAccounts(options.hostOrigin, workbuddyConfig, { signal })
1498
+ if (accounts) {
1499
+ const complete = !accounts.incomplete && accounts.unknownCount === 0 && accounts.selectedIds.length > 0
1500
+ return { windows: { balance: textWindowOf(complete ? formatWorkBuddyBalance(accounts.knownRemaining) : '—') },
1501
+ workbuddy: accounts, message: complete ? '' : locale === 'zh' ? '请选择账号;查询失败的账号不计为零,已读取的余额见账号明细。' : 'Select accounts; unavailable balances are not zero. See account details for known balances.' }
1502
+ }
1503
+ credits = await fetchWorkBuddyConnectCredits(options.hostOrigin, { signal })
1504
+ if (['all', 'selected'].includes(workbuddyConfig?.accountScope)) {
1505
+ return { windows: { balance: textWindowOf('—') },
1506
+ message: locale === 'zh' ? '当前连接插件未返回多账号数据。请刷新重试,或选择“当前账号”查看单账号积分。' : 'The connector returned no multi-account data. Retry or select current accounts to see a single-account balance.' }
1507
+ }
1508
+ if (!credits) {
1509
+ credential = resolveWorkBuddyCredential(options)
1510
+ if (isWorkBuddyTokenExpired(credential)) throw Object.assign(new Error(), { code: 'workbuddyTokenExpired' })
1511
+ credits = await fetchWorkBuddyCredits(credential, { signal })
1512
+ }
1513
+ } catch (error) {
1514
+ const code = typeof error?.code === 'string' ? error.code : ''
1515
+ if (['workbuddyNotInstalled', 'workbuddyAuthEncrypted', 'workbuddyTokenExpired', 'workbuddyConnectUnavailable', 'workbuddyConnectLoginFailed', 'workbuddyConnectCreditsFailed'].includes(code)) {
1516
+ const soft = new Error(t(locale, code, { provider: meta.label }))
1517
+ soft.soft = true
1518
+ throw soft
1519
+ }
1520
+ throw error
1521
+ }
1522
+ if (credits.unlimited) {
1523
+ return {
1524
+ windows: { balance: textWindowOf(t(locale, 'workbuddyUnlimited', { provider: meta.label })) },
1525
+ endpoint: undefined,
1526
+ }
1527
+ }
1528
+ const windows = {
1529
+ balance: textWindowOf(formatWorkBuddyBalance(credits.total)),
1530
+ }
1531
+ if (typeof credits.cycleResetTime === 'string' && credits.cycleResetTime !== '') {
1532
+ windows.cycleReset = { resetsAt: normalizeResetAt(credits.cycleResetTime) ?? '', text: '' }
1533
+ }
1534
+ return { windows, ...(credits.workbuddy ? { workbuddy: credits.workbuddy } : {}), endpoint: credits.endpoint ?? workbuddyBillingBase(credential) }
1535
+ }
1536
+ if (key === null || typeof key !== 'string' || key.trim().length === 0) {
1537
+ const error = new Error(t(locale, 'codingPlanKeyMissing', { provider: meta.label }))
1538
+ error.soft = true
1539
+ throw error
1540
+ }
1541
+ const urls = provider === 'minimax' ? miniMaxEndpoints(baseUrl) : CODING_PLAN_ENDPOINTS[provider]
1542
+ const attempts = []
1543
+ const trace = (url, status, stage, code) => {
1544
+ if (provider !== 'zai') return
1545
+ const endpoint = new URL(url)
1546
+ const attempt = { url: endpoint.origin + endpoint.pathname, status, stage, code }
1547
+ attempts.push(attempt)
1548
+ // Diagnostics contain only fixed endpoint paths/status/categories, never
1549
+ // headers, credentials, response bodies or an upstream exception message.
1550
+ try { onAttempt?.({ ...attempt }) } catch { /* Logging cannot change query results. */ }
1551
+ }
1552
+ const release = async response => { try { await response.body?.cancel?.() } catch {} }
1553
+ let lastError = null
1554
+ // 200 但解析失败的「结构化错误」(业务信封 / 结构已变):比后续端点的 404 等传输层
1555
+ // 错误更有诊断价值,单独保留且最终优先抛出——否则最后端点的 404 会盖住 monitor
1556
+ // 端点解析失败的真实原因(issue #44 的误导性报错即由此而来)。
1557
+ let parseError = null
1558
+ for (const url of urls) {
1559
+ signal?.throwIfAborted()
1560
+ // kimi 双端点形态(issue #53):api.kimi.com 为订阅配额(专用 UA + 解析器),
1561
+ // api.moonshot.cn 为 PAYG 余额;订阅端点 401 视为「无订阅 Key」继续降级尝试。
1562
+ let isKimiCoding = false
1563
+ if (provider === 'kimi') {
1564
+ try {
1565
+ isKimiCoding = new URL(url).hostname.toLowerCase() === 'api.kimi.com'
1566
+ } catch {
1567
+ isKimiCoding = false
1568
+ }
1569
+ }
1570
+ const parse = isKimiCoding ? parseKimiCodingUsage : CODING_PLAN_PARSERS[provider]
1571
+ let response
1572
+ try {
1573
+ const headers = {
1574
+ authorization: `Bearer ${key.trim()}`,
1575
+ 'user-agent': 'dsh-cost-meter/1.4 (DeepSeek Harness plugin)',
1576
+ }
1577
+ if (isKimiCoding) {
1578
+ // 订阅端点疑似校验客户端标识:不带官方 CLI 的 UA 会失败(issue #53 实测)。
1579
+ headers['user-agent'] = 'KimiCLI/1.6'
1580
+ }
1581
+ // 瞬时网络错误先在单端点上重试(issue #28 同一封装;attempts=2 控制多端点
1582
+ // 回退链的最坏串行耗时),仍失败再换端点变体。
1583
+ response = await fetchWithRetry(url, { headers, signal,
1584
+ ...(provider === 'minimax' ? { redirect: 'error' } : provider === 'zai' ? { redirect: 'manual' } : {}),
1585
+ }, { timeoutMs: 15000, attempts: 2 })
1586
+ } catch (error) {
1587
+ signal?.throwIfAborted()
1588
+ const code = error?.cause?.code ?? error?.code
1589
+ const known = ['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH',
1590
+ 'ENETUNREACH', 'EPIPE', 'UND_ERR_SOCKET', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT',
1591
+ 'UND_ERR_BODY_TIMEOUT', 'CERT_HAS_EXPIRED', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN',
1592
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'ERR_TLS_CERT_ALTNAME_INVALID']
1593
+ trace(url, null, 'network', error?.name === 'TimeoutError' ? 'TIMEOUT' : known.includes(code) ? code : 'NETWORK_ERROR')
1594
+ lastError = error
1595
+ continue // 网络错误:尝试下一个端点变体
1596
+ }
1597
+ if (response.status === 401 || response.status === 403) {
1598
+ trace(url, response.status, 'http', 'UNAUTHORIZED')
1599
+ await release(response)
1600
+ const error = new Error(t(locale, 'codingPlanUnauthorized', { provider: meta.label, code: String(response.status) }))
1601
+ error.soft = true // Key 无效/无订阅属预期场景,面板中性提示
1602
+ // zai 国内(bigmodel.cn)/国际(z.ai)域名 Key 不互通:单域 401 只说明 Key
1603
+ // 不属于该域,继续尝试下一域;全部 401 才认定为凭据无效(issue #42)。
1604
+ // minimax 国内(minimaxi.com)/国际(minimax.io)同为双域不互通,同待遇(issue #42 同型)。
1605
+ // kimi 订阅端点 401 = 该 Key 不是订阅 Key(sk-kimi-*)或无订阅:继续走
1606
+ // PAYG 余额兜底,不直接报错(issue #53)。
1607
+ if (provider === 'zai' || provider === 'minimax' || isKimiCoding) { lastError = error; continue }
1608
+ throw error
1609
+ }
1610
+ if (!response.ok) {
1611
+ trace(url, response.status, 'http', 'HTTP_ERROR')
1612
+ await release(response)
1613
+ // 带上实际请求 URL:404 往往是端点变更信号,便于定位(issue #17)。
1614
+ lastError = new Error(t(locale, 'codingPlanHttp', { provider: meta.label, code: String(response.status), url }))
1615
+ continue
1616
+ }
1617
+ let data
1618
+ try { data = provider === 'minimax' || provider === 'zai' ? await readJsonBounded(response) : await response.json() }
1619
+ catch (error) {
1620
+ signal?.throwIfAborted()
1621
+ trace(url, response.status, 'json', error?.code === 'RESPONSE_TOO_LARGE' ? 'RESPONSE_TOO_LARGE' : 'INVALID_JSON')
1622
+ const failure = new Error(t(locale, 'codingPlanInvalidJson', { provider: meta.label, url }))
1623
+ parseError ??= failure
1624
+ lastError = failure
1625
+ continue
1626
+ }
1627
+ if (data === null || typeof data !== 'object') {
1628
+ trace(url, response.status, 'json', 'INVALID_JSON')
1629
+ // 200 但响应体非 JSON(如网关/CDN 的 HTML 拦截页):视为当前端点失败,
1630
+ // 继续尝试下一端点,不让 SyntaxError 中断整条多端点回退链。
1631
+ lastError = new Error(t(locale, 'codingPlanHttp', { provider: meta.label, code: String(response.status), url }))
1632
+ parseError ??= lastError
1633
+ continue
1634
+ }
1635
+ const windows = parse(data)
1636
+ if (windows === null) {
1637
+ const apiCode = typeof data.code === 'number' && Number.isSafeInteger(data.code) && ![0, 200].includes(data.code)
1638
+ ? `API_CODE_${data.code}` : 'NO_USAGE'
1639
+ trace(url, response.status, 'usage', apiCode)
1640
+ // 200 但业务失败(如 Z.ai 的错误信封 {code:1001,msg:...}):透出服务端 msg,避免误报「接口结构已变」。
1641
+ const envelope = data !== null && typeof data === 'object' && typeof data.code === 'number' && data.code !== 0
1642
+ && typeof (data.msg ?? data.message) === 'string' ? (data.msg ?? data.message) : null
1643
+ const error = envelope !== null
1644
+ ? new Error(`${meta.label}: ${envelope}`)
1645
+ : new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
1646
+ parseError ??= error
1647
+ lastError = error
1648
+ continue
1649
+ }
1650
+ trace(url, response.status, 'ok', 'OK')
1651
+ if (provider === 'commandcode') {
1652
+ try { await enhanceCommandCodeMonthly(windows, data, key, signal) } catch { signal?.throwIfAborted() }
1653
+ }
1654
+ return { windows, endpoint: url }
1655
+ }
1656
+ if (provider === 'zai' && attempts.length > 0) {
1657
+ const error = new Error(`${t(locale, 'codingPlanCandidatesFailed', { provider: meta.label })}: `
1658
+ + attempts.map(a => `${a.url} → ${a.status === null ? '' : `HTTP ${a.status} / `}${a.code}`).join('; '))
1659
+ error.code = 'QUOTA_CANDIDATES_FAILED'
1660
+ error.attempts = attempts
1661
+ const active = attempts.filter(a => a.status !== 404)
1662
+ error.soft = active.length > 0 && active.every(a => a.status === 401 || a.status === 403)
1663
+ throw error
1664
+ }
1665
+ throw parseError ?? lastError ?? new Error(t(locale, 'codingPlanNoUsage', { provider: meta.label }))
1666
+ }
1667
+
1668
+ export { CUSTOM_BALANCE_ADAPTER_ID, emptyCustomBalance, extractByRule, queryCustomBalance } from './custom-balance.mjs'