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/README.md +24 -2
- package/bin/creditdaddy.js +131 -2
- package/package.json +29 -29
- package/src/bgdaemon.js +332 -0
- package/src/checkin.js +3 -2
- package/src/daemon.js +65 -3
- package/src/logger.js +57 -12
- package/src/minimaxClient.js +10 -1
- package/src/minimaxGateway.js +14 -8
- package/src/panel.html +252 -14
- package/src/store.js +16 -1
- package/src/tenrouterAccounts.js +31 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +679 -0
- package/src/zcodeClient.js +69 -11
- package/src/zcodeGateway.js +25 -13
- package/src/zcodePlanShape.json +3 -3
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
|
|
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 (
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* 测试收尾 / 进程退出用:关闭当前归档流,落盘后 gzip 归档并清理过期文件。
|
|
145
|
+
*
|
|
146
|
+
* 返回 Promise,等流真正 close(gzip 已完成)才 resolve —— 优雅退出路径紧接着就要
|
|
147
|
+
* process.exit(),不等的话回调根本没机会跑,今天的日志既不落盘也不归档。
|
|
148
|
+
* 调用方不 await(测试里就是)也完全兼容,只是没人等它。
|
|
149
|
+
*/
|
|
111
150
|
export function closeArchiveStream() {
|
|
112
|
-
if (archiveStream)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
}
|
package/src/minimaxClient.js
CHANGED
|
@@ -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
|
-
|
|
261
|
+
// expires_at 是毫秒时间戳:再乘 1000 会显示成 58729 年(实测免费版返回 0,按无到期处理)
|
|
262
|
+
planExpiresAt: isoMs(data.expires_at),
|
|
254
263
|
parts,
|
|
255
264
|
empty: total <= 0,
|
|
256
265
|
};
|
package/src/minimaxGateway.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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 账号均调用失败',
|