creditdaddy 1.6.0 → 1.6.3

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.
@@ -7,7 +7,7 @@
7
7
  * 架构特性:
8
8
  * 1. 账号多路轮换(provider=trae):自动在有效账号间按剩余积分加权轮询(SWRR)
9
9
  * 2. 凭据热对齐:401 时若为本机导入账号,尝试重读 storage.json 最新登录态
10
- * 3. 429 限流保护:429 限流自动冷却 5 分钟
10
+ * 3. 429 限流保护:429 限流自动冷却(退避阶梯 30s → 1min → 2min → 5min → 10min 封顶,成功一次即清零)
11
11
  * 4. 局域网访问控制:继承相同的白名单与回环检查机制
12
12
  * 5. SSE 增量转换:将 Trae SOLO 累积流转换为标准 Anthropic 流式 SSE 或一次性 JSON 响应
13
13
  */
@@ -18,9 +18,12 @@ import { fetchTraeQuota } from './traeClient.js';
18
18
  import { gatewayHostSuggestion } from './tenrouter.js';
19
19
  import { logger, summarizeAttempts } from './logger.js';
20
20
  import { t } from './i18n.js';
21
+ import { formatDurationMs } from './formatDuration.js';
21
22
 
22
23
  const TRAE_SOLO_BASE = 'https://solo.trae.cn/api/remote/v1';
23
24
  const ACCOUNT_COOLING_MS = 5 * 60_000;
25
+ // 429 退避阶梯:30s → 1min → 2min → 5min → 10min 封顶(与 ZCode 网关同款)。
26
+ const RATE_LIMIT_BACKOFF_MS = [30_000, 60_000, 120_000, 300_000, 600_000];
24
27
  const MAX_ATTEMPTS = 5;
25
28
  const UPSTREAM_TIMEOUT_MS = 600_000;
26
29
  const UPSTREAM_CONNECT_MS = 15_000;
@@ -38,12 +41,15 @@ export async function setGatewayEnabled(v) {
38
41
  if (on) {
39
42
  dead.clear();
40
43
  cooling.clear();
44
+ coolingStreak.clear();
41
45
  }
42
46
  }
43
47
 
44
48
  // ── 账号轮换状态(进程内) ──
45
49
 
46
50
  const cooling = new Map(); // accountId → 冷却截止(ms)
51
+ // accountId → 连续 429 次数(成功即清零):决定本次 429 走到退避阶梯的哪一档
52
+ const coolingStreak = new Map();
47
53
  const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/导入,自动复活)
48
54
  const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }
49
55
  const WEIGHT_TTL_MS = 10 * 60_000; // 超过 10 分钟重查额度,让积分变化反映到分流上
@@ -64,6 +70,16 @@ function markCooling(id, ms = ACCOUNT_COOLING_MS) {
64
70
  cooling.set(id, Date.now() + ms);
65
71
  }
66
72
 
73
+ function markRateLimited(id) {
74
+ const streak = (coolingStreak.get(id) || 0) + 1;
75
+ coolingStreak.set(id, streak);
76
+ const ms = RATE_LIMIT_BACKOFF_MS[Math.min(streak, RATE_LIMIT_BACKOFF_MS.length) - 1];
77
+ cooling.set(id, Date.now() + ms);
78
+ return ms;
79
+ }
80
+
81
+ function resetCoolingStreak(id) { coolingStreak.delete(id); }
82
+
67
83
  function markDead(account) {
68
84
  dead.set(account.id, credFingerprint(account));
69
85
  cooling.set(account.id, Number.MAX_SAFE_INTEGER);
@@ -75,12 +91,20 @@ function isDead(account) {
75
91
  if (fp === credFingerprint(account)) return true;
76
92
  dead.delete(account.id);
77
93
  cooling.delete(account.id);
94
+ coolingStreak.delete(account.id);
78
95
  logger.info('TRAE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
79
96
  return false;
80
97
  }
81
98
 
99
+ /**
100
+ * 测试专用:把某账号的冷却截止拨到指定时间戳(0 = 立即到期,用于快进冷却窗口)。
101
+ * 生产不走这里——冷却窗口只能自然到期。与 zcodeGateway.__setCoolingForTests 同款。
102
+ */
103
+ export function __setCoolingForTests(accountId, untilMs) { cooling.set(accountId, untilMs); }
104
+
82
105
  export function __resetForTests() {
83
106
  cooling.clear();
107
+ coolingStreak.clear();
84
108
  dead.clear();
85
109
  weights.clear();
86
110
  swrr.clear();
@@ -397,9 +421,11 @@ export async function handleGateway(req, res) {
397
421
  }
398
422
 
399
423
  if (sessionRes.status === 429) {
400
- markCooling(account.id, ACCOUNT_COOLING_MS);
401
- attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
402
- logger.info('TRAE-GW', '{label} 429 限流,冷却 5 分钟', { label });
424
+ const ms = markRateLimited(account.id);
425
+ const dur = formatDurationMs(ms);
426
+ const streak = coolingStreak.get(account.id);
427
+ attempted.push({ account: label, ok: false, error: `429 限流,冷却 ${dur}` });
428
+ logger.info('TRAE-GW', '{label} 429 限流,冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
403
429
  continue;
404
430
  }
405
431
 
@@ -446,11 +472,22 @@ export async function handleGateway(req, res) {
446
472
 
447
473
  if (!eventsRes.ok || !eventsRes.body) {
448
474
  cleanupSession();
475
+ // 事件流被 429(会话建好了、拉流被限流)同样按阶梯冷却——不判的话这个号
476
+ // 下一轮会话创建仍从 30 秒档起算,继续撞限流
477
+ if (eventsRes.status === 429) {
478
+ const ms = markRateLimited(account.id);
479
+ const dur = formatDurationMs(ms);
480
+ const streak = coolingStreak.get(account.id);
481
+ attempted.push({ account: label, ok: false, error: `429 限流(事件流),冷却 ${dur}` });
482
+ logger.info('TRAE-GW', '{label} 429 限流(事件流),冷却 {dur}(连续第 {streak} 次)', { label, dur, streak });
483
+ continue;
484
+ }
449
485
  attempted.push({ account: label, ok: false, error: `事件流 HTTP ${eventsRes.status}` });
450
486
  continue;
451
487
  }
452
488
 
453
489
  stats.lastAccount = label;
490
+ resetCoolingStreak(account.id); // 成功一次即清零连续 429 计数
454
491
  logger.debug('TRAE-GW', '{label} 连接成功,开始流式输出 ({model})', { label, model: requestedModel });
455
492
 
456
493
  // 成功建立流,按 isStream 分发
@@ -691,7 +728,18 @@ export async function gatewayStatus() {
691
728
  lastAccount: stats.lastAccount,
692
729
  },
693
730
  accounts: all.length,
731
+ // cooling 保持字符串数组(历史形状);冷却细节走 coolingDetail(与 zcodeGateway 同款)
694
732
  cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
733
+ coolingDetail: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now)
734
+ .map((a) => {
735
+ const remainMs = (cooling.get(a.id) || 0) - now;
736
+ return {
737
+ id: a.id, name: a.name || a.uid || a.id,
738
+ remainMs, remainSeconds: Math.max(0, Math.ceil(remainMs / 1000)),
739
+ remainText: formatDurationMs(remainMs),
740
+ streak: coolingStreak.get(a.id) || 0,
741
+ };
742
+ }),
695
743
  dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
696
744
  endpoint: '/gateway/trae/v1/messages',
697
745
  };
@@ -99,6 +99,46 @@ export function currentWorkbuddyUid() {
99
99
  return readWorkbuddySessions().accounts.find((a) => a.current)?.uid || null;
100
100
  }
101
101
 
102
+ // 客户端可执行文件候选(VS Code 系安装惯例:%LOCALAPPDATA%\Programs\<名>\<名>.exe)。
103
+ // 「已安装」只认 exe:凭据目录不能当依据——CreditDaddy 自己切换时 mkdir 也会把目录建出来
104
+ // (无客户端时静默切换留下的空壳目录),拿目录判断会把没装客户端的机器误报成已安装。
105
+ const CLIENT_CANDIDATES = process.platform === 'win32'
106
+ ? [
107
+ ['Programs', 'CodeBuddy', 'CodeBuddy.exe'],
108
+ ['Programs', 'WorkBuddy', 'WorkBuddy.exe'],
109
+ ['CodeBuddy', 'CodeBuddy.exe'],
110
+ ['WorkBuddy', 'WorkBuddy.exe'],
111
+ ]
112
+ : process.platform === 'darwin'
113
+ ? ['/Applications/CodeBuddy.app', '/Applications/WorkBuddy.app']
114
+ : [];
115
+
116
+ /**
117
+ * 本机 WorkBuddy / CodeBuddy 客户端探测(面板「客户端:未安装」提示与切换拦截用)。
118
+ * Linux / NAS 没有已知安装路径,退而用「凭据目录里有会话文件」判定——
119
+ * 会话文件只有客户端登录后才写,纯 Token 导入的机器不会产生。
120
+ */
121
+ export function detectWorkbuddy() {
122
+ const dir = workbuddyAuthDir();
123
+ let dirExists = false;
124
+ try { dirExists = fs.statSync(dir).isDirectory(); } catch { /* 未安装 / 从未登录 */ }
125
+ let clientPath = null;
126
+ for (const cand of CLIENT_CANDIDATES) {
127
+ const p = process.platform === 'win32' && Array.isArray(cand)
128
+ ? path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), ...cand)
129
+ : cand;
130
+ try { if (fs.existsSync(p)) { clientPath = p; break; } } catch { /* 下一个候选 */ }
131
+ }
132
+ const sessions = dirExists ? readWorkbuddySessions() : { accounts: [] };
133
+ return {
134
+ dir,
135
+ dirExists,
136
+ clientPath,
137
+ installed: Boolean(clientPath) || (CLIENT_CANDIDATES.length === 0 && sessions.accounts.length > 0),
138
+ signedIn: sessions.accounts.some((a) => a.current),
139
+ };
140
+ }
141
+
102
142
  /**
103
143
  * 把账号写成 WorkBuddy 当前会话(切换账号)。需要账号带有导入时保存的会话信息。
104
144
  * @returns {{ file, previousUid }}
@@ -67,7 +67,10 @@ export async function zcodeAutoClaim(account) {
67
67
  // 第 2 步:先试不带验证码
68
68
  try {
69
69
  const r = await claimPlan(account, plan.planId, {});
70
- claims.push({ plan: planName, via: 'direct', ok: true });
70
+ // startsAt 是「到账时间」:活动权益常带 effective_at(如当天 23:00),
71
+ // 领到了但余额里还没有——不把这个时间带出去,面板只能显示冷冰冰的
72
+ // 「待生效」,用户以为没领上。
73
+ claims.push({ plan: planName, via: 'direct', ok: true, startsAt: r?.startsAt || null });
71
74
  anySuccess = true;
72
75
  logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(免验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
73
76
  continue;
@@ -97,8 +100,8 @@ export async function zcodeAutoClaim(account) {
97
100
  const cfg = await fetchCaptchaConfig();
98
101
  if (!cfg.enabled || !cfg.sceneId) throw new Error('验证码配置不可用');
99
102
  const { captchaParam, region } = await captchaProvider(cfg);
100
- await claimPlan(account, plan.planId, { captchaParam, region });
101
- claims.push({ plan: planName, via: 'captcha', ok: true });
103
+ const r2 = await claimPlan(account, plan.planId, { captchaParam, region });
104
+ claims.push({ plan: planName, via: 'captcha', ok: true, startsAt: r2?.startsAt || null });
102
105
  anySuccess = true;
103
106
  logger.info('ZCODE-CLAIM', '{label} 已领取「{plan}」(静默验证码){grants}', { label, plan: planName, grants: grantsText(plan) ? ':' + grantsText(plan) : '' });
104
107
  } catch (e2) {
@@ -128,8 +131,22 @@ export async function zcodeAutoClaim(account) {
128
131
  const status = names.length ? 'checked-in'
129
132
  : claims.every((c) => c.already) ? 'already'
130
133
  : 'failed';
134
+ // 到账时间:新领的活动若带 effective_at(当天稍晚才进余额),把时间写进消息——
135
+ // 否则面板只显示「待生效」,用户无法判断是没领上还是要等。
136
+ const fmtAt = (iso) => {
137
+ if (!iso) return '';
138
+ const d = new Date(iso);
139
+ if (Number.isNaN(d.getTime())) return '';
140
+ const p2 = (n) => String(n).padStart(2, '0');
141
+ const now = new Date();
142
+ const sameDay = d.toDateString() === now.toDateString();
143
+ const hm = `${p2(d.getHours())}:${p2(d.getMinutes())}`;
144
+ return sameDay ? hm : `${d.getMonth() + 1}-${p2(d.getDate())} ${hm}`;
145
+ };
146
+ const starts = claims.filter((c) => c.ok && c.startsAt && new Date(c.startsAt).getTime() > Date.now());
147
+ const startsText = starts.length ? `(${fmtAt(starts[0].startsAt)} 到账)` : '';
131
148
  const message = status === 'checked-in'
132
- ? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}`
149
+ ? `已领取:${names.join('、')}${claims.some((c) => c.via === 'captcha') ? '(静默验证码)' : ''}${startsText}`
133
150
  : status === 'already' ? '活动均已领取过'
134
151
  : needManual ? `有 ${claims.filter((c) => c.needManual).length} 个活动需要验证码,请在面板手动领取`
135
152
  : `领取失败:${claims.filter((c) => !c.ok).map((c) => c.message).filter(Boolean)[0] || '未知错误'}`;
@@ -502,6 +502,8 @@ export function normalizeBalance(balanceResp) {
502
502
  const pending = Boolean(startsAt && new Date(startsAt).getTime() > now);
503
503
  return {
504
504
  name: e.show_name || e.entitlement_id || '活动额度',
505
+ // 活动名(如「ZCode 1024 Dev Day」):未生效权益靠它告诉用户「这是哪个活动发的」
506
+ planName: String(p.name || p.plan_id || '') || null,
505
507
  unit: 'Token',
506
508
  total: grant,
507
509
  used: 0,
@@ -514,6 +516,10 @@ export function normalizeBalance(balanceResp) {
514
516
  }),
515
517
  );
516
518
  const parts = balanceParts.length ? balanceParts : entitlementParts;
519
+ // 未生效权益汇总:面板用它显示「待生效 N Token · 活动名 · HH:MM 到账」,
520
+ // 而不是把 0 当成「已用完」——活动权益通常要到生效点才进 balances
521
+ const pendingParts = parts.filter((p) => p.pending);
522
+ const pendingAt = pendingParts.map((p) => p.startsAt).filter(Boolean).sort()[0] || null;
517
523
  // 套餐有效期卡片上显示在「有效期至」列之外,这里记录最近的套餐结束时间
518
524
  const planEndsAt = plans.map((p) => epochMs(p.ends_at)).filter(Boolean).sort()[0] || null;
519
525
  const sum = (k) => parts.reduce((s, p) => s + (p[k] || 0), 0);
@@ -523,6 +529,8 @@ export function normalizeBalance(balanceResp) {
523
529
  plan: plans[0]?.name || plans[0]?.plan_id || null,
524
530
  planEndsAt,
525
531
  parts,
532
+ pendingTotal: pendingParts.reduce((s, p) => s + (p.total || 0), 0),
533
+ pendingAt,
526
534
  empty: parts.length === 0 && plans.length === 0,
527
535
  exceeded: false,
528
536
  };