creditdaddy 1.3.0 → 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 +87 -13
- package/bin/creditdaddy.js +158 -8
- package/package.json +32 -29
- package/src/accounts.js +10 -0
- package/src/authDevice.js +5 -4
- package/src/bgdaemon.js +341 -0
- package/src/checkin.js +72 -41
- package/src/daemon.js +1266 -997
- 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 +162 -20
- package/src/minimaxClient.js +28 -22
- package/src/minimaxGateway.js +74 -50
- package/src/minimaxLocal.js +61 -16
- package/src/mirasimClient.js +6 -1
- package/src/panel.html +1342 -591
- package/src/qoderClient.js +24 -4
- package/src/qoderUmid.js +4 -3
- package/src/refreshGuard.js +69 -0
- package/src/store.js +27 -1
- package/src/tenrouter.js +14 -7
- package/src/tenrouterAccounts.js +31 -3
- package/src/traeClient.js +6 -1
- package/src/traeGateway.js +698 -0
- package/src/transfer.js +44 -6
- package/src/workbuddyClient.js +14 -7
- package/src/zcodeAutoClaim.js +47 -7
- package/src/zcodeClient.js +76 -13
- package/src/zcodeGateway.js +41 -22
- package/src/zcodePlanShape.json +3 -3
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trae SOLO 本地 Anthropic 兼容网关。
|
|
3
|
+
*
|
|
4
|
+
* 数据面端点:POST /gateway/trae/v1/messages
|
|
5
|
+
* (上游为 ByteDance Trae SOLO 远程 Agent 协议:https://solo.trae.cn/api/remote/v1)
|
|
6
|
+
*
|
|
7
|
+
* 架构特性:
|
|
8
|
+
* 1. 账号多路轮换(provider=trae):自动在有效账号间按剩余积分加权轮询(SWRR)
|
|
9
|
+
* 2. 凭据热对齐:401 时若为本机导入账号,尝试重读 storage.json 最新登录态
|
|
10
|
+
* 3. 429 限流保护:429 限流自动冷却 5 分钟
|
|
11
|
+
* 4. 局域网访问控制:继承相同的白名单与回环检查机制
|
|
12
|
+
* 5. SSE 增量转换:将 Trae SOLO 累积流转换为标准 Anthropic 流式 SSE 或一次性 JSON 响应
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { fetchJsonRace } from './zcodeClient.js';
|
|
16
|
+
import { loadAccounts, loadSettings, saveSettings } from './store.js';
|
|
17
|
+
import { fetchTraeQuota } from './traeClient.js';
|
|
18
|
+
import { gatewayHostSuggestion } from './tenrouter.js';
|
|
19
|
+
import { logger, summarizeAttempts } from './logger.js';
|
|
20
|
+
import { t } from './i18n.js';
|
|
21
|
+
|
|
22
|
+
const TRAE_SOLO_BASE = 'https://solo.trae.cn/api/remote/v1';
|
|
23
|
+
const ACCOUNT_COOLING_MS = 5 * 60_000;
|
|
24
|
+
const MAX_ATTEMPTS = 5;
|
|
25
|
+
const UPSTREAM_TIMEOUT_MS = 600_000;
|
|
26
|
+
const UPSTREAM_CONNECT_MS = 15_000;
|
|
27
|
+
const DEFAULT_MODEL = 'Doubao-Seed-Code';
|
|
28
|
+
|
|
29
|
+
// ── 开关(持久化在 settings.traeGateway) ──
|
|
30
|
+
|
|
31
|
+
export async function gatewayEnabled() {
|
|
32
|
+
return (await loadSettings()).traeGateway === true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function setGatewayEnabled(v) {
|
|
36
|
+
const on = v === true;
|
|
37
|
+
await saveSettings({ traeGateway: on });
|
|
38
|
+
if (on) {
|
|
39
|
+
dead.clear();
|
|
40
|
+
cooling.clear();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── 账号轮换状态(进程内) ──
|
|
45
|
+
|
|
46
|
+
const cooling = new Map(); // accountId → 冷却截止(ms)
|
|
47
|
+
const dead = new Map(); // accountId → 拉黑时的凭据指纹(指纹变化 = 已重新授权/导入,自动复活)
|
|
48
|
+
const weights = new Map(); // accountId → { w: 剩余积分权重, at: 查询时刻 }
|
|
49
|
+
const WEIGHT_TTL_MS = 10 * 60_000; // 超过 10 分钟重查额度,让积分变化反映到分流上
|
|
50
|
+
|
|
51
|
+
export const stats = {
|
|
52
|
+
lastCallAt: null,
|
|
53
|
+
calls: 0,
|
|
54
|
+
lastAccount: null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const MIN_WEIGHT = 1;
|
|
58
|
+
|
|
59
|
+
function credFingerprint(a) {
|
|
60
|
+
return `${a.token || ''}|${a.uid || ''}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function markCooling(id, ms = ACCOUNT_COOLING_MS) {
|
|
64
|
+
cooling.set(id, Date.now() + ms);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function markDead(account) {
|
|
68
|
+
dead.set(account.id, credFingerprint(account));
|
|
69
|
+
cooling.set(account.id, Number.MAX_SAFE_INTEGER);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isDead(account) {
|
|
73
|
+
const fp = dead.get(account.id);
|
|
74
|
+
if (fp === undefined) return false;
|
|
75
|
+
if (fp === credFingerprint(account)) return true;
|
|
76
|
+
dead.delete(account.id);
|
|
77
|
+
cooling.delete(account.id);
|
|
78
|
+
logger.info('TRAE-GW', '{label} 凭据已更新,解除拉黑', { label: account.name || account.uid || account.id });
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function __resetForTests() {
|
|
83
|
+
cooling.clear();
|
|
84
|
+
dead.clear();
|
|
85
|
+
weights.clear();
|
|
86
|
+
swrr.clear();
|
|
87
|
+
stats.lastCallAt = null;
|
|
88
|
+
stats.calls = 0;
|
|
89
|
+
stats.lastAccount = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function accountWeight(a) {
|
|
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
|
+
// 额度查询失败保留上次快照权重(网络抖动不该把主力踢成保底权重)
|
|
101
|
+
}
|
|
102
|
+
weights.set(a.id, { w, at: Date.now() });
|
|
103
|
+
return w;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const swrr = new Map();
|
|
107
|
+
function ensureSwrr(id) { if (!swrr.has(id)) swrr.set(id, 0); }
|
|
108
|
+
|
|
109
|
+
async function rotationQueue() {
|
|
110
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'trae');
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const ready = [];
|
|
113
|
+
for (const a of all) {
|
|
114
|
+
if (isDead(a)) continue;
|
|
115
|
+
if ((cooling.get(a.id) || 0) > now) continue;
|
|
116
|
+
if (!a.token) continue;
|
|
117
|
+
ensureSwrr(a.id);
|
|
118
|
+
const w = await accountWeight(a);
|
|
119
|
+
swrr.set(a.id, (swrr.get(a.id) || 0) + w);
|
|
120
|
+
ready.push({ account: a, weight: w });
|
|
121
|
+
}
|
|
122
|
+
if (!ready.length) return [];
|
|
123
|
+
|
|
124
|
+
let pick = ready[0];
|
|
125
|
+
for (const it of ready) if ((swrr.get(it.account.id) || 0) > (swrr.get(pick.account.id) || 0)) pick = it;
|
|
126
|
+
const total = ready.reduce((s, it) => s + it.weight, 0);
|
|
127
|
+
swrr.set(pick.account.id, (swrr.get(pick.account.id) || 0) - total);
|
|
128
|
+
|
|
129
|
+
const rest = ready.filter((it) => it.account.id !== pick.account.id)
|
|
130
|
+
.sort((x, y) => y.weight - x.weight)
|
|
131
|
+
.map((it) => it.account);
|
|
132
|
+
return [pick.account, ...rest];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function unavailableReason() {
|
|
136
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'trae');
|
|
137
|
+
if (!all.length) return '没有 Trae 账号:请在 Trae 客户端登录后在面板进行本机导入';
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
const n = { dead: 0, cooling: 0, noToken: 0 };
|
|
140
|
+
for (const a of all) {
|
|
141
|
+
if (isDead(a)) n.dead++;
|
|
142
|
+
else if ((cooling.get(a.id) || 0) > now) n.cooling++;
|
|
143
|
+
else if (!a.token) n.noToken++;
|
|
144
|
+
}
|
|
145
|
+
const parts = [];
|
|
146
|
+
if (n.cooling) parts.push(`${n.cooling} 个临时冷却中(稍后自动恢复)`);
|
|
147
|
+
if (n.dead) parts.push(`${n.dead} 个凭据已失效(需在 Trae 客户端重新登录后导入)`);
|
|
148
|
+
if (n.noToken) parts.push(`${n.noToken} 个缺少 Token`);
|
|
149
|
+
return `没有可用的 Trae 账号:${parts.join(';')}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const LOOPBACK_RE = /^(::1|::ffff:127\.0\.0\.1|127\.0\.0\.1)$/;
|
|
153
|
+
function isLoopbackRemote(remote) {
|
|
154
|
+
return !remote || LOOPBACK_RE.test(String(remote));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function remoteHost(remote) {
|
|
158
|
+
return String(remote || '').replace(/^::ffff:/i, '').trim();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function remoteAllowed(remote, allowList) {
|
|
162
|
+
const host = remoteHost(remote);
|
|
163
|
+
if (!host) return false;
|
|
164
|
+
return (Array.isArray(allowList) ? allowList : []).some((entry) => {
|
|
165
|
+
const e = String(entry || '').trim();
|
|
166
|
+
if (!e) return false;
|
|
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
|
+
}
|
|
174
|
+
return host.toLowerCase() === e.toLowerCase();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function readRawBody(req) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const chunks = [];
|
|
181
|
+
let size = 0;
|
|
182
|
+
const limit = 20 * 1024 * 1024;
|
|
183
|
+
req.on('data', (chunk) => {
|
|
184
|
+
size += chunk.length;
|
|
185
|
+
if (size > limit) {
|
|
186
|
+
req.pause();
|
|
187
|
+
const err = new Error('请求体过大(上限 20MB)');
|
|
188
|
+
err.status = 413;
|
|
189
|
+
reject(err);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
chunks.push(chunk);
|
|
193
|
+
});
|
|
194
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
195
|
+
req.on('error', reject);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 将 Anthropic messages 格式转成 Trae query 单轮/多轮提示词
|
|
201
|
+
*/
|
|
202
|
+
function buildTraeQuery(bodyObj) {
|
|
203
|
+
const parts = [];
|
|
204
|
+
if (bodyObj.system) {
|
|
205
|
+
const sysText = typeof bodyObj.system === 'string'
|
|
206
|
+
? bodyObj.system
|
|
207
|
+
: Array.isArray(bodyObj.system)
|
|
208
|
+
? bodyObj.system.map((s) => (typeof s === 'string' ? s : s.text || '')).join('\n')
|
|
209
|
+
: '';
|
|
210
|
+
if (sysText.trim()) parts.push(`System: ${sysText.trim()}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const msgs = Array.isArray(bodyObj.messages) ? bodyObj.messages : [];
|
|
214
|
+
for (const m of msgs) {
|
|
215
|
+
const roleLabel = m.role === 'assistant' ? 'Assistant' : 'User';
|
|
216
|
+
let text = '';
|
|
217
|
+
if (typeof m.content === 'string') {
|
|
218
|
+
text = m.content;
|
|
219
|
+
} else if (Array.isArray(m.content)) {
|
|
220
|
+
text = m.content
|
|
221
|
+
.map((c) => (c.type === 'text' ? c.text : c.type === 'image' ? '[Image]' : ''))
|
|
222
|
+
.filter(Boolean)
|
|
223
|
+
.join('\n');
|
|
224
|
+
}
|
|
225
|
+
if (text.trim()) {
|
|
226
|
+
parts.push(`${roleLabel}: ${text.trim()}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!parts.length) {
|
|
231
|
+
return '你好';
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 如果只有一条用户消息且没有系统消息,直接返回该文本
|
|
235
|
+
if (parts.length === 1 && parts[0].startsWith('User: ')) {
|
|
236
|
+
return parts[0].slice(6).trim();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return parts.join('\n\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 处理 Trae 本地网关请求
|
|
244
|
+
*/
|
|
245
|
+
export async function handleGateway(req, res) {
|
|
246
|
+
if (req.method !== 'POST') {
|
|
247
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
248
|
+
return res.end(JSON.stringify({ error: 'POST /gateway/trae/v1/messages(Anthropic /v1/messages 形态)' }));
|
|
249
|
+
}
|
|
250
|
+
const settings = await loadSettings();
|
|
251
|
+
if (settings.traeGateway !== true) {
|
|
252
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
253
|
+
return res.end(JSON.stringify({ error: 'Trae 本地网关未开启(面板 → Trae → 接口设置)' }));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 局域网访问控制
|
|
257
|
+
const remote = (req.socket && req.socket.remoteAddress) || '';
|
|
258
|
+
if (!isLoopbackRemote(remote)) {
|
|
259
|
+
if (settings.traeGatewayLan !== true) {
|
|
260
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
261
|
+
return res.end(JSON.stringify({ error: 'Trae 网关未允许局域网访问(面板 → Trae → 接口设置)' }));
|
|
262
|
+
}
|
|
263
|
+
if (!remoteAllowed(remote, settings.traeGatewayAllow)) {
|
|
264
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
265
|
+
return res.end(JSON.stringify({ error: `来源 ${remoteHost(remote) || '未知'} 不在 Trae 网关 IP 白名单内` }));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let rawBody;
|
|
270
|
+
try {
|
|
271
|
+
rawBody = await readRawBody(req);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
res.writeHead(e.status || 400, { 'Content-Type': 'application/json' });
|
|
274
|
+
res.on('finish', () => req.destroy());
|
|
275
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let bodyObj = null;
|
|
279
|
+
try {
|
|
280
|
+
bodyObj = JSON.parse(rawBody);
|
|
281
|
+
} catch {
|
|
282
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
283
|
+
return res.end(JSON.stringify({ error: '无效的 JSON 请求体' }));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const requestedModel = (bodyObj.model || '').trim() || DEFAULT_MODEL;
|
|
287
|
+
const isStream = bodyObj.stream !== false;
|
|
288
|
+
const promptText = buildTraeQuery(bodyObj);
|
|
289
|
+
|
|
290
|
+
const queue = await rotationQueue();
|
|
291
|
+
if (!queue.length) {
|
|
292
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
293
|
+
return res.end(JSON.stringify({ error: await unavailableReason() }));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
stats.lastCallAt = new Date().toISOString();
|
|
297
|
+
stats.calls += 1;
|
|
298
|
+
|
|
299
|
+
const attempted = [];
|
|
300
|
+
const clientAbort = new AbortController();
|
|
301
|
+
req.once('close', () => { if (!res.writableEnded) clientAbort.abort(); });
|
|
302
|
+
|
|
303
|
+
for (let i = 0; i < Math.min(MAX_ATTEMPTS, queue.length); i++) {
|
|
304
|
+
const account = queue[i];
|
|
305
|
+
if (isDead(account) || (cooling.get(account.id) || 0) > Date.now()) continue;
|
|
306
|
+
const label = account.name || account.uid || account.id;
|
|
307
|
+
|
|
308
|
+
let token = account.token;
|
|
309
|
+
const sessionPayload = {
|
|
310
|
+
mode: 'code',
|
|
311
|
+
environment_id: 'default',
|
|
312
|
+
initial_message: {
|
|
313
|
+
chat_session_id: '',
|
|
314
|
+
content: [],
|
|
315
|
+
query: JSON.stringify([{ type: 'text', data: { content: promptText } }]),
|
|
316
|
+
model_name: requestedModel,
|
|
317
|
+
agent_type: 'solo_agent_remote',
|
|
318
|
+
model_selection_strategy: 'manual',
|
|
319
|
+
common_params: JSON.stringify({
|
|
320
|
+
language: 'zh-cn',
|
|
321
|
+
app_language: 'zh-cn',
|
|
322
|
+
quality: 'stable',
|
|
323
|
+
app_version: '1.0.0.1229',
|
|
324
|
+
web_id: '',
|
|
325
|
+
user_identity: 'Free',
|
|
326
|
+
is_freshman: '0',
|
|
327
|
+
biz_user_id: '',
|
|
328
|
+
user_unique_id: '',
|
|
329
|
+
scope: 'marscode-cn',
|
|
330
|
+
tenant: 'marscode',
|
|
331
|
+
region: 'CN',
|
|
332
|
+
aiRegion: 'CN',
|
|
333
|
+
is_privacy_mode: 0,
|
|
334
|
+
privacy_mode: 'off',
|
|
335
|
+
solo_chat_mode: 'code',
|
|
336
|
+
}),
|
|
337
|
+
},
|
|
338
|
+
env: 'remote',
|
|
339
|
+
auto_create_project: false,
|
|
340
|
+
origin: 'web',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const makeHeaders = (tok) => ({
|
|
344
|
+
Authorization: tok.startsWith('Cloud-IDE-JWT ') ? tok : `Cloud-IDE-JWT ${tok.trim()}`,
|
|
345
|
+
'Content-Type': 'application/json',
|
|
346
|
+
'X-Trae-Client-Type': 'web',
|
|
347
|
+
Referer: 'https://solo.trae.cn/',
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
let sessionRes;
|
|
351
|
+
try {
|
|
352
|
+
sessionRes = await fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions`, {
|
|
353
|
+
method: 'POST',
|
|
354
|
+
headers: makeHeaders(token),
|
|
355
|
+
body: JSON.stringify(sessionPayload),
|
|
356
|
+
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
357
|
+
connectMs: UPSTREAM_CONNECT_MS,
|
|
358
|
+
signal: clientAbort.signal,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// 401 时若为本机导入账号,尝试对齐本地最新凭据
|
|
362
|
+
if (sessionRes.status === 401) {
|
|
363
|
+
try {
|
|
364
|
+
const { readLiveAuth } = await import('./traeLocal.js');
|
|
365
|
+
const live = readLiveAuth();
|
|
366
|
+
if (live?.auth?.token && live.auth.token !== token) {
|
|
367
|
+
token = live.auth.token;
|
|
368
|
+
account.token = token;
|
|
369
|
+
const { withAccounts } = await import('./store.js');
|
|
370
|
+
await withAccounts((list) => {
|
|
371
|
+
const cur = list.find((a) => a.id === account.id);
|
|
372
|
+
if (cur) cur.token = token;
|
|
373
|
+
});
|
|
374
|
+
logger.info('TRAE-GW', '{label} 凭据已对齐本地客户端,重试会话创建...', { label });
|
|
375
|
+
sessionRes = await fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions`, {
|
|
376
|
+
method: 'POST',
|
|
377
|
+
headers: makeHeaders(token),
|
|
378
|
+
body: JSON.stringify(sessionPayload),
|
|
379
|
+
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
380
|
+
connectMs: UPSTREAM_CONNECT_MS,
|
|
381
|
+
signal: clientAbort.signal,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
} catch {}
|
|
385
|
+
}
|
|
386
|
+
} catch (e) {
|
|
387
|
+
if (clientAbort.signal.aborted) return;
|
|
388
|
+
attempted.push({ account: label, ok: false, error: e.message });
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (sessionRes.status === 401) {
|
|
393
|
+
markDead(account);
|
|
394
|
+
attempted.push({ account: label, ok: false, error: 'Token 彻底失效 (401),拉黑' });
|
|
395
|
+
logger.info('TRAE-GW', '{label} 凭据失效,拉黑', { label });
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
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 });
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const sessionData = await sessionRes.json().catch(() => null);
|
|
407
|
+
if (!sessionRes.ok || sessionData?.code !== 0 || !sessionData?.data?.chat_session_id) {
|
|
408
|
+
const errMsg = sessionData?.message || `HTTP ${sessionRes.status}`;
|
|
409
|
+
attempted.push({ account: label, ok: false, error: `会话创建失败: ${errMsg}` });
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const chatSessionId = sessionData.data.chat_session_id;
|
|
414
|
+
const messageId = sessionData.data.message_id;
|
|
415
|
+
|
|
416
|
+
// 清理 session 的辅助函数
|
|
417
|
+
const cleanupSession = () => {
|
|
418
|
+
fetch(`${TRAE_SOLO_BASE}/chat_sessions/${chatSessionId}`, {
|
|
419
|
+
method: 'DELETE',
|
|
420
|
+
headers: makeHeaders(token),
|
|
421
|
+
}).catch(() => {});
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// 获取 events 流
|
|
425
|
+
let eventsRes;
|
|
426
|
+
try {
|
|
427
|
+
// 走 fetchJsonRace:连接段 15s 限时 + 统一代理 / 直连兜底与会话请求自洽(此前裸 fetch——
|
|
428
|
+
// 上游黑洞时一直挂到客户端断开,也整体不走面板配置的出口代理,不是统一的接口设计)
|
|
429
|
+
eventsRes = await fetchJsonRace(`${TRAE_SOLO_BASE}/chat_sessions/${chatSessionId}/events?reply_to_message_id=${encodeURIComponent(messageId)}`, {
|
|
430
|
+
headers: {
|
|
431
|
+
Authorization: token.startsWith('Cloud-IDE-JWT ') ? token : `Cloud-IDE-JWT ${token.trim()}`,
|
|
432
|
+
Accept: 'text/event-stream',
|
|
433
|
+
'X-Trae-Client-Type': 'web',
|
|
434
|
+
Referer: 'https://solo.trae.cn/',
|
|
435
|
+
},
|
|
436
|
+
signal: clientAbort.signal,
|
|
437
|
+
timeoutMs: UPSTREAM_TIMEOUT_MS,
|
|
438
|
+
connectMs: 15_000,
|
|
439
|
+
});
|
|
440
|
+
} catch (e) {
|
|
441
|
+
cleanupSession();
|
|
442
|
+
if (clientAbort.signal.aborted) return;
|
|
443
|
+
attempted.push({ account: label, ok: false, error: `建立事件流失败: ${e.message}` });
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!eventsRes.ok || !eventsRes.body) {
|
|
448
|
+
cleanupSession();
|
|
449
|
+
attempted.push({ account: label, ok: false, error: `事件流 HTTP ${eventsRes.status}` });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
stats.lastAccount = label;
|
|
454
|
+
logger.debug('TRAE-GW', '{label} 连接成功,开始流式输出 ({model})', { label, model: requestedModel });
|
|
455
|
+
|
|
456
|
+
// 成功建立流,按 isStream 分发
|
|
457
|
+
if (isStream) {
|
|
458
|
+
res.writeHead(200, {
|
|
459
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
460
|
+
'Cache-Control': 'no-cache',
|
|
461
|
+
Connection: 'keep-alive',
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
const msgId = `msg_${Date.now()}_${chatSessionId.slice(0, 8)}`;
|
|
465
|
+
let emittedThinking = '';
|
|
466
|
+
let emittedText = '';
|
|
467
|
+
let thinkingOpen = false;
|
|
468
|
+
let textOpen = false;
|
|
469
|
+
let nextBlockIndex = 0;
|
|
470
|
+
let thinkingIndex = 0;
|
|
471
|
+
let textIndex = 0;
|
|
472
|
+
let usageInfo = null;
|
|
473
|
+
|
|
474
|
+
// 发送 message_start
|
|
475
|
+
const sendSse = (event, data) => {
|
|
476
|
+
if (res.writableEnded) return;
|
|
477
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
sendSse('message_start', {
|
|
481
|
+
type: 'message_start',
|
|
482
|
+
message: {
|
|
483
|
+
id: msgId,
|
|
484
|
+
type: 'message',
|
|
485
|
+
role: 'assistant',
|
|
486
|
+
model: requestedModel,
|
|
487
|
+
content: [],
|
|
488
|
+
stop_reason: null,
|
|
489
|
+
stop_sequence: null,
|
|
490
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
491
|
+
},
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const reader = eventsRes.body.getReader();
|
|
495
|
+
const decoder = new TextDecoder();
|
|
496
|
+
let buffer = '';
|
|
497
|
+
|
|
498
|
+
let streamCompleted = false;
|
|
499
|
+
try {
|
|
500
|
+
while (true) {
|
|
501
|
+
const { done, value } = await reader.read();
|
|
502
|
+
if (done) { streamCompleted = true; break; }
|
|
503
|
+
buffer += decoder.decode(value, { stream: true });
|
|
504
|
+
const lines = buffer.split('\n');
|
|
505
|
+
buffer = lines.pop();
|
|
506
|
+
|
|
507
|
+
for (const line of lines) {
|
|
508
|
+
if (!line.startsWith('data:')) continue;
|
|
509
|
+
const raw = line.slice(5).trim();
|
|
510
|
+
if (!raw) continue;
|
|
511
|
+
let data;
|
|
512
|
+
try { data = JSON.parse(raw); } catch { continue; }
|
|
513
|
+
|
|
514
|
+
// 1. Thinking delta
|
|
515
|
+
if (data.reasoning_content && data.reasoning_content.length > emittedThinking.length) {
|
|
516
|
+
const delta = data.reasoning_content.slice(emittedThinking.length);
|
|
517
|
+
emittedThinking = data.reasoning_content;
|
|
518
|
+
if (!thinkingOpen) {
|
|
519
|
+
thinkingIndex = nextBlockIndex++;
|
|
520
|
+
sendSse('content_block_start', {
|
|
521
|
+
type: 'content_block_start',
|
|
522
|
+
index: thinkingIndex,
|
|
523
|
+
content_block: { type: 'thinking', thinking: '' },
|
|
524
|
+
});
|
|
525
|
+
thinkingOpen = true;
|
|
526
|
+
}
|
|
527
|
+
sendSse('content_block_delta', {
|
|
528
|
+
type: 'content_block_delta',
|
|
529
|
+
index: thinkingIndex,
|
|
530
|
+
delta: { type: 'thinking_delta', thinking: delta },
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 2. Text delta (thought or summary)
|
|
535
|
+
const candidateText = data.thought || data.tool_call_info?.params?.summary || '';
|
|
536
|
+
if (candidateText && candidateText.length > emittedText.length) {
|
|
537
|
+
const delta = candidateText.slice(emittedText.length);
|
|
538
|
+
emittedText = candidateText;
|
|
539
|
+
if (thinkingOpen) {
|
|
540
|
+
sendSse('content_block_stop', { type: 'content_block_stop', index: thinkingIndex });
|
|
541
|
+
thinkingOpen = false;
|
|
542
|
+
}
|
|
543
|
+
if (!textOpen) {
|
|
544
|
+
textIndex = nextBlockIndex++;
|
|
545
|
+
sendSse('content_block_start', {
|
|
546
|
+
type: 'content_block_start',
|
|
547
|
+
index: textIndex,
|
|
548
|
+
content_block: { type: 'text', text: '' },
|
|
549
|
+
});
|
|
550
|
+
textOpen = true;
|
|
551
|
+
}
|
|
552
|
+
sendSse('content_block_delta', {
|
|
553
|
+
type: 'content_block_delta',
|
|
554
|
+
index: textIndex,
|
|
555
|
+
delta: { type: 'text_delta', text: delta },
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 3. Usage info
|
|
560
|
+
if (data.prompt_tokens !== undefined || data.last_turn_total_tokens !== undefined) {
|
|
561
|
+
usageInfo = data;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
} catch (err) {
|
|
566
|
+
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
567
|
+
logger.debug('TRAE-GW', '流式读取中断:{err}', { err: err.message });
|
|
568
|
+
} finally {
|
|
569
|
+
if (thinkingOpen) {
|
|
570
|
+
sendSse('content_block_stop', { type: 'content_block_stop', index: thinkingIndex });
|
|
571
|
+
thinkingOpen = false;
|
|
572
|
+
}
|
|
573
|
+
if (textOpen) {
|
|
574
|
+
sendSse('content_block_stop', { type: 'content_block_stop', index: textIndex });
|
|
575
|
+
textOpen = false;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const inputTokens = Number(usageInfo?.prompt_tokens) || 0;
|
|
579
|
+
const outputTokens = Number(usageInfo?.completion_tokens) || Math.ceil((emittedThinking.length + emittedText.length) / 3);
|
|
580
|
+
const cacheReadTokens = Number(usageInfo?.cache_read_input_tokens) || 0;
|
|
581
|
+
|
|
582
|
+
const deltaUsage = { output_tokens: outputTokens };
|
|
583
|
+
if (inputTokens > 0) deltaUsage.input_tokens = inputTokens;
|
|
584
|
+
if (cacheReadTokens > 0) deltaUsage.cache_read_input_tokens = cacheReadTokens;
|
|
585
|
+
|
|
586
|
+
// 上游没正常收尾(断流 / EOF / reader 抛错)时,stop_reason 别伪装成 end_turn——
|
|
587
|
+
// 客户端把截断的字当完整答案就再也看不见了(两个分支都报 interrupted)。
|
|
588
|
+
const stopReason = streamCompleted ? 'end_turn' : 'interrupted';
|
|
589
|
+
|
|
590
|
+
sendSse('message_delta', {
|
|
591
|
+
type: 'message_delta',
|
|
592
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
593
|
+
usage: deltaUsage,
|
|
594
|
+
});
|
|
595
|
+
sendSse('message_stop', { type: 'message_stop' });
|
|
596
|
+
res.end();
|
|
597
|
+
cleanupSession();
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
} else {
|
|
601
|
+
// 非流式响应
|
|
602
|
+
const reader = eventsRes.body.getReader();
|
|
603
|
+
const decoder = new TextDecoder();
|
|
604
|
+
let buffer = '';
|
|
605
|
+
let fullThinking = '';
|
|
606
|
+
let fullText = '';
|
|
607
|
+
let usageInfo = null;
|
|
608
|
+
let streamCompleted = false;
|
|
609
|
+
|
|
610
|
+
try {
|
|
611
|
+
while (true) {
|
|
612
|
+
const { done, value } = await reader.read();
|
|
613
|
+
if (done) { streamCompleted = true; break; }
|
|
614
|
+
buffer += decoder.decode(value, { stream: true });
|
|
615
|
+
const lines = buffer.split('\n');
|
|
616
|
+
buffer = lines.pop();
|
|
617
|
+
|
|
618
|
+
for (const line of lines) {
|
|
619
|
+
if (!line.startsWith('data:')) continue;
|
|
620
|
+
const raw = line.slice(5).trim();
|
|
621
|
+
if (!raw) continue;
|
|
622
|
+
try {
|
|
623
|
+
const data = JSON.parse(raw);
|
|
624
|
+
if (data.reasoning_content) fullThinking = data.reasoning_content;
|
|
625
|
+
const t = data.thought || data.tool_call_info?.params?.summary;
|
|
626
|
+
if (t) fullText = t;
|
|
627
|
+
if (data.prompt_tokens !== undefined || data.last_turn_total_tokens !== undefined) {
|
|
628
|
+
usageInfo = data;
|
|
629
|
+
}
|
|
630
|
+
} catch {}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
} catch (err) {
|
|
634
|
+
// 客户端主动断开(点停止/换一句)属正常收尾,不是故障
|
|
635
|
+
logger.debug('TRAE-GW', '非流式读取中断:{err}', { err: err.message });
|
|
636
|
+
} finally {
|
|
637
|
+
cleanupSession();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const content = [];
|
|
641
|
+
if (fullThinking) content.push({ type: 'thinking', thinking: fullThinking });
|
|
642
|
+
if (fullText) content.push({ type: 'text', text: fullText });
|
|
643
|
+
|
|
644
|
+
const inputTokens = Number(usageInfo?.prompt_tokens) || 0;
|
|
645
|
+
const outputTokens = Number(usageInfo?.completion_tokens) || Math.ceil((fullThinking.length + fullText.length) / 3);
|
|
646
|
+
const cacheReadTokens = Number(usageInfo?.cache_read_input_tokens) || 0;
|
|
647
|
+
|
|
648
|
+
const usageObj = { input_tokens: inputTokens, output_tokens: outputTokens };
|
|
649
|
+
if (cacheReadTokens > 0) usageObj.cache_read_input_tokens = cacheReadTokens;
|
|
650
|
+
|
|
651
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
652
|
+
res.end(JSON.stringify({
|
|
653
|
+
id: `msg_${Date.now()}_${chatSessionId.slice(0, 8)}`,
|
|
654
|
+
type: 'message',
|
|
655
|
+
role: 'assistant',
|
|
656
|
+
model: requestedModel,
|
|
657
|
+
content,
|
|
658
|
+
stop_reason: streamCompleted ? 'end_turn' : 'interrupted',
|
|
659
|
+
stop_sequence: null,
|
|
660
|
+
usage: usageObj,
|
|
661
|
+
}));
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// 全部重试失败
|
|
667
|
+
const failedDetail = summarizeAttempts(attempted);
|
|
668
|
+
logger.warn('TRAE-GW', '全部 {n} 次尝试失败{detail}(下一请求自动重试)', { n: attempted.length, detail: failedDetail ? ':' + failedDetail : '' });
|
|
669
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
670
|
+
res.end(JSON.stringify({
|
|
671
|
+
error: '所有 Trae 账号均调用失败',
|
|
672
|
+
attempts: attempted,
|
|
673
|
+
}));
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export async function gatewayStatus() {
|
|
678
|
+
const enabled = await gatewayEnabled();
|
|
679
|
+
const all = (await loadAccounts()).filter((a) => a.provider === 'trae');
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
const settings = await loadSettings();
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
enabled,
|
|
685
|
+
lan: settings.traeGatewayLan === true,
|
|
686
|
+
allow: Array.isArray(settings.traeGatewayAllow) ? settings.traeGatewayAllow : [],
|
|
687
|
+
suggestAllow: gatewayHostSuggestion(),
|
|
688
|
+
stats: {
|
|
689
|
+
lastCallAt: stats.lastCallAt,
|
|
690
|
+
calls: stats.calls,
|
|
691
|
+
lastAccount: stats.lastAccount,
|
|
692
|
+
},
|
|
693
|
+
accounts: all.length,
|
|
694
|
+
cooling: all.filter((a) => !isDead(a) && (cooling.get(a.id) || 0) > now).map((a) => a.name || a.uid || a.id),
|
|
695
|
+
dead: all.filter((a) => isDead(a)).map((a) => a.name || a.uid || a.id),
|
|
696
|
+
endpoint: '/gateway/trae/v1/messages',
|
|
697
|
+
};
|
|
698
|
+
}
|