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.
- package/PROTOCOL.md +168 -1
- package/README.md +63 -14
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/account-bridge.mjs +57 -0
- package/lib/client.js +5 -5
- package/lib/context-bridge.mjs +1715 -0
- package/lib/cost-meter-bridge.mjs +794 -0
- package/lib/cost-meter-dashboard-bridge.mjs +1449 -0
- package/lib/dsh-host-adapter.mjs +1 -1
- package/lib/index.mjs +111 -15
- package/lib/plugin-updates.mjs +103 -10
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -0
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -0
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -0
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -0
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -0
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -0
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -0
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -0
- package/lib/vendor/cost-meter/net.mjs +183 -0
- package/lib/vendor/cost-meter/opendesign.mjs +204 -0
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -0
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -0
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -0
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -54
|
@@ -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'
|