dsh-router-qoder 0.0.0-stage → 0.1.1
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 -0
- package/README.md +275 -2
- package/cordis.patch.yml +10 -0
- package/lib/index.js +1766 -0
- package/package.json +63 -4
- package/src/cn.ts +41 -0
- package/src/contract.ts +169 -0
- package/src/core.test.ts +552 -0
- package/src/core.ts +425 -0
- package/src/cosy.ts +270 -0
- package/src/global.ts +38 -0
- package/src/icon.ts +8 -0
- package/src/index.ts +64 -0
- package/src/messages.ts +337 -0
- package/src/sse.ts +176 -0
- package/src/types.ts +42 -0
- package/src/upstream.ts +992 -0
package/src/core.ts
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qoder 族供应商共享实现。
|
|
3
|
+
*
|
|
4
|
+
* 国内版(gateway.qoder.com.cn)与国际版(api1.qoder.sh)是同一套 cosy 协议的
|
|
5
|
+
* 两个部署:契约、签名、消息形状完全一致,只差主机与「sash 面是否要设备风控头」
|
|
6
|
+
* 两件事,所以全部共享逻辑住在这里,差异收敛进 `QoderProfile`;cn.ts / global.ts
|
|
7
|
+
* 各给一份 profile。
|
|
8
|
+
*
|
|
9
|
+
* 与 codebuddy 族最大的不同:**认证是 PAT 而不是 OAuth**。用户粘贴一个 `pt-…`
|
|
10
|
+
* 就完事,没有浏览器登录、没有回调、没有 refresh token —— 所以这里实现的是
|
|
11
|
+
* 契约里的 `addApiKey` 那条路,不是 `generateLoginUrl`/`completeLogin`。
|
|
12
|
+
*
|
|
13
|
+
* 另一个不同:上游不是 OpenAI 兼容端点,因此 chat 必须自己把 Qoder 的 SSE 转成
|
|
14
|
+
* OpenAI SSE(见 sse.ts)。核心对插件流的要求就是「OpenAI SSE 字节」。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-router-qoder/core
|
|
17
|
+
*/
|
|
18
|
+
import type { ChatRequest, ModelInfo } from './types.ts'
|
|
19
|
+
import type {
|
|
20
|
+
AccountState,
|
|
21
|
+
ChatOnceResult,
|
|
22
|
+
SupplierEnv,
|
|
23
|
+
SupplierModule,
|
|
24
|
+
SupplierStatusNow,
|
|
25
|
+
} from './contract.ts'
|
|
26
|
+
import { QoderClient, isQoderError, type QoderErrorKind, type QoderModel, type QoderRegion } from './upstream.ts'
|
|
27
|
+
import { toOpenAiSse } from './sse.ts'
|
|
28
|
+
|
|
29
|
+
/** 一个上游部署的差异描述。 */
|
|
30
|
+
export interface QoderProfile {
|
|
31
|
+
/** 供应商 id。**同时是凭证/配置/积分的存储键**,改它等于换一个供应商。 */
|
|
32
|
+
id: string
|
|
33
|
+
/** 面板展示名。 */
|
|
34
|
+
name: string
|
|
35
|
+
/** 该 profile 对应的上游区域(决定主机与设备风控要求)。 */
|
|
36
|
+
region: QoderRegion
|
|
37
|
+
/** 面板图标(内联 data URI,不能放网络 URL)。 */
|
|
38
|
+
icon: string
|
|
39
|
+
/** 池内排序(越小越靠前)。 */
|
|
40
|
+
priority: number
|
|
41
|
+
/** 账号 uid 前缀(`qd-1` / `qdg-1`)。 */
|
|
42
|
+
uidPrefix: string
|
|
43
|
+
/** 默认昵称(上游不下发昵称时用)。 */
|
|
44
|
+
defaultNickname: string
|
|
45
|
+
/** 错误文本前缀,如 `qoder-cn: …`。 */
|
|
46
|
+
errPrefix: string
|
|
47
|
+
/** 「添加 API key」弹窗里的提示文案。 */
|
|
48
|
+
apiKeyHint: string
|
|
49
|
+
/** 上游拉不到模型时的兜底列表(只是最后退路,不是「当前可用模型」)。 */
|
|
50
|
+
fallbackModels: ModelInfo[]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 凭证 blob。 */
|
|
54
|
+
interface QoderCred {
|
|
55
|
+
nickname: string
|
|
56
|
+
/** 个人访问令牌(`pt-…`)。 */
|
|
57
|
+
pat: string
|
|
58
|
+
/** 上游账号 id(交换 jobToken 时拿到,仅用于展示/去重)。 */
|
|
59
|
+
userId?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 积分缓存 10 分钟(与 codebuddy 同口径)。 */
|
|
63
|
+
const CREDITS_TTL_MS = 10 * 60 * 1000
|
|
64
|
+
/** 拿不到积分时报它(**不是 0**):核心据此保留上次持久化的值。 */
|
|
65
|
+
const CREDITS_UNKNOWN = -1
|
|
66
|
+
|
|
67
|
+
/** 上游错误种类 → 契约的账号状态。 */
|
|
68
|
+
function stateOf(kind: QoderErrorKind): AccountState {
|
|
69
|
+
switch (kind) {
|
|
70
|
+
case 'auth':
|
|
71
|
+
return 'session_dead'
|
|
72
|
+
case 'quota-exhausted':
|
|
73
|
+
return 'quota'
|
|
74
|
+
case 'rate-limited':
|
|
75
|
+
return 'rate_limit'
|
|
76
|
+
// 瞬时(5xx / 排队 / 超时):短冷换号确实管用,归 rate_limit 让核心换下一个
|
|
77
|
+
case 'transient':
|
|
78
|
+
return 'rate_limit'
|
|
79
|
+
// 内容审核与请求形态问题都不是账号的错:同一个请求对池里每个号都会失败
|
|
80
|
+
case 'content-policy':
|
|
81
|
+
case 'invalid-request':
|
|
82
|
+
return 'bad_request'
|
|
83
|
+
case 'network':
|
|
84
|
+
return 'transport'
|
|
85
|
+
default:
|
|
86
|
+
return 'unknown'
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 剩余额度:基础包 + 加量包 + 企业包(缺哪个算哪个;都缺 = undefined)。 */
|
|
91
|
+
function remainingOf(usage: { userQuota?: { remaining?: number }; addOnQuota?: { remaining?: number }; orgResourcePackage?: { remaining?: number } }): number | undefined {
|
|
92
|
+
const parts: number[] = []
|
|
93
|
+
for (const bucket of [usage.userQuota, usage.addOnQuota, usage.orgResourcePackage]) {
|
|
94
|
+
if (bucket !== undefined && typeof bucket.remaining === 'number' && Number.isFinite(bucket.remaining)) {
|
|
95
|
+
parts.push(bucket.remaining)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (parts.length === 0) return undefined
|
|
99
|
+
return Math.round(parts.reduce((a, b) => a + b, 0) * 100) / 100
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 消息里有没有图片(决定 `model_config.is_vl`)。只看 user 消息的 parts。 */
|
|
103
|
+
function hasImageInput(messages: unknown[]): boolean {
|
|
104
|
+
for (const m of messages) {
|
|
105
|
+
if (m === null || typeof m !== 'object') continue
|
|
106
|
+
const msg = m as Record<string, unknown>
|
|
107
|
+
if (msg.role !== 'user') continue
|
|
108
|
+
const content = msg.content
|
|
109
|
+
if (!Array.isArray(content)) continue
|
|
110
|
+
for (const part of content as unknown[]) {
|
|
111
|
+
if (part === null || typeof part !== 'object') continue
|
|
112
|
+
const p = part as Record<string, unknown>
|
|
113
|
+
if (p.type === 'image_url' || p.type === 'input_image') return true
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 按 profile 造一个供应商模块。 */
|
|
120
|
+
export function createSupplier(p: QoderProfile): (env: SupplierEnv) => SupplierModule {
|
|
121
|
+
return function factory(env: SupplierEnv): SupplierModule {
|
|
122
|
+
const id = p.id
|
|
123
|
+
const creds = env.credentials
|
|
124
|
+
const store = env.store
|
|
125
|
+
const log = env.log
|
|
126
|
+
const client = new QoderClient(p.region, { log })
|
|
127
|
+
|
|
128
|
+
/** 积分缓存:uid → { value, at }。积分本身由核心持久化,这里只管内存 TTL。 */
|
|
129
|
+
const creditsCache = new Map<string, { value: number; at: number }>()
|
|
130
|
+
/** 正在拉积分的 uid:并发 status() 共享同一次请求。 */
|
|
131
|
+
const creditsInflight = new Set<string>()
|
|
132
|
+
/** 模型缓存 + 并发去重。 */
|
|
133
|
+
let modelsCache: QoderModel[] | undefined
|
|
134
|
+
let modelsInflight: Promise<QoderModel[]> | undefined
|
|
135
|
+
|
|
136
|
+
function listUids(): string[] {
|
|
137
|
+
return creds.list(id)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getCred(uid: string): QoderCred | undefined {
|
|
141
|
+
return creds.get<QoderCred>(id, uid)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 账号顺序:池顺序优先,未配置按凭证原始顺序。 */
|
|
145
|
+
function orderedUids(): string[] {
|
|
146
|
+
const all = listUids()
|
|
147
|
+
const order = store.get(id).poolOrder
|
|
148
|
+
return [...order.filter((u) => all.includes(u)), ...all.filter((u) => !order.includes(u))]
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 当前别名前缀(核心可能在 store 里覆盖默认值)。 */
|
|
152
|
+
function currentAlias(): string {
|
|
153
|
+
return store.get(id).alias || id
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 剥离本供应商的 alias 前缀。
|
|
158
|
+
*
|
|
159
|
+
* 只剥自己的(`qoder/xxx`),模型 id 自带的斜杠保留 —— 否则上游的
|
|
160
|
+
* `org/name` 会被剥成 `name`,请求必然 404。
|
|
161
|
+
*/
|
|
162
|
+
function stripAlias(model: string): string {
|
|
163
|
+
const alias = currentAlias()
|
|
164
|
+
return alias !== '' && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function refreshCredits(uid: string): Promise<number | undefined> {
|
|
168
|
+
if (creditsInflight.has(uid)) return undefined
|
|
169
|
+
const cred = getCred(uid)
|
|
170
|
+
if (cred === undefined) return undefined
|
|
171
|
+
creditsInflight.add(uid)
|
|
172
|
+
try {
|
|
173
|
+
const usage = await client.fetchUsage(cred.pat)
|
|
174
|
+
if (usage === undefined) return undefined
|
|
175
|
+
const value = remainingOf(usage)
|
|
176
|
+
if (value === undefined) return undefined
|
|
177
|
+
creditsCache.set(uid, { value, at: Date.now() })
|
|
178
|
+
return value
|
|
179
|
+
} catch (error: unknown) {
|
|
180
|
+
// 读不到就报 -1(核心保留上次的值);但凭据被拒要落日志,
|
|
181
|
+
// 否则面板上只看到「积分 -1」,看不出这个号已经登不上了。
|
|
182
|
+
if (isQoderError(error) && error.kind === 'auth') {
|
|
183
|
+
log(`${p.errPrefix} credits ${uid}: ${error.message}`)
|
|
184
|
+
}
|
|
185
|
+
return undefined
|
|
186
|
+
} finally {
|
|
187
|
+
creditsInflight.delete(uid)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function fetchModelsFromUpstream(): Promise<QoderModel[]> {
|
|
192
|
+
for (const uid of orderedUids()) {
|
|
193
|
+
const cred = getCred(uid)
|
|
194
|
+
if (cred === undefined) continue
|
|
195
|
+
try {
|
|
196
|
+
const list = await client.fetchModels(cred.pat)
|
|
197
|
+
if (list.length > 0) return list
|
|
198
|
+
} catch (error: unknown) {
|
|
199
|
+
log(`${p.errPrefix} models ${uid}: ${(error as Error).message.slice(0, 160)}`)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return []
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 上游模型 → 契约的 `ModelInfo`。
|
|
207
|
+
*
|
|
208
|
+
* `id` 是上游 key(`qmodel_38max`)—— 组合里存的、请求里发的都是它;
|
|
209
|
+
* `name` 带上游的 `display_name`(`Qwen3.8-Max`),面板拿它做主标签。
|
|
210
|
+
* 不带 name 的话,面板上只剩一串猜不出含义的 key,「获取模型」之后
|
|
211
|
+
* 用户仍然不知道自己勾的是哪个模型。`context_length` 按 k 报,与别的插件一致。
|
|
212
|
+
*/
|
|
213
|
+
function toModelInfo(m: QoderModel): ModelInfo {
|
|
214
|
+
return m.maxInputTokens === undefined
|
|
215
|
+
? { id: m.key, name: m.displayName }
|
|
216
|
+
: { id: m.key, name: m.displayName, context_length: Math.round(m.maxInputTokens / 1000) }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function allModels(force: boolean): Promise<ModelInfo[]> {
|
|
220
|
+
// 核心按 TTL 缓存,force=false 的频繁调用直接用缓存,别打上游
|
|
221
|
+
if (!force && modelsCache !== undefined) return modelsCache.map(toModelInfo)
|
|
222
|
+
if (modelsInflight !== undefined) return (await modelsInflight).map(toModelInfo)
|
|
223
|
+
modelsInflight = fetchModelsFromUpstream()
|
|
224
|
+
.then((list) => {
|
|
225
|
+
if (list.length > 0) modelsCache = list
|
|
226
|
+
return modelsCache ?? []
|
|
227
|
+
})
|
|
228
|
+
.catch(() => modelsCache ?? [])
|
|
229
|
+
.finally(() => {
|
|
230
|
+
modelsInflight = undefined
|
|
231
|
+
})
|
|
232
|
+
const list = await modelsInflight
|
|
233
|
+
// 上游一个都拿不到(没账号 / 全挂了)时才用兜底表:它只是「别让面板空着」,
|
|
234
|
+
// 不是「当前可用模型」。
|
|
235
|
+
return (list.length > 0 ? list : p.fallbackModels.map((m) => ({
|
|
236
|
+
key: m.id,
|
|
237
|
+
displayName: m.name ?? m.id,
|
|
238
|
+
enable: true,
|
|
239
|
+
isDefault: false,
|
|
240
|
+
isVl: false,
|
|
241
|
+
isReasoning: false,
|
|
242
|
+
isFree: false,
|
|
243
|
+
priceFactor: undefined,
|
|
244
|
+
maxInputTokens: m.context_length === undefined ? undefined : m.context_length * 1000,
|
|
245
|
+
maxOutputTokens: undefined,
|
|
246
|
+
efforts: [],
|
|
247
|
+
}))).map(toModelInfo)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 装一个不落盘的新账号(uid 按前缀递增,跳过已占用的)。 */
|
|
251
|
+
function allocateUid(): string {
|
|
252
|
+
let n = listUids().length + 1
|
|
253
|
+
let uid = `${p.uidPrefix}-${n}`
|
|
254
|
+
while (getCred(uid) !== undefined) uid = `${p.uidPrefix}-${++n}`
|
|
255
|
+
return uid
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
id,
|
|
260
|
+
name: p.name,
|
|
261
|
+
priority: p.priority,
|
|
262
|
+
icon: p.icon,
|
|
263
|
+
apiKeyHint: p.apiKeyHint,
|
|
264
|
+
status: (): SupplierStatusNow => {
|
|
265
|
+
const now = Date.now()
|
|
266
|
+
const accounts = orderedUids().map((uid) => {
|
|
267
|
+
const cred = getCred(uid)
|
|
268
|
+
const cached = creditsCache.get(uid)
|
|
269
|
+
// 过期就后台刷一次,本次仍报缓存值(面板同步接口不能等网络)
|
|
270
|
+
if (cached === undefined || now - cached.at > CREDITS_TTL_MS) void refreshCredits(uid)
|
|
271
|
+
return {
|
|
272
|
+
uid,
|
|
273
|
+
nickname: cred?.nickname || p.defaultNickname,
|
|
274
|
+
// 没缓存过报 -1,不是 0:0 是「真的用完了」,核心分得清两者
|
|
275
|
+
credits: cached?.value ?? CREDITS_UNKNOWN,
|
|
276
|
+
state: (cred === undefined ? 'session_dead' : 'ok') as AccountState,
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
return { id, name: p.name, accounts }
|
|
280
|
+
},
|
|
281
|
+
listModels: (force?: boolean): Promise<ModelInfo[]> => allModels(!!force),
|
|
282
|
+
/**
|
|
283
|
+
* 添加账号:粘贴 PAT。
|
|
284
|
+
*
|
|
285
|
+
* 这里**真的去交换一次 jobToken** 才落盘 —— 一个打错一个字符的 PAT 存进去
|
|
286
|
+
* 之后,面板上只会显示「凭据失效」,用户不知道是当初就错了还是后来过期了。
|
|
287
|
+
* 交换顺带拿到显示名,比 `qd-1` 这种占位名有用。
|
|
288
|
+
*/
|
|
289
|
+
addApiKey: async (input: { name: string; apiKey: string }): Promise<{ ok: boolean; error?: string; account?: { uid: string; nickname: string } }> => {
|
|
290
|
+
const pat = normalizePat(input.apiKey)
|
|
291
|
+
if (pat === '') return { ok: false, error: 'PAT 不能为空' }
|
|
292
|
+
// 同一个 PAT 重复添加:直接报出来,别偷偷造出第二个号(两个号共用一个
|
|
293
|
+
// 额度,池里会互相打脸)
|
|
294
|
+
for (const uid of listUids()) {
|
|
295
|
+
if (getCred(uid)?.pat === pat) return { ok: false, error: `这个 PAT 已经添加过了(${uid})` }
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const verified = await client.verifyCredential(pat)
|
|
299
|
+
const uid = allocateUid()
|
|
300
|
+
const nickname = input.name.trim() || verified.name || p.defaultNickname
|
|
301
|
+
creds.save(id, uid, { nickname, pat, ...(verified.id === '' ? {} : { userId: verified.id }) })
|
|
302
|
+
log(`${p.errPrefix} account added: ${uid} (${nickname})`)
|
|
303
|
+
return { ok: true, account: { uid, nickname } }
|
|
304
|
+
} catch (error: unknown) {
|
|
305
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
306
|
+
return { ok: false, error: `PAT 校验失败:${message}` }
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
removeLink: (uid: string): Promise<boolean> => {
|
|
310
|
+
const cred = getCred(uid)
|
|
311
|
+
if (cred === undefined) return Promise.resolve(false)
|
|
312
|
+
creds.remove(id, uid)
|
|
313
|
+
creditsCache.delete(uid)
|
|
314
|
+
client.forget(cred.pat)
|
|
315
|
+
return Promise.resolve(true)
|
|
316
|
+
},
|
|
317
|
+
/** 对单个账号调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
|
|
318
|
+
async chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult> {
|
|
319
|
+
const base = stripAlias(req.model)
|
|
320
|
+
if (base === '') {
|
|
321
|
+
return { ok: false, state: 'no_such_model', message: `unknown model ${JSON.stringify(req.model)}` }
|
|
322
|
+
}
|
|
323
|
+
const cred = getCred(uid)
|
|
324
|
+
if (cred === undefined) {
|
|
325
|
+
return { ok: false, state: 'no_such_model', message: `unknown account ${JSON.stringify(uid)}` }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
let messages: Array<Record<string, unknown>>
|
|
329
|
+
let tools: unknown[] | undefined
|
|
330
|
+
try {
|
|
331
|
+
const body = JSON.parse(req.rawBody) as Record<string, unknown>
|
|
332
|
+
const rawMessages = Array.isArray(body.messages) ? (body.messages as unknown[]) : []
|
|
333
|
+
messages = rawMessages.filter((m): m is Record<string, unknown> => m !== null && typeof m === 'object')
|
|
334
|
+
tools = Array.isArray(body.tools) && body.tools.length > 0 ? (body.tools as unknown[]) : undefined
|
|
335
|
+
} catch {
|
|
336
|
+
return { ok: false, state: 'bad_request', message: '请求体不是合法 JSON' }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 思考强度:`auto`/空 不下发(用模型自己的默认档),其余原样带给上游。
|
|
340
|
+
// 上游只认它自己声明的档位(low/high/max…),传不认识的档会被拒 ——
|
|
341
|
+
// 那是**这个模型**不支持该档,不是账号错,归 bad_request。
|
|
342
|
+
const effort = lv === 'auto' || lv === '' || lv === 'none' || lv === 'off' ? undefined : lv
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const upstreamBody = await client.openChat(cred.pat, base, messages, {
|
|
346
|
+
...(tools === undefined ? {} : { tools }),
|
|
347
|
+
...(effort === undefined ? {} : { reasoningEffort: effort }),
|
|
348
|
+
isVl: hasImageInput(messages),
|
|
349
|
+
})
|
|
350
|
+
const stream = toOpenAiSse(upstreamBody, {
|
|
351
|
+
model: base,
|
|
352
|
+
onLateError: (error) => {
|
|
353
|
+
// 流已开始,状态码发不出去了;把「这个号额度用尽 / 被限流」这条
|
|
354
|
+
// 事后事实报给核心,让后续请求别再选中它。
|
|
355
|
+
env.onLateFailure?.(uid, base, stateOf(error.kind), error.message)
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
return { ok: true, stream }
|
|
359
|
+
} catch (error: unknown) {
|
|
360
|
+
if (isQoderError(error)) {
|
|
361
|
+
// 凭据被判失效时顺手把会话丢掉:下次换号回来会重新交换,
|
|
362
|
+
// 而不是继续用一份已经不被认的 cosy 会话。
|
|
363
|
+
if (error.kind === 'auth') client.forget(cred.pat)
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
state: stateOf(error.kind),
|
|
367
|
+
message: `${p.errPrefix} ${error.message}`,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { ok: false, state: 'unknown', message: (error as Error).message }
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
/**
|
|
374
|
+
* 签到:领取今天那份每日 Credits。
|
|
375
|
+
*
|
|
376
|
+
* 上游把它做成「活动」(campaigns),不是一个叫签到/checkin 的端点。
|
|
377
|
+
* `already-claimed` 是**成功**(幂等),不是错误。
|
|
378
|
+
*/
|
|
379
|
+
checkinNow: async (uid: string): Promise<{ ok: boolean; status: string; message?: string }> => {
|
|
380
|
+
const cred = getCred(uid)
|
|
381
|
+
if (cred === undefined) return { ok: false, status: 'error', message: '凭证缺失' }
|
|
382
|
+
try {
|
|
383
|
+
const outcome = await client.claimDailyCredits(cred.pat)
|
|
384
|
+
if (outcome.status === 'claimed') {
|
|
385
|
+
await refreshCredits(uid)
|
|
386
|
+
const amount = outcome.amount === undefined ? '' : `+${outcome.amount} Credits`
|
|
387
|
+
const days = outcome.validityDays === undefined ? '' : `(${outcome.validityDays} 天有效)`
|
|
388
|
+
log(`${p.errPrefix} checkin ${uid}: claimed ${amount}`)
|
|
389
|
+
return { ok: true, status: 'ok', message: `${amount}${days}` || '签到成功' }
|
|
390
|
+
}
|
|
391
|
+
if (outcome.status === 'already-claimed') {
|
|
392
|
+
await refreshCredits(uid)
|
|
393
|
+
return { ok: true, status: 'already', message: '今日已签到' }
|
|
394
|
+
}
|
|
395
|
+
return { ok: false, status: 'error', message: outcome.message }
|
|
396
|
+
} catch (error: unknown) {
|
|
397
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
398
|
+
log(`${p.errPrefix} checkin ${uid}: ${message.slice(0, 160)}`)
|
|
399
|
+
return { ok: false, status: 'error', message: `${p.errPrefix} ${message}` }
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
dispose: (): void => {
|
|
403
|
+
creditsCache.clear()
|
|
404
|
+
creditsInflight.clear()
|
|
405
|
+
modelsCache = undefined
|
|
406
|
+
modelsInflight = undefined
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 把用户粘进来的输入当作 PAT(容忍带 `Bearer ` 前缀或首尾空白)。
|
|
414
|
+
*
|
|
415
|
+
* 面板上粘贴时很容易带上从文档里复制来的 `Bearer `,那不是令牌的一部分,
|
|
416
|
+
* 直接当 PAT 用只会得到一个难以理解的 401。
|
|
417
|
+
*/
|
|
418
|
+
export function normalizePat(input: string): string {
|
|
419
|
+
return input.trim().replace(/^Bearer\s+/i, '').trim()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 判断一段输入看起来像不像 PAT(`pt-…`;只用于提示,不做硬校验)。 */
|
|
423
|
+
export function looksLikePat(input: string): boolean {
|
|
424
|
+
return /^pt-[A-Za-z0-9._-]{8,}$/.test(normalizePat(input))
|
|
425
|
+
}
|
package/src/cosy.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qoder「cosy」协议原语 —— 自定义 base64、请求签名、RSA/AES 会话、设备指纹。
|
|
3
|
+
*
|
|
4
|
+
* 这里只有**无状态纯函数 + 加密原语**,一行网络调用都没有;流程在 upstream.ts。
|
|
5
|
+
*
|
|
6
|
+
* 协议来源(社区逆向 + 官方产物核对,均为 MIT):
|
|
7
|
+
* - dingminhua/dsh-connect-qoder 的 `src/cosy.ts`(本文件的移植来源,逐字节对齐);
|
|
8
|
+
* - fengyinxia/qoder2api 的 `qoder_auth.py`(PAT→jobToken 交换、cosy 签名、
|
|
9
|
+
* 自定义 base64、RSA/AES 会话构建,CN 网关实测可用);
|
|
10
|
+
* - x_Zhengyuuuui/qoder2api(Go)的 `internal/cosy/*`(同一协议 + 指纹派生 +
|
|
11
|
+
* CN/Global 双区域端点)。
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ 这份文件里几乎所有常量都是**上游的字节**(字母表、公钥、签名格式、
|
|
14
|
+
* 会话载荷的键序)。它们看起来可以「整理得更整齐」,但每一个都直接决定
|
|
15
|
+
* 网关认不认这个请求 —— 改任何一个字母都会变成 401 `Signature invalid`。
|
|
16
|
+
*/
|
|
17
|
+
import { createCipheriv, createDecipheriv, createHash, privateDecrypt, publicEncrypt } from 'node:crypto'
|
|
18
|
+
import { randomBytes, randomUUID } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
/** cosy 自定义 base64 字母表(与 qoder2api 逐字节一致)。 */
|
|
21
|
+
const CUSTOM_ALPHABET = '_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!'
|
|
22
|
+
const STD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
23
|
+
const CUSTOM_PAD = '$'
|
|
24
|
+
|
|
25
|
+
const stdToCustom = new Map<string, string>()
|
|
26
|
+
const customToStd = new Map<string, string>()
|
|
27
|
+
for (let i = 0; i < 64; i += 1) {
|
|
28
|
+
stdToCustom.set(STD_ALPHABET[i]!, CUSTOM_ALPHABET[i]!)
|
|
29
|
+
customToStd.set(CUSTOM_ALPHABET[i]!, STD_ALPHABET[i]!)
|
|
30
|
+
}
|
|
31
|
+
stdToCustom.set('=', CUSTOM_PAD)
|
|
32
|
+
customToStd.set(CUSTOM_PAD, '=')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* cosy 自定义 base64 编码:标准 base64 → 按 `n/3` 三块重排 → 逐字符换字母表。
|
|
36
|
+
*
|
|
37
|
+
* 重排是 `std.slice(n-a) + std.slice(a, n-a) + std.slice(0, a)`,`a = floor(n/3)`。
|
|
38
|
+
* 这不是「混淆一下」:网关按同样的顺序反解,顺序错一位整段就是乱码。
|
|
39
|
+
*/
|
|
40
|
+
export function cosyEncode(plaintext: Uint8Array): string {
|
|
41
|
+
const std = Buffer.from(plaintext).toString('base64')
|
|
42
|
+
const n = std.length
|
|
43
|
+
const a = Math.floor(n / 3)
|
|
44
|
+
const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a)
|
|
45
|
+
let out = ''
|
|
46
|
+
for (const ch of rearranged) {
|
|
47
|
+
const mapped = stdToCustom.get(ch)
|
|
48
|
+
if (mapped === undefined) throw new Error(`cosy encode: char out of alphabet: ${ch}`)
|
|
49
|
+
out += mapped
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 自定义 base64 解码({@link cosyEncode} 的逆)。 */
|
|
55
|
+
export function cosyDecode(encoded: string): Uint8Array {
|
|
56
|
+
let mapped = ''
|
|
57
|
+
for (const ch of encoded) {
|
|
58
|
+
const std = customToStd.get(ch)
|
|
59
|
+
if (std === undefined) throw new Error(`cosy decode: char out of custom alphabet: ${ch}`)
|
|
60
|
+
mapped += std
|
|
61
|
+
}
|
|
62
|
+
const n = mapped.length
|
|
63
|
+
const a = Math.floor(n / 3)
|
|
64
|
+
const std = mapped.slice(n - a) + mapped.slice(a, n - a) + mapped.slice(0, a)
|
|
65
|
+
return new Uint8Array(Buffer.from(std, 'base64'))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** RFC1123 的当前 UTC 时刻(旧式签名的 `date` 头)。 */
|
|
69
|
+
export function currentRfc1123Date(): string {
|
|
70
|
+
return new Date().toUTCString()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function md5Hex(input: string): string {
|
|
74
|
+
return createHash('md5').update(input, 'utf8').digest('hex')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sha512Base64Url(input: string): string {
|
|
78
|
+
return createHash('sha512').update(input, 'utf8').digest('base64url')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const COSY_APPCODE = 'cosy'
|
|
82
|
+
/** base64("war, war never changes") —— appcode 的密钥,名字就是这串字面量。 */
|
|
83
|
+
const COSY_SECRET_B64 = 'd2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw=='
|
|
84
|
+
|
|
85
|
+
/** 旧式请求签名(jobToken 交换用):`md5("cosy&<secret>&<rfc1123date>")`。 */
|
|
86
|
+
export function signLegacy(date: string): string {
|
|
87
|
+
return md5Hex(`${COSY_APPCODE}&${COSY_SECRET_B64}&${date}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 网关公钥(客户端用它加密会话临时密钥)。与 qoder2api / Go 克隆一致。 */
|
|
91
|
+
const SERVER_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
92
|
+
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
93
|
+
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
|
|
94
|
+
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
|
95
|
+
XcW+ML9FoCI6AOvOzwIDAQAB
|
|
96
|
+
-----END PUBLIC KEY-----`
|
|
97
|
+
|
|
98
|
+
/** cosy 版本号(客户端身份的一部分,与 qoder2api 实测一致)。 */
|
|
99
|
+
const COSY_VERSION = '0.1.43'
|
|
100
|
+
|
|
101
|
+
/** AES-128-CBC + PKCS7(IV = key,上游如此)。 */
|
|
102
|
+
function aesCbcEncrypt(plain: Uint8Array, key: Uint8Array): Uint8Array {
|
|
103
|
+
const iv = key
|
|
104
|
+
const blockSize = 16
|
|
105
|
+
const padLen = blockSize - (plain.length % blockSize)
|
|
106
|
+
const padded = new Uint8Array(plain.length + padLen)
|
|
107
|
+
padded.set(plain)
|
|
108
|
+
padded.fill(padLen, plain.length)
|
|
109
|
+
const cipher = createCipheriv('aes-128-cbc', key, iv)
|
|
110
|
+
return new Uint8Array(Buffer.concat([cipher.update(padded), cipher.final()]))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** AES-CBC 解密(PKCS7)。 */
|
|
114
|
+
export function aesCbcDecrypt(encrypted: Uint8Array, key: Uint8Array): Uint8Array {
|
|
115
|
+
const decipher = createDecipheriv('aes-128-cbc', key, key)
|
|
116
|
+
return new Uint8Array(Buffer.concat([decipher.update(encrypted), decipher.final()]))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** RSA PKCS1v1.5 加密(用网关公钥加密会话临时密钥)。 */
|
|
120
|
+
function rsaEncrypt(plain: Uint8Array): Uint8Array {
|
|
121
|
+
const out = publicEncrypt({ key: SERVER_PUBKEY_PEM, padding: 1 }, Buffer.from(plain))
|
|
122
|
+
return new Uint8Array(out)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** RSA PKCS1v1.5 解密(只在测试里用;运行时插件没有私钥)。 */
|
|
126
|
+
export function rsaDecrypt(encrypted: Uint8Array, privateKeyPem: string): Uint8Array {
|
|
127
|
+
return new Uint8Array(privateDecrypt({ key: privateKeyPem, padding: 1 }, Buffer.from(encrypted)))
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 会话身份(jobToken 交换响应里的用户字段,全部蛇形命名 —— 上游就是这么读的)。 */
|
|
131
|
+
export interface CosyAuthIdentity {
|
|
132
|
+
name: string
|
|
133
|
+
aid: string
|
|
134
|
+
uid: string
|
|
135
|
+
yx_uid: string
|
|
136
|
+
organization_id: string
|
|
137
|
+
organization_name: string
|
|
138
|
+
user_type: string
|
|
139
|
+
security_oauth_token: string
|
|
140
|
+
refresh_token: string
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 一次 cosy 会话的全部签名要素。 */
|
|
144
|
+
export interface CosySession {
|
|
145
|
+
/** 16 字节临时密钥(随机 hex 的前 16 个字符)。 */
|
|
146
|
+
tempKey: Uint8Array
|
|
147
|
+
/** base64(RSA(tempKey)) —— 每个请求进 `cosy-key` 头。 */
|
|
148
|
+
cosyKey: string
|
|
149
|
+
/** base64(AES(tempKey, identityPayload)) —— 每个 Bearer 的 info 段。 */
|
|
150
|
+
info: string
|
|
151
|
+
identity: CosyAuthIdentity
|
|
152
|
+
machineId: string
|
|
153
|
+
machineToken: string
|
|
154
|
+
machineType: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 从 jobToken 响应构建会话身份(字段名见 upstream 的交换实现)。 */
|
|
158
|
+
export function identityFromJobToken(jobToken: Record<string, unknown>): CosyAuthIdentity {
|
|
159
|
+
return {
|
|
160
|
+
name: String(jobToken.name ?? ''),
|
|
161
|
+
aid: String(jobToken.id ?? ''),
|
|
162
|
+
uid: String(jobToken.id ?? ''),
|
|
163
|
+
yx_uid: '',
|
|
164
|
+
organization_id: '',
|
|
165
|
+
organization_name: '',
|
|
166
|
+
user_type: String(jobToken.userType ?? 'personal_standard'),
|
|
167
|
+
security_oauth_token: String(jobToken.securityOauthToken ?? ''),
|
|
168
|
+
refresh_token: String(jobToken.refreshToken ?? ''),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 身份载荷(键序固定:上游按这份 JSON 原样 AES,键序变了密文就变了)。 */
|
|
173
|
+
function identityPayload(identity: CosyAuthIdentity): Record<string, string> {
|
|
174
|
+
return {
|
|
175
|
+
name: identity.name,
|
|
176
|
+
aid: identity.aid,
|
|
177
|
+
uid: identity.uid,
|
|
178
|
+
yx_uid: identity.yx_uid,
|
|
179
|
+
organization_id: identity.organization_id,
|
|
180
|
+
organization_name: identity.organization_name,
|
|
181
|
+
user_type: identity.user_type,
|
|
182
|
+
security_oauth_token: identity.security_oauth_token,
|
|
183
|
+
refresh_token: identity.refresh_token,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 新会话:随机临时密钥 → RSA 得 cosyKey → AES 身份载荷得 info。 */
|
|
188
|
+
export function newCosySession(
|
|
189
|
+
identity: CosyAuthIdentity,
|
|
190
|
+
fingerprint: CosyFingerprint,
|
|
191
|
+
): CosySession {
|
|
192
|
+
const tempKey = new Uint8Array(Buffer.from(randomBytes(16).toString('hex').slice(0, 16), 'utf8'))
|
|
193
|
+
const cosyKey = Buffer.from(rsaEncrypt(tempKey)).toString('base64')
|
|
194
|
+
const payload = JSON.stringify(identityPayload(identity))
|
|
195
|
+
const info = Buffer.from(aesCbcEncrypt(new Uint8Array(Buffer.from(payload, 'utf8')), tempKey)).toString('base64')
|
|
196
|
+
return {
|
|
197
|
+
tempKey,
|
|
198
|
+
cosyKey,
|
|
199
|
+
info,
|
|
200
|
+
identity,
|
|
201
|
+
machineId: fingerprint.machineId,
|
|
202
|
+
machineToken: fingerprint.machineToken,
|
|
203
|
+
machineType: fingerprint.machineType,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Bearer 载荷:`{cosyVersion, ideVersion, info, requestId, version}` 的**标准**
|
|
209
|
+
* base64,键按字典序排(上游解析时依赖这个顺序,与 qoder2api 一致)。
|
|
210
|
+
*
|
|
211
|
+
* 注意这里是标准 base64 而不是 {@link cosyEncode} —— 两个编码用在两个不同地方,
|
|
212
|
+
* 混用会得到「签名对、载荷读不出」这种最难查的 401。
|
|
213
|
+
*/
|
|
214
|
+
export function buildPayloadB64(info: string): string {
|
|
215
|
+
const payload = {
|
|
216
|
+
cosyVersion: COSY_VERSION,
|
|
217
|
+
ideVersion: '',
|
|
218
|
+
info,
|
|
219
|
+
requestId: randomUUID(),
|
|
220
|
+
version: 'v1',
|
|
221
|
+
}
|
|
222
|
+
const sorted = Object.fromEntries(Object.entries(payload).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
223
|
+
return Buffer.from(JSON.stringify(sorted)).toString('base64')
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** 请求签名:`md5(payloadB64 \n cosyKey \n date \n body \n pathWithoutAlgo)`。 */
|
|
227
|
+
export function signRequest(payloadB64: string, cosyKey: string, cosyDate: string, body: string, pathSig: string): string {
|
|
228
|
+
return md5Hex(`${payloadB64}\n${cosyKey}\n${cosyDate}\n${body}\n${pathSig}`)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `Authorization: Bearer COSY.<payloadB64>.<sig>`。 */
|
|
232
|
+
export function composeBearer(payloadB64: string, sig: string): string {
|
|
233
|
+
return `Bearer COSY.${payloadB64}.${sig}`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 签名用的路径:去掉 `/algo` 前缀、丢掉查询串。 */
|
|
237
|
+
export function pathSigFrom(fullUrl: string): string {
|
|
238
|
+
let path: string
|
|
239
|
+
try {
|
|
240
|
+
path = new URL(fullUrl).pathname
|
|
241
|
+
} catch {
|
|
242
|
+
path = fullUrl
|
|
243
|
+
}
|
|
244
|
+
return path.startsWith('/algo') ? path.slice('/algo'.length) : path
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 设备指纹(长期稳定)。 */
|
|
248
|
+
export interface CosyFingerprint {
|
|
249
|
+
machineId: string
|
|
250
|
+
machineType: string
|
|
251
|
+
machineToken: string
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* 派生设备指纹:以账号 UID(还没有 UID 时用凭据本身)为种子单向哈希。
|
|
256
|
+
*
|
|
257
|
+
* 同一个账号每次派生结果必须一致 —— 网关把这三件套当「这台机器」,每次请求都换
|
|
258
|
+
* 一个新身份反而是异常信号。`uid` 未知时用 `cred:<PAT>` 兜底,保证同一凭据稳定。
|
|
259
|
+
*/
|
|
260
|
+
export function deriveFingerprint(uid: string, credential: string, installSalt?: string): CosyFingerprint {
|
|
261
|
+
const seed = uid !== '' ? uid : `cred:${credential}`
|
|
262
|
+
const salted = installSalt ? `${seed}|salt:${installSalt}` : seed
|
|
263
|
+
return {
|
|
264
|
+
machineId: md5Hex(`machine:${salted}`),
|
|
265
|
+
machineType: md5Hex(`machinetype:${salted}`).slice(0, 18),
|
|
266
|
+
machineToken: sha512Base64Url(`machinetoken:${salted}`).slice(0, 43),
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export { CUSTOM_PAD }
|