creditdaddy 1.3.2 → 1.4.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.
- package/README.md +65 -13
- package/bin/creditdaddy.js +33 -12
- package/package.json +5 -2
- package/src/accounts.js +10 -0
- package/src/authDevice.js +5 -4
- package/src/bgdaemon.js +14 -5
- package/src/checkin.js +69 -39
- package/src/daemon.js +1266 -1059
- package/src/i18n/en.json +1490 -0
- package/src/i18n/ja.json +1490 -0
- package/src/i18n/ko.json +1490 -0
- package/src/i18n/zh-TW.json +1490 -0
- package/src/i18n.js +47 -0
- package/src/logger.js +114 -17
- package/src/minimaxClient.js +18 -21
- package/src/minimaxGateway.js +63 -45
- package/src/minimaxLocal.js +61 -16
- package/src/mirasimClient.js +6 -1
- package/src/panel.html +1139 -626
- package/src/qoderClient.js +24 -4
- package/src/qoderUmid.js +4 -3
- package/src/refreshGuard.js +69 -0
- package/src/store.js +11 -0
- package/src/tenrouter.js +14 -7
- package/src/traeGateway.js +43 -24
- package/src/transfer.js +44 -6
- package/src/workbuddyClient.js +14 -7
- package/src/zcodeAutoClaim.js +47 -7
- package/src/zcodeClient.js +8 -3
- package/src/zcodeGateway.js +22 -15
package/src/qoderClient.js
CHANGED
|
@@ -15,14 +15,31 @@ import {
|
|
|
15
15
|
buildQoderHeaders, buildExchangeHeaders, FETCH_TIMEOUT_MS, webBase,
|
|
16
16
|
} from './constants.js';
|
|
17
17
|
import { getRiskIdentity, clientVersion, machineOs, machineHostname, machineId } from './qoderApp.js';
|
|
18
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
18
19
|
import { logger } from './logger.js';
|
|
20
|
+
import { t } from './i18n.js';
|
|
19
21
|
|
|
20
22
|
export function apiBase(provider) {
|
|
21
23
|
return provider === 'qoder-cn' ? CN_OPENAPI_BASE : OPENAPI_BASE;
|
|
22
24
|
}
|
|
23
25
|
|
|
26
|
+
// 所有 openapi / web 请求统一走 fetchJsonRace(连接段限时 + 配置了出口代理时直连/代理双路兜底)——
|
|
27
|
+
// 此前裸 fetch:面板「网络出口 = 代理优先」对 Qoder 所有调用完全无效(审计 M2 漏网)。
|
|
28
|
+
// options.preferProxy:国际版(按 IP 识别地区)由调用方给 true。
|
|
24
29
|
async function req(url, options = {}) {
|
|
25
|
-
|
|
30
|
+
const { preferProxy = false, ...rest } = options;
|
|
31
|
+
return fetchJsonRace(url, { ...rest, timeoutMs: FETCH_TIMEOUT_MS, preferProxy });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 网页会话链路(探测归属 / 逐资源包用量)专用:国际版接口按 IP 识别地区,国内直连
|
|
36
|
+
* qoder.com 会被拦——登录窗有系统代理能登录成功,daemon 拿到 Cookie 后直连探测却失败,
|
|
37
|
+
* Cookie 被当无效丢弃,徽章「缺网页会话」常驻。配置了出口代理时国际版优先走代理
|
|
38
|
+
* (直连兜底),国内版保持直连不动。
|
|
39
|
+
*/
|
|
40
|
+
async function webReq(provider, url, options = {}) {
|
|
41
|
+
if (provider === 'qoder-cn') return req(url, options);
|
|
42
|
+
return fetchJsonRace(url, { ...options, timeoutMs: options.timeoutMs ?? FETCH_TIMEOUT_MS, preferProxy: true });
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
/** PAT (pt-...) 兑换短期 job token (jt-...)。普通 JSON POST,无需 COSY 签名。 */
|
|
@@ -32,6 +49,7 @@ export async function exchangePatToJobToken(pat, isCn = false) {
|
|
|
32
49
|
method: 'POST',
|
|
33
50
|
headers: buildExchangeHeaders(),
|
|
34
51
|
body: JSON.stringify({ personal_token: pat }),
|
|
52
|
+
preferProxy: !isCn,
|
|
35
53
|
});
|
|
36
54
|
if (!res.ok) {
|
|
37
55
|
const text = await res.text().catch(() => '');
|
|
@@ -63,6 +81,7 @@ export async function fetchUserinfo(account) {
|
|
|
63
81
|
if (!token) throw new Error('token 不可用');
|
|
64
82
|
const res = await req(`${apiBase(account.provider)}${USERINFO_PATH}`, {
|
|
65
83
|
headers: buildQoderHeaders(token),
|
|
84
|
+
preferProxy: account.provider === 'qoder',
|
|
66
85
|
});
|
|
67
86
|
if (!res.ok) throw new Error(`userinfo HTTP ${res.status}`);
|
|
68
87
|
return res.json();
|
|
@@ -74,6 +93,7 @@ export async function fetchQuotaUsage(account) {
|
|
|
74
93
|
if (!token) throw new Error('token 不可用');
|
|
75
94
|
const res = await req(`${apiBase(account.provider)}${QUOTA_USAGE_PATH}`, {
|
|
76
95
|
headers: buildQoderHeaders(token),
|
|
96
|
+
preferProxy: account.provider === 'qoder',
|
|
77
97
|
});
|
|
78
98
|
if (res.status === 401 || res.status === 403) throw new Error(`鉴权失败 (HTTP ${res.status}),token 可能已过期`);
|
|
79
99
|
if (!res.ok) throw new Error(`quota HTTP ${res.status}`);
|
|
@@ -91,7 +111,7 @@ function webHeaders(cookie, provider) {
|
|
|
91
111
|
|
|
92
112
|
/** 用原始 Cookie 探测网页会话属于哪个 Qoder 用户(登录窗口抓来的 Cookie 按 uid 归到账号上) */
|
|
93
113
|
export async function probeWebSession(provider, cookie) {
|
|
94
|
-
const res = await
|
|
114
|
+
const res = await webReq(provider, `${webBase(provider)}${WEB_USAGE_PATH}`, { headers: webHeaders(cookie, provider) });
|
|
95
115
|
if (!res.ok) return null;
|
|
96
116
|
const data = await res.json().catch(() => null);
|
|
97
117
|
return data?.user_id ? data : null;
|
|
@@ -108,7 +128,7 @@ export async function probeWebSession(provider, cookie) {
|
|
|
108
128
|
export async function fetchUsageDetail(account) {
|
|
109
129
|
const cookie = String(account.meta?.qoderWebSession?.cookie || '').trim();
|
|
110
130
|
if (!cookie) return null;
|
|
111
|
-
const res = await
|
|
131
|
+
const res = await webReq(account.provider, `${webBase(account.provider)}${WEB_USAGE_PATH}`, { headers: webHeaders(cookie, account.provider) });
|
|
112
132
|
if (res.status === 401 || res.status === 403) {
|
|
113
133
|
const err = new Error(`网页会话已失效 (HTTP ${res.status})`);
|
|
114
134
|
err.auth = true;
|
|
@@ -191,7 +211,7 @@ export async function checkinOne(account) {
|
|
|
191
211
|
|
|
192
212
|
// 2) 带客户端身份 + 设备风控身份重新拉取(国际版只有这样才会出现每日积分活动)
|
|
193
213
|
const { headers, risk, riskError } = await buildCampaignHeaders(account, token, uid);
|
|
194
|
-
if (riskError) logger.debug('CHECKIN',
|
|
214
|
+
if (riskError) logger.debug('CHECKIN', '{label}:{err}', { label, err: riskError });
|
|
195
215
|
const { campaigns, uid: uid2 } = (risk || !first) ? await getCampaigns(apiBase(account.provider), headers) : first;
|
|
196
216
|
uid = uid2 || uid;
|
|
197
217
|
|
package/src/qoderUmid.js
CHANGED
|
@@ -18,6 +18,7 @@ import zlib from 'node:zlib';
|
|
|
18
18
|
import { execFile } from 'node:child_process';
|
|
19
19
|
import { dataDir } from './store.js';
|
|
20
20
|
import { logger } from './logger.js';
|
|
21
|
+
import { t } from './i18n.js';
|
|
21
22
|
|
|
22
23
|
const PKG = '@qoder-ai/qodercli';
|
|
23
24
|
const REGISTRIES = ['https://registry.npmmirror.com', 'https://registry.npmjs.org'];
|
|
@@ -154,7 +155,7 @@ async function doInstall() {
|
|
|
154
155
|
throw new Error(`镜像版本(${meta.version})与 npmjs(${authority.version})不一致`);
|
|
155
156
|
}
|
|
156
157
|
const expected = authority && authority.version === meta.version ? authority.integrity : integrity;
|
|
157
|
-
|
|
158
|
+
logger.info('UMID', '下载 {pkg}@{version}({registry})', { pkg: PKG, version: meta.version, registry });
|
|
158
159
|
const tgz = await fetchBuf(tarball);
|
|
159
160
|
if (!checkIntegrity(tgz, expected)) throw new Error('下载内容与 npm integrity 不一致');
|
|
160
161
|
const bundle = tarEntry(zlib.gunzipSync(tgz), BUNDLE_ENTRY);
|
|
@@ -173,11 +174,11 @@ async function doInstall() {
|
|
|
173
174
|
installedAt: new Date().toISOString(),
|
|
174
175
|
};
|
|
175
176
|
fs.writeFileSync(manifestPath(), JSON.stringify(manifest, null, 2), { mode: 0o600 });
|
|
176
|
-
|
|
177
|
+
logger.info('UMID', '设备身份组件已安装:qodercli {version} / {arch}', { version: meta.version, arch: process.arch });
|
|
177
178
|
return { ...manifest, path: binPath() };
|
|
178
179
|
} catch (e) {
|
|
179
180
|
errors.push(`${registry}:${e.message}`);
|
|
180
|
-
|
|
181
|
+
logger.warn('UMID', '从 {registry} 安装失败:{err}', { registry, err: e.message });
|
|
181
182
|
}
|
|
182
183
|
}
|
|
183
184
|
throw new Error('设备身份组件安装失败 —— ' + errors.join(';'));
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 凭据刷新公共守卫(所有 provider 的 401-刷新通路共用)。
|
|
3
|
+
*
|
|
4
|
+
* refreshToken 是一次性的、刷新即轮换:两路并发各自拿同一条链去刷新,必然一个成功一个
|
|
5
|
+
* invalid_grant——面板每 2 分钟一轮额度查询和每小时签到轮曾因此互相误伤。两个护栏:
|
|
6
|
+
*
|
|
7
|
+
* 1) in-flight 合并:同一账号的并发刷新只跑一次 refreshFn,后到者共享同一份结果;
|
|
8
|
+
* 2) 失效链熔断:某条 refreshToken 被服务端作废(401 / invalid_grant / 12153),
|
|
9
|
+
* 在它被换新(重新登录、本机导入、或刷新成功后链自然更新)之前不再自动重试,
|
|
10
|
+
* 也不会再打出几十上百次无效请求(2026-10-09 实记录 400+ 次)。
|
|
11
|
+
*
|
|
12
|
+
* 消 bug 的调用约定:
|
|
13
|
+
* - refreshFn 内部可以再写 account(把新 token/refreshToken 落回对象),调用方负责持久化;
|
|
14
|
+
* - 认证失效的判定:err.authInvalid === true 或 err.auth === true(cold 有 provider 用两种标记);
|
|
15
|
+
* - deadMessage / deadNotice 均为可被 i18n 直接命中的字符串模板。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const inFlight = new Map(); // accountKey → Promise(共享给并发的后来者)
|
|
19
|
+
const deadFp = new Map(); // accountKey → 已被服务端作废的 refreshToken
|
|
20
|
+
|
|
21
|
+
function keyOf(account) {
|
|
22
|
+
return String(account.id || account.refreshToken || account.token || 'anon');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 该账号当前持有的 refreshToken 是否已被记为失效链。凭据换新(链指纹变化)自动放行。 */
|
|
26
|
+
export function refreshFpDead(account) {
|
|
27
|
+
const fp = account.refreshToken;
|
|
28
|
+
return typeof fp === 'string' && fp.length > 0 && deadFp.get(keyOf(account)) === fp;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 测试专用:清空进程内守卫状态 */
|
|
32
|
+
export function __resetRefreshGuardForTests() {
|
|
33
|
+
inFlight.clear();
|
|
34
|
+
deadFp.clear();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} account 账号(至少含 id / refreshToken;refreshFn 要拿的都是它字段)
|
|
39
|
+
* @param {(account: object) => Promise<object>} refreshFn 真刷新:成功后返回新凭据
|
|
40
|
+
* @param {{ log?: (msg: string) => void | string, deadMessage?: string, deadNotice?: string }} [ctx]
|
|
41
|
+
*/
|
|
42
|
+
export function withRefreshDedupe(account, refreshFn, ctx = {}) {
|
|
43
|
+
const key = keyOf(account);
|
|
44
|
+
const inflight = inFlight.get(key);
|
|
45
|
+
if (inflight) return inflight;
|
|
46
|
+
|
|
47
|
+
const p = (async () => {
|
|
48
|
+
if (refreshFpDead(account)) {
|
|
49
|
+
const err = new Error(ctx.deadMessage || '凭据链已被服务端作废,等待重新登录或重新导入该账号');
|
|
50
|
+
err.authInvalid = true;
|
|
51
|
+
err.dead = true;
|
|
52
|
+
throw err; // 静默快拒:不再打服务端,不重复刷日志(首次失效时已提示)
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return await refreshFn(account);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if ((err?.authInvalid || err?.auth) && typeof account.refreshToken === 'string' && account.refreshToken.length > 0) {
|
|
58
|
+
deadFp.set(key, account.refreshToken);
|
|
59
|
+
if (ctx.deadNotice !== undefined && ctx.log) {
|
|
60
|
+
try { ctx.log(ctx.deadNotice || '凭据链已失效,将不再自动重试(重新登录或重新导入该账号后自动恢复)'); } catch {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
})().finally(() => inFlight.delete(key));
|
|
66
|
+
|
|
67
|
+
inFlight.set(key, p);
|
|
68
|
+
return p;
|
|
69
|
+
}
|
package/src/store.js
CHANGED
|
@@ -85,6 +85,17 @@ async function readJson(file, fallback) {
|
|
|
85
85
|
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
86
86
|
} catch (e) {
|
|
87
87
|
if (e.code === 'ENOENT') return fallback;
|
|
88
|
+
// 主文件损坏/截断(断电半写、杀软抖动)时,写侧维护了一份同目录 .bak——先试它,
|
|
89
|
+
// 不至于直接让守护起不来;仍失败才抛出原(保留报错方向,不静默重置)。
|
|
90
|
+
if (e instanceof SyntaxError || e.name === 'SyntaxError') {
|
|
91
|
+
const bak = file + '.bak';
|
|
92
|
+
try {
|
|
93
|
+
const bakText = await fs.readFile(bak, 'utf8');
|
|
94
|
+
const parsed = JSON.parse(bakText);
|
|
95
|
+
console.error('readJson: 主文件损坏,已用备份恢复 ' + bak);
|
|
96
|
+
return parsed;
|
|
97
|
+
} catch { /* 没有备份或备份同坏——按原错误上抛 */ }
|
|
98
|
+
}
|
|
88
99
|
throw new Error(`读取 ${file} 失败: ${e.message}`);
|
|
89
100
|
}
|
|
90
101
|
}
|
package/src/tenrouter.js
CHANGED
|
@@ -13,6 +13,7 @@ import path from 'node:path';
|
|
|
13
13
|
import crypto from 'node:crypto';
|
|
14
14
|
import { dataDir } from './store.js';
|
|
15
15
|
import { logger } from './logger.js';
|
|
16
|
+
import { t } from './i18n.js';
|
|
16
17
|
import { SOURCES, SOURCE_LABEL, collectSource, selectSince, detectSources } from './usageSync.js';
|
|
17
18
|
|
|
18
19
|
const FILE = () => path.join(dataDir(), 'tenrouter.json');
|
|
@@ -41,17 +42,18 @@ export function loadConfig() {
|
|
|
41
42
|
} catch { return structuredClone(DEFAULT_CONFIG); }
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
|
|
45
|
+
// 配置落盘走 store.atomicWrite:Windows 上杀软/同步盘瞬时占文件会直接 EPERM,
|
|
46
|
+
// 裸 renameSync 会整路抛到 API 500;atomicWrite 内置占用重试与直写兜底
|
|
47
|
+
async function saveConfig(c) {
|
|
45
48
|
fs.mkdirSync(dataDir(), { recursive: true, mode: 0o700 });
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
fs.renameSync(tmp, FILE());
|
|
49
|
+
const { atomicWrite } = await import('./store.js');
|
|
50
|
+
await atomicWrite(FILE(), JSON.stringify(c, null, 2));
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// 读改写串行,避免面板保存与后台同步互相覆盖
|
|
52
54
|
let chain = Promise.resolve();
|
|
53
55
|
function withConfig(fn) {
|
|
54
|
-
const run = chain.then(async () => { const c = loadConfig(); const r = await fn(c); saveConfig(c); return r; });
|
|
56
|
+
const run = chain.then(async () => { const c = loadConfig(); const r = await fn(c); await saveConfig(c); return r; });
|
|
55
57
|
chain = run.catch(() => {});
|
|
56
58
|
return run;
|
|
57
59
|
}
|
|
@@ -252,7 +254,12 @@ async function doSync({ dryRun, trigger }) {
|
|
|
252
254
|
: `同步完成:新增 ${imported} 行,跳过 ${skippedDup} 行重复`;
|
|
253
255
|
if (!dryRun) {
|
|
254
256
|
await withConfig((cc) => { cc.lastSync = { at: new Date().toISOString(), trigger, summary, results }; });
|
|
255
|
-
|
|
257
|
+
// 日志走 key+args(按界面语言实时重渲染);summary 原文继续供面板展示
|
|
258
|
+
const logTpl = failed.length
|
|
259
|
+
? '同步完成:新增 {imported} 行,跳过 {skipped} 行重复,{failed} 个来源失败({detail})'
|
|
260
|
+
: '同步完成:新增 {imported} 行,跳过 {skipped} 行重复';
|
|
261
|
+
(failed.length ? logger.warn : logger.info).call(logger, '10R', logTpl,
|
|
262
|
+
{ imported, skipped: skippedDup, failed: failed.length, detail: failed.map((r) => r.label + ':' + r.error).join(';') });
|
|
256
263
|
}
|
|
257
264
|
return { summary, results };
|
|
258
265
|
}
|
|
@@ -263,7 +270,7 @@ export function startUsageSyncScheduler() {
|
|
|
263
270
|
const tick = () => {
|
|
264
271
|
const c = loadConfig();
|
|
265
272
|
if (!isConfigured(c) || !c.sync.enabled) return;
|
|
266
|
-
|
|
273
|
+
runUsageSync({ trigger: 'auto' }).catch((e) => logger.warn('10R', '自动同步失败:{err}', { err: e.message }));
|
|
267
274
|
};
|
|
268
275
|
setTimeout(tick, 60_000).unref?.(); // 启动 1 分钟后先跑一轮
|
|
269
276
|
timer = setInterval(tick, SYNC_INTERVAL_MS);
|
package/src/traeGateway.js
CHANGED
|
@@ -17,6 +17,7 @@ import { loadAccounts, loadSettings, saveSettings } from './store.js';
|
|
|
17
17
|
import { fetchTraeQuota } from './traeClient.js';
|
|
18
18
|
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
19
19
|
import { logger, summarizeAttempts } from './logger.js';
|
|
20
|
+
import { t } from './i18n.js';
|
|
20
21
|
|
|
21
22
|
const TRAE_SOLO_BASE = 'https://solo.trae.cn/api/remote/v1';
|
|
22
23
|
const ACCOUNT_COOLING_MS = 5 * 60_000;
|
|
@@ -44,7 +45,8 @@ export async function setGatewayEnabled(v) {
|
|
|
44
45
|
|
|
45
46
|
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
46
47
|
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/导入,自动复活)
|
|
47
|
-
const weights = new Map(); // accountId →
|
|
48
|
+
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }
|
|
49
|
+
const WEIGHT_TTL_MS = 10 * 60_000; // 超过 10 分钟重查额度,让积分变化反映到分流上
|
|
48
50
|
|
|
49
51
|
export const stats = {
|
|
50
52
|
lastCallAt: null,
|
|
@@ -73,7 +75,7 @@ function isDead(account) {
|
|
|
73
75
|
if (fp === credFingerprint(account)) return true;
|
|
74
76
|
dead.delete(account.id);
|
|
75
77
|
cooling.delete(account.id);
|
|
76
|
-
logger.info('TRAE-GW',
|
|
78
|
+
logger.info('TRAE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
|
|
77
79
|
return false;
|
|
78
80
|
}
|
|
79
81
|
|
|
@@ -88,16 +90,17 @@ export function __resetForTests() {
|
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
async function accountWeight(a) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
93
|
+
const hit = weights.get(a.id);
|
|
94
|
+
if (hit && Date.now() - hit.at < WEIGHT_TTL_MS) return hit.w;
|
|
95
|
+
let w = hit?.w ?? MIN_WEIGHT;
|
|
96
|
+
try {
|
|
97
|
+
const q = await fetchTraeQuota(a);
|
|
98
|
+
w = Math.max(MIN_WEIGHT, Number(q?.remaining) || 0);
|
|
99
|
+
} catch {
|
|
100
|
+
// 额度查询失败保留上次快照权重(网络抖动不该把主力踢成保底权重)
|
|
99
101
|
}
|
|
100
|
-
|
|
102
|
+
weights.set(a.id, { w, at: Date.now() });
|
|
103
|
+
return w;
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
const swrr = new Map();
|
|
@@ -161,7 +164,13 @@ function remoteAllowed(remote, allowList) {
|
|
|
161
164
|
return (Array.isArray(allowList) ? allowList : []).some((entry) => {
|
|
162
165
|
const e = String(entry || '').trim();
|
|
163
166
|
if (!e) return false;
|
|
164
|
-
if (e.endsWith('*'))
|
|
167
|
+
if (e.endsWith('*')) {
|
|
168
|
+
// 通配要求带点段尾:防止 192.168.31.1* 这类把 .100-.199 整段一起放行的怪手。
|
|
169
|
+
// 要扩整段子网,把写法统一为 192.168.31.*(带尾句点)。
|
|
170
|
+
const prefix = e.slice(0, -1);
|
|
171
|
+
if (!prefix.endsWith('.')) return false;
|
|
172
|
+
return host.toLowerCase().startsWith(prefix.toLowerCase());
|
|
173
|
+
}
|
|
165
174
|
return host.toLowerCase() === e.toLowerCase();
|
|
166
175
|
});
|
|
167
176
|
}
|
|
@@ -362,7 +371,7 @@ export async function handleGateway(req, res) {
|
|
|
362
371
|
const cur = list.find((a) => a.id === account.id);
|
|
363
372
|
if (cur) cur.token = token;
|
|
364
373
|
});
|
|
365
|
-
|
|
374
|
+
logger.info('TRAE-GW', '{label} 凭据已对齐本地客户端,重试会话创建...', { label });
|
|
366
375
|
sessionRes = await fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions`, {
|
|
367
376
|
method: 'POST',
|
|
368
377
|
headers: makeHeaders(token),
|
|
@@ -383,14 +392,14 @@ export async function handleGateway(req, res) {
|
|
|
383
392
|
if (sessionRes.status === 401) {
|
|
384
393
|
markDead(account);
|
|
385
394
|
attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
|
|
386
|
-
logger.info('TRAE-GW',
|
|
395
|
+
logger.info('TRAE-GW', '{label} 凭据失效,拉黑', { label });
|
|
387
396
|
continue;
|
|
388
397
|
}
|
|
389
398
|
|
|
390
399
|
if (sessionRes.status === 429) {
|
|
391
400
|
markCooling(account.id, ACCOUNT_COOLING_MS);
|
|
392
401
|
attempted.push({ account: label, ok: false, error: '429 限流,冷却 5 分钟' });
|
|
393
|
-
logger.info('TRAE-GW',
|
|
402
|
+
logger.info('TRAE-GW', '{label} 429 限流,冷却 5 分钟', { label });
|
|
394
403
|
continue;
|
|
395
404
|
}
|
|
396
405
|
|
|
@@ -415,7 +424,9 @@ export async function handleGateway(req, res) {
|
|
|
415
424
|
// 获取 events 流
|
|
416
425
|
let eventsRes;
|
|
417
426
|
try {
|
|
418
|
-
|
|
427
|
+
// 走 fetchJsonRace:连接段 15s 限时 + 统一代理 / 直连兜底与会话请求自洽(此前裸 fetch——
|
|
428
|
+
// 上游黑洞时一直挂到客户端断开,也整体不走面板配置的出口代理,不是统一的接口设计)
|
|
429
|
+
eventsRes = await fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions/${chatSessionId}/events?reply_to_message_id=${encodeURIComponent(messageId)}`, {
|
|
419
430
|
headers: {
|
|
420
431
|
Authorization: token.startsWith('Cloud-IDE-JWT ') ? token : `Cloud-IDE-JWT ${token.trim()}`,
|
|
421
432
|
Accept: 'text/event-stream',
|
|
@@ -423,6 +434,8 @@ export async function handleGateway(req, res) {
|
|
|
423
434
|
Referer: 'https://solo.trae.cn/',
|
|
424
435
|
},
|
|
425
436
|
signal: clientAbort.signal,
|
|
437
|
+
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
438
|
+
connectMs: 15_000,
|
|
426
439
|
});
|
|
427
440
|
} catch (e) {
|
|
428
441
|
cleanupSession();
|
|
@@ -438,7 +451,7 @@ export async function handleGateway(req, res) {
|
|
|
438
451
|
}
|
|
439
452
|
|
|
440
453
|
stats.lastAccount = label;
|
|
441
|
-
|
|
454
|
+
logger.debug('TRAE-GW', '{label} 连接成功,开始流式输出 ({model})', { label, model: requestedModel });
|
|
442
455
|
|
|
443
456
|
// 成功建立流,按 isStream 分发
|
|
444
457
|
if (isStream) {
|
|
@@ -482,10 +495,11 @@ export async function handleGateway(req, res) {
|
|
|
482
495
|
const decoder = new TextDecoder();
|
|
483
496
|
let buffer = '';
|
|
484
497
|
|
|
498
|
+
let streamCompleted = false;
|
|
485
499
|
try {
|
|
486
500
|
while (true) {
|
|
487
501
|
const { done, value } = await reader.read();
|
|
488
|
-
if (done) break;
|
|
502
|
+
if (done) { streamCompleted = true; break; }
|
|
489
503
|
buffer += decoder.decode(value, { stream: true });
|
|
490
504
|
const lines = buffer.split('\n');
|
|
491
505
|
buffer = lines.pop();
|
|
@@ -550,7 +564,7 @@ export async function handleGateway(req, res) {
|
|
|
550
564
|
}
|
|
551
565
|
} catch (err) {
|
|
552
566
|
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
553
|
-
|
|
567
|
+
logger.debug('TRAE-GW', '流式读取中断:{err}', { err: err.message });
|
|
554
568
|
} finally {
|
|
555
569
|
if (thinkingOpen) {
|
|
556
570
|
sendSse('content_block_stop', { type: 'content_block_stop', index: thinkingIndex });
|
|
@@ -569,9 +583,13 @@ export async function handleGateway(req, res) {
|
|
|
569
583
|
if (inputTokens > 0) deltaUsage.input_tokens = inputTokens;
|
|
570
584
|
if (cacheReadTokens > 0) deltaUsage.cache_read_input_tokens = cacheReadTokens;
|
|
571
585
|
|
|
586
|
+
// 上游没正常收尾(断流 / EOF / reader 抛错)时,stop_reason 别伪装成 end_turn——
|
|
587
|
+
// 客户端把截断的字当完整答案就再也看不见了(两个分支都报 interrupted)。
|
|
588
|
+
const stopReason = streamCompleted ? 'end_turn' : 'interrupted';
|
|
589
|
+
|
|
572
590
|
sendSse('message_delta', {
|
|
573
591
|
type: 'message_delta',
|
|
574
|
-
delta: { stop_reason:
|
|
592
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
575
593
|
usage: deltaUsage,
|
|
576
594
|
});
|
|
577
595
|
sendSse('message_stop', { type: 'message_stop' });
|
|
@@ -587,11 +605,12 @@ export async function handleGateway(req, res) {
|
|
|
587
605
|
let fullThinking = '';
|
|
588
606
|
let fullText = '';
|
|
589
607
|
let usageInfo = null;
|
|
608
|
+
let streamCompleted = false;
|
|
590
609
|
|
|
591
610
|
try {
|
|
592
611
|
while (true) {
|
|
593
612
|
const { done, value } = await reader.read();
|
|
594
|
-
if (done) break;
|
|
613
|
+
if (done) { streamCompleted = true; break; }
|
|
595
614
|
buffer += decoder.decode(value, { stream: true });
|
|
596
615
|
const lines = buffer.split('\n');
|
|
597
616
|
buffer = lines.pop();
|
|
@@ -613,7 +632,7 @@ export async function handleGateway(req, res) {
|
|
|
613
632
|
}
|
|
614
633
|
} catch (err) {
|
|
615
634
|
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
616
|
-
|
|
635
|
+
logger.debug('TRAE-GW', '非流式读取中断:{err}', { err: err.message });
|
|
617
636
|
} finally {
|
|
618
637
|
cleanupSession();
|
|
619
638
|
}
|
|
@@ -636,7 +655,7 @@ export async function handleGateway(req, res) {
|
|
|
636
655
|
role: 'assistant',
|
|
637
656
|
model: requestedModel,
|
|
638
657
|
content,
|
|
639
|
-
stop_reason: 'end_turn',
|
|
658
|
+
stop_reason: streamCompleted ? 'end_turn' : 'interrupted',
|
|
640
659
|
stop_sequence: null,
|
|
641
660
|
usage: usageObj,
|
|
642
661
|
}));
|
|
@@ -646,7 +665,7 @@ export async function handleGateway(req, res) {
|
|
|
646
665
|
|
|
647
666
|
// 全部重试失败
|
|
648
667
|
const failedDetail = summarizeAttempts(attempted);
|
|
649
|
-
logger.warn('TRAE-GW',
|
|
668
|
+
logger.warn('TRAE-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
|
|
650
669
|
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
651
670
|
res.end(JSON.stringify({
|
|
652
671
|
error: '所有 Trae 账号均调用失败',
|
package/src/transfer.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import crypto from 'node:crypto';
|
|
16
16
|
import { PROVIDERS, productOf } from './constants.js';
|
|
17
|
+
import { inspectToken } from './workbuddyClient.js';
|
|
17
18
|
|
|
18
19
|
export const TRANSFER_FORMAT = '10router-oauth-secure-v1';
|
|
19
20
|
export const EXPORT_FORMAT = 'creditdaddy-accounts';
|
|
@@ -155,9 +156,17 @@ export function parseImport(data, { password } = {}) {
|
|
|
155
156
|
const accounts = [];
|
|
156
157
|
let skipped = 0;
|
|
157
158
|
for (const item of list) {
|
|
158
|
-
//
|
|
159
|
-
const
|
|
160
|
-
|
|
159
|
+
// 兼容 workbuddy-switch 导出的明文备份文件(没有 provider 字段,但有 domain / auth_raw / 顶层 access_token)
|
|
160
|
+
const token = item?.accessToken || item?.access_token || item?.token || item?.apiKey || item?.auth_raw?.accessToken || item?.auth_raw?.auth?.accessToken;
|
|
161
|
+
let provider = item?.provider ? toProvider(item.provider) : fallbackProvider;
|
|
162
|
+
let isWbSwitch = false;
|
|
163
|
+
if (!provider && typeof token === 'string' && token.trim()) {
|
|
164
|
+
const inspected = inspectToken(token);
|
|
165
|
+
if (inspected.provider) {
|
|
166
|
+
provider = inspected.provider;
|
|
167
|
+
isWbSwitch = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
161
170
|
if (!provider || typeof token !== 'string' || !token.trim()) { skipped++; continue; }
|
|
162
171
|
const psd = item.providerSpecificData || {};
|
|
163
172
|
const email = typeof item.email === 'string' && item.email.includes('@') && !/^qoder(-cn)?-user-/.test(item.email) ? item.email : null;
|
|
@@ -167,15 +176,44 @@ export function parseImport(data, { password } = {}) {
|
|
|
167
176
|
? { cookie: psd.creditDaddyWebSession.cookie, capturedAt: psd.creditDaddyWebSession.capturedAt || null } : null;
|
|
168
177
|
const meta = { ...(item.meta && typeof item.meta === 'object' ? item.meta : {}) };
|
|
169
178
|
if (webSession && !meta.qoderWebSession) meta.qoderWebSession = webSession;
|
|
179
|
+
|
|
180
|
+
// 对 workbuddy-switch 导出还原 session 结构,使之支持在 CreditDaddy 面板一键切换
|
|
181
|
+
if (isWbSwitch) {
|
|
182
|
+
if (source === 'creditdaddy') source = 'wb-switch';
|
|
183
|
+
if (item.domain && !meta.domain) meta.domain = item.domain;
|
|
184
|
+
if (item.enterpriseId && !meta.enterpriseId) meta.enterpriseId = item.enterpriseId;
|
|
185
|
+
if (item.refreshExpiresAt && !meta.refreshExpiresAt) {
|
|
186
|
+
meta.refreshExpiresAt = Number.isFinite(item.refreshExpiresAt) ? new Date(item.refreshExpiresAt).toISOString() : String(item.refreshExpiresAt);
|
|
187
|
+
}
|
|
188
|
+
if (!meta.session) {
|
|
189
|
+
const accountObj = item.auth_raw?.account || item.profile_raw || {
|
|
190
|
+
uid: item.uid || null,
|
|
191
|
+
nickname: item.nickname || item.name || null,
|
|
192
|
+
phoneNumber: item.phoneNumber || item.phone || null,
|
|
193
|
+
enterpriseId: item.enterpriseId || null,
|
|
194
|
+
type: 'personal',
|
|
195
|
+
};
|
|
196
|
+
const authObj = item.auth_raw?.auth || (item.auth_raw && item.auth_raw.accessToken ? item.auth_raw : { domain: item.domain });
|
|
197
|
+
meta.session = {
|
|
198
|
+
account: accountObj,
|
|
199
|
+
auth: authObj,
|
|
200
|
+
accounts: Array.isArray(item.auth_raw?.accounts) ? item.auth_raw.accounts : [],
|
|
201
|
+
...(Array.isArray(item.auth_raw?.allAccounts) ? { allAccounts: item.auth_raw.allAccounts } : {}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const expiresAt = item.expiresAt ?? (item.expiresIn ? Date.now() + Number(item.expiresIn) * 1000 : null);
|
|
207
|
+
|
|
170
208
|
accounts.push({
|
|
171
209
|
provider,
|
|
172
210
|
token,
|
|
173
211
|
name: item.name || item.nickname || email || null,
|
|
174
212
|
email,
|
|
175
213
|
uid: item.uid || psd.userId || null,
|
|
176
|
-
refreshToken: item.refreshToken || item.refresh_token || null,
|
|
177
|
-
expiresAt
|
|
178
|
-
source: source === '10router' ? '10router' : 'import',
|
|
214
|
+
refreshToken: item.refreshToken || item.refresh_token || item.auth_raw?.refreshToken || item.auth_raw?.auth?.refreshToken || null,
|
|
215
|
+
expiresAt,
|
|
216
|
+
source: isWbSwitch ? 'wb-switch' : (source === '10router' ? '10router' : 'import'),
|
|
179
217
|
...(Object.keys(meta).length ? { meta } : {}),
|
|
180
218
|
});
|
|
181
219
|
}
|
package/src/workbuddyClient.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { FETCH_TIMEOUT_MS } from './constants.js';
|
|
20
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
21
|
+
import { withRefreshDedupe } from './refreshGuard.js';
|
|
20
22
|
|
|
21
23
|
const USER_AGENT = 'CLI/2.108.1 CodeBuddy/2.108.1';
|
|
22
24
|
const CN_DEFAULT_HOST = 'www.codebuddy.cn';
|
|
@@ -79,11 +81,12 @@ function headersFor(account, token) {
|
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
async function post(account, path, token) {
|
|
82
|
-
|
|
84
|
+
// fetchJsonRace 统一出口:连接段限时 + 配置了出口代理时直连/代理双路兜底
|
|
85
|
+
const res = await fetchJsonRace(`https://${apiHost(account)}${path}`, {
|
|
83
86
|
method: 'POST',
|
|
84
87
|
headers: headersFor(account, token),
|
|
85
88
|
body: '{}',
|
|
86
|
-
|
|
89
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
87
90
|
});
|
|
88
91
|
const text = await res.text();
|
|
89
92
|
let body = null;
|
|
@@ -96,7 +99,7 @@ async function post(account, path, token) {
|
|
|
96
99
|
// 对齐 10router src/sse/services/codebuddyCheckin.js 的 intl 探测(stream 网关的系统提示与 typed blocks 是必需的,否则 11101)。
|
|
97
100
|
const INTL_PROBE_MODEL = 'hy4-preview';
|
|
98
101
|
async function postIntlProbe(account, token) {
|
|
99
|
-
const res = await
|
|
102
|
+
const res = await fetchJsonRace(`https://${apiHost(account)}/v2/chat/completions`, {
|
|
100
103
|
method: 'POST',
|
|
101
104
|
headers: { ...headersFor(account, token), 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
102
105
|
body: JSON.stringify({
|
|
@@ -108,7 +111,7 @@ async function postIntlProbe(account, token) {
|
|
|
108
111
|
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
|
109
112
|
],
|
|
110
113
|
}),
|
|
111
|
-
|
|
114
|
+
timeoutMs: Math.max(FETCH_TIMEOUT_MS, 25_000),
|
|
112
115
|
});
|
|
113
116
|
// 2xx = 会话建立成功,把 SSE 流排干(最多几个 token)
|
|
114
117
|
const text = await res.text().catch(() => '');
|
|
@@ -151,7 +154,7 @@ export async function refreshWorkbuddyToken(account) {
|
|
|
151
154
|
let lastError = null;
|
|
152
155
|
for (const host of hosts) {
|
|
153
156
|
try {
|
|
154
|
-
const res = await
|
|
157
|
+
const res = await fetchJsonRace(`https://${host}/v2/plugin/auth/token/refresh`, {
|
|
155
158
|
method: 'POST',
|
|
156
159
|
headers: {
|
|
157
160
|
'Content-Type': 'application/json',
|
|
@@ -165,7 +168,7 @@ export async function refreshWorkbuddyToken(account) {
|
|
|
165
168
|
...(account.meta?.enterpriseId ? { 'X-Enterprise-Id': account.meta.enterpriseId } : {}),
|
|
166
169
|
},
|
|
167
170
|
body: '{}',
|
|
168
|
-
|
|
171
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
169
172
|
});
|
|
170
173
|
const j = await res.json().catch(() => ({}));
|
|
171
174
|
const d = j?.data;
|
|
@@ -202,7 +205,11 @@ async function withToken(account, fn, onRefresh) {
|
|
|
202
205
|
let token = account.token;
|
|
203
206
|
let refreshed = false;
|
|
204
207
|
const doRefresh = async () => {
|
|
205
|
-
|
|
208
|
+
// 并发互斥 + 失效链熔断:与 minimax 共用 refreshGuard(面板额度查询与签到轮同链抢刷的根堵点)
|
|
209
|
+
const creds = await withRefreshDedupe(account, refreshWorkbuddyToken, {
|
|
210
|
+
deadMessage: 'WorkBuddy 凭据链已被服务端作废,等待重新登录',
|
|
211
|
+
deadNotice: 'WorkBuddy 凭据链已失效,将不再自动重试(重新登录或重新导入该账号后自动恢复)',
|
|
212
|
+
});
|
|
206
213
|
Object.assign(account, { token: creds.token, refreshToken: creds.refreshToken, expiresAt: creds.expiresAt });
|
|
207
214
|
token = creds.token;
|
|
208
215
|
refreshed = true;
|