creditdaddy 0.9.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,529 +1,580 @@
1
- /**
2
- * ZCode(智谱 GLM / Z.ai)API 客户端 — 额度查询。协议移植自 zcode-switch(MIT)src-tauri/src/quota.rs。
3
- *
4
- * GET https://open.bigmodel.cn/api/monitor/usage/quota/limit Coding Plan 窗口额度(提示次数 / 使用时长)
5
- * GET https://open.bigmodel.cn/api/biz/subscription/list 当前订阅(套餐名 / 到期)
6
- * GET https://zcode.z.ai/api/v1/zcode-plan/billing/balance Start Plan / Z.ai 渠道余额
7
- * 以上都是 ZCode 客户端自身调用的官方接口;鉴权 token 在多个候选里依次尝试:
8
- * config.json 中 coding-plan provider 的 apiKey → zcodejwttoken → oauth:<provider>:access_token
9
- *
10
- * 活动领取(同样移植自 zcode-switch claim.rs):
11
- * GET https://zcode.z.ai/api/v1/zcode-plan/billing/preview 可领取的活动套餐列表
12
- * POST https://zcode.z.ai/api/v1/zcode-plan/billing/claim 领取(需 X-Aliyun-Captcha-Verify-Param,验证码在面板里跑)
13
- * GET https://zcode.z.ai/api/v1/client/configs data.configs.captcha:验证码 sceneId / prefix / region
14
- * ZCode 没有“每日签到”,providers.js 中 ZCode 不提供 checkin。
15
- */
16
-
17
- import crypto from 'node:crypto';
18
- import fs from 'node:fs';
19
- import http from 'node:http';
20
- import https from 'node:https';
21
- import os from 'node:os';
22
- import path from 'node:path';
23
- import tls from 'node:tls';
24
- import { execFile } from 'node:child_process';
25
- import { FETCH_TIMEOUT_MS } from './constants.js';
26
- import { defaultSecret, safeDecrypt } from './zcrypto.js';
27
-
28
- const QUOTA_LIMIT_URL = 'https://open.bigmodel.cn/api/monitor/usage/quota/limit';
29
- const SUBSCRIPTION_URL = 'https://open.bigmodel.cn/api/biz/subscription/list';
30
- const BILLING_BALANCE_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/balance';
31
- const BILLING_PREVIEW_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/preview';
32
- const BILLING_CLAIM_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/claim';
33
- const CLIENT_CONFIGS_URL = 'https://zcode.z.ai/api/v1/client/configs';
34
- // 与 zcode-switch 一致:billing 类接口对客户端版本有校验(版本过低直接 3001 parameter error),
35
- // 所以优先上报本机已安装 ZCode 的版本(注册表读取),没有客户端时退回兜底值。
36
- const APP_VERSION_FALLBACK = '3.11.2';
37
- let appVersionCache = null;
38
- let appVersionPromise = null;
39
-
40
- /** 读注册表拿本机已安装 ZCode 的版本(单 hive,失败返回空串) */
41
- function queryUninstallHive(hive) {
42
- return new Promise((resolve) => {
43
- execFile('reg', ['query', hive, '/s'], { encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 4 * 1024 * 1024 },
44
- (err, stdout) => resolve(err ? '' : String(stdout)));
45
- });
46
- }
47
-
48
- async function detectAppVersion() {
49
- if (process.platform !== 'win32') return null;
50
- for (const hive of [
51
- 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
52
- 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
53
- 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
54
- ]) {
55
- const out = await queryUninstallHive(hive);
56
- const m = out.match(/ZCode[^\r\n]*\r?\n(?:.*\r?\n)*?\s*DisplayVersion\s+REG_SZ\s+([\d.]+)/)
57
- || out.match(/DisplayName\s+REG_SZ\s+ZCode[^\r\n]*[\s\S]{0,400}?DisplayVersion\s+REG_SZ\s+([\d.]+)/);
58
- if (m) return m[1];
59
- }
60
- return null;
61
- }
62
-
63
- /** 客户端版本号(异步:注册表枚举较慢,不能在请求处理里同步执行阻塞事件循环) */
64
- export function zcodeAppVersion() {
65
- if (appVersionCache) return Promise.resolve(appVersionCache);
66
- if (!appVersionPromise) {
67
- appVersionPromise = detectAppVersion()
68
- .catch(() => null)
69
- .then((v) => { appVersionCache = v || APP_VERSION_FALLBACK; return appVersionCache; });
70
- }
71
- return appVersionPromise;
72
- }
73
-
74
- /** 启动时预热版本探测(调度器调用),首个真实请求前缓存即可就绪 */
75
- export function warmZcodeAppVersion() {
76
- zcodeAppVersion().catch(() => {});
77
- }
78
- /** 测试用:重置版本探测缓存 */
79
- export function _resetAppVersionCache(v) { appVersionCache = v || null; appVersionPromise = null; }
80
-
81
- // ── HTTP 出口:直连优先 / 代理优先(可切换),任一路成功即返回 ──
82
- // 代理地址:优先用面板里保存的 proxyUrl(zcode-net.json,0600),没有则退回 HTTPS_PROXY 环境变量
83
-
84
- function codeHome() {
85
- return process.env.CREDITDADDY_HOME || process.env.QODERDADDY_HOME || path.join(os.homedir(), '.creditdaddy');
86
- }
87
- const NET_PREFS_FILE = () => path.join(codeHome(), 'zcode-net.json');
88
-
89
- let netPrefCache = null;
90
- function netPref() {
91
- if (netPrefCache) return netPrefCache;
92
- try {
93
- const j = JSON.parse(fs.readFileSync(NET_PREFS_FILE(), 'utf8'));
94
- netPrefCache = {
95
- proxyFirst: j.proxyFirst === true,
96
- proxyUrl: typeof j.proxyUrl === 'string' && j.proxyUrl.trim() ? j.proxyUrl.trim() : null,
97
- autoClaim: j.autoClaim === true,
98
- };
99
- } catch { netPrefCache = { proxyFirst: false, proxyUrl: null, autoClaim: false }; }
100
- return netPrefCache;
101
- }
102
- function writeNetPref(p) {
103
- netPrefCache = p;
104
- fs.mkdirSync(codeHome(), { recursive: true, mode: 0o700 });
105
- fs.writeFileSync(NET_PREFS_FILE(), JSON.stringify({ proxyFirst: p.proxyFirst, proxyUrl: p.proxyUrl, autoClaim: p.autoClaim }, null, 2), { mode: 0o600 });
106
- }
107
- export function proxyFirst() { return netPref().proxyFirst; }
108
- export function proxyUrl() { return netPref().proxyUrl; }
109
- /** 活动自动轮询领取开关(默认关:活动期前再开,平时不轮询) */
110
- export function autoClaimEnabled() { return netPref().autoClaim; }
111
- export function setAutoClaimEnabled(v) { writeNetPref({ ...netPref(), autoClaim: v === true }); }
112
- export function setProxyFirst(v) { writeNetPref({ ...netPref(), proxyFirst: v === true }); }
113
- export function setProxyUrl(u) {
114
- const t = typeof u === 'string' ? u.trim() : '';
115
- if (t && !/^http:\/\/\S+/i.test(t)) throw new Error('目前只支持 http:// 代理地址');
116
- writeNetPref({ ...netPref(), proxyUrl: t || null });
117
- }
118
-
119
- const envProxy = () => process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || null;
120
- /** 生效代理:面板保存的优先,其次环境变量 */
121
- const effectiveProxy = () => proxyUrl() || envProxy();
122
-
123
- /** 目标是否应绕过代理(loopback / NO_PROXY 后缀匹配) */
124
- function proxyBypass(url) {
125
- const host = new URL(url).hostname.toLowerCase();
126
- if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host.endsWith('.local')) return true;
127
- for (const pat of (process.env.NO_PROXY || process.env.no_proxy || '').split(',')) {
128
- const p = pat.trim().toLowerCase();
129
- if (!p) continue;
130
- const sfx = p.startsWith('.') ? p.slice(1) : p;
131
- if (host === sfx || host.endsWith('.' + sfx)) return true;
132
- }
133
- return false;
134
- }
135
-
136
- /**
137
- * 经 HTTP 代理访问目标(纯 node 内置模块实现,保持核心零依赖):
138
- * https 目标走 CONNECT 隧道 + TLS;http 目标走绝对 URI 转发。
139
- * 支持代理认证(URL 内嵌 user:password → Proxy-Authorization)。
140
- */
141
- function connectTunnel(proxyUrl, target) {
142
- return new Promise((resolve, reject) => {
143
- const p = new URL(proxyUrl);
144
- const headers = {};
145
- if (p.username || p.password) {
146
- const auth = `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}`;
147
- headers['Proxy-Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`;
148
- }
149
- const req = http.request({ host: p.hostname, port: Number(p.port) || 80, method: 'CONNECT', path: target, headers, timeout: 10000 });
150
- req.on('connect', (res, socket) => {
151
- if (res.statusCode !== 200) { socket.destroy(); reject(new Error(`代理隧道失败(CONNECT ${res.statusCode})`)); return; }
152
- resolve(socket);
153
- });
154
- req.on('error', reject);
155
- req.on('timeout', () => req.destroy(new Error('连接代理超时')));
156
- req.end();
157
- });
158
- }
159
-
160
- async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
161
- const proxy = effectiveProxy();
162
- if (!proxy || !/^http:\/\//i.test(proxy)) throw new Error('没有可用的 http 代理(面板配置或 HTTPS_PROXY 环境变量)');
163
- const u = new URL(url);
164
- const p = new URL(proxy);
165
- const isHttps = u.protocol === 'https:';
166
- let requestOpts;
167
- if (isHttps) {
168
- const socket = await connectTunnel(proxy, `${u.hostname}:443`);
169
- const tlsSocket = tls.connect({ socket, servername: u.hostname });
170
- requestOpts = {
171
- hostname: u.hostname, port: 443, path: u.pathname + u.search, method,
172
- headers: { ...headers, Host: u.host }, timeout: timeoutMs,
173
- createConnection: () => tlsSocket,
174
- };
175
- } else {
176
- requestOpts = {
177
- hostname: p.hostname, port: Number(p.port) || 80, path: url, method,
178
- headers: { ...headers, Host: u.host }, timeout: timeoutMs,
179
- };
180
- }
181
- return new Promise((resolve, reject) => {
182
- const req = (isHttps ? https : http).request(requestOpts, (res) => {
183
- const chunks = [];
184
- res.on('data', (c) => chunks.push(c));
185
- res.on('end', () => resolve(new Response(Buffer.concat(chunks), { status: res.statusCode, headers: res.headers })));
186
- });
187
- req.on('error', reject);
188
- req.on('timeout', () => req.destroy(new Error('请求超时')));
189
- if (body != null) req.write(typeof body === 'string' ? body : JSON.stringify(body));
190
- req.end();
191
- });
192
- }
193
-
194
- /**
195
- * 统一出口:按「代理优先」开关决定请求顺序,首个成功的返回。
196
- * - 默认直连优先、代理兜底(无代理环境时等同纯直连,零行为变化)
197
- * - 代理优先适合直连不稳 / 被拦的网络
198
- * 返回 fetch Response;调用方按业务 code 自行判定(业务错误也算“成功送达”,不再回退)。
199
- */
200
- // 测试可替换代理请求实现(生产代码不传)
201
- let viaProxyImpl = null;
202
- export function _setViaProxyForTests(fn) { viaProxyImpl = fn || null; }
203
-
204
- export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
205
- const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: AbortSignal.timeout(timeoutMs) };
206
- const useProxy = Boolean(effectiveProxy()) && !proxyBypass(url);
207
- const direct = () => fetch(url, base);
208
- const viaProxy = viaProxyImpl
209
- ? () => viaProxyImpl(url, { method, headers, body, timeoutMs })
210
- : () => fetchViaProxy(url, { method, headers, body, timeoutMs });
211
- const attempts = useProxy ? (proxyFirst() ? [viaProxy, direct] : [direct, viaProxy]) : [direct];
212
- let lastErr = null;
213
- for (const attempt of attempts) {
214
- try { return await attempt(); } catch (e) { lastErr = e; }
215
- }
216
- throw lastErr || new Error('fetch failed');
217
- }
218
-
219
- /** 领取失败码 → 中文提示(取自 zcode-switch i18n.rs) */
220
- const CLAIM_FAIL = {
221
- 1001: '套餐不存在',
222
- 1002: '活动已结束或套餐暂不可领取',
223
- 1003: '该套餐已经领取过',
224
- 1004: '不符合领取条件',
225
- 1005: '今日领取名额已用完',
226
- 3001: '领取参数错误,请刷新后重试',
227
- 3007: '验证码校验失败,请重试',
228
- 401: '请先登录后再领取',
229
- };
230
-
231
- const platform = () => `${process.platform}-${process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : process.arch}`;
232
-
233
- async function zaiHeaders(token, deviceMid) {
234
- const ver = await zcodeAppVersion();
235
- return {
236
- 'User-Agent': `ZCode/${ver}`,
237
- 'HTTP-Referer': 'https://zcode.z.ai',
238
- 'X-Title': 'Z Code@electron',
239
- 'X-ZCode-App-Version': ver,
240
- 'X-Platform': platform(),
241
- 'X-Release-Channel': 'stable',
242
- 'X-Client-Language': 'zh-CN',
243
- 'X-Client-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
244
- 'X-Os-Category': process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux',
245
- ...(deviceMid ? { 'X-Device-Mid': deviceMid } : {}),
246
- Authorization: `Bearer ${token}`,
247
- 'x-request-id': crypto.randomUUID(),
248
- };
249
- }
250
-
251
- async function bigmodelHeaders(token) {
252
- return { Authorization: `Bearer ${token}`, 'User-Agent': `ZCode/${await zcodeAppVersion()}`, 'x-request-id': crypto.randomUUID() };
253
- }
254
-
255
- async function getJson(url, headers) {
256
- const res = await fetchJsonRace(url, { headers, timeoutMs: FETCH_TIMEOUT_MS });
257
- const text = await res.text();
258
- try { return JSON.parse(text); } catch { return { code: res.status, msg: text.slice(0, 120) }; }
259
- }
260
-
261
- const businessOk = (v) => {
262
- const code = v?.code;
263
- return (code === undefined || code === 200 || code === 0) && v?.success !== false;
264
- };
265
-
266
- /** 按 zcode-switch candidate_tokens 的顺序收集可用 token(去重) */
267
- export function candidateTokens(account) {
268
- const creds = account.meta?.credentials || {};
269
- const config = account.meta?.config || {};
270
- const secret = defaultSecret(os.homedir());
271
- const out = [];
272
- const add = (t) => { if (typeof t === 'string' && t.trim().length > 20 && !out.includes(t)) out.push(t); };
273
- const providers = Object.entries(config.provider || {})
274
- .sort(([, a], [, b]) => (b?.enabled === true) - (a?.enabled === true));
275
- for (const [id, p] of providers) {
276
- const k = p?.options?.apiKey;
277
- if (id.includes('coding-plan') && typeof k === 'string' && !k.startsWith('enc:')) add(k);
278
- }
279
- add(safeDecrypt(creds.zcodejwttoken, secret));
280
- const active = safeDecrypt(creds['oauth:active_provider'], secret) || 'zai';
281
- for (const key of [`oauth:${active}:access_token`, 'oauth:bigmodel:access_token', 'oauth:zai:access_token']) {
282
- add(safeDecrypt(creds[key], secret));
283
- }
284
- return out;
285
- }
286
-
287
- /** Start Plan 渠道要用 zcodejwttoken 或 config 中 start-plan provider 的 key */
288
- function billingTokens(account) {
289
- const creds = account.meta?.credentials || {};
290
- const config = account.meta?.config || {};
291
- const secret = defaultSecret(os.homedir());
292
- const out = [];
293
- const jwt = safeDecrypt(creds.zcodejwttoken, secret);
294
- if (jwt && jwt.length > 20) out.push(jwt);
295
- for (const [id, p] of Object.entries(config.provider || {})) {
296
- const k = p?.options?.apiKey;
297
- if (id.includes('start-plan') && typeof k === 'string' && !k.startsWith('enc:') && k.length > 20 && !out.includes(k)) out.push(k);
298
- }
299
- return out;
300
- }
301
-
302
- /** 领取用的 token:zcodejwttoken 优先,其次 start-plan key(与 claim.rs claim_token 一致) */
303
- function claimToken(account) {
304
- const t = billingTokens(account);
305
- if (!t.length) throw new Error('账号快照里没有可用的 zcodejwttoken,请在 ZCode 重新登录(或重新本机导入)后再领取');
306
- return t[0];
307
- }
308
-
309
- const iso = (ms) => (Number.isFinite(Number(ms)) && Number(ms) > 0 ? new Date(Number(ms)).toISOString() : null);
310
-
311
- /** BigModel quota/limit → 统一结构(unit 字段标明非积分单位,面板不计入“剩余积分”合计) */
312
- export function normalizeQuotaLimit(limitResp, subResp) {
313
- const limits = Array.isArray(limitResp?.data?.limits) ? limitResp.data.limits : [];
314
- const parts = limits.map((l) => {
315
- const unitLabel = l.type === 'TOKENS_LIMIT' ? '次' : l.type === 'TIME_LIMIT' ? '分钟' : '';
316
- const name = l.type === 'TOKENS_LIMIT' ? '提示次数' : l.type === 'TIME_LIMIT' ? '使用时长' : String(l.type || '额度');
317
- const total = Number(l.usage);
318
- const used = Number(l.currentValue);
319
- const remaining = Number.isFinite(Number(l.remaining)) ? Number(l.remaining) : Math.max(0, total - used);
320
- return {
321
- name, unit: unitLabel,
322
- total: Number.isFinite(total) ? total : 0,
323
- used: Number.isFinite(used) ? used : 0,
324
- remaining: Number.isFinite(remaining) ? remaining : 0,
325
- expiresAt: iso(l.nextResetTime),
326
- recurring: true,
327
- };
328
- });
329
- const sub = businessOk(subResp) && Array.isArray(subResp?.data)
330
- ? (subResp.data.find((s) => s.status === 'VALID' && s.inCurrentPeriod !== false) || subResp.data[0])
331
- : null;
332
- // 主额度优先取“使用时长”,与 zcode-switch 一致
333
- const main = parts.find((p) => p.unit === '分钟' && p.total > 0) || parts.find((p) => p.total > 0) || null;
334
- return {
335
- total: main?.total || 0,
336
- used: main?.used || 0,
337
- remaining: main?.remaining || 0,
338
- unit: main?.unit || '',
339
- plan: sub?.productName || limitResp?.data?.level || null,
340
- planExpiresAt: sub?.endTime || sub?.expireTime || null,
341
- parts,
342
- empty: parts.length === 0,
343
- exceeded: false,
344
- };
345
- }
346
-
347
- /** zcode.z.ai billing/balance → 统一结构 */
348
- export function normalizeBalance(balanceResp) {
349
- const d = balanceResp?.data || {};
350
- const epochMs = (v) => iso(Number(v) * (Number(v) < 1e12 ? 1000 : 1));
351
- const plans = Array.isArray(d.plans) ? d.plans.filter((p) => String(p.status || '').toLowerCase() === 'active') : [];
352
- const balanceParts = (Array.isArray(d.balances) ? d.balances : []).map((b) => {
353
- const total = Number(b.total_units);
354
- const used = Number(b.used_units);
355
- const remaining = Number(b.remaining_units ?? b.available_units);
356
- return {
357
- name: b.show_name || b.name || b.entitlement_id || b.plan_id || '额度',
358
- unit: b.unit_type === 'token' ? 'Token' : (b.unit_type || ''),
359
- total: Number.isFinite(total) ? total : 0,
360
- used: Number.isFinite(used) ? used : 0,
361
- remaining: Number.isFinite(remaining) ? remaining : Math.max(0, (total || 0) - (used || 0)),
362
- expiresAt: epochMs(b.expire_at || b.period_end || 0),
363
- recurring: Boolean(b.period && b.period !== 'one_time'),
364
- };
365
- });
366
- // balances 可能为空(例如活动权益还没到 effective_at):此时从生效套餐的权益里派生展示额度。
367
- // effective_at 在未来的权益标记 pending,面板显示「xx 生效」而不是「剩余 xx」。
368
- const now = Date.now();
369
- const entitlementParts = plans.flatMap((p) =>
370
- (Array.isArray(p.entitlements) ? p.entitlements : [])
371
- .filter((e) => e.unit_type === 'token' && Number(e.grant_units) > 0)
372
- .map((e) => {
373
- const grant = Number(e.grant_units);
374
- const startsAt = epochMs(e.effective_at || p.starts_at);
375
- const pending = Boolean(startsAt && new Date(startsAt).getTime() > now);
376
- return {
377
- name: e.show_name || e.entitlement_id || '活动额度',
378
- unit: 'Token',
379
- total: grant,
380
- used: 0,
381
- remaining: pending ? 0 : grant,
382
- pending,
383
- startsAt,
384
- expiresAt: epochMs(p.ends_at),
385
- recurring: Boolean(e.period && e.period !== 'one_time'),
386
- };
387
- }),
388
- );
389
- const parts = balanceParts.length ? balanceParts : entitlementParts;
390
- // 套餐有效期卡片上显示在「有效期至」列之外,这里记录最近的套餐结束时间
391
- const planEndsAt = plans.map((p) => epochMs(p.ends_at)).filter(Boolean).sort()[0] || null;
392
- const sum = (k) => parts.reduce((s, p) => s + (p[k] || 0), 0);
393
- return {
394
- total: sum('total'), used: sum('used'), remaining: sum('remaining'),
395
- unit: parts[0]?.unit || 'Token',
396
- plan: plans[0]?.name || plans[0]?.plan_id || null,
397
- planEndsAt,
398
- parts,
399
- empty: parts.length === 0 && plans.length === 0,
400
- exceeded: false,
401
- };
402
- }
403
-
404
- const PLAN_PERIOD_LABEL = { daily: '每日', weekly: '每周', monthly: '每月' };
405
- const fmtGrantUnits = (n) => (n >= 1e8 ? `${+(n / 1e8).toFixed(1)}亿` : n >= 1e4 ? `${+(n / 1e4).toFixed(1)}万` : String(Math.round(n)));
406
-
407
- /** billing/preview → 可领取活动列表(parse_plan 的移植) */
408
- export function normalizePlans(previewResp) {
409
- const plans = Array.isArray(previewResp?.data?.plans) ? previewResp.data.plans : [];
410
- const out = [];
411
- for (const p of plans) {
412
- const planId = String(p.plan_id || p.planId || '').trim();
413
- if (!planId) continue;
414
- const grantItems = (Array.isArray(p.entitlements) ? p.entitlements : [])
415
- .filter((e) => e.meter === 'model_usage' && e.unit_type === 'token' && String(e.show_name || '').trim())
416
- .map((e) => ({
417
- name: String(e.show_name).trim(),
418
- units: Number(e.grant_units || e.grantUnits || 0),
419
- period: String(e.period || 'one_time'),
420
- }));
421
- out.push({
422
- planId,
423
- name: String(p.name || '').trim(),
424
- description: String(p.description || '').trim(),
425
- priority: Number(p.priority || 0),
426
- grants: grantItems.map((g) => `${g.name} · ${fmtGrantUnits(g.units)} Token(${PLAN_PERIOD_LABEL[g.period] || '一次性'})`),
427
- });
428
- }
429
- out.sort((a, b) => b.priority - a.priority || a.planId.localeCompare(b.planId));
430
- return out;
431
- }
432
-
433
- /**
434
- * 额度查询:先试 BigModel Coding Plan(quota/limit),再试 Z.ai / Start Plan(billing/balance)。
435
- * 都没有有效套餐时返回 { empty: true }(不是错误:很多账号只用免费额度)。
436
- */
437
- export async function fetchZcodeQuota(account) {
438
- const tokens = candidateTokens(account);
439
- if (!tokens.length) throw new Error('账号快照里没有可用 token,请在 ZCode 登录后重新导入');
440
- let lastErr = null;
441
- let authFails = 0;
442
- for (const t of tokens) {
443
- try {
444
- const limit = await getJson(QUOTA_LIMIT_URL, await bigmodelHeaders(t));
445
- if (businessOk(limit)) {
446
- const sub = await getJson(SUBSCRIPTION_URL, await bigmodelHeaders(t)).catch(() => null);
447
- return { ...normalizeQuotaLimit(limit, sub), source: 'bigmodel' };
448
- }
449
- if (limit?.code === 401) authFails++;
450
- else lastErr = limit?.msg || lastErr;
451
- } catch (e) { lastErr = e.message; }
452
- }
453
- for (const t of billingTokens(account)) {
454
- try {
455
- const bal = await getJson(`${BILLING_BALANCE_URL}?app_version=${await zcodeAppVersion()}`, await zaiHeaders(t, account.meta?.deviceMid));
456
- if (businessOk(bal)) return { ...normalizeBalance(bal), source: 'zcode.z.ai' };
457
- if (bal?.code === 401) authFails++;
458
- } catch (e) { lastErr = e.message; }
459
- }
460
- if (authFails && authFails >= tokens.length) throw new Error('鉴权失败,登录可能已过期,请在 ZCode 重新登录后重新导入');
461
- // “当前用户不存在coding plan”之类属于无套餐,按空额度返回
462
- if (!lastErr || /不存在|no.*plan/i.test(lastErr)) {
463
- return { total: 0, used: 0, remaining: 0, unit: '', plan: null, parts: [], empty: true, exceeded: false };
464
- }
465
- throw new Error(`额度查询失败:${lastErr}`);
466
- }
467
-
468
- // ── 活动领取 ──
469
-
470
- /** 可领取的活动列表。账户当前没有可领活动时返回 [](不是错误)。 */
471
- export async function fetchClaimPlans(account) {
472
- const token = claimToken(account);
473
- const v = await getJson(
474
- `${BILLING_PREVIEW_URL}?app_version=${await zcodeAppVersion()}&platform=${platform()}`,
475
- await zaiHeaders(token, account.meta?.deviceMid),
476
- );
477
- if (v?.code !== 0) throw new Error(v?.msg || v?.message || `查询活动列表失败(code ${v?.code})`);
478
- return { plans: normalizePlans(v), serverTime: v?.data?.server_time ? new Date(v.data.server_time * 1000).toISOString() : null };
479
- }
480
-
481
- /** 验证码配置(无需登录)。当前服务端未下发时返回 { enabled: false }。 */
482
- export async function fetchCaptchaConfig() {
483
- const v = await getJson(CLIENT_CONFIGS_URL, await zaiHeaders(''));
484
- if (v?.code !== 0) throw new Error('获取验证码配置失败');
485
- const c = v?.data?.configs?.captcha || {};
486
- return {
487
- enabled: c.enabled === true,
488
- region: typeof c.region === 'string' ? c.region : '',
489
- prefix: typeof c.prefix === 'string' ? c.prefix : '',
490
- sceneId: typeof c.sceneId === 'string' ? c.sceneId : '',
491
- };
492
- }
493
-
494
- /**
495
- * 领取活动套餐。captchaParam 为空时不带验证码头(服务端风控可能拒绝,错误信息原样返回)。
496
- * 返回 { planName, startsAt, endsAt, serverTime };失败抛出带 code / nextAt 的 Error。
497
- */
498
- export async function claimPlan(account, planId, { captchaParam = '', region = '' } = {}) {
499
- const token = claimToken(account);
500
- const headers = await zaiHeaders(token, account.meta?.deviceMid);
501
- if (captchaParam && captchaParam.trim()) headers['X-Aliyun-Captcha-Verify-Param'] = captchaParam.trim();
502
- if (region && region.trim()) headers['X-Aliyun-Captcha-Verify-Region'] = region.trim();
503
- const res = await fetchJsonRace(BILLING_CLAIM_URL, {
504
- method: 'POST',
505
- headers: { ...headers, 'Content-Type': 'application/json' },
506
- body: { plan_id: planId },
507
- timeoutMs: 25_000,
508
- });
509
- const text = await res.text();
510
- let v;
511
- try { v = JSON.parse(text); } catch { v = { code: res.status, msg: text.slice(0, 200) }; }
512
- const code = Number(v?.code ?? -1);
513
- if (code !== 0) {
514
- const serverMsg = v?.msg || v?.message || '';
515
- const base = CLAIM_FAIL[code] || '领取失败';
516
- const err = new Error(serverMsg ? `${base}(${serverMsg})` : base);
517
- err.code = code;
518
- // 1005(名额用完)带上套餐结束时间,方便提示下次什么时候再试
519
- if (code === 1005 && v?.data?.plan?.ends_at) err.nextAt = new Date(v.data.plan.ends_at * 1000).toISOString();
520
- throw err;
521
- }
522
- const plan = v?.data?.plan || {};
523
- return {
524
- planName: plan.name || planId,
525
- startsAt: plan.starts_at ? new Date(plan.starts_at * 1000).toISOString() : null,
526
- endsAt: plan.ends_at ? new Date(plan.ends_at * 1000).toISOString() : null,
527
- serverTime: v?.data?.server_time ? new Date(v.data.server_time * 1000).toISOString() : null,
528
- };
529
- }
1
+ /**
2
+ * ZCode(智谱 GLM / Z.ai)API 客户端 — 额度查询。协议移植自 zcode-switch(MIT)src-tauri/src/quota.rs。
3
+ *
4
+ * GET https://open.bigmodel.cn/api/monitor/usage/quota/limit Coding Plan 窗口额度(提示次数 / 使用时长)
5
+ * GET https://open.bigmodel.cn/api/biz/subscription/list 当前订阅(套餐名 / 到期)
6
+ * GET https://zcode.z.ai/api/v1/zcode-plan/billing/balance Start Plan / Z.ai 渠道余额
7
+ * 以上都是 ZCode 客户端自身调用的官方接口;鉴权 token 在多个候选里依次尝试:
8
+ * config.json 中 coding-plan provider 的 apiKey → zcodejwttoken → oauth:<provider>:access_token
9
+ *
10
+ * 活动领取(同样移植自 zcode-switch claim.rs):
11
+ * GET https://zcode.z.ai/api/v1/zcode-plan/billing/preview 可领取的活动套餐列表
12
+ * POST https://zcode.z.ai/api/v1/zcode-plan/billing/claim 领取(需 X-Aliyun-Captcha-Verify-Param,验证码在面板里跑)
13
+ * GET https://zcode.z.ai/api/v1/client/configs data.configs.captcha:验证码 sceneId / prefix / region
14
+ * ZCode 没有“每日签到”,providers.js 中 ZCode 不提供 checkin。
15
+ */
16
+
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import http from 'node:http';
20
+ import https from 'node:https';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import tls from 'node:tls';
24
+ import { execFile } from 'node:child_process';
25
+ import { FETCH_TIMEOUT_MS } from './constants.js';
26
+ import { defaultSecret, safeDecrypt } from './zcrypto.js';
27
+ import { ensureVirtualDeviceMid } from './zcodeLocal.js';
28
+
29
+ const QUOTA_LIMIT_URL = 'https://open.bigmodel.cn/api/monitor/usage/quota/limit';
30
+ const SUBSCRIPTION_URL = 'https://open.bigmodel.cn/api/biz/subscription/list';
31
+ const BILLING_BALANCE_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/balance';
32
+ const BILLING_PREVIEW_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/preview';
33
+ const BILLING_CLAIM_URL = 'https://zcode.z.ai/api/v1/zcode-plan/billing/claim';
34
+ const CLIENT_CONFIGS_URL = 'https://zcode.z.ai/api/v1/client/configs';
35
+ // 与 zcode-switch 一致:billing 类接口对客户端版本有校验(版本过低直接 3001 parameter error),
36
+ // 所以优先上报本机已安装 ZCode 的版本(注册表读取),没有客户端时退回兜底值。
37
+ const APP_VERSION_FALLBACK = '3.14.3';
38
+ let appVersionCache = null;
39
+ let appVersionPromise = null;
40
+
41
+ /** 读注册表拿本机已安装 ZCode 的版本(单 hive,失败返回空串) */
42
+ function queryUninstallHive(hive) {
43
+ return new Promise((resolve) => {
44
+ execFile('reg', ['query', hive, '/s'], { encoding: 'utf8', windowsHide: true, timeout: 8000, maxBuffer: 4 * 1024 * 1024 },
45
+ (err, stdout) => resolve(err ? '' : String(stdout)));
46
+ });
47
+ }
48
+
49
+ async function detectAppVersion() {
50
+ if (process.platform !== 'win32') return null;
51
+ for (const hive of [
52
+ 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
53
+ 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
54
+ 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
55
+ ]) {
56
+ const out = await queryUninstallHive(hive);
57
+ const m = out.match(/ZCode[^\r\n]*\r?\n(?:.*\r?\n)*?\s*DisplayVersion\s+REG_SZ\s+([\d.]+)/)
58
+ || out.match(/DisplayName\s+REG_SZ\s+ZCode[^\r\n]*[\s\S]{0,400}?DisplayVersion\s+REG_SZ\s+([\d.]+)/);
59
+ if (m) return m[1];
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** 客户端版本号(异步:注册表枚举较慢,不能在请求处理里同步执行阻塞事件循环) */
65
+ export function zcodeAppVersion() {
66
+ if (appVersionCache) return Promise.resolve(appVersionCache);
67
+ if (!appVersionPromise) {
68
+ appVersionPromise = detectAppVersion()
69
+ .catch(() => null)
70
+ .then((v) => { appVersionCache = v || APP_VERSION_FALLBACK; return appVersionCache; });
71
+ }
72
+ return appVersionPromise;
73
+ }
74
+
75
+ /** 启动时预热版本探测(调度器调用),首个真实请求前缓存即可就绪 */
76
+ export function warmZcodeAppVersion() {
77
+ zcodeAppVersion().catch(() => {});
78
+ }
79
+ /** 测试用:重置版本探测缓存 */
80
+ export function _resetAppVersionCache(v) { appVersionCache = v || null; appVersionPromise = null; }
81
+
82
+ // ── HTTP 出口:直连优先 / 代理优先(可切换),任一路成功即返回 ──
83
+ // 代理地址:优先用面板里保存的 proxyUrl(zcode-net.json,0600),没有则退回 HTTPS_PROXY 环境变量
84
+
85
+ function codeHome() {
86
+ return process.env.CREDITDADDY_HOME || process.env.QODERDADDY_HOME || path.join(os.homedir(), '.creditdaddy');
87
+ }
88
+ const NET_PREFS_FILE = () => path.join(codeHome(), 'zcode-net.json');
89
+
90
+ let netPrefCache = null;
91
+ function netPref() {
92
+ if (netPrefCache) return netPrefCache;
93
+ try {
94
+ const j = JSON.parse(fs.readFileSync(NET_PREFS_FILE(), 'utf8'));
95
+ netPrefCache = {
96
+ proxyFirst: j.proxyFirst === true,
97
+ proxyUrl: typeof j.proxyUrl === 'string' && j.proxyUrl.trim() ? j.proxyUrl.trim() : null,
98
+ autoClaim: j.autoClaim === true,
99
+ autoClaimUntil: Number.isFinite(j.autoClaimUntil) ? j.autoClaimUntil : null,
100
+ claimIntervalMin: Number.isFinite(j.claimIntervalMin) ? Number(j.claimIntervalMin) : null,
101
+ claimWindowMin: Number.isFinite(j.claimWindowMin) ? Number(j.claimWindowMin) : null,
102
+ };
103
+ } catch { netPrefCache = { proxyFirst: false, proxyUrl: null, autoClaim: false, autoClaimUntil: null, claimIntervalMin: null, claimWindowMin: null }; }
104
+ return netPrefCache;
105
+ }
106
+ function writeNetPref(p) {
107
+ netPrefCache = p;
108
+ fs.mkdirSync(codeHome(), { recursive: true, mode: 0o700 });
109
+ fs.writeFileSync(NET_PREFS_FILE(), JSON.stringify({ proxyFirst: p.proxyFirst, proxyUrl: p.proxyUrl, autoClaim: p.autoClaim, autoClaimUntil: p.autoClaimUntil || null, claimIntervalMin: p.claimIntervalMin ?? null, claimWindowMin: p.claimWindowMin ?? null }, null, 2), { mode: 0o600 });
110
+ }
111
+ export function proxyFirst() { return netPref().proxyFirst; }
112
+ export function proxyUrl() { return netPref().proxyUrl; }
113
+ /** 活动自动轮询领取开关(默认关:活动期前再开) */
114
+ export function autoClaimEnabled() {
115
+ const p = netPref();
116
+ if (p.autoClaim && p.autoClaimUntil && Date.now() >= p.autoClaimUntil) {
117
+ writeNetPref({ ...p, autoClaim: false, autoClaimUntil: null });
118
+ return false;
119
+ }
120
+ return p.autoClaim;
121
+ }
122
+ export function autoClaimUntil() { return autoClaimEnabled() ? netPref().autoClaimUntil : null; }
123
+ export function setAutoClaimEnabled(v) {
124
+ writeNetPref({ ...netPref(), autoClaim: v === true, autoClaimUntil: null });
125
+ }
126
+ export function enableAutoClaimFor(durationMs) {
127
+ const duration = Number(durationMs);
128
+ if (!Number.isFinite(duration) || duration <= 0) throw new Error('自动领取时长无效');
129
+ writeNetPref({ ...netPref(), autoClaim: true, autoClaimUntil: Date.now() + duration });
130
+ }
131
+
132
+ // ── 自动领取计划:轮询周期 / 单次运行时长(用户可在面板弹窗里改,落 zcode-net.json) ──
133
+
134
+ /** 轮询周期(分钟)。默认 2,可配 1–720 */
135
+ export function claimIntervalMin() {
136
+ const raw = netPref().claimIntervalMin;
137
+ if (raw === null || raw === undefined) return 2;
138
+ const n = Math.round(Number(raw));
139
+ return Number.isFinite(n) && n >= 1 && n <= 720 ? n : 2;
140
+ }
141
+ export function setClaimIntervalMin(v) {
142
+ const n = Math.round(Number(v));
143
+ if (!Number.isFinite(n) || n < 1 || n > 720) throw new Error('轮询周期需在 1–720 分钟之间');
144
+ writeNetPref({ ...netPref(), claimIntervalMin: n });
145
+ }
146
+
147
+ /** 单次运行时长(分钟)。默认 60;0 = 一直运行直到手动关闭 */
148
+ export function claimWindowMin() {
149
+ const raw = netPref().claimWindowMin;
150
+ if (raw === null || raw === undefined) return 60;
151
+ const n = Math.round(Number(raw));
152
+ if (n === 0) return 0;
153
+ return n >= 5 && n <= 1440 ? n : 60;
154
+ }
155
+ export function setClaimWindowMin(v) {
156
+ const n = Math.round(Number(v));
157
+ if (!Number.isFinite(n) || n < 0 || n > 1440 || (n > 0 && n < 5)) throw new Error('运行时长需为 0(一直运行)或 5–1440 分钟');
158
+ writeNetPref({ ...netPref(), claimWindowMin: n });
159
+ }
160
+ export function setProxyFirst(v) { writeNetPref({ ...netPref(), proxyFirst: v === true }); }
161
+ export function setProxyUrl(u) {
162
+ const t = typeof u === 'string' ? u.trim() : '';
163
+ if (t && !/^http:\/\/\S+/i.test(t)) throw new Error('目前只支持 http:// 代理地址');
164
+ writeNetPref({ ...netPref(), proxyUrl: t || null });
165
+ }
166
+
167
+ const envProxy = () => process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || null;
168
+ /** 生效代理:面板保存的优先,其次环境变量 */
169
+ const effectiveProxy = () => proxyUrl() || envProxy();
170
+
171
+ /** 目标是否应绕过代理(loopback / NO_PROXY 后缀匹配) */
172
+ function proxyBypass(url) {
173
+ const host = new URL(url).hostname.toLowerCase();
174
+ if (host === '127.0.0.1' || host === 'localhost' || host === '::1' || host.endsWith('.local')) return true;
175
+ for (const pat of (process.env.NO_PROXY || process.env.no_proxy || '').split(',')) {
176
+ const p = pat.trim().toLowerCase();
177
+ if (!p) continue;
178
+ const sfx = p.startsWith('.') ? p.slice(1) : p;
179
+ if (host === sfx || host.endsWith('.' + sfx)) return true;
180
+ }
181
+ return false;
182
+ }
183
+
184
+ /**
185
+ * 经 HTTP 代理访问目标(纯 node 内置模块实现,保持核心零依赖):
186
+ * https 目标走 CONNECT 隧道 + TLS;http 目标走绝对 URI 转发。
187
+ * 支持代理认证(URL 内嵌 user:password → Proxy-Authorization)。
188
+ */
189
+ function connectTunnel(proxyUrl, target) {
190
+ return new Promise((resolve, reject) => {
191
+ const p = new URL(proxyUrl);
192
+ const headers = {};
193
+ if (p.username || p.password) {
194
+ const auth = `${decodeURIComponent(p.username)}:${decodeURIComponent(p.password)}`;
195
+ headers['Proxy-Authorization'] = `Basic ${Buffer.from(auth).toString('base64')}`;
196
+ }
197
+ const req = http.request({ host: p.hostname, port: Number(p.port) || 80, method: 'CONNECT', path: target, headers, timeout: 10000 });
198
+ req.on('connect', (res, socket) => {
199
+ if (res.statusCode !== 200) { socket.destroy(); reject(new Error(`代理隧道失败(CONNECT ${res.statusCode})`)); return; }
200
+ resolve(socket);
201
+ });
202
+ req.on('error', reject);
203
+ req.on('timeout', () => req.destroy(new Error('连接代理超时')));
204
+ req.end();
205
+ });
206
+ }
207
+
208
+ async function fetchViaProxy(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000 } = {}) {
209
+ const proxy = effectiveProxy();
210
+ if (!proxy || !/^http:\/\//i.test(proxy)) throw new Error('没有可用的 http 代理(面板配置或 HTTPS_PROXY 环境变量)');
211
+ const u = new URL(url);
212
+ const p = new URL(proxy);
213
+ const isHttps = u.protocol === 'https:';
214
+ let requestOpts;
215
+ if (isHttps) {
216
+ const socket = await connectTunnel(proxy, `${u.hostname}:443`);
217
+ const tlsSocket = tls.connect({ socket, servername: u.hostname });
218
+ requestOpts = {
219
+ hostname: u.hostname, port: 443, path: u.pathname + u.search, method,
220
+ headers: { ...headers, Host: u.host }, timeout: timeoutMs,
221
+ createConnection: () => tlsSocket,
222
+ };
223
+ } else {
224
+ requestOpts = {
225
+ hostname: p.hostname, port: Number(p.port) || 80, path: url, method,
226
+ headers: { ...headers, Host: u.host }, timeout: timeoutMs,
227
+ };
228
+ }
229
+ return new Promise((resolve, reject) => {
230
+ const req = (isHttps ? https : http).request(requestOpts, (res) => {
231
+ const chunks = [];
232
+ res.on('data', (c) => chunks.push(c));
233
+ res.on('end', () => resolve(new Response(Buffer.concat(chunks), { status: res.statusCode, headers: res.headers })));
234
+ });
235
+ req.on('error', reject);
236
+ req.on('timeout', () => req.destroy(new Error('请求超时')));
237
+ if (body != null) req.write(typeof body === 'string' ? body : JSON.stringify(body));
238
+ req.end();
239
+ });
240
+ }
241
+
242
+ /**
243
+ * 统一出口:按「代理优先」开关决定请求顺序,首个成功的返回。
244
+ * - 默认直连优先、代理兜底(无代理环境时等同纯直连,零行为变化)
245
+ * - 代理优先适合直连不稳 / 被拦的网络
246
+ * 返回 fetch Response;调用方按业务 code 自行判定(业务错误也算“成功送达”,不再回退)。
247
+ */
248
+ // 测试可替换代理请求实现(生产代码不传)
249
+ let viaProxyImpl = null;
250
+ export function _setViaProxyForTests(fn) { viaProxyImpl = fn || null; }
251
+
252
+ export async function fetchJsonRace(url, { method = 'GET', headers = {}, body = null, timeoutMs = 15000, signal = null } = {}) {
253
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
254
+ const abortSignal = signal ? AbortSignal.any([timeoutSignal, signal]) : timeoutSignal;
255
+ const base = { method, headers: { ...headers }, ...(body != null ? { body: typeof body === 'string' ? body : JSON.stringify(body) } : {}), signal: abortSignal };
256
+ const useProxy = Boolean(effectiveProxy()) && !proxyBypass(url);
257
+ const direct = () => fetch(url, base);
258
+ const viaProxy = viaProxyImpl
259
+ ? () => viaProxyImpl(url, { method, headers, body, timeoutMs })
260
+ : () => fetchViaProxy(url, { method, headers, body, timeoutMs });
261
+ const attempts = useProxy ? (proxyFirst() ? [viaProxy, direct] : [direct, viaProxy]) : [direct];
262
+ let lastErr = null;
263
+ for (const attempt of attempts) {
264
+ try { return await attempt(); } catch (e) { lastErr = e; }
265
+ }
266
+ throw lastErr || new Error('fetch failed');
267
+ }
268
+
269
+ /** 领取失败码 → 中文提示(取自 zcode-switch i18n.rs) */
270
+ const CLAIM_FAIL = {
271
+ 1001: '套餐不存在',
272
+ 1002: '活动已结束或套餐暂不可领取',
273
+ 1003: '该套餐已经领取过',
274
+ 1004: '不符合领取条件',
275
+ 1005: '今日领取名额已用完',
276
+ 3001: '领取参数错误,请刷新后重试',
277
+ 3007: '验证码校验失败,请重试',
278
+ 401: '请先登录后再领取',
279
+ };
280
+
281
+ const platform = () => `${process.platform}-${process.arch === 'arm64' ? 'arm64' : process.arch === 'x64' ? 'x64' : process.arch}`;
282
+
283
+ export async function zaiHeaders(token, deviceMid) {
284
+ const ver = await zcodeAppVersion();
285
+ return {
286
+ 'User-Agent': `ZCode/${ver}`,
287
+ 'HTTP-Referer': 'https://zcode.z.ai',
288
+ 'X-Title': 'Z Code@electron',
289
+ 'X-ZCode-App-Version': ver,
290
+ 'X-Platform': platform(),
291
+ 'X-Release-Channel': 'stable',
292
+ 'X-Client-Language': 'zh-CN',
293
+ 'X-Client-Timezone': Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
294
+ 'X-Os-Category': process.platform === 'win32' ? 'windows' : process.platform === 'darwin' ? 'macos' : 'linux',
295
+ ...(deviceMid ? { 'X-Device-Mid': deviceMid } : {}),
296
+ Authorization: `Bearer ${token}`,
297
+ 'x-request-id': crypto.randomUUID(),
298
+ };
299
+ }
300
+
301
+ async function bigmodelHeaders(token) {
302
+ return { Authorization: `Bearer ${token}`, 'User-Agent': `ZCode/${await zcodeAppVersion()}`, 'x-request-id': crypto.randomUUID() };
303
+ }
304
+
305
+ async function getJson(url, headers) {
306
+ const res = await fetchJsonRace(url, { headers, timeoutMs: FETCH_TIMEOUT_MS });
307
+ const text = await res.text();
308
+ try { return JSON.parse(text); } catch { return { code: res.status, msg: text.slice(0, 120) }; }
309
+ }
310
+
311
+ const businessOk = (v) => {
312
+ const code = v?.code;
313
+ return (code === undefined || code === 200 || code === 0) && v?.success !== false;
314
+ };
315
+
316
+ /** 按 zcode-switch candidate_tokens 的顺序收集可用 token(去重) */
317
+ export function candidateTokens(account) {
318
+ const creds = account.meta?.credentials || {};
319
+ const config = account.meta?.config || {};
320
+ const secret = defaultSecret(os.homedir());
321
+ const out = [];
322
+ const add = (t) => { if (typeof t === 'string' && t.trim().length > 20 && !out.includes(t)) out.push(t); };
323
+ const providers = Object.entries(config.provider || {})
324
+ .sort(([, a], [, b]) => (b?.enabled === true) - (a?.enabled === true));
325
+ for (const [id, p] of providers) {
326
+ const k = p?.options?.apiKey;
327
+ if (id.includes('coding-plan') && typeof k === 'string' && !k.startsWith('enc:')) add(k);
328
+ }
329
+ add(safeDecrypt(creds.zcodejwttoken, secret));
330
+ const active = safeDecrypt(creds['oauth:active_provider'], secret) || 'zai';
331
+ for (const key of [`oauth:${active}:access_token`, 'oauth:bigmodel:access_token', 'oauth:zai:access_token']) {
332
+ add(safeDecrypt(creds[key], secret));
333
+ }
334
+ return out;
335
+ }
336
+
337
+ /** Start Plan 渠道要用 zcodejwttoken 或 config 中 start-plan provider 的 key */
338
+ function billingTokens(account) {
339
+ const creds = account.meta?.credentials || {};
340
+ const config = account.meta?.config || {};
341
+ const secret = defaultSecret(os.homedir());
342
+ const out = [];
343
+ const jwt = safeDecrypt(creds.zcodejwttoken, secret);
344
+ if (jwt && jwt.length > 20) out.push(jwt);
345
+ for (const [id, p] of Object.entries(config.provider || {})) {
346
+ const k = p?.options?.apiKey;
347
+ if (id.includes('start-plan') && typeof k === 'string' && !k.startsWith('enc:') && k.length > 20 && !out.includes(k)) out.push(k);
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /** 领取/网关补全用的 token:zcodejwttoken 优先,其次 start-plan key(与 claim.rs claim_token 一致) */
353
+ export function claimToken(account) {
354
+ const t = billingTokens(account);
355
+ if (!t.length) throw new Error('账号快照里没有可用的 zcodejwttoken,请在 ZCode 重新登录(或重新本机导入)后再领取');
356
+ return t[0];
357
+ }
358
+
359
+ const iso = (ms) => (Number.isFinite(Number(ms)) && Number(ms) > 0 ? new Date(Number(ms)).toISOString() : null);
360
+
361
+ /** BigModel quota/limit → 统一结构(unit 字段标明非积分单位,面板不计入“剩余积分”合计) */
362
+ export function normalizeQuotaLimit(limitResp, subResp) {
363
+ const limits = Array.isArray(limitResp?.data?.limits) ? limitResp.data.limits : [];
364
+ const parts = limits.map((l) => {
365
+ const unitLabel = l.type === 'TOKENS_LIMIT' ? '次' : l.type === 'TIME_LIMIT' ? '分钟' : '';
366
+ const name = l.type === 'TOKENS_LIMIT' ? '提示次数' : l.type === 'TIME_LIMIT' ? '使用时长' : String(l.type || '额度');
367
+ const total = Number(l.usage);
368
+ const used = Number(l.currentValue);
369
+ const remaining = Number.isFinite(Number(l.remaining)) ? Number(l.remaining) : Math.max(0, total - used);
370
+ return {
371
+ name, unit: unitLabel,
372
+ total: Number.isFinite(total) ? total : 0,
373
+ used: Number.isFinite(used) ? used : 0,
374
+ remaining: Number.isFinite(remaining) ? remaining : 0,
375
+ expiresAt: iso(l.nextResetTime),
376
+ recurring: true,
377
+ };
378
+ });
379
+ const sub = businessOk(subResp) && Array.isArray(subResp?.data)
380
+ ? (subResp.data.find((s) => s.status === 'VALID' && s.inCurrentPeriod !== false) || subResp.data[0])
381
+ : null;
382
+ // 主额度优先取“使用时长”,与 zcode-switch 一致
383
+ const main = parts.find((p) => p.unit === '分钟' && p.total > 0) || parts.find((p) => p.total > 0) || null;
384
+ return {
385
+ total: main?.total || 0,
386
+ used: main?.used || 0,
387
+ remaining: main?.remaining || 0,
388
+ unit: main?.unit || '',
389
+ plan: sub?.productName || limitResp?.data?.level || null,
390
+ planExpiresAt: sub?.endTime || sub?.expireTime || null,
391
+ parts,
392
+ empty: parts.length === 0,
393
+ exceeded: false,
394
+ };
395
+ }
396
+
397
+ /** zcode.z.ai billing/balance → 统一结构 */
398
+ export function normalizeBalance(balanceResp) {
399
+ const d = balanceResp?.data || {};
400
+ const epochMs = (v) => iso(Number(v) * (Number(v) < 1e12 ? 1000 : 1));
401
+ const plans = Array.isArray(d.plans) ? d.plans.filter((p) => String(p.status || '').toLowerCase() === 'active') : [];
402
+ const balanceParts = (Array.isArray(d.balances) ? d.balances : []).map((b) => {
403
+ const total = Number(b.total_units);
404
+ const used = Number(b.used_units);
405
+ const remaining = Number(b.remaining_units ?? b.available_units);
406
+ return {
407
+ name: b.show_name || b.name || b.entitlement_id || b.plan_id || '额度',
408
+ unit: b.unit_type === 'token' ? 'Token' : (b.unit_type || ''),
409
+ total: Number.isFinite(total) ? total : 0,
410
+ used: Number.isFinite(used) ? used : 0,
411
+ remaining: Number.isFinite(remaining) ? remaining : Math.max(0, (total || 0) - (used || 0)),
412
+ expiresAt: epochMs(b.expire_at || b.period_end || 0),
413
+ recurring: Boolean(b.period && b.period !== 'one_time'),
414
+ };
415
+ });
416
+ // balances 可能为空(例如活动权益还没到 effective_at):此时从生效套餐的权益里派生展示额度。
417
+ // effective_at 在未来的权益标记 pending,面板显示「xx 生效」而不是「剩余 xx」。
418
+ const now = Date.now();
419
+ const entitlementParts = plans.flatMap((p) =>
420
+ (Array.isArray(p.entitlements) ? p.entitlements : [])
421
+ .filter((e) => e.unit_type === 'token' && Number(e.grant_units) > 0)
422
+ .map((e) => {
423
+ const grant = Number(e.grant_units);
424
+ const startsAt = epochMs(e.effective_at || p.starts_at);
425
+ const pending = Boolean(startsAt && new Date(startsAt).getTime() > now);
426
+ return {
427
+ name: e.show_name || e.entitlement_id || '活动额度',
428
+ unit: 'Token',
429
+ total: grant,
430
+ used: 0,
431
+ remaining: pending ? 0 : grant,
432
+ pending,
433
+ startsAt,
434
+ expiresAt: epochMs(p.ends_at),
435
+ recurring: Boolean(e.period && e.period !== 'one_time'),
436
+ };
437
+ }),
438
+ );
439
+ const parts = balanceParts.length ? balanceParts : entitlementParts;
440
+ // 套餐有效期卡片上显示在「有效期至」列之外,这里记录最近的套餐结束时间
441
+ const planEndsAt = plans.map((p) => epochMs(p.ends_at)).filter(Boolean).sort()[0] || null;
442
+ const sum = (k) => parts.reduce((s, p) => s + (p[k] || 0), 0);
443
+ return {
444
+ total: sum('total'), used: sum('used'), remaining: sum('remaining'),
445
+ unit: parts[0]?.unit || 'Token',
446
+ plan: plans[0]?.name || plans[0]?.plan_id || null,
447
+ planEndsAt,
448
+ parts,
449
+ empty: parts.length === 0 && plans.length === 0,
450
+ exceeded: false,
451
+ };
452
+ }
453
+
454
+ const PLAN_PERIOD_LABEL = { daily: '每日', weekly: '每周', monthly: '每月' };
455
+ const fmtGrantUnits = (n) => (n >= 1e8 ? `${+(n / 1e8).toFixed(1)}亿` : n >= 1e4 ? `${+(n / 1e4).toFixed(1)}万` : String(Math.round(n)));
456
+
457
+ /** billing/preview → 可领取活动列表(parse_plan 的移植) */
458
+ export function normalizePlans(previewResp) {
459
+ const plans = Array.isArray(previewResp?.data?.plans) ? previewResp.data.plans : [];
460
+ const out = [];
461
+ for (const p of plans) {
462
+ const planId = String(p.plan_id || p.planId || '').trim();
463
+ if (!planId) continue;
464
+ const grantItems = (Array.isArray(p.entitlements) ? p.entitlements : [])
465
+ .filter((e) => e.meter === 'model_usage' && e.unit_type === 'token' && String(e.show_name || '').trim())
466
+ .map((e) => ({
467
+ name: String(e.show_name).trim(),
468
+ units: Number(e.grant_units || e.grantUnits || 0),
469
+ period: String(e.period || 'one_time'),
470
+ }));
471
+ out.push({
472
+ planId,
473
+ name: String(p.name || '').trim(),
474
+ description: String(p.description || '').trim(),
475
+ priority: Number(p.priority || 0),
476
+ grants: grantItems.map((g) => `${g.name} · ${fmtGrantUnits(g.units)} Token(${PLAN_PERIOD_LABEL[g.period] || '一次性'})`),
477
+ });
478
+ }
479
+ out.sort((a, b) => b.priority - a.priority || a.planId.localeCompare(b.planId));
480
+ return out;
481
+ }
482
+
483
+ /**
484
+ * 额度查询:先试 BigModel Coding Plan(quota/limit),再试 Z.ai / Start Plan(billing/balance)。
485
+ * 都没有有效套餐时返回 { empty: true }(不是错误:很多账号只用免费额度)。
486
+ */
487
+ export async function fetchZcodeQuota(account) {
488
+ const tokens = candidateTokens(account);
489
+ if (!tokens.length) throw new Error('账号快照里没有可用 token,请在 ZCode 登录后重新导入');
490
+ let lastErr = null;
491
+ let authFails = 0;
492
+ for (const t of tokens) {
493
+ try {
494
+ const limit = await getJson(QUOTA_LIMIT_URL, await bigmodelHeaders(t));
495
+ if (businessOk(limit)) {
496
+ const sub = await getJson(SUBSCRIPTION_URL, await bigmodelHeaders(t)).catch(() => null);
497
+ return { ...normalizeQuotaLimit(limit, sub), source: 'bigmodel' };
498
+ }
499
+ if (limit?.code === 401) authFails++;
500
+ else lastErr = limit?.msg || lastErr;
501
+ } catch (e) { lastErr = e.message; }
502
+ }
503
+ for (const t of billingTokens(account)) {
504
+ try {
505
+ const bal = await getJson(`${BILLING_BALANCE_URL}?app_version=${await zcodeAppVersion()}`, await zaiHeaders(t, ensureVirtualDeviceMid(account)));
506
+ if (businessOk(bal)) return { ...normalizeBalance(bal), source: 'zcode.z.ai' };
507
+ if (bal?.code === 401) authFails++;
508
+ } catch (e) { lastErr = e.message; }
509
+ }
510
+ if (authFails && authFails >= tokens.length) throw new Error('鉴权失败,登录可能已过期,请在 ZCode 重新登录后重新导入');
511
+ // “当前用户不存在coding plan”之类属于无套餐,按空额度返回
512
+ if (!lastErr || /不存在|no.*plan/i.test(lastErr)) {
513
+ return { total: 0, used: 0, remaining: 0, unit: '', plan: null, parts: [], empty: true, exceeded: false };
514
+ }
515
+ throw new Error(`额度查询失败:${lastErr}`);
516
+ }
517
+
518
+ // ── 活动领取 ──
519
+
520
+ /** 可领取的活动列表。账户当前没有可领活动时返回 [](不是错误)。 */
521
+ export async function fetchClaimPlans(account) {
522
+ const token = claimToken(account);
523
+ const v = await getJson(
524
+ `${BILLING_PREVIEW_URL}?app_version=${await zcodeAppVersion()}&platform=${platform()}`,
525
+ await zaiHeaders(token, ensureVirtualDeviceMid(account)),
526
+ );
527
+ if (v?.code !== 0) throw new Error(v?.msg || v?.message || `查询活动列表失败(code ${v?.code})`);
528
+ return { plans: normalizePlans(v), serverTime: v?.data?.server_time ? new Date(v.data.server_time * 1000).toISOString() : null };
529
+ }
530
+
531
+ /** 验证码配置(无需登录)。当前服务端未下发时返回 { enabled: false }。 */
532
+ export async function fetchCaptchaConfig() {
533
+ // 公开端点:不带任何鉴权头(空 token 会生成畸形的 "Authorization: Bearer ",被上游判为 3001)
534
+ const v = await getJson(CLIENT_CONFIGS_URL);
535
+ if (v?.code !== 0) throw new Error('获取验证码配置失败');
536
+ const c = v?.data?.configs?.captcha || {};
537
+ return {
538
+ enabled: c.enabled === true,
539
+ region: typeof c.region === 'string' ? c.region : '',
540
+ prefix: typeof c.prefix === 'string' ? c.prefix : '',
541
+ sceneId: typeof c.sceneId === 'string' ? c.sceneId : '',
542
+ };
543
+ }
544
+
545
+ /**
546
+ * 领取活动套餐。captchaParam 为空时不带验证码头(服务端风控可能拒绝,错误信息原样返回)。
547
+ * 返回 { planName, startsAt, endsAt, serverTime };失败抛出带 code / nextAt 的 Error。
548
+ */
549
+ export async function claimPlan(account, planId, { captchaParam = '', region = '' } = {}) {
550
+ const token = claimToken(account);
551
+ const headers = await zaiHeaders(token, ensureVirtualDeviceMid(account));
552
+ if (captchaParam && captchaParam.trim()) headers['X-Aliyun-Captcha-Verify-Param'] = captchaParam.trim();
553
+ if (region && region.trim()) headers['X-Aliyun-Captcha-Verify-Region'] = region.trim();
554
+ const res = await fetchJsonRace(BILLING_CLAIM_URL, {
555
+ method: 'POST',
556
+ headers: { ...headers, 'Content-Type': 'application/json' },
557
+ body: { plan_id: planId },
558
+ timeoutMs: 25_000,
559
+ });
560
+ const text = await res.text();
561
+ let v;
562
+ try { v = JSON.parse(text); } catch { v = { code: res.status, msg: text.slice(0, 200) }; }
563
+ const code = Number(v?.code ?? -1);
564
+ if (code !== 0) {
565
+ const serverMsg = v?.msg || v?.message || '';
566
+ const base = CLAIM_FAIL[code] || '领取失败';
567
+ const err = new Error(serverMsg ? `${base}(${serverMsg})` : base);
568
+ err.code = code;
569
+ // 1005(名额用完)带上套餐结束时间,方便提示下次什么时候再试
570
+ if (code === 1005 && v?.data?.plan?.ends_at) err.nextAt = new Date(v.data.plan.ends_at * 1000).toISOString();
571
+ throw err;
572
+ }
573
+ const plan = v?.data?.plan || {};
574
+ return {
575
+ planName: plan.name || planId,
576
+ startsAt: plan.starts_at ? new Date(plan.starts_at * 1000).toISOString() : null,
577
+ endsAt: plan.ends_at ? new Date(plan.ends_at * 1000).toISOString() : null,
578
+ serverTime: v?.data?.server_time ? new Date(v.data.server_time * 1000).toISOString() : null,
579
+ };
580
+ }