dsh-account-pool 0.1.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.
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Trae(TRAE SOLO)上游客户端:对话、模型目录、签到、积分。
3
+ *
4
+ * 与 WorkBuddy 的关键差异都集中在请求体改写里(见 prepareChatBody):
5
+ * - content 必须包成数组 [{type:'text',text:'...'}]
6
+ * - 模型名要同时写进 config_name 与 model
7
+ * - 必带 function: 'solo_work_lite'
8
+ * - assistant 的 tool_calls 要转成上游的 function_call
9
+ */
10
+
11
+ import { bridgeTraeSseToOpenAI } from './trae-bridge.js'
12
+ import { TRAE } from './trae-accounts.js'
13
+
14
+ const { AGENT_HOST, UG_HOST, SOLO_FUNCTION, DEFAULT_MODEL, soloHeaders, ugHeaders, postJson } = TRAE
15
+
16
+ /** 对话与模型端点。 */
17
+ const EP_CHAT = '/api/agent/v3/llm_utils_chat'
18
+ const EP_MODELS = '/api/ide/v1/get_detail_param'
19
+
20
+ /** 签到与积分端点。 */
21
+ /**
22
+ * Trae 的业务码表(HTTP 200 也会出现的数字码)。
23
+ *
24
+ * 上游用「HTTP 200 + code != 0」表达业务失败,靠英文措辞判断不可靠——
25
+ * 换个近义说法就漏掉。数字码是稳定契约,用它分类。
26
+ *
27
+ * 取值来自实测(2026-09-21,真实账号):
28
+ * 4008 对话额度用尽 → 该换号(与本插件 hard_credit 同类)
29
+ * 1005 额度/鉴权问题 → 该换号
30
+ * 9074 参与用户过多 → 稍后重试(软失败)
31
+ * 9095 该设备今日已签到 → 不是错误,是"已完成",中性提示
32
+ * 9004 请求参数不正确 → 客户端问题(如缺 x-device-id)
33
+ * 4023 需要自带上游 key → BYOK 模板未配置
34
+ */
35
+ const TRAE_BIZ_CODES = {
36
+ 4008: { kind: 'hard_credit', label: '对话额度已用尽' },
37
+ 1005: { kind: 'hard_credit', label: '额度或鉴权异常' },
38
+ 9074: { kind: 'soft_rate', label: '参与用户太多,请稍后再试' },
39
+ 9095: { kind: 'checkin_done', label: '该设备今日已签到' },
40
+ 9004: { kind: 'client', label: '请求参数不正确' },
41
+ 4023: { kind: 'client', label: '该模型需要自带上游 API key' },
42
+ }
43
+
44
+ const EP_CHECKIN_STATUS = '/trae/api/v2/ug/checkin_credits/status'
45
+ const EP_CHECKIN_CLAIM = '/trae/api/v2/ug/checkin_credits/claim'
46
+ const EP_ENT_USAGE = '/trae/api/v2/pay/ide_user_ent_usage'
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // 请求体改写
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /** tool_choice 压平成字符串(上游只认字符串形式)。 */
53
+ function normalizeToolChoice(obj) {
54
+ const choice = obj.tool_choice
55
+ if (choice === undefined || choice === null || typeof choice === 'string') return
56
+ if (typeof choice === 'object' && typeof choice.type === 'string') {
57
+ obj.tool_choice = choice.type
58
+ return
59
+ }
60
+ delete obj.tool_choice
61
+ }
62
+
63
+ /**
64
+ * 把 DSH 发来的 OpenAI 请求体改写成 Trae 认的形态。
65
+ *
66
+ * 解析失败时原样返回,让上游自己报错,不在本地吞掉。
67
+ */
68
+ export function prepareChatBody(source) {
69
+ let body
70
+ try {
71
+ body = JSON.parse(source)
72
+ } catch {
73
+ return source
74
+ }
75
+ if (typeof body !== 'object' || body === null || Array.isArray(body)) return source
76
+
77
+ // 上游只支持流式
78
+ body.stream = true
79
+ body.function = SOLO_FUNCTION
80
+
81
+ if (Array.isArray(body.messages)) {
82
+ for (const message of body.messages) {
83
+ if (typeof message !== 'object' || message === null || Array.isArray(message)) continue
84
+
85
+ // assistant 带 tool_calls 时:OpenAI 的 function{name,arguments}
86
+ // 要转成上游的 function_call{name,arguments};没有 name 的丢弃
87
+ // (上游要求 Name 必填,留着会让整个请求 400)。
88
+ if (message.role === 'assistant' && Array.isArray(message.tool_calls)) {
89
+ const kept = []
90
+ for (const call of message.tool_calls) {
91
+ if (typeof call !== 'object' || call === null) continue
92
+ if (typeof call.function === 'object' && call.function !== null) {
93
+ call.function_call = call.function
94
+ delete call.function
95
+ }
96
+ const name = call.function_call?.name
97
+ if (typeof name !== 'string' || name.trim() === '') continue
98
+ kept.push(call)
99
+ }
100
+ if (kept.length === 0) delete message.tool_calls
101
+ else message.tool_calls = kept
102
+ }
103
+
104
+ // content 字符串要包成数组形式;已经是数组就透传(多模态)
105
+ if (message.content === undefined || message.content === null) continue
106
+ if (typeof message.content === 'string') {
107
+ message.content = [{ type: 'text', text: message.content }]
108
+ }
109
+ }
110
+ }
111
+
112
+ // tools[].function.parameters 必须序列化成 JSON 字符串。
113
+ // 上游要的是字符串,传对象会被判无效(参照实现明确标注过这一点)。
114
+ if (Array.isArray(body.tools)) {
115
+ for (const tool of body.tools) {
116
+ const fn = tool?.function
117
+ if (typeof fn !== 'object' || fn === null) continue
118
+ if (typeof fn.parameters === 'object' && fn.parameters !== null) {
119
+ fn.parameters = JSON.stringify(fn.parameters)
120
+ }
121
+ }
122
+ }
123
+
124
+ // 模型名:空则用默认,且要同时写 config_name
125
+ let model = typeof body.model === 'string' ? body.model.trim() : ''
126
+ if (model === '') model = DEFAULT_MODEL
127
+ body.config_name = model
128
+ body.model = model
129
+
130
+ normalizeToolChoice(body)
131
+ return JSON.stringify(body)
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // 对话
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * 发一次对话请求,返回上游原始 Response(SSE 流)。
140
+ * 不做账号选择——由调用方决定用哪个凭证。
141
+ */
142
+ export async function chatStream(credential, bodyJson, signal) {
143
+ let response
144
+ try {
145
+ response = await fetch(`${AGENT_HOST}${EP_CHAT}`, {
146
+ method: 'POST',
147
+ headers: soloHeaders(credential, true),
148
+ body: bodyJson,
149
+ signal,
150
+ })
151
+ } catch (error) {
152
+ return { ok: false, status: 0, kind: 'server', message: `网络错误:${error?.message ?? error}` }
153
+ }
154
+
155
+ if (response.ok) {
156
+ // shim 对 pi-ai 承诺 OpenAI 格式的流;Trae 上游是自己的命名事件格式,
157
+ // 必须在这里转换(详见 trae-bridge.js)。没有这层,pi-ai 等不到
158
+ // delta 会一直等到流空闲超时——表现为「很慢、几乎都失败」。
159
+ const model = (() => {
160
+ try { return JSON.parse(bodyJson)?.model ?? 'unknown' } catch { return 'unknown' }
161
+ })()
162
+ return { ok: true, response: bridgeTraeSseToOpenAI(response, model) }
163
+ }
164
+
165
+ const text = (await response.text()).slice(0, 4096)
166
+ return {
167
+ ok: false,
168
+ status: response.status,
169
+ kind: classifyError(response.status, text),
170
+ message: text,
171
+ }
172
+ }
173
+
174
+ /**
175
+ * 失败分类。
176
+ *
177
+ * Trae 的限流/欠费信号与 WorkBuddy 不同(业务码走 body 里的 code),
178
+ * 这里按状态码 + 关键词组合判断。
179
+ */
180
+ /** 从响应体里取出业务码(HTTP 200 也可能带 code)。 */
181
+ function businessCodeOf(text) {
182
+ const match = /"code"\s*:\s*(\d+)/.exec(text)
183
+ return match === null ? undefined : Number(match[1])
184
+ }
185
+
186
+ export function classifyError(status, body) {
187
+ const text = String(body ?? '')
188
+
189
+ // 先看业务码:Trae 用「HTTP 200 + code != 0」表达失败,数字码比
190
+ // 英文措辞稳定。不先看它就只能靠关键词碰运气(曾靠 "quota" 侥幸命中)。
191
+ const code = businessCodeOf(text)
192
+ if (code !== undefined && code !== 0) {
193
+ const known = TRAE_BIZ_CODES[code]
194
+ if (known !== undefined) return known.kind
195
+ // 未知业务码:按区间粗分。4xxx 多为额度/鉴权类,9xxx 多为限流类
196
+ if (code >= 4000 && code < 5000) return 'hard_credit'
197
+ if (code >= 9000) return 'soft_rate'
198
+ return 'client'
199
+ }
200
+
201
+ if (status === 401 || /unauthorized|token.*(invalid|expired)/i.test(text)) return 'session_dead'
202
+ if (status === 403 || /forbidden|permission/i.test(text)) return 'hard_credit'
203
+ if (status === 402 || /balance|quota|credit.*(insufficient|exhaust)/i.test(text)) return 'hard_credit'
204
+ if (status === 429 || /rate.?limit|too many/i.test(text)) return 'soft_rate'
205
+ if (status === 404) return 'not_found'
206
+ if (status >= 500) return 'server'
207
+ return 'client'
208
+ }
209
+
210
+ /** 哪些失败值得换个账号重试。 */
211
+ export function isRetryableWithAnotherAccount(kind) {
212
+ return kind === 'hard_credit' || kind === 'soft_rate'
213
+ || kind === 'session_dead' || kind === 'server' || kind === 'not_found'
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // 模型目录
218
+ // ---------------------------------------------------------------------------
219
+
220
+ /**
221
+ * 拉取可用模型。
222
+ *
223
+ * 上游返回的是 config 列表,每项带 config_name 与 display_name;
224
+ * 超出列表的字段一律不编造。
225
+ */
226
+ /**
227
+ * 目录里有、但不该展示的模型。
228
+ *
229
+ * 目录(get_detail_param)与调用通道(llm_utils_chat)并不一致,
230
+ * 有些条目走 SOLO 轻量通道会失败。不剔除的话,用户选中后每次对话
231
+ * 都失败,而不是"看不到"。名单来自受控最小对话实测(2026-09-20),
232
+ * 对照组 glm-5.2 / DeepSeek-V4-Flash 同时间窗正常出字。
233
+ */
234
+ export const UNCALLABLE_MODELS = new Set([
235
+ // —— 通道不支持(4001 param is invalid)——
236
+ 'auto',
237
+ 'glm-5.3',
238
+ 'muse-spark-1.2-contributor-free',
239
+ 'n-mimo-v2.5-free',
240
+ 'nvidia/deepseek-v4-flash-0731',
241
+ // —— Trae 内部的辅助 agent(不是给用户直接对话用的)——
242
+ 'computer_use_subagent',
243
+ 'browser_use_subagent',
244
+ 'file_search_agent',
245
+ 'explore_sub_agent_v2',
246
+ 'summary',
247
+ // —— 未公开命名的内部模型(display_name 是占位符 "-")——
248
+ 'sagitta',
249
+ 'aquila',
250
+ ])
251
+
252
+ /**
253
+ * BYOK 模板:需要用户自带上游 API key 才有意义。
254
+ *
255
+ * 不配 key 时实测回 4023(gemini/claude/kimi/deepseek 系等 12 个)。
256
+ * gpt-5 / gpt-6 虽然实测有默认映射能调通,但那是上游的灰度行为,
257
+ * 随时可能收回——按「只保留正式模型」的口径统一剔除。
258
+ */
259
+ export const BYOK_MODEL_PREFIX = 'custom_model_'
260
+
261
+ /** 内部辅助 agent 的命名特征(防新增变体漏网)。 */
262
+ const INTERNAL_AGENT_PATTERN = /(^|_)(sub_?agent|agent|summary)(_|$)/i
263
+
264
+ /** 该模型是否属于不展示的类别。 */
265
+ export function isHiddenModel(id) {
266
+ const text = String(id)
267
+ if (UNCALLABLE_MODELS.has(text)) return true
268
+ if (text.startsWith(BYOK_MODEL_PREFIX)) return true
269
+ return INTERNAL_AGENT_PATTERN.test(text)
270
+ }
271
+
272
+ export async function fetchModels(credential) {
273
+ const envelope = await postJson(`${AGENT_HOST}${EP_MODELS}`, {
274
+ headers: soloHeaders(credential, false),
275
+ body: {
276
+ function: SOLO_FUNCTION,
277
+ config_names: null,
278
+ need_prompt: false,
279
+ current_config_info: null,
280
+ poly_prompt: true,
281
+ mode_type: null,
282
+ agent_type: null,
283
+ },
284
+ })
285
+
286
+ // 实测(2026-09-20,真实凭证):顶层就是 config_info_list(小写下划线),
287
+ // 没有 Result 包装、也不是大写驼峰。之前找错字段名导致
288
+ // 「HTTP 200 却解析出 0 个模型」。
289
+ const list = envelope.config_info_list
290
+ ?? envelope.Result?.ConfigInfoList
291
+ ?? envelope.ConfigInfoList
292
+ ?? []
293
+ if (!Array.isArray(list)) return []
294
+
295
+ const models = []
296
+ // DSH 的模型校验要求 id 唯一(重复直接抛 INVALID_CATALOG,
297
+ // 整个 provider 的模型都加载失败)。上游确实会给出重复的
298
+ // config_name(实测 DeepSeek-V4-Flash 出现两次),所以这里
299
+ // 必须按 id 去重:同 id 后出现的条目丢弃。
300
+ const seen = new Set()
301
+ for (const item of list) {
302
+ const id = String(item?.config_name ?? '').trim()
303
+ if (id === '') continue
304
+ if (isHiddenModel(id)) continue
305
+ if (seen.has(id)) continue
306
+ seen.add(id)
307
+ // display_name 为 "-" 或空是上游的占位符(未公开命名的内部模型,
308
+ // 实测 sagitta / aquila)。显示成 "- " 会让人以为数据坏了,
309
+ // 回退到 config_name。
310
+ const rawDisplay = String(item?.display_config?.display_name ?? '').trim()
311
+ const display = rawDisplay === '' || rawDisplay === '-' ? id : rawDisplay
312
+
313
+ // 字段名对照 dsh-connect-trae 的实测记录(2026-08-30):
314
+ // 上下文 = model_detail_list[].prompt_max_tokens,或 context_window_tokens.dev
315
+ // 最大输出 = model_detail_list[].max_tokens
316
+ // (不存在 max_input_tokens / max_output_tokens,读它们只会得到空值)
317
+ const detail = Array.isArray(item?.model_detail_list) && item.model_detail_list.length > 0
318
+ ? item.model_detail_list[0]
319
+ : {}
320
+ const ctxTokens = typeof item?.context_window_tokens === 'object' && item?.context_window_tokens !== null
321
+ ? item.context_window_tokens
322
+ : {}
323
+ const contextWindow = Number(detail.prompt_max_tokens ?? ctxTokens.dev ?? 0) || 128_000
324
+ const maxTokens = Number(detail.max_tokens ?? 0) || 16_000
325
+
326
+ models.push({
327
+ id,
328
+ // 展示名取 display_name,拿不到就用 id
329
+ name: display === '' ? id : display,
330
+ contextWindow,
331
+ maxTokens,
332
+ multimodal: false,
333
+ reasoning: undefined,
334
+ supportsToolCall: true,
335
+ })
336
+ }
337
+ return models
338
+ }
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // 签到与积分
342
+ // ---------------------------------------------------------------------------
343
+
344
+ /**
345
+ * 查签到状态(**只读**)。
346
+ * 比 WorkBuddy 好——那边没有只读接口,只能靠调幂等写接口判断。
347
+ *
348
+ * @returns {{checkedIn:boolean, credits:number, enabled:boolean}}
349
+ */
350
+ export async function checkinStatus(credential) {
351
+ const envelope = await postJson(`${UG_HOST}${EP_CHECKIN_STATUS}`, {
352
+ headers: ugHeaders(credential),
353
+ body: {},
354
+ })
355
+ const data = envelope.Result ?? envelope
356
+ return {
357
+ checkedIn: data.checked_in === true,
358
+ // did_checked_in 表示「这台设备今天已经签过了」——本账号没签,
359
+ // 但同设备上的另一个账号签了。Trae 的签到按设备计数,所以这个
360
+ // 状态必须与 checkedIn 区分开,否则界面会显示"未签到"让人反复点。
361
+ deviceCheckedIn: data.did_checked_in === true,
362
+ credits: Number(data.credits ?? 0),
363
+ enabled: data.enable !== false,
364
+ }
365
+ }
366
+
367
+ /** 领取签到奖励。 */
368
+ export async function checkinClaim(credential) {
369
+ await postJson(`${UG_HOST}${EP_CHECKIN_CLAIM}`, {
370
+ headers: ugHeaders(credential),
371
+ body: {},
372
+ })
373
+ }
374
+
375
+ /**
376
+ * 一键签到:先查状态,未签到才领(幂等,重复调用不会重复领)。
377
+ *
378
+ * @returns {{status:'done'|'already'|'failed'|'disabled', message:string}}
379
+ */
380
+ export async function checkin(credential) {
381
+ try {
382
+ const status = await checkinStatus(credential)
383
+ if (!status.enabled) return { status: 'disabled', message: '该账号未开启签到' }
384
+ if (status.checkedIn) return { status: 'already', message: '今天已签到' }
385
+
386
+ await checkinClaim(credential)
387
+ return { status: 'done', message: '签到成功' }
388
+ } catch (error) {
389
+ // 9095「该设备今日已签到」不是失败:Trae 的签到按**设备**计数,
390
+ // 同一台机器上的第二个账号领取时必然遇到。显示成红色"失败"会让人
391
+ // 以为账号坏了,其实它只是"今天已经签过了"。
392
+ if (error?.code === 9095) {
393
+ return { status: 'already', message: '该设备今日已签到(同设备的账号共享签到)' }
394
+ }
395
+ return { status: 'failed', message: error?.message ?? String(error) }
396
+ }
397
+ }
398
+
399
+ /** 查剩余积分(汇总所有权益包)。 */
400
+ export async function fetchCredits(credential) {
401
+ const envelope = await postJson(`${UG_HOST}${EP_ENT_USAGE}`, {
402
+ headers: ugHeaders(credential),
403
+ body: { require_usage: true },
404
+ })
405
+ // 实测:顶层直接是 usage_summary / user_entitlement_pack_list(无 Result 包装)
406
+ const data = envelope.Result ?? envelope
407
+
408
+ /**
409
+ * 口径必须与 Trae 桌面端的「用量管理」一致:**剩余 = 总量 − 已消耗**。
410
+ *
411
+ * 实测对照(2026-09-20):
412
+ * usage_summary = { total_amount: 2000, consumed_amount: 378.27 }
413
+ * 2000 − 378.27 = 1621.73 —— 与桌面显示完全一致。
414
+ *
415
+ * 之前按「各积分包的额度上限求和」算——但实测 quota 里放的是
416
+ * enable_solo_* 功能开关,根本没有数字字段,求和恒为 0;
417
+ * 恰好又撞上别处返回的 2000,掩盖了错误。
418
+ */
419
+ const summary = data?.usage_summary
420
+ const totalAmount = Number(summary?.total_amount)
421
+ const consumed = Number(summary?.consumed_amount)
422
+ if (Number.isFinite(totalAmount) && Number.isFinite(consumed)) {
423
+ // 保留两位小数:桌面显示也是两位(1621.73)。
424
+ // size = 总量,供界面悬停展示「总量 / 已用 / 剩余」。
425
+ return {
426
+ total: Math.round((totalAmount - consumed) * 100) / 100,
427
+ size: totalAmount,
428
+ }
429
+ }
430
+ return { total: 0 }
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // 每日任务编排
435
+ // ---------------------------------------------------------------------------
436
+
437
+ /**
438
+ * 对一个账号执行全部每日任务。
439
+ *
440
+ * Trae 的任务比 WorkBuddy 少:只有签到与积分查询,
441
+ * 没有旅行 / 连登兑换 / 抽奖那套活动体系。
442
+ *
443
+ * 顺序:先签到(可能有奖励),再查积分(拿到签到后的最新值)。
444
+ *
445
+ * @param {object} credential
446
+ * @returns {Promise<object>} 各任务结果
447
+ */
448
+ export async function runDailyTasks(credential) {
449
+ const out = {
450
+ accountId: credential.id,
451
+ nickname: credential.nickname ?? credential.uid,
452
+ }
453
+
454
+ out.checkin = await checkin(credential)
455
+
456
+ try {
457
+ const credits = await fetchCredits(credential)
458
+ out.credits = credits.total
459
+ } catch (error) {
460
+ out.creditsError = error?.message ?? String(error)
461
+ }
462
+
463
+ return out
464
+ }
465
+
466
+ /**
467
+ * 只读:账号当前状态(签到情况 + 积分)。
468
+ * 不改动任何东西,供界面展示。
469
+ */
470
+ export async function readStatus(credential) {
471
+ const out = { accountId: credential.id, nickname: credential.nickname ?? credential.uid }
472
+ try {
473
+ out.checkin = await checkinStatus(credential)
474
+ } catch (error) {
475
+ out.checkinError = error?.message ?? String(error)
476
+ }
477
+ try {
478
+ const credits = await fetchCredits(credential)
479
+ out.credits = credits.total
480
+ out.creditsSize = credits.size
481
+ } catch (error) {
482
+ out.creditsError = error?.message ?? String(error)
483
+ }
484
+ return out
485
+ }