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,266 @@
1
+ /**
2
+ * Trae SSE → OpenAI chat-completions SSE 协议转换。
3
+ *
4
+ * ## 为什么必须有这一层
5
+ *
6
+ * shim 对 pi-ai 承诺的是 OpenAI 格式的流(`data: {"choices":[{"delta":...}]}`)。
7
+ * WorkBuddy 的上游本来就是 OpenAI 格式,直通即可;**Trae 的上游是自己的
8
+ * 命名事件格式**(`event:metadata` / `event:output` / `event:done`…)。
9
+ *
10
+ * 没有这一层时 shim 把 Trae 原生事件原样转发,pi-ai 认不出任何一个,
11
+ * 等不到 `delta`,撑到流空闲超时判失败——表现为「很慢、几乎都失败」,
12
+ * 而直连上游实测 1.5 秒就正常出字。协议转换是 Trae 接入的必要环节,
13
+ * 参照实现(dsh-connect-trae 的 solo-bridge)也是这么做的。
14
+ *
15
+ * ## 事件映射
16
+ *
17
+ * ```text
18
+ * Trae → OpenAI chunk
19
+ * ─────────────────────────────────────────────────────────
20
+ * output {response, → delta.content
21
+ * reasoning_content, delta.reasoning_content
22
+ * tool_calls[]} delta.tool_calls
23
+ * token_usage {prompt_tokens…} → (累积,附在末尾 chunk 上)
24
+ * error {code,message} → 流错误(上游失败要暴露,不能装成功)
25
+ * done {finish_reason} → finish_reason chunk + data: [DONE]
26
+ * 其余(metadata/timing…) → 忽略
27
+ * ```
28
+ */
29
+
30
+ import { randomUUID } from 'node:crypto'
31
+
32
+ /** 上游错误,转换层抛出、shim 捕获后按失败处置(换号或回错)。 */
33
+ export class TraeUpstreamError extends Error {
34
+ constructor(message, code) {
35
+ super(message)
36
+ this.name = 'TraeUpstreamError'
37
+ this.code = code
38
+ }
39
+ }
40
+
41
+ /** 容错 JSON 解析:Trae 的 data 偶有非 JSON 行,不能让它断流。 */
42
+ function parseJson(text) {
43
+ try {
44
+ return JSON.parse(text)
45
+ } catch {
46
+ return undefined
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 把上游的原始响应体转换成 OpenAI SSE 流。
52
+ *
53
+ * @param {object} response 上游 fetch 的 Response(body 为 Trae 命名事件流)
54
+ * @param {string} model DSH 请求的模型名(原样回填进 chunk,便于 pi-ai 对账)
55
+ * @returns {object} 带 OpenAI 格式 body 的新 Response
56
+ */
57
+ export function bridgeTraeSseToOpenAI(response, model) {
58
+ const source = response.body
59
+ if (source === null) {
60
+ return new Response(null, { status: 502 })
61
+ }
62
+
63
+ const id = `chatcmpl-${randomUUID().replaceAll('-', '').slice(0, 24)}`
64
+ const created = Math.floor(Date.now() / 1000)
65
+ const decoder = new TextDecoder()
66
+ const encoder = new TextEncoder()
67
+
68
+ /** 工具调用是否出现过:决定 finish_reason 用 tool_calls 还是 stop。 */
69
+ let sawToolCalls = false
70
+ /** 是否已发过 finish chunk:done 与流 EOF 可能都触发,只发一次。 */
71
+ let emittedFinish = false
72
+ /** 上游是否已明确结束(done 事件)。 */
73
+ let upstreamEnded = false
74
+ /** 上游业务错误:error 事件或 code>=4000 都算,不能伪装成成功。 */
75
+ let upstreamError
76
+ /** token_usage 累积:OpenAI 惯例附在最后一个 chunk 上。 */
77
+ let usage
78
+
79
+ /** 组一个 OpenAI chunk 的字节序列。 */
80
+ const chunk = (delta, finishReason = null) => encoder.encode(
81
+ `data: ${JSON.stringify({
82
+ id,
83
+ object: 'chat.completion.chunk',
84
+ created,
85
+ model,
86
+ choices: [{ index: 0, delta, finish_reason: finishReason }],
87
+ ...(usage === undefined ? {} : { usage }),
88
+ })}\n\n`,
89
+ )
90
+
91
+ /**
92
+ * 处理一条 Trae 事件。
93
+ *
94
+ * Trae 事件的 data 有两种形态:裸字符串(progress_notice)和 JSON 对象,
95
+ * 都要容错。
96
+ */
97
+ const consume = (event) => {
98
+ if (event.data === '[DONE]') {
99
+ // Trae 偶尔也发 [DONE];真正收尾在流 EOF 处统一处理
100
+ return
101
+ }
102
+ const payload = parseJson(event.data)
103
+
104
+ // —— 错误:error 事件,或任何带 code>=4000 的载荷 ——
105
+ const code = typeof payload?.code === 'number' ? payload.code : undefined
106
+ if (event.event === 'error' || (code !== undefined && code >= 4000)) {
107
+ upstreamError = new TraeUpstreamError(
108
+ typeof payload?.message === 'string' && payload.message !== ''
109
+ ? payload.message
110
+ : `Trae 上游错误(code ${code ?? '?'})`,
111
+ code,
112
+ )
113
+ return
114
+ }
115
+
116
+ // —— 用量 ——
117
+ if (event.event === 'token_usage') {
118
+ usage = {
119
+ ...(typeof payload?.prompt_tokens === 'number' ? { prompt_tokens: payload.prompt_tokens } : {}),
120
+ ...(typeof payload?.completion_tokens === 'number' ? { completion_tokens: payload.completion_tokens } : {}),
121
+ ...(typeof payload?.total_tokens === 'number' ? { total_tokens: payload.total_tokens } : {}),
122
+ }
123
+ return
124
+ }
125
+
126
+ // —— 结束 ——
127
+ if (event.event === 'done' || typeof payload?.finish_reason === 'string') {
128
+ upstreamEnded = true
129
+ if (!emittedFinish) {
130
+ emittedFinish = true
131
+ if (upstreamError !== undefined) {
132
+ controller.error(upstreamError)
133
+ return
134
+ }
135
+ // 工具调用过的流 finish_reason 要用 tool_calls,pi-ai 靠它走工具循环
136
+ controller.enqueue(chunk({}, sawToolCalls ? 'tool_calls' : (payload?.finish_reason ?? 'stop')))
137
+ }
138
+ return
139
+ }
140
+
141
+ // —— 增量内容 ——
142
+ if (
143
+ event.event === 'output'
144
+ || payload?.response !== undefined
145
+ || payload?.reasoning_content !== undefined
146
+ ) {
147
+ const delta = {}
148
+ const text = typeof payload?.response === 'string' ? payload.response : ''
149
+ const reasoning = typeof payload?.reasoning_content === 'string' ? payload.reasoning_content : ''
150
+ if (text !== '') delta.content = text
151
+ if (reasoning !== '') delta.reasoning_content = reasoning
152
+ // tool_calls:Trae 用 function_call{name,arguments},转回 OpenAI 的 function 形态
153
+ const calls = normalizeToolCalls(payload?.tool_calls)
154
+ if (calls.length > 0) {
155
+ sawToolCalls = true
156
+ delta.tool_calls = calls
157
+ }
158
+ // 全空的 delta 不发:pi-ai 对空 chunk 宽容,但没必要制造噪音
159
+ if (Object.keys(delta).length > 0) controller.enqueue(chunk(delta))
160
+ }
161
+ // metadata / timing_cost / progress_notice 等与内容无关,忽略
162
+ }
163
+
164
+ let controller
165
+ const stream = new ReadableStream({
166
+ start(c) {
167
+ controller = c
168
+ },
169
+ async pull() {
170
+ // 读取由 start 里的异步循环驱动;pull 不使用
171
+ },
172
+ cancel(reason) {
173
+ return source.cancel(reason)
174
+ },
175
+ })
176
+
177
+ // 驱动循环:读上游 → 解析 SSE → 转换 → 推给消费者
178
+ void (async () => {
179
+ const reader = source.getReader()
180
+ let buffer = ''
181
+ let currentEvent
182
+ const lines = []
183
+
184
+ /** 逐行喂 SSE;空行 = 一条事件结束。 */
185
+ const feed = (line) => {
186
+ if (line === '') {
187
+ if (lines.length > 0) {
188
+ const dataLines = []
189
+ for (const l of lines) {
190
+ if (l.startsWith('data:')) dataLines.push(l.slice(5).trimStart())
191
+ }
192
+ consume({ event: currentEvent, data: dataLines.join('\n') })
193
+ lines.length = 0
194
+ currentEvent = undefined
195
+ }
196
+ return
197
+ }
198
+ if (line.startsWith('event:')) currentEvent = line.slice(6).trim()
199
+ lines.push(line)
200
+ }
201
+
202
+ try {
203
+ while (true) {
204
+ const next = await reader.read()
205
+ if (next.done) break
206
+ buffer += decoder.decode(next.value, { stream: true })
207
+ let idx
208
+ while ((idx = buffer.indexOf('\n')) !== -1) {
209
+ const line = buffer.slice(0, idx).replace(/\r$/, '')
210
+ buffer = buffer.slice(idx + 1)
211
+ feed(line)
212
+ }
213
+ }
214
+ if (buffer !== '') feed(buffer)
215
+
216
+ // 上游 EOF:即使没有显式 done,也要合成 finish(Trae 有时省略)
217
+ if (!upstreamEnded) {
218
+ emittedFinish = true
219
+ if (upstreamError !== undefined) {
220
+ controller.error(upstreamError)
221
+ return
222
+ }
223
+ controller.enqueue(chunk({}, sawToolCalls ? 'tool_calls' : 'stop'))
224
+ }
225
+ if (upstreamError !== undefined) {
226
+ // done 已到但错误在先:仍要以失败收场
227
+ controller.error(upstreamError)
228
+ return
229
+ }
230
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
231
+ controller.close()
232
+ } catch (error) {
233
+ controller.error(error)
234
+ } finally {
235
+ reader.releaseLock()
236
+ }
237
+ })()
238
+
239
+ return new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } })
240
+
241
+ /** tool_calls 归一化:Trae 的 function_call 形态 → OpenAI 形态。 */
242
+ function normalizeToolCalls(value) {
243
+ if (!Array.isArray(value)) return []
244
+ const calls = []
245
+ for (const raw of value) {
246
+ if (typeof raw !== 'object' || raw === null) continue
247
+ const fn = typeof raw.function_call === 'object' && raw.function_call !== null
248
+ ? raw.function_call
249
+ : typeof raw.function === 'object' && raw.function !== null
250
+ ? raw.function
251
+ : {}
252
+ const name = typeof fn.name === 'string' ? fn.name : ''
253
+ if (name === '') continue // 上游要求 Name 必填,空的不转
254
+ calls.push({
255
+ index: typeof raw.index === 'number' ? raw.index : calls.length,
256
+ ...(typeof raw.id === 'string' ? { id: raw.id } : {}),
257
+ type: 'function',
258
+ function: {
259
+ name,
260
+ ...(typeof fn.arguments === 'string' ? { arguments: fn.arguments } : {}),
261
+ },
262
+ })
263
+ }
264
+ return calls
265
+ }
266
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * 解析 Trae 桌面端的 storage.json 凭证。
3
+ *
4
+ * ## 为什么走这条路
5
+ *
6
+ * Trae 授权页是把 token **fetch 投递到 `127.0.0.1:18080`** 的(不是导航跳转)。
7
+ * 当 DSH 跑在 NAS、浏览器在另一台电脑时,那个 127.0.0.1 指的是**用户自己的
8
+ * 电脑**,请求被拒 → 页面报「网络错误」,而且因为是 fetch 而非导航,
9
+ * 地址栏不会出现带 token 的链接,**连手动复制都拿不到**。
10
+ *
11
+ * 所以改为「从桌面端已登录的凭证里读」:
12
+ * Trae 桌面端把登录态写在 `storage.json` 的 `iCubeAuthInfo://icube.cloudide`
13
+ * 字段里(AES-CBC 加密)。用户把这个文件拷过来即可,不需要在 NAS 上
14
+ * 复现整个 OAuth 流程。
15
+ *
16
+ * ## 关键结论:解密不依赖本机
17
+ *
18
+ * 密钥完全由「硬编码盐值 + 密文自带的随机数」派生:
19
+ *
20
+ * ```text
21
+ * salt = SALT_A xor SALT_B (或 aes-private 用 SALT_C xor SALT_D)
22
+ * first = sha512(random) (random 取自密文第 7..38 字节)
23
+ * derived = sha512(first + salt)
24
+ * key = derived[0..16] iv = derived[16..32]
25
+ * ```
26
+ *
27
+ * 没有机器码、没有 DPAPI、没有系统密钥——**任何机器上都能解**。
28
+ * 明文前 64 字节是 sha512 校验值,用来验证解密正确。
29
+ */
30
+
31
+ import { createDecipheriv, createHash } from 'node:crypto'
32
+
33
+ /** storage.json 里存放鉴权信息的键。 */
34
+ export const AUTH_STORAGE_KEY = 'iCubeAuthInfo://icube.cloudide'
35
+
36
+ /** 设备中心 id 的键前缀,用于取 deviceId。 */
37
+ const DEVICE_CENTER_PREFIX = 'iCubeAuthInfo://icube-dc:'
38
+
39
+ // 硬编码盐值。这些是 Trae 客户端自身的常量,不是可配置项。
40
+ const SALT_A = Uint8Array.from([82, 9, 106, 213, 48, 54, 165, 56, 191, 64, 163, 158, 129, 243, 215, 251, 124, 227, 57, 130, 155, 47, 255, 135, 52, 142, 67, 68, 196, 222, 233, 203, 84, 123, 148, 50, 166, 194, 35, 61, 238, 76, 149, 11, 66, 250, 195, 78, 8, 46, 161, 102, 40, 217, 36, 178, 118, 91, 162, 73, 109, 139, 209, 37])
41
+ const SALT_B = Uint8Array.from([31, 221, 168, 51, 136, 7, 199, 49, 177, 18, 16, 89, 39, 128, 236, 95, 96, 81, 127, 169, 25, 181, 74, 13, 45, 229, 122, 159, 147, 201, 156, 239, 160, 224, 59, 77, 174, 42, 245, 176, 200, 235, 187, 60, 131, 83, 153, 97, 23, 43, 4, 126, 186, 119, 214, 38, 225, 105, 20, 99, 85, 33, 12, 125])
42
+ const SALT_C = Uint8Array.from([191, 192, 216, 250, 122, 246, 220, 97, 31, 254, 98, 27, 8, 72, 71, 176, 135, 99, 96, 18, 127, 101, 203, 104, 211, 102, 191, 125, 37, 72, 150, 156, 51, 229, 121, 35, 17, 153, 141, 177, 110, 131, 150, 128, 172, 255, 254, 6, 18, 140, 55, 62, 236, 249, 135, 64, 135, 12, 117, 4, 89, 149, 168, 209])
43
+ const SALT_D = Uint8Array.from([246, 204, 26, 232, 232, 70, 129, 109, 223, 146, 169, 242, 23, 241, 105, 145, 50, 196, 165, 42, 254, 120, 3, 54, 244, 207, 209, 85, 53, 6, 138, 106, 175, 148, 31, 204, 186, 186, 165, 182, 87, 142, 49, 10, 39, 110, 26, 154, 86, 56, 173, 125, 18, 64, 198, 225, 99, 99, 83, 82, 191, 134, 76, 170])
44
+
45
+ /** 两种密文头,对应两套盐值。 */
46
+ const HEADER_AES = Buffer.from([0x74, 0x63, 0x05, 0x10, 0x00, 0x00])
47
+ const HEADER_AES_PRIVATE = Buffer.from([18, 57, 32, 32, 2, 3])
48
+
49
+ /** 逐字节异或。 */
50
+ function xor(a, b) {
51
+ return Buffer.from(a.map((value, index) => value ^ (b[index] ?? 0)))
52
+ }
53
+
54
+ function saltFor(header) {
55
+ if (header.equals(HEADER_AES)) return xor(SALT_A, SALT_B)
56
+ if (header.equals(HEADER_AES_PRIVATE)) return xor(SALT_C, SALT_D)
57
+ throw new Error('无法识别的凭证加密头(可能不是 Trae 的 storage.json)')
58
+ }
59
+
60
+ /**
61
+ * 解密 `iCubeAuthInfo://icube.cloudide` 字段的值。
62
+ *
63
+ * @param {string} encoded base64 密文
64
+ * @returns {string} 明文 JSON 字符串
65
+ */
66
+ export function decryptAuthValue(encoded) {
67
+ const buffer = Buffer.from(String(encoded ?? '').trim(), 'base64')
68
+ if (buffer.length <= 102) throw new Error('凭证密文太短,可能复制不完整')
69
+
70
+ const salt = saltFor(buffer.subarray(0, 6))
71
+ const random = buffer.subarray(6, 38)
72
+ const encrypted = buffer.subarray(38)
73
+
74
+ const first = createHash('sha512').update(random).digest()
75
+ const derived = createHash('sha512').update(Buffer.concat([first, salt])).digest()
76
+ const decipher = createDecipheriv('aes-128-cbc', derived.subarray(0, 16), derived.subarray(16, 32))
77
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
78
+
79
+ if (decrypted.length < 64) throw new Error('凭证明文太短')
80
+ // 前 64 字节是明文自身的 sha512,用来验证解密是否成功
81
+ const expected = decrypted.subarray(0, 64)
82
+ const plaintext = decrypted.subarray(64)
83
+ const actual = createHash('sha512').update(plaintext).digest()
84
+ if (!expected.equals(actual)) {
85
+ throw new Error('凭证完整性校验失败(文件可能被改动或复制不完整)')
86
+ }
87
+ return plaintext.toString('utf8')
88
+ }
89
+
90
+ /** 值是明文 JSON 就直接用,是密文就解。 */
91
+ function readAuthJson(value) {
92
+ const text = String(value ?? '').trim()
93
+ if (text === '') throw new Error('凭证字段为空')
94
+ const plaintext = text.startsWith('{') ? text : decryptAuthValue(text)
95
+ let parsed
96
+ try {
97
+ parsed = JSON.parse(plaintext)
98
+ } catch {
99
+ throw new Error('凭证解密后不是合法 JSON')
100
+ }
101
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
102
+ throw new Error('凭证结构异常')
103
+ }
104
+ return parsed
105
+ }
106
+
107
+ /** 取第一个非空字符串。 */
108
+ function firstString(...values) {
109
+ for (const value of values) {
110
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ /** 时间字段可能是秒或毫秒,统一成毫秒。 */
116
+ function toMs(value) {
117
+ // 数字:秒(<1e12)或毫秒
118
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
119
+ return value > 1e12 ? value : value * 1000
120
+ }
121
+ // 字符串:可能是纯数字时间戳,也可能是 ISO 日期。
122
+ // Trae 各版本字段类型不统一——只处理数字会丢掉 ISO 形式,
123
+ // 导致 expiresAtMs 落成 0、token 被误判为已过期而立刻触发刷新。
124
+ if (typeof value === 'string' && value.trim() !== '') {
125
+ const numeric = Number(value)
126
+ if (Number.isFinite(numeric) && numeric > 0) {
127
+ return numeric > 1e12 ? numeric : numeric * 1000
128
+ }
129
+ const parsed = Date.parse(value)
130
+ return Number.isFinite(parsed) ? parsed : undefined
131
+ }
132
+ return undefined
133
+ }
134
+
135
+ /** 从 storage.json 文本里取设备标识。 */
136
+ function readDeviceId(storage) {
137
+ const keys = Object.keys(storage).filter(key => key.startsWith(DEVICE_CENTER_PREFIX))
138
+ // 只有一个候选时才用,避免多个设备 id 时猜错
139
+ if (keys.length !== 1) return { deviceId: '', machineId: '' }
140
+ const deviceId = keys[0].slice(DEVICE_CENTER_PREFIX.length)
141
+ const machineId = firstString(storage['telemetry.machineId']) ?? ''
142
+ return { deviceId, machineId }
143
+ }
144
+
145
+ /**
146
+ * 解析 Trae 桌面端的 storage.json。
147
+ *
148
+ * 支持两种输入:
149
+ * 1. 完整的 storage.json 内容(含 iCubeAuthInfo://icube.cloudide 键)
150
+ * 2. 直接就是那个字段的值(密文或明文 JSON)
151
+ *
152
+ * @param {string} text 文件内容或字段值
153
+ * @returns {object} 归一化后的凭证
154
+ */
155
+ export function parseTraeStorage(text) {
156
+ const raw = String(text ?? '').trim()
157
+ if (raw === '') throw new Error('内容为空')
158
+
159
+ let authValue
160
+ let device = { deviceId: '', machineId: '' }
161
+
162
+ if (raw.startsWith('{')) {
163
+ let document
164
+ try {
165
+ document = JSON.parse(raw)
166
+ } catch {
167
+ throw new Error('不是合法的 JSON')
168
+ }
169
+ if (document[AUTH_STORAGE_KEY] !== undefined) {
170
+ // 情况 1:完整 storage.json
171
+ authValue = document[AUTH_STORAGE_KEY]
172
+ device = readDeviceId(document)
173
+ } else if (document.token !== undefined || document.accessToken !== undefined) {
174
+ // 情况 2:已经是鉴权对象本身
175
+ authValue = raw
176
+ } else {
177
+ throw new Error(
178
+ `这份 JSON 里没有 ${AUTH_STORAGE_KEY} 字段,`
179
+ + '可能不是 Trae 的 storage.json(或选错了 edition 的文件)',
180
+ )
181
+ }
182
+ } else {
183
+ // 密文
184
+ authValue = raw
185
+ }
186
+
187
+ const auth = readAuthJson(authValue)
188
+ const accessToken = firstString(auth.token, auth.accessToken)
189
+ if (accessToken === undefined) throw new Error('凭证里没有 token/accessToken')
190
+
191
+ const account = typeof auth.account === 'object' && auth.account !== null ? auth.account : undefined
192
+ const refreshToken = firstString(auth.refreshToken) ?? ''
193
+ const userId = firstString(auth.userId, auth.uid) ?? ''
194
+
195
+ // 区域:优先凭证自带的 userRegion(大小写都可能),据它决定上游主机
196
+ const regionRaw = firstString(
197
+ typeof auth.userRegion === 'string' ? auth.userRegion : undefined,
198
+ auth.userRegion?.region,
199
+ auth.region,
200
+ )
201
+ const region = regionRaw?.toLowerCase() === 'sg' ? 'sg' : 'cn'
202
+
203
+ return {
204
+ accessToken,
205
+ refreshToken,
206
+ userId,
207
+ accountName: firstString(account?.username) ?? '',
208
+ host: firstString(auth.host) ?? '',
209
+ region,
210
+ expiresAtMs: toMs(auth.expiredAt ?? auth.expiresAt) ?? 0,
211
+ refreshExpiresAtMs: toMs(auth.refreshExpiredAt ?? auth.refreshExpiresAt),
212
+ deviceId: device.deviceId,
213
+ machineId: device.machineId,
214
+ }
215
+ }