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