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.
- package/README.md +362 -0
- package/cordis.patch.yml +26 -0
- package/lib/accounts.js +576 -0
- package/lib/client.js +2049 -0
- package/lib/headers.js +76 -0
- package/lib/index.js +1142 -0
- package/lib/selector.js +343 -0
- package/lib/shim.js +370 -0
- package/lib/tasks.js +429 -0
- package/lib/trae-accounts.js +453 -0
- package/lib/trae-bridge.js +266 -0
- package/lib/trae-storage.js +215 -0
- package/lib/trae-upstream.js +485 -0
- package/lib/upstream.js +610 -0
- package/lib/usage.js +420 -0
- package/package.json +68 -0
package/lib/shim.js
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback shim:插件自带的「网关」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:
|
|
5
|
+
* pi-ai provider 只认「一个 baseURL + 一个 apiKey」。但我们要在多账号之间
|
|
6
|
+
* 自动切换,而切换必须发生在每次请求——不能让 pi-ai 知道账号的存在。
|
|
7
|
+
* 所以在 127.0.0.1 上起一个 OpenAI 兼容端点,pi-ai 指向它;
|
|
8
|
+
* 它收到请求后自己选号、转发上游、失败就换号重试。
|
|
9
|
+
*
|
|
10
|
+
* DSH → pi-ai adapter → 本 shim(127.0.0.1:随机端口)→ copilot.tencent.com
|
|
11
|
+
* ↑ 自动切换就发生在这里
|
|
12
|
+
*
|
|
13
|
+
* 安全加固(参照 dsh-connect-workbuddy,安全代码不做「改善」):
|
|
14
|
+
* - 只绑 127.0.0.1,绝不监听其他网卡
|
|
15
|
+
* - 随机端口,随机进程内 secret 作 Bearer;真 token 不交给 pi-ai
|
|
16
|
+
* - Host 必须回环(防 DNS rebinding)
|
|
17
|
+
* - Origin 若存在必须回环(防浏览器跨站请求)
|
|
18
|
+
* - 常量时间比较 secret(防时序侧信道)
|
|
19
|
+
* - 请求体上限,防内存打爆
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
23
|
+
import { createServer } from 'node:http'
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
chatStream as workbuddyChatStream,
|
|
27
|
+
prepareChatBody as workbuddyPrepareBody,
|
|
28
|
+
isRetryableWithAnotherAccount as workbuddyRetryable,
|
|
29
|
+
} from './upstream.js'
|
|
30
|
+
import { extractUsage } from './usage.js'
|
|
31
|
+
|
|
32
|
+
/** 请求体上限 64 MiB(与参照项目一致,长上下文请求会比较大)。 */
|
|
33
|
+
const REQUEST_BODY_LIMIT = 64 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
/** 回环主机名白名单。 */
|
|
36
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]'])
|
|
37
|
+
|
|
38
|
+
/** 最多为一次请求尝试几个账号。防止全挂时无限重试。 */
|
|
39
|
+
const MAX_ACCOUNT_ATTEMPTS = 3
|
|
40
|
+
|
|
41
|
+
/** 上游失败类型 → 回给 pi-ai 的 HTTP 状态码。 */
|
|
42
|
+
const KIND_STATUS = {
|
|
43
|
+
hard_credit: 402,
|
|
44
|
+
soft_rate: 429,
|
|
45
|
+
session_dead: 401,
|
|
46
|
+
not_found: 502,
|
|
47
|
+
server: 502,
|
|
48
|
+
client: 400,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 从 Host 头里剥掉端口,IPv6 方括号要特殊处理。 */
|
|
52
|
+
function hostnameOfHost(host) {
|
|
53
|
+
let value = String(host).trim().toLowerCase()
|
|
54
|
+
if (value.startsWith('[')) {
|
|
55
|
+
const end = value.indexOf(']')
|
|
56
|
+
return end === -1 ? value : value.slice(0, end + 1)
|
|
57
|
+
}
|
|
58
|
+
const colon = value.lastIndexOf(':')
|
|
59
|
+
if (colon !== -1 && /^\d+$/.test(value.slice(colon + 1))) value = value.slice(0, colon)
|
|
60
|
+
return value
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Host 必须指向回环——DNS rebinding 攻击会带自己的域名,这里挡掉。 */
|
|
64
|
+
function hostIsLoopback(host) {
|
|
65
|
+
if (host === undefined || String(host).trim() === '') return false
|
|
66
|
+
return LOOPBACK_HOSTS.has(hostnameOfHost(host))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 浏览器发的 Origin 必须在回环;插件自己的 fetch 不带 Origin,放行。 */
|
|
70
|
+
function originIsLoopback(origin) {
|
|
71
|
+
if (origin === undefined || String(origin).trim() === '') return true
|
|
72
|
+
try {
|
|
73
|
+
const { hostname } = new URL(origin)
|
|
74
|
+
return LOOPBACK_HOSTS.has(hostname) || hostname === '::1'
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 常量时间比较,避免逐字节比较泄露 secret。 */
|
|
81
|
+
function secretMatches(provided, expected) {
|
|
82
|
+
const a = Buffer.from(String(provided ?? ''))
|
|
83
|
+
const b = Buffer.from(expected)
|
|
84
|
+
if (a.length !== b.length) return false
|
|
85
|
+
return timingSafeEqual(a, b)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 从 Authorization 头取出 Bearer token。 */
|
|
89
|
+
function bearerOf(headers) {
|
|
90
|
+
const raw = headers.authorization
|
|
91
|
+
if (typeof raw !== 'string') return ''
|
|
92
|
+
const match = /^Bearer\s+(.+)$/i.exec(raw.trim())
|
|
93
|
+
return match === null ? '' : match[1]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeJson(res, status, body) {
|
|
97
|
+
const payload = JSON.stringify(body)
|
|
98
|
+
res.writeHead(status, {
|
|
99
|
+
'content-type': 'application/json',
|
|
100
|
+
'content-length': Buffer.byteLength(payload),
|
|
101
|
+
})
|
|
102
|
+
res.end(payload)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 按 OpenAI 错误格式回错,pi-ai 才能正确分类。 */
|
|
106
|
+
function writeOpenAIError(res, status, kind, message) {
|
|
107
|
+
writeJson(res, status, { error: { message, type: kind, code: kind } })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 读取并限制请求体大小。 */
|
|
111
|
+
async function readBody(req) {
|
|
112
|
+
const chunks = []
|
|
113
|
+
let size = 0
|
|
114
|
+
for await (const chunk of req) {
|
|
115
|
+
size += chunk.length
|
|
116
|
+
if (size > REQUEST_BODY_LIMIT) throw new Error('请求体超过上限')
|
|
117
|
+
chunks.push(chunk)
|
|
118
|
+
}
|
|
119
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 起一个 shim。
|
|
124
|
+
*
|
|
125
|
+
* @param {object} options
|
|
126
|
+
* @param {() => Promise<Array<{id:string, credential:object}>>} options.listAccounts
|
|
127
|
+
* 返回本次可选的账号(已含可用 token)。每次请求现取,保证拿到最新 token。
|
|
128
|
+
* @param {import('./selector.js').AccountPicker} options.picker 选号器
|
|
129
|
+
* @param {{info:Function, warn:Function, error:Function}} options.logger
|
|
130
|
+
* @param {object} [options.usage] 用量记录器(有就记,没有就跳过)
|
|
131
|
+
* @param {object} [options.adapter] 上游适配器;不传则用 WorkBuddy 的默认实现。
|
|
132
|
+
* 三个成员:
|
|
133
|
+
* chatStream(credential, bodyJson, signal) → 上游响应
|
|
134
|
+
* prepareBody(rawBody) → 改写成该上游认的请求体
|
|
135
|
+
* retryable(kind) → 该失败是否值得换号重试
|
|
136
|
+
* @returns {Promise<{baseUrl:()=>string, token:()=>string, close:()=>Promise<void>}>}
|
|
137
|
+
*/
|
|
138
|
+
export async function createShim({ listAccounts, picker, logger, usage, adapter }) {
|
|
139
|
+
// 上游差异全部收敛在这个适配器里——选号、重试、用量、流处理都是共用的。
|
|
140
|
+
// Trae 与 WorkBuddy 的协议不同(头部、请求体改写、错误分类),
|
|
141
|
+
// 但「多个账号之间怎么选、失败怎么换」这套逻辑完全相同。
|
|
142
|
+
const upstream = {
|
|
143
|
+
chatStream: adapter?.chatStream ?? workbuddyChatStream,
|
|
144
|
+
prepareBody: adapter?.prepareBody ?? workbuddyPrepareBody,
|
|
145
|
+
retryable: adapter?.retryable ?? workbuddyRetryable,
|
|
146
|
+
}
|
|
147
|
+
/** 进程内随机 secret:pi-ai 拿它当 apiKey,真 token 不出去。 */
|
|
148
|
+
const secret = randomBytes(32).toString('hex')
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 处理一次 chat 请求。核心:选号 → 转发 → 失败换号重试。
|
|
152
|
+
*/
|
|
153
|
+
async function handleChat(req, res, rawBody) {
|
|
154
|
+
const sessionKeyOf = (body) => {
|
|
155
|
+
// DSH 会把会话信息透传下来,用它做粘性键;没有就退化为不粘。
|
|
156
|
+
return body?.metadata?.conversation_id
|
|
157
|
+
?? body?.metadata?.conversationId
|
|
158
|
+
?? body?.conversation_id
|
|
159
|
+
?? body?.conversationId
|
|
160
|
+
?? ''
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let body
|
|
164
|
+
try {
|
|
165
|
+
body = JSON.parse(rawBody)
|
|
166
|
+
} catch {
|
|
167
|
+
writeOpenAIError(res, 400, 'client', '请求体不是合法 JSON')
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const sessionKey = sessionKeyOf(body)
|
|
172
|
+
const requestedModel = typeof body?.model === 'string' ? body.model : '(unknown)'
|
|
173
|
+
const prepared = upstream.prepareBody(rawBody)
|
|
174
|
+
|
|
175
|
+
// 每次请求现取账号,保证用的是最新 token。
|
|
176
|
+
let accounts
|
|
177
|
+
try {
|
|
178
|
+
accounts = await listAccounts()
|
|
179
|
+
} catch (error) {
|
|
180
|
+
writeOpenAIError(res, 502, 'server', `无法读取账号:${error?.message ?? error}`)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
if (accounts.length === 0) {
|
|
184
|
+
writeOpenAIError(res, 401, 'session_dead', '没有已登录的 WorkBuddy 账号,请到插件设置页添加')
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 客户端断开(DSH 里点「停止生成」)时要中止上游请求。
|
|
189
|
+
// 否则上游流会继续跑完,白白消耗积分。
|
|
190
|
+
const controller = new AbortController()
|
|
191
|
+
let clientGone = false
|
|
192
|
+
res.on('close', () => {
|
|
193
|
+
// 响应正常结束时也会触发 close,用 writableEnded 区分
|
|
194
|
+
if (!res.writableEnded) {
|
|
195
|
+
clientGone = true
|
|
196
|
+
controller.abort()
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const candidates = accounts.map(a => a.id)
|
|
201
|
+
const tried = new Set()
|
|
202
|
+
let lastFailure
|
|
203
|
+
|
|
204
|
+
// 依次尝试:每次挑一个没试过的号,失败就换下一个。
|
|
205
|
+
for (let attempt = 0; attempt < Math.min(MAX_ACCOUNT_ATTEMPTS, candidates.length); attempt += 1) {
|
|
206
|
+
// 客户端已经断开就没必要再换号重试了。
|
|
207
|
+
if (clientGone) break
|
|
208
|
+
const remaining = candidates.filter(id => !tried.has(id))
|
|
209
|
+
if (remaining.length === 0) break
|
|
210
|
+
|
|
211
|
+
const accountId = picker.pick(remaining, sessionKey)
|
|
212
|
+
if (accountId === undefined) break
|
|
213
|
+
tried.add(accountId)
|
|
214
|
+
const startedAt = Date.now()
|
|
215
|
+
|
|
216
|
+
const account = accounts.find(a => a.id === accountId)
|
|
217
|
+
if (account === undefined) continue
|
|
218
|
+
|
|
219
|
+
const result = await upstream.chatStream(account.credential, prepared, controller.signal)
|
|
220
|
+
|
|
221
|
+
if (result.ok) {
|
|
222
|
+
picker.reportSuccess(accountId)
|
|
223
|
+
// 首帧前的错误已经排除,直接把上游 SSE 流回给 pi-ai。
|
|
224
|
+
res.writeHead(200, {
|
|
225
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
226
|
+
'cache-control': 'no-cache',
|
|
227
|
+
connection: 'keep-alive',
|
|
228
|
+
})
|
|
229
|
+
// 边转发边抓 usage:上游在流末尾发一帧带 usage(含 credit)的数据。
|
|
230
|
+
// 用 TextDecoder 累积文本,因为 usage 帧可能跨 chunk 边界。
|
|
231
|
+
const decoder = new TextDecoder()
|
|
232
|
+
let tail = ''
|
|
233
|
+
let captured
|
|
234
|
+
try {
|
|
235
|
+
for await (const chunk of result.response.body) {
|
|
236
|
+
res.write(chunk)
|
|
237
|
+
if (usage !== undefined) {
|
|
238
|
+
tail += decoder.decode(chunk, { stream: true })
|
|
239
|
+
const found = extractUsage(tail)
|
|
240
|
+
if (found !== undefined) captured = found
|
|
241
|
+
// 控制内存:只在**换行边界**截断。
|
|
242
|
+
// 直接 slice 会把一个 SSE 帧切成两半,若 usage 帧正好跨在
|
|
243
|
+
// 切点上就永远抓不到——按行切不会破坏帧的完整性。
|
|
244
|
+
if (tail.length > 64 * 1024) {
|
|
245
|
+
const boundary = tail.lastIndexOf('\n')
|
|
246
|
+
// 保留最后一段完整行;万一整块没有换行(异常),才退化为保留尾部
|
|
247
|
+
tail = boundary > 0 ? tail.slice(boundary) : tail.slice(-8192)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
// 客户端主动断开是正常情况(abort),不当异常报
|
|
253
|
+
if (!clientGone) logger.warn(`上游流出错:${error?.message ?? error}`)
|
|
254
|
+
}
|
|
255
|
+
if (!clientGone) res.end()
|
|
256
|
+
// 记录用量。ok 以「是否拿到 usage」判定:拿不到说明这次尝试
|
|
257
|
+
// 没成功产出 token。
|
|
258
|
+
// 只有成功请求才传 latencyMs——见下面失败分支的说明。
|
|
259
|
+
if (usage !== undefined) {
|
|
260
|
+
usage.add({
|
|
261
|
+
accountId,
|
|
262
|
+
model: captured?.model ?? requestedModel,
|
|
263
|
+
ok: captured?.usage !== undefined,
|
|
264
|
+
usage: captured?.usage,
|
|
265
|
+
latencyMs: Date.now() - startedAt,
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 失败:处置账号,记录原因。
|
|
272
|
+
const action = picker.reportFailure(accountId, result.kind)
|
|
273
|
+
logger.warn(`账号 ${accountId} 请求失败(${result.kind}):${action}`)
|
|
274
|
+
// 失败的尝试计入请求数——重试放大正是靠这一列才看得出来。
|
|
275
|
+
//
|
|
276
|
+
// 但**不记耗时**:失败往往在上游快速返回(429/4xx 只要几百毫秒),
|
|
277
|
+
// 而成功要跑完整条流(数千毫秒)。混在一起算会把平均延迟拉低,
|
|
278
|
+
// 按小时分桶时更会忽高忽低,看不出真实响应速度。
|
|
279
|
+
// 「平均延迟」的语义是「请求要等多久」,失败的等待没有意义。
|
|
280
|
+
if (usage !== undefined) {
|
|
281
|
+
usage.add({
|
|
282
|
+
accountId,
|
|
283
|
+
model: requestedModel,
|
|
284
|
+
ok: false,
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
lastFailure = result
|
|
288
|
+
|
|
289
|
+
// 请求本身有问题的话换号也没用,直接回错,省得白打一遍。
|
|
290
|
+
if (!upstream.retryable(result.kind)) break
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (lastFailure === undefined) {
|
|
294
|
+
writeOpenAIError(res, 502, 'server', '所有账号都不可用')
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
writeOpenAIError(
|
|
298
|
+
res,
|
|
299
|
+
KIND_STATUS[lastFailure.kind] ?? 502,
|
|
300
|
+
lastFailure.kind,
|
|
301
|
+
`所有账号尝试失败:${lastFailure.message}`,
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const server = createServer((req, res) => {
|
|
306
|
+
void (async () => {
|
|
307
|
+
try {
|
|
308
|
+
// ---- 入站加固:四重校验,任一不过直接拒绝 ----
|
|
309
|
+
if (!hostIsLoopback(req.headers.host)) {
|
|
310
|
+
writeJson(res, 403, { error: { message: 'Host 非回环', type: 'forbidden', code: 'forbidden' } })
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
if (!originIsLoopback(req.headers.origin)) {
|
|
314
|
+
writeJson(res, 403, { error: { message: 'Origin 非回环', type: 'forbidden', code: 'forbidden' } })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
318
|
+
if (!url.pathname.endsWith('/chat/completions')) {
|
|
319
|
+
writeJson(res, 404, { error: { message: '未知路径', type: 'not_found', code: 'not_found' } })
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
if (req.method !== 'POST') {
|
|
323
|
+
res.writeHead(405, { allow: 'POST' })
|
|
324
|
+
res.end()
|
|
325
|
+
return
|
|
326
|
+
}
|
|
327
|
+
if (!secretMatches(bearerOf(req.headers), secret)) {
|
|
328
|
+
writeJson(res, 401, { error: { message: 'secret 不匹配', type: 'unauthorized', code: 'unauthorized' } })
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
const contentType = String(req.headers['content-type'] ?? '')
|
|
332
|
+
if (!contentType.toLowerCase().startsWith('application/json')) {
|
|
333
|
+
writeJson(res, 415, { error: { message: '需要 application/json', type: 'unsupported', code: 'unsupported' } })
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const rawBody = await readBody(req)
|
|
338
|
+
await handleChat(req, res, rawBody)
|
|
339
|
+
} catch (error) {
|
|
340
|
+
logger.error(`shim 处理请求异常:${error?.stack ?? error}`)
|
|
341
|
+
if (!res.headersSent) {
|
|
342
|
+
writeJson(res, 500, { error: { message: '内部错误', type: 'server', code: 'server' } })
|
|
343
|
+
} else {
|
|
344
|
+
res.end()
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
})()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// 绑随机端口、只绑回环。
|
|
351
|
+
await new Promise((resolve, reject) => {
|
|
352
|
+
server.once('error', reject)
|
|
353
|
+
server.listen(0, '127.0.0.1', () => {
|
|
354
|
+
server.removeListener('error', reject)
|
|
355
|
+
resolve()
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const address = server.address()
|
|
360
|
+
const origin = `http://127.0.0.1:${address.port}`
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
baseUrl: () => origin,
|
|
364
|
+
token: () => secret,
|
|
365
|
+
close: () => new Promise((resolve) => {
|
|
366
|
+
server.closeAllConnections?.()
|
|
367
|
+
server.close(() => resolve())
|
|
368
|
+
}),
|
|
369
|
+
}
|
|
370
|
+
}
|