creditdaddy 1.3.0 → 1.3.2

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/src/daemon.js CHANGED
@@ -59,6 +59,7 @@ import { liveToAccount as catpawLiveAccount, switchTo as catpawSwitchTo, current
59
59
  import { liveToAccount as traeLiveAccount, detectTrae, switchTo as traeSwitchTo, snapshotLive as traeSnapshotLive } from './traeLocal.js';
60
60
  import { liveToAccount as minimaxLiveAccount, detectMiniMax, currentMiniMaxUid, currentMiniMaxToken, currentMiniMaxRecordKey } from './minimaxLocal.js';
61
61
  import * as minimaxGateway from './minimaxGateway.js';
62
+ import * as traeGateway from './traeGateway.js';
62
63
  import { fetchClaimPlans, claimPlan, fetchCaptchaConfig, proxyFirst, setProxyFirst, proxyUrl, setProxyUrl, autoClaimEnabled, autoClaimUntil, setAutoClaimEnabled, claimIntervalMin, setClaimIntervalMin, claimWindowMin, setClaimWindowMin } from './zcodeClient.js';
63
64
  import { exportAccounts, parseImport, TransferError } from './transfer.js';
64
65
  import { syncAccountsTo10r } from './tenrouterAccounts.js';
@@ -404,10 +405,33 @@ async function handleApi(req, res, url) {
404
405
  await (await import('./store.js')).saveSettings(patch);
405
406
  const lanNow = patch.minimaxGatewayLan ?? (await (await import('./store.js')).loadSettings()).minimaxGatewayLan === true;
406
407
  const allowText = patch.minimaxGatewayAllow ? patch.minimaxGatewayAllow.join(', ') : '';
407
- logger.info('DAEMON', `MiniMax 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}`);
408
+ logger.info('DAEMON', `MiniMax 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}(绑定改动重启后生效)`);
408
409
  }
409
410
  return json(res, 200, await minimaxGateway.gatewayStatus());
410
411
  }
412
+ // Trae 本地网关控制
413
+ if (p === '/api/trae-gateway' && method === 'GET') {
414
+ return json(res, 200, await traeGateway.gatewayStatus());
415
+ }
416
+ if (p === '/api/trae-gateway' && method === 'PUT') {
417
+ const body = await readBody(req).catch(() => ({}));
418
+ if (body?.enabled !== undefined) await traeGateway.setGatewayEnabled(body.enabled === true);
419
+ if (body?.lan !== undefined || body?.allow !== undefined) {
420
+ const patch = {};
421
+ if (body?.lan !== undefined) patch.traeGatewayLan = body.lan === true;
422
+ if (body?.allow !== undefined) {
423
+ if (!Array.isArray(body.allow)) return json(res, 400, { error: 'allow 需为字符串数组' });
424
+ const cleaned = [...new Set(body.allow.map((x) => String(x || '').trim()).filter(Boolean))];
425
+ if (cleaned.some((x) => /[^\w.*:-]/.test(x))) return json(res, 400, { error: '白名单条目只能是 IP / 主机名(支持 192.168.31.* 通配)' });
426
+ patch.traeGatewayAllow = cleaned;
427
+ }
428
+ await (await import('./store.js')).saveSettings(patch);
429
+ const lanNow = patch.traeGatewayLan ?? (await (await import('./store.js')).loadSettings()).traeGatewayLan === true;
430
+ const allowText = patch.traeGatewayAllow ? patch.traeGatewayAllow.join(', ') : '';
431
+ logger.info('DAEMON', `Trae 网关局域网:${lanNow ? '开' : '关(仅本机)'}${allowText ? `,白名单:${allowText}` : ''}(绑定改动重启后生效)`);
432
+ }
433
+ return json(res, 200, await traeGateway.gatewayStatus());
434
+ }
411
435
  if (p === '/api/zcode/net' && method === 'GET') {
412
436
  const u = proxyUrl();
413
437
  const masked = u ? (() => { try { const x = new URL(u); x.password = x.password ? '*'.repeat(4) : ''; return x.toString(); } catch { return '***'; } })() : null;
@@ -913,9 +937,37 @@ async function handleApi(req, res, url) {
913
937
  return json(res, 200, { added: r.added, updated: r.updated, skipped, source: parsed.source });
914
938
  }
915
939
 
940
+ // 重启服务(桌面客户端或 CLI)
941
+ if (p === '/api/system/restart' && method === 'POST') {
942
+ json(res, 200, { ok: true, message: '正在重启服务…' });
943
+ setTimeout(async () => {
944
+ try {
945
+ if (restartHandler) {
946
+ await restartHandler();
947
+ return;
948
+ }
949
+ // Node CLI 环境兜底重启:派生自身并退出当前进程
950
+ const { spawn } = await import('node:child_process');
951
+ spawn(process.execPath, process.argv.slice(1), {
952
+ detached: true,
953
+ stdio: 'inherit',
954
+ env: process.env,
955
+ }).unref();
956
+ process.exit(0);
957
+ } catch (err) {
958
+ logger.error('DAEMON', `自动重启失败: ${err?.message || err}`);
959
+ }
960
+ }, 400);
961
+ return;
962
+ }
963
+
916
964
  return json(res, 404, { error: '未知接口' });
917
965
  }
918
966
 
967
+ let restartHandler = null;
968
+ /** 设置外部重启处理器(桌面版 Electron 注入 app.relaunch / app.exit) */
969
+ export function setRestartHandler(fn) { restartHandler = typeof fn === 'function' ? fn : null; }
970
+
919
971
  /** 当前生效的面板访问密码(同进程宿主——桌面壳——调用本机 API 时带上 x-qd-key 头) */
920
972
  export function getPanelKey() { return PANEL_KEY; }
921
973
 
@@ -933,15 +985,25 @@ export async function startDaemon(port = DEFAULT_PORT, host = '127.0.0.1') {
933
985
  return json(res, 403, { error: denied });
934
986
  }
935
987
  // ZCode 免费额度网关(数据面,先于面板路由;仅本机回路,见 zcodeGateway.js)。
936
- // /v1/messages 别名与 zcode-api 端点路径同形——10router 的 zcode-free 供应商
988
+ // 规范路径带品牌段 /gateway/zcode/v1/messages,与 minimax/trae 网关命名一致,也是
989
+ // gatewayStatus() 展示的 endpoint。/gateway/v1/messages 与 /v1/messages 为旧别名,
990
+ // 保留向后兼容:/v1/messages 与 zcode-api 端点同形,10router 的 zcode-free 供应商
937
991
  // 只需换 host:port 即可在 CreditDaddy 网关与 zcode-api 之间切换。
938
- if (url.pathname === '/gateway/v1/messages' || url.pathname === '/v1/messages') {
992
+ if (
993
+ url.pathname === '/gateway/zcode/v1/messages'
994
+ || url.pathname === '/gateway/v1/messages'
995
+ || url.pathname === '/v1/messages'
996
+ ) {
939
997
  return await zcodeGateway.handleGateway(req, res);
940
998
  }
941
999
  // MiniMax Code 本地 Anthropic 兼容网关
942
1000
  if (url.pathname === '/gateway/minimax/v1/messages') {
943
1001
  return await minimaxGateway.handleGateway(req, res);
944
1002
  }
1003
+ // Trae SOLO 本地 Anthropic 兼容网关
1004
+ if (url.pathname === '/gateway/trae/v1/messages') {
1005
+ return await traeGateway.handleGateway(req, res);
1006
+ }
945
1007
  if (url.pathname.startsWith('/api/')) {
946
1008
  return await handleApi(req, res, url);
947
1009
  }
package/src/logger.js CHANGED
@@ -51,14 +51,21 @@ function ts() {
51
51
  + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
52
52
  }
53
53
 
54
- /** 把一份已关闭的日志 gzip 成 .log.gz(此时流已 flush、fd 已释放,可安全同步读),并按保留期清理过期归档 */
54
+ /**
55
+ * 把一份已关闭的日志 gzip 成 .log.gz(此时流已 flush、fd 已释放,可安全同步读),并按保留期清理过期归档。
56
+ *
57
+ * ⚠️ 压缩后**截断**原文件而不是删除它:同一天的文件可能被另一个实例(前台 daemon /
58
+ * 桌面版)以 O_APPEND 持有同一个 fd。删掉原文件后,POSIX 上它的后续写入会落进一个
59
+ * 已无链接的 inode —— 别的实例还在正常跑,日志却静默消失。截断成 0 字节后 O_APPEND
60
+ * 会从 0 重新追加,同一个 inode、同一批数据,谁都不会丢。
61
+ */
55
62
  function gzipAndCleanup(file) {
56
63
  try {
57
64
  if (fsSync.existsSync(file)) {
58
65
  const raw = fsSync.readFileSync(file);
59
66
  if (raw.length) {
60
67
  fsSync.writeFileSync(file + '.gz', zlib.gzipSync(raw));
61
- fsSync.unlinkSync(file);
68
+ fsSync.truncateSync(file, 0);
62
69
  }
63
70
  }
64
71
  } catch {}
@@ -102,20 +109,58 @@ export const logger = {
102
109
  debug: (tag, msg) => log('debug', tag, msg),
103
110
  };
104
111
 
112
+ /**
113
+ * 把各账号的失败结论压缩成「失败类型×次数」摘要,写进“全部失败”日志——
114
+ * 网络抖动 / 验证码 / 额度 / 限流 / 拉黑 一眼可辨,不用再靠“有没有伴随 3012”去反推。
115
+ * 三处网关共用同一口径;未知错误归入「上游错误」。
116
+ */
117
+ export function summarizeAttempts(attempted) {
118
+ if (!Array.isArray(attempted) || !attempted.length) return '';
119
+ const buckets = new Map();
120
+ for (const a of attempted) {
121
+ const err = String(a && a.error ? a.error : '未知错误');
122
+ let key;
123
+ if (a && a.captcha) key = '验证码被拒';
124
+ else if (/验证码/.test(err)) key = '验证码被拒';
125
+ else if (/拉黑|凭据失效|JWT|Token 彻底失效|401/.test(err)) key = '账号拉黑';
126
+ else if (/额度耗尽|额度不足|已打标/.test(err)) key = '额度耗尽';
127
+ else if (/额度/.test(err)) key = '额度瞬时拒绝';
128
+ else if (/429|限流/.test(err)) key = '429 限流';
129
+ else if (/超时|timeout|timed out|连接|ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|terminated|aborted|fetch failed|网络|建立事件流失败/.test(err)) key = '网络错误';
130
+ else key = '上游错误';
131
+ buckets.set(key, (buckets.get(key) || 0) + 1);
132
+ }
133
+ return [...buckets.entries()].map(([k, n]) => `${k}×${n}`).join('、');
134
+ }
135
+
105
136
  export function getLogs(limit = 100, tag = null) {
106
- const lines = tag ? ring.filter((l) => l.tag === tag) : ring;
137
+ // tag=null 是面板「运行日志」:网关调用日志(*-GW)刷屏快、口径不同,
138
+ // 只在自己的标签页里展示,不混进运行日志;显式传 tag 的查询不受影响。
139
+ const lines = tag ? ring.filter((l) => l.tag === tag) : ring.filter((l) => !(l.tag || '').endsWith('-GW'));
107
140
  return lines.slice(-limit);
108
141
  }
109
142
 
110
- /** 测试收尾 / 进程退出用:关闭当前归档流,落盘后 gzip 归档并清理过期文件 */
143
+ /**
144
+ * 测试收尾 / 进程退出用:关闭当前归档流,落盘后 gzip 归档并清理过期文件。
145
+ *
146
+ * 返回 Promise,等流真正 close(gzip 已完成)才 resolve —— 优雅退出路径紧接着就要
147
+ * process.exit(),不等的话回调根本没机会跑,今天的日志既不落盘也不归档。
148
+ * 调用方不 await(测试里就是)也完全兼容,只是没人等它。
149
+ */
111
150
  export function closeArchiveStream() {
112
- if (archiveStream) {
113
- const prev = archiveStream;
114
- const prevFile = path.join(archiveDir, `daemon-${archiveDay}.log`);
115
- archiveStream = null;
116
- prev.on('error', () => {});
117
- prev.on('close', () => gzipAndCleanup(prevFile));
118
- prev.end();
119
- }
151
+ if (!archiveStream) return Promise.resolve();
152
+ const prev = archiveStream;
153
+ const prevFile = path.join(archiveDir, `daemon-${archiveDay}.log`);
154
+ archiveStream = null;
120
155
  archiveDay = null;
156
+ return new Promise((resolve) => {
157
+ let settled = false;
158
+ const done = () => { if (!settled) { settled = true; resolve(); } };
159
+ prev.on('error', done);
160
+ prev.on('close', () => { gzipAndCleanup(prevFile); done(); });
161
+ // 兜底:万一 close 事件不来(fd 被外部占住),不让退出流程永远挂在这里
162
+ const timer = setTimeout(done, 2000);
163
+ timer.unref?.();
164
+ prev.end(done);
165
+ });
121
166
  }
@@ -25,6 +25,14 @@ export const SIGNIN_STATUS_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/sig
25
25
  export const SIGNIN_CLAIM_URL = `${MINIMAX_AGENT_BASE}/minimax-cloud/api/v1/signin/claim?timezone_offset=28800&is_desktop=1&client=desktop`;
26
26
  export const TOKEN_REFRESH_URL = `${MINIMAX_ACCOUNT_BASE}/oauth2/token`;
27
27
 
28
+ /** 时间戳宽容转换(口径同 traeClient.iso):≤1e11 视为秒,否则视为毫秒;0/无效 → null */
29
+ function isoMs(v) {
30
+ const n = Number(v);
31
+ if (!Number.isFinite(n) || n <= 0) return null;
32
+ const d = new Date(n < 1e11 ? n * 1000 : n);
33
+ return Number.isFinite(d.getTime()) && d.getUTCFullYear() < 9000 ? d.toISOString() : null;
34
+ }
35
+
28
36
  function authHeaders(token) {
29
37
  const t = token?.startsWith('Bearer ') ? token : `Bearer ${String(token || '').trim()}`;
30
38
  return {
@@ -250,7 +258,8 @@ export function normalizeMiniMaxQuota(data) {
250
258
  // 顶层不设 unit:面板 creditSum 用「有 unit 即异单位」来排除合计,
251
259
  // MiniMax 与 Qoder / Trae 同为「积分」口径,必须计入剩余积分合计。
252
260
  plan: data.plan_name || (data.is_pro_builder ? 'Pro' : '免费版'),
253
- planExpiresAt: data.expires_at ? new Date(data.expires_at * 1000).toISOString() : null,
261
+ // expires_at 是毫秒时间戳:再乘 1000 会显示成 58729 年(实测免费版返回 0,按无到期处理)
262
+ planExpiresAt: isoMs(data.expires_at),
254
263
  parts,
255
264
  empty: total <= 0,
256
265
  };
@@ -17,11 +17,12 @@ import { loadAccounts, loadSettings, saveSettings } from './store.js';
17
17
  import { refreshMiniMaxToken, fetchMiniMaxQuota, MINIMAX_MESSAGES_URL } from './minimaxClient.js';
18
18
  import { refreshContext } from './accounts.js';
19
19
  import { gatewayHostSuggestion } from './tenrouter.js';
20
- import { logger } from './logger.js';
20
+ import { logger, summarizeAttempts } from './logger.js';
21
21
 
22
22
  const ACCOUNT_COOLING_MS = 5 * 60_000;
23
23
  const MAX_ATTEMPTS = 5;
24
24
  const UPSTREAM_TIMEOUT_MS = 600_000;
25
+ const UPSTREAM_CONNECT_MS = 15_000;
25
26
 
26
27
  // ── 开关(持久化在 settings.minimaxGateway) ──
27
28
 
@@ -212,7 +213,8 @@ export async function handleGateway(req, res) {
212
213
  res.writeHead(405, { 'Content-Type': 'application/json' });
213
214
  return res.end(JSON.stringify({ error: 'POST /gateway/minimax/v1/messages(Anthropic /v1/messages 形态)' }));
214
215
  }
215
- if (!(await gatewayEnabled())) {
216
+ const settings = await loadSettings();
217
+ if (settings.minimaxGateway !== true) {
216
218
  res.writeHead(503, { 'Content-Type': 'application/json' });
217
219
  return res.end(JSON.stringify({ error: 'MiniMax 本地网关未开启(面板 → MiniMax → 接口设置)' }));
218
220
  }
@@ -220,7 +222,6 @@ export async function handleGateway(req, res) {
220
222
  // 局域网访问控制
221
223
  const remote = (req.socket && req.socket.remoteAddress) || '';
222
224
  if (!isLoopbackRemote(remote)) {
223
- const settings = await loadSettings();
224
225
  if (settings.minimaxGatewayLan !== true) {
225
226
  res.writeHead(403, { 'Content-Type': 'application/json' });
226
227
  return res.end(JSON.stringify({ error: 'MiniMax 网关未允许局域网访问(面板 → MiniMax → 接口设置)' }));
@@ -251,14 +252,15 @@ export async function handleGateway(req, res) {
251
252
  stats.calls += 1;
252
253
 
253
254
  const attempted = [];
255
+ const clientAbort = new AbortController();
256
+ req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
257
+
254
258
  for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length); i++) {
255
259
  const account = queue[i];
256
260
  if (isDead(account) || (cooling.get(account.id) || 0) > Date.now()) continue;
257
261
  const label = account.name || account.uid || account.id;
258
262
 
259
263
  let upstream;
260
- const controller = new AbortController();
261
- req.on('close', () => controller.abort());
262
264
 
263
265
  let activeToken = account.token;
264
266
  const sendUpstream = async (tok) => {
@@ -273,7 +275,8 @@ export async function handleGateway(req, res) {
273
275
  headers,
274
276
  body: rawBody,
275
277
  timeoutMs: UPSTREAM_TIMEOUT_MS,
276
- signal: controller.signal,
278
+ connectMs: UPSTREAM_CONNECT_MS,
279
+ signal: clientAbort.signal,
277
280
  });
278
281
  };
279
282
 
@@ -314,7 +317,7 @@ export async function handleGateway(req, res) {
314
317
  }
315
318
  }
316
319
  } catch (e) {
317
- if (controller.signal.aborted) return;
320
+ if (clientAbort.signal.aborted) return;
318
321
  attempted.push({ account: label, ok: false, error: e.message });
319
322
  continue;
320
323
  }
@@ -343,7 +346,8 @@ export async function handleGateway(req, res) {
343
346
  }
344
347
  }
345
348
  } catch (e) {
346
- logger.warn('MINIMAX-GW', `流式转发中断:${e.message}`);
349
+ // 客户端主动断开(点停止/换一句)属正常收尾,不是故障
350
+ logger.debug('MINIMAX-GW', `流式转发中断:${e.message}`);
347
351
  }
348
352
  res.end();
349
353
  } else {
@@ -372,6 +376,8 @@ export async function handleGateway(req, res) {
372
376
  }
373
377
 
374
378
  // 全部重试失败
379
+ const failedDetail = summarizeAttempts(attempted);
380
+ logger.warn('MINIMAX-GW', `全部 ${attempted.length} 次尝试失败${failedDetail ? ':' + failedDetail : ''}(下一请求自动重试)`);
375
381
  res.writeHead(502, { 'Content-Type': 'application/json' });
376
382
  res.end(JSON.stringify({
377
383
  error: '所有 MiniMax 账号均调用失败',