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.test.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qoder 族共享实现的回归闸门。
|
|
3
|
+
*
|
|
4
|
+
* 这里锁的是**协议事实**,不是实现细节 —— 那些字节(自定义 base64、签名格式、
|
|
5
|
+
* 会话载荷、sash 与 cosy 两套凭据)每一个都直接决定网关认不认请求,改错了
|
|
6
|
+
* 不会报编译错,只会得到 401 `Signature invalid`:
|
|
7
|
+
*
|
|
8
|
+
* 1. cosy 编码/解码可逆、字母表与签名格式逐字固定;
|
|
9
|
+
* 2. 设备指纹对同一凭据稳定;
|
|
10
|
+
* 3. 添加账号先验一次 jobToken(打错的 PAT 不落盘),重复 PAT 不重复建号;
|
|
11
|
+
* 4. chat 请求是**cosy 编码后的密文**、带 `Bearer COSY.*` 与 cosy 头;
|
|
12
|
+
* 5. 上游 SSE 信封被转成 OpenAI SSE(含 role/content/usage/[DONE]);
|
|
13
|
+
* 6. 错误分类:403+code110 是额度耗尽(不是凭据被拒),401 才是凭证失效;
|
|
14
|
+
* 7. 积分读 sash(jt- 令牌)、模型目录过滤上游的 `auto` 路由、并带上
|
|
15
|
+
* `display_name`(面板靠它显示可读名,请求仍用 key);
|
|
16
|
+
* 8. 签到走活动接口,`CLAIMED` 幂等算成功。
|
|
17
|
+
*
|
|
18
|
+
* 运行:npm test(node --test,靠 Node 内建 TS 类型剥离)。
|
|
19
|
+
*/
|
|
20
|
+
import { test } from 'node:test'
|
|
21
|
+
import assert from 'node:assert/strict'
|
|
22
|
+
import { createSupplier, looksLikePat, normalizePat } from './core.ts'
|
|
23
|
+
import { profile as cn } from './cn.ts'
|
|
24
|
+
import { profile as global } from './global.ts'
|
|
25
|
+
import { cosyDecode, cosyEncode, deriveFingerprint, signLegacy } from './cosy.ts'
|
|
26
|
+
import { classifyFailure, deepestBusinessCode, parseStreamDelta, parseUsage } from './upstream.ts'
|
|
27
|
+
import type { SupplierEnv } from './contract.ts'
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------- 夹具
|
|
30
|
+
|
|
31
|
+
interface Call {
|
|
32
|
+
url: string
|
|
33
|
+
init: RequestInit
|
|
34
|
+
/** 请求体原文(chat 是 cosy 密文,别的多是 JSON) */
|
|
35
|
+
body: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FAR_FUTURE = Date.now() + 3600_000
|
|
39
|
+
const PAT = 'pt-abcdefghijklmnop'
|
|
40
|
+
|
|
41
|
+
/** 一个假 env:凭证放在内存 map 里,fetch 由 `route` 决定。 */
|
|
42
|
+
function harness(opts: { cred?: { nickname: string; pat: string } } = {}) {
|
|
43
|
+
const calls: Call[] = []
|
|
44
|
+
const creds = new Map<string, unknown>()
|
|
45
|
+
if (opts.cred !== undefined) creds.set('u1', opts.cred)
|
|
46
|
+
const env: SupplierEnv = {
|
|
47
|
+
dataDir: '/tmp',
|
|
48
|
+
log: () => {},
|
|
49
|
+
store: {
|
|
50
|
+
get: () => ({ alias: '', disabled: [], custom: [], poolOrder: [], poolStrategy: 'fallback' as const, credits: {} }),
|
|
51
|
+
setAlias: () => {},
|
|
52
|
+
setPoolOrder: () => {},
|
|
53
|
+
setPoolStrategy: () => {},
|
|
54
|
+
setModelEnabled: () => {},
|
|
55
|
+
addCustomModel: () => {},
|
|
56
|
+
removeCustomModel: () => {},
|
|
57
|
+
setAllModelsEnabled: () => {},
|
|
58
|
+
getCredits: () => -1,
|
|
59
|
+
putCredits: (_i, _u, v) => v,
|
|
60
|
+
clearCredits: () => {},
|
|
61
|
+
},
|
|
62
|
+
credentials: {
|
|
63
|
+
list: () => [...creds.keys()],
|
|
64
|
+
get: <T,>(_s: string, uid: string) => creds.get(uid) as T | undefined,
|
|
65
|
+
save: (_s, uid, blob) => void creds.set(uid, blob),
|
|
66
|
+
remove: (_s, uid) => void creds.delete(uid),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
return { env, calls, creds }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function jsonResponse(obj: unknown, status = 200): Response {
|
|
73
|
+
return new Response(JSON.stringify(obj), { status })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 把一次上游调用记下来并按 URL 返回响应。 */
|
|
77
|
+
function route(
|
|
78
|
+
calls: Call[],
|
|
79
|
+
handler: (url: string, body: string, seen: number) => Response,
|
|
80
|
+
): void {
|
|
81
|
+
const counts = new Map<string, number>()
|
|
82
|
+
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
83
|
+
const url = String(input)
|
|
84
|
+
const body = typeof init?.body === 'string' ? init.body : ''
|
|
85
|
+
calls.push({ url, init: init ?? {}, body })
|
|
86
|
+
const key = url.split('?')[0]!
|
|
87
|
+
const seen = counts.get(key) ?? 0
|
|
88
|
+
counts.set(key, seen + 1)
|
|
89
|
+
return handler(url, body, seen)
|
|
90
|
+
}) as typeof fetch
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** jobToken 交换的响应(两个 region 共用)。 */
|
|
94
|
+
function jobTokenResponse(): Response {
|
|
95
|
+
return jsonResponse({
|
|
96
|
+
id: 'user-1',
|
|
97
|
+
name: 'tester',
|
|
98
|
+
expireTime: FAR_FUTURE,
|
|
99
|
+
securityOauthToken: 'jt-oauth-token',
|
|
100
|
+
refreshToken: '',
|
|
101
|
+
userType: 'personal_standard',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 一帧 Qoder SSE 信封。 */
|
|
106
|
+
function sseFrame(inner: unknown): string {
|
|
107
|
+
return `data: ${JSON.stringify({ headers: {}, body: JSON.stringify(inner), statusCodeValue: 200 })}\n\n`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sseBody(chunks: string[], inner: unknown[]): Response {
|
|
111
|
+
return new Response(
|
|
112
|
+
new ReadableStream({
|
|
113
|
+
start(c) {
|
|
114
|
+
const enc = new TextEncoder()
|
|
115
|
+
for (const text of chunks) c.enqueue(enc.encode(text))
|
|
116
|
+
for (const i of inner) c.enqueue(enc.encode(sseFrame(i)))
|
|
117
|
+
c.close()
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
125
|
+
const reader = stream.getReader()
|
|
126
|
+
const dec = new TextDecoder()
|
|
127
|
+
let out = ''
|
|
128
|
+
for (;;) {
|
|
129
|
+
const { done, value } = await reader.read()
|
|
130
|
+
if (done) break
|
|
131
|
+
out += dec.decode(value, { stream: true })
|
|
132
|
+
}
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const chatReq = (extra: Record<string, unknown> = {}) => ({
|
|
137
|
+
rawBody: JSON.stringify({
|
|
138
|
+
model: 'm',
|
|
139
|
+
messages: [{ role: 'user', content: '你好' }],
|
|
140
|
+
...extra,
|
|
141
|
+
}),
|
|
142
|
+
stream: true,
|
|
143
|
+
model: 'm',
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------- 身份
|
|
147
|
+
|
|
148
|
+
test('两个供应商的 id / 前缀 / 区域逐字保留', () => {
|
|
149
|
+
assert.equal(cn.id, 'qoder')
|
|
150
|
+
assert.equal(global.id, 'qoder-global')
|
|
151
|
+
assert.equal(cn.region, 'cn')
|
|
152
|
+
assert.equal(global.region, 'global')
|
|
153
|
+
assert.equal(cn.uidPrefix, 'qd')
|
|
154
|
+
assert.equal(global.uidPrefix, 'qdg')
|
|
155
|
+
// 图标必须内联 data URI:面板图标不该依赖另一个服务活着
|
|
156
|
+
for (const p of [cn, global]) assert.match(p.icon, /^data:image\//)
|
|
157
|
+
assert.equal(cn.icon, global.icon, '同族共用一张图')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('PAT 归一:容忍 Bearer 前缀与空白,识别 pt- 形状', () => {
|
|
161
|
+
assert.equal(normalizePat(' Bearer pt-abc12345678 '), 'pt-abc12345678')
|
|
162
|
+
assert.equal(normalizePat('pt-abc12345678'), 'pt-abc12345678')
|
|
163
|
+
assert.equal(looksLikePat('Bearer pt-abc12345678'), true)
|
|
164
|
+
assert.equal(looksLikePat('sk-abc'), false)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------- cosy 原语
|
|
168
|
+
|
|
169
|
+
test('cosy 编码可逆,且拒绝字母表外的字符', () => {
|
|
170
|
+
const text = '{"a":1,"中文":"值","emoji":"🙂"}'
|
|
171
|
+
const encoded = cosyEncode(new TextEncoder().encode(text))
|
|
172
|
+
assert.ok(encoded.length > 0)
|
|
173
|
+
// 自定义字母表里的 pad 是 `$`,不是 `=`
|
|
174
|
+
assert.ok(!encoded.includes('='), '自定义编码不该出现标准 pad')
|
|
175
|
+
assert.equal(new TextDecoder().decode(cosyDecode(encoded)), text)
|
|
176
|
+
assert.throws(() => cosyDecode('='), /custom alphabet/)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('旧式签名格式固定(md5("cosy&<secret>&<date>"))', () => {
|
|
180
|
+
// 期望值由外部 md5 独立算得:改签名格式 = 这个断言必须红
|
|
181
|
+
assert.equal(signLegacy('Mon, 01 Jan 2024 00:00:00 GMT'), '5392744e067ea01616d61a1d617bf9d1')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('设备指纹对同一凭据稳定、对不同凭据不同', () => {
|
|
185
|
+
const a = deriveFingerprint('', 'pt-abc')
|
|
186
|
+
const b = deriveFingerprint('', 'pt-abc')
|
|
187
|
+
const c = deriveFingerprint('', 'pt-xyz')
|
|
188
|
+
assert.deepEqual(a, b)
|
|
189
|
+
assert.notEqual(a.machineId, c.machineId)
|
|
190
|
+
assert.equal(a.machineType.length, 18)
|
|
191
|
+
assert.equal(a.machineToken.length, 43)
|
|
192
|
+
// 有 uid 时用 uid 做种子(与「还没有 uid 时用凭据」区分开)
|
|
193
|
+
assert.notEqual(deriveFingerprint('user-1', 'pt-abc').machineId, a.machineId)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
// ---------------------------------------------------------------- 添加账号
|
|
197
|
+
|
|
198
|
+
test('addApiKey:先验 jobToken 再落盘,昵称取上游下发的名字', async () => {
|
|
199
|
+
const { env, calls, creds } = harness()
|
|
200
|
+
route(calls, (url) => {
|
|
201
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash-token' })
|
|
202
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
203
|
+
return jsonResponse({ code: '404' }, 404)
|
|
204
|
+
})
|
|
205
|
+
const m = createSupplier(cn)(env)
|
|
206
|
+
const r = await m.addApiKey!({ name: '', apiKey: PAT })
|
|
207
|
+
assert.equal(r.ok, true)
|
|
208
|
+
assert.equal(r.account?.uid, 'qd-1')
|
|
209
|
+
assert.equal(r.account?.nickname, 'tester', '空名字应回落到上游账号名')
|
|
210
|
+
assert.equal((creds.get('qd-1') as { pat: string }).pat, PAT)
|
|
211
|
+
// 第一次请求必须是 jobToken 交换(用旧式签名头)
|
|
212
|
+
const first = calls[0]!
|
|
213
|
+
assert.match(first.url, /\/algo\/api\/v3\/user\/jobToken/)
|
|
214
|
+
const headers = first.init.headers as Record<string, string>
|
|
215
|
+
assert.equal(headers.appcode, 'cosy')
|
|
216
|
+
assert.match(headers.signature ?? '', /^[0-9a-f]{32}$/)
|
|
217
|
+
assert.match(headers.date ?? '', /GMT$/)
|
|
218
|
+
// 请求体是 cosy 密文:解码后应该是 {payload, encodeVersion}
|
|
219
|
+
const decoded = JSON.parse(new TextDecoder().decode(cosyDecode(first.body))) as { encodeVersion: string; payload: string }
|
|
220
|
+
assert.equal(decoded.encodeVersion, '1')
|
|
221
|
+
assert.equal(JSON.parse(decoded.payload).personalToken, PAT)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('addApiKey:PAT 被拒就不落盘,重复 PAT 不重复建号', async () => {
|
|
225
|
+
const { env, calls, creds } = harness()
|
|
226
|
+
route(calls, (url) => {
|
|
227
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jsonResponse({ code: '105', message: 'token invalid' }, 401)
|
|
228
|
+
return jsonResponse({}, 404)
|
|
229
|
+
})
|
|
230
|
+
const rejected = await createSupplier(cn)(env).addApiKey!({ name: 'x', apiKey: 'pt-badbadbadbad' })
|
|
231
|
+
assert.equal(rejected.ok, false)
|
|
232
|
+
assert.match(String(rejected.error), /PAT 校验失败/)
|
|
233
|
+
assert.equal(creds.size, 0, '打错的 PAT 绝不能存进去')
|
|
234
|
+
|
|
235
|
+
const ok = harness()
|
|
236
|
+
route(ok.calls, (url) => {
|
|
237
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 't' })
|
|
238
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
239
|
+
return jsonResponse({}, 404)
|
|
240
|
+
})
|
|
241
|
+
const m = createSupplier(cn)(ok.env)
|
|
242
|
+
assert.equal((await m.addApiKey!({ name: 'a', apiKey: PAT })).ok, true)
|
|
243
|
+
const again = await m.addApiKey!({ name: 'b', apiKey: PAT })
|
|
244
|
+
assert.equal(again.ok, false)
|
|
245
|
+
assert.match(String(again.error), /已经添加过/)
|
|
246
|
+
assert.equal(ok.creds.size, 1, '同一 PAT 不该建出第二个号')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------- chat
|
|
250
|
+
|
|
251
|
+
test('chatOnce:cosy 密文请求 + Bearer COSY 头 + SSE 转 OpenAI 格式', async () => {
|
|
252
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
253
|
+
route(calls, (url) => {
|
|
254
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
255
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
256
|
+
if (url.includes('/algo/api/v2/service/pro/sse/agent_chat_generation')) {
|
|
257
|
+
return sseBody([], [
|
|
258
|
+
{ choices: [{ delta: { role: 'assistant', content: '你' } }] },
|
|
259
|
+
{ choices: [{ delta: { content: '好' } }] },
|
|
260
|
+
{ choices: [{ delta: {} }], usage: { prompt_tokens: 12, completion_tokens: 2, prompt_tokens_details: { cached_tokens: 8 } } },
|
|
261
|
+
])
|
|
262
|
+
}
|
|
263
|
+
return jsonResponse({}, 500)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
const m = createSupplier(cn)(env)
|
|
267
|
+
const r = await m.chatOnce('u1', 'auto', chatReq({ tools: [] }))
|
|
268
|
+
assert.equal(r.ok, true)
|
|
269
|
+
const chatCall = calls.find((c) => c.url.includes('agent_chat_generation'))!
|
|
270
|
+
const headers = chatCall.init.headers as Record<string, string>
|
|
271
|
+
assert.match(headers.authorization ?? '', /^Bearer COSY\./)
|
|
272
|
+
assert.equal(headers['cosy-version'], '0.1.43')
|
|
273
|
+
assert.equal(headers['x-model-key'], 'm')
|
|
274
|
+
assert.match(headers['cosy-key'] ?? '', /^[A-Za-z0-9+/=]+$/)
|
|
275
|
+
// 签名参与的是**密文**,不是明文;解码后应还原出模板
|
|
276
|
+
const template = JSON.parse(new TextDecoder().decode(cosyDecode(chatCall.body))) as Record<string, unknown>
|
|
277
|
+
assert.equal((template.model_config as Record<string, unknown>).key, 'm')
|
|
278
|
+
assert.equal(template.stream, true)
|
|
279
|
+
assert.equal(template.agent_id, 'agent_common')
|
|
280
|
+
assert.match(String(template.request_id), /^[0-9a-f-]{36}$/)
|
|
281
|
+
|
|
282
|
+
const sse = await readAll((r as { ok: true; stream: ReadableStream<Uint8Array> }).stream)
|
|
283
|
+
assert.match(sse, /"delta":\{"role":"assistant"\}/)
|
|
284
|
+
assert.match(sse, /"content":"你"/)
|
|
285
|
+
assert.match(sse, /"content":"好"/)
|
|
286
|
+
assert.match(sse, /"usage":\{"prompt_tokens":12,"completion_tokens":2,"total_tokens":14,"prompt_tokens_details":\{"cached_tokens":8\}\}/)
|
|
287
|
+
assert.match(sse, /"finish_reason":"stop"/)
|
|
288
|
+
assert.ok(sse.trimEnd().endsWith('data: [DONE]'), '必须收尾 [DONE],否则客户端一直等结束帧')
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('chatOnce:无凭证的号报 no_such_model(不记在账号头上)', async () => {
|
|
292
|
+
const { env, calls } = harness()
|
|
293
|
+
route(calls, () => jsonResponse({}, 500))
|
|
294
|
+
const r = await createSupplier(cn)(env).chatOnce('nope', 'auto', chatReq())
|
|
295
|
+
assert.equal(r.ok, false)
|
|
296
|
+
assert.equal(r.ok === false ? r.state : '', 'no_such_model')
|
|
297
|
+
assert.equal(calls.length, 0, '不该打上游')
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('chatOnce:模型带别名前缀时剥掉再打上游', async () => {
|
|
301
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
302
|
+
route(calls, (url) => {
|
|
303
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
304
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
305
|
+
if (url.includes('agent_chat_generation')) return sseBody([], [{ choices: [{ delta: { content: 'ok' } }] }])
|
|
306
|
+
return jsonResponse({}, 500)
|
|
307
|
+
})
|
|
308
|
+
const r = await createSupplier(cn)(env).chatOnce('u1', 'auto', {
|
|
309
|
+
...chatReq(),
|
|
310
|
+
model: 'qoder/qmodel_x',
|
|
311
|
+
rawBody: JSON.stringify({ model: 'qoder/qmodel_x', messages: [{ role: 'user', content: 'hi' }] }),
|
|
312
|
+
})
|
|
313
|
+
assert.equal(r.ok, true)
|
|
314
|
+
const chatCall = calls.find((c) => c.url.includes('agent_chat_generation'))!
|
|
315
|
+
assert.equal((chatCall.init.headers as Record<string, string>)['x-model-key'], 'qmodel_x')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------- 错误分类
|
|
319
|
+
|
|
320
|
+
test('错误分类:业务码优先于 HTTP 状态(403+110 是额度耗尽,不是凭据被拒)', () => {
|
|
321
|
+
assert.equal(classifyFailure(403, JSON.stringify({ code: '110', message: 'Billing daily count exceeded' })), 'quota-exhausted')
|
|
322
|
+
assert.equal(classifyFailure(403, JSON.stringify({ code: '113' })), 'quota-exhausted')
|
|
323
|
+
assert.equal(classifyFailure(401, '{"code":"105"}'), 'auth')
|
|
324
|
+
assert.equal(classifyFailure(429, ''), 'rate-limited')
|
|
325
|
+
assert.equal(classifyFailure(400, ''), 'invalid-request')
|
|
326
|
+
assert.equal(classifyFailure(500, ''), 'transient')
|
|
327
|
+
// 层层套的信封:外层 403 是 HTTP 语义,内层 10605 才是真码(排队 → 瞬时)
|
|
328
|
+
assert.equal(deepestBusinessCode(JSON.stringify({ code: 403, message: JSON.stringify({ code: '10605' }) })), '10605')
|
|
329
|
+
assert.equal(classifyFailure(403, JSON.stringify({ code: 403, message: JSON.stringify({ code: '10605' }) })), 'transient')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('错误映射:额度耗尽 → quota(不判账号死),凭据被拒 → session_dead', async () => {
|
|
333
|
+
for (const [response, want] of [
|
|
334
|
+
[() => jsonResponse({ code: '110', message: 'Billing daily count exceeded' }, 403), 'quota'],
|
|
335
|
+
[() => jsonResponse({ code: '105', message: 'token expired' }, 401), 'session_dead'],
|
|
336
|
+
[() => new Response('slow down', { status: 429 }), 'rate_limit'],
|
|
337
|
+
[() => jsonResponse({ code: '430', message: 'not supported' }, 400), 'bad_request'],
|
|
338
|
+
] as const) {
|
|
339
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
340
|
+
route(calls, (url) => {
|
|
341
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
342
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
343
|
+
return response()
|
|
344
|
+
})
|
|
345
|
+
const r = await createSupplier(cn)(env).chatOnce('u1', 'auto', chatReq())
|
|
346
|
+
assert.equal(r.ok === false ? r.state : '', want)
|
|
347
|
+
}
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
test('流内信封错误:发一条 OpenAI error 帧 + [DONE],并上报迟到失败', async () => {
|
|
351
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
352
|
+
const late: Array<{ uid: string; state: string }> = []
|
|
353
|
+
env.onLateFailure = (uid, _model, state) => late.push({ uid, state })
|
|
354
|
+
route(calls, (url) => {
|
|
355
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
356
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
357
|
+
// HTTP 200 已建流,然后信封里报「今日额度用尽」
|
|
358
|
+
return new Response(
|
|
359
|
+
new ReadableStream({
|
|
360
|
+
start(c) {
|
|
361
|
+
c.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ statusCodeValue: 403, body: JSON.stringify({ code: '110', message: 'Billing daily count exceeded' }) })}\n\n`))
|
|
362
|
+
c.close()
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
{ status: 200 },
|
|
366
|
+
)
|
|
367
|
+
})
|
|
368
|
+
const r = await createSupplier(cn)(env).chatOnce('u1', 'auto', chatReq())
|
|
369
|
+
assert.equal(r.ok, true, 'HTTP 已经 200,插件只能把错误放进流里')
|
|
370
|
+
const sse = await readAll((r as { ok: true; stream: ReadableStream<Uint8Array> }).stream)
|
|
371
|
+
assert.match(sse, /"error":\{/)
|
|
372
|
+
assert.match(sse, /quota-exhausted/)
|
|
373
|
+
assert.ok(sse.trimEnd().endsWith('data: [DONE]'))
|
|
374
|
+
assert.deepEqual(late, [{ uid: 'u1', state: 'quota' }], '迟到的失败要报给核心,后续请求才会避开这个号')
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------- 模型
|
|
378
|
+
|
|
379
|
+
test('模型目录:读上游 chat[],过滤 auto 路由,带上可读名,上下文按 k 报', async () => {
|
|
380
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
381
|
+
route(calls, (url) => {
|
|
382
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
383
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
384
|
+
if (url.includes('/algo/api/v2/model/list')) {
|
|
385
|
+
return jsonResponse({
|
|
386
|
+
chat: [
|
|
387
|
+
{ key: 'qmodel_38max', display_name: 'Qwen3.8-Max', enable: true, is_default: false, is_vl: true, max_input_tokens: 200000 },
|
|
388
|
+
{ key: 'qmodel_flash', display_name: 'Qwen3.8-Flash', enable: true, is_free: true },
|
|
389
|
+
// 上游的 auto 是「默认路由」而不是模型,不能出现在选择器里
|
|
390
|
+
{ key: 'auto', display_name: 'Auto', is_default: true },
|
|
391
|
+
],
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
return jsonResponse({}, 500)
|
|
395
|
+
})
|
|
396
|
+
const models = await createSupplier(cn)(env).listModels(true)
|
|
397
|
+
// id = 上游 key(组合/请求认它),name = display_name(面板只拿它显示):
|
|
398
|
+
// 少了 name,面板上就只有 `qmodel_38max` 这种猜不出含义的 key。
|
|
399
|
+
assert.deepEqual(models, [
|
|
400
|
+
{ id: 'qmodel_38max', name: 'Qwen3.8-Max', context_length: 200 },
|
|
401
|
+
{ id: 'qmodel_flash', name: 'Qwen3.8-Flash' },
|
|
402
|
+
])
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
test('模型目录读不到时列表为空(不编造模型 key)', async () => {
|
|
406
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
407
|
+
route(calls, (url) => {
|
|
408
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
409
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
410
|
+
return jsonResponse({ code: '110' }, 403)
|
|
411
|
+
})
|
|
412
|
+
assert.deepEqual(await createSupplier(cn)(env).listModels(true), [])
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
// ---------------------------------------------------------------- 积分
|
|
416
|
+
|
|
417
|
+
test('积分:读 sash 用量,剩余 = 基础包 + 加量包 + 企业包', async () => {
|
|
418
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
419
|
+
route(calls, (url) => {
|
|
420
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash-token' })
|
|
421
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
422
|
+
if (url.includes('/sash/api/v2/me/usage')) {
|
|
423
|
+
return jsonResponse({
|
|
424
|
+
qoderUsage: {
|
|
425
|
+
userType: 'personal_pro',
|
|
426
|
+
userQuota: { total: 500, used: 380, remaining: 120 },
|
|
427
|
+
addOnQuota: { total: 1000, remaining: 250.5 },
|
|
428
|
+
isQuotaExceeded: false,
|
|
429
|
+
},
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
return jsonResponse({}, 500)
|
|
433
|
+
})
|
|
434
|
+
const m = createSupplier(cn)(env)
|
|
435
|
+
m.status() // 触发后台拉取
|
|
436
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
437
|
+
assert.equal(m.status().accounts[0]!.credits, 370.5)
|
|
438
|
+
// sash 面用的是 jobToken 那族令牌,不是 COSY Bearer
|
|
439
|
+
const usage = calls.find((c) => c.url.includes('/sash/api/v2/me/usage'))!
|
|
440
|
+
const headers = usage.init.headers as Record<string, string>
|
|
441
|
+
assert.equal(headers.authorization, 'Bearer sash-token')
|
|
442
|
+
assert.equal(headers['cosy-clienttype'], '10')
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
test('积分读不到报 -1(不是 0),凭据被拒也要如实落日志', async () => {
|
|
446
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
447
|
+
const logs: string[] = []
|
|
448
|
+
env.log = (m) => logs.push(m)
|
|
449
|
+
route(calls, (url) => {
|
|
450
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
451
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
452
|
+
return jsonResponse({ code: '105', message: 'token expired' }, 401)
|
|
453
|
+
})
|
|
454
|
+
const m = createSupplier(cn)(env)
|
|
455
|
+
m.status()
|
|
456
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
457
|
+
assert.equal(m.status().accounts[0]!.credits, -1, '报 0 会把缓存冲成 0')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
test('parseUsage:兼容 qoderUsage 内层与平铺两种形状', () => {
|
|
461
|
+
assert.equal(parseUsage({ qoderUsage: { userQuota: { remaining: 5 } } })?.userQuota?.remaining, 5)
|
|
462
|
+
assert.equal(parseUsage({ userQuota: { remaining: 7 } })?.userQuota?.remaining, 7)
|
|
463
|
+
assert.equal(parseUsage({})?.isQuotaExceeded, false)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------- 签到
|
|
467
|
+
|
|
468
|
+
test('签到:可领 → 领取成功;已领 → already;没活动 → error', async () => {
|
|
469
|
+
const cases = [
|
|
470
|
+
{
|
|
471
|
+
campaigns: { campaigns: [{ campaignId: 'c1', actionType: 'CLAIM_BENEFIT', claimStatus: 'CLAIMABLE', benefit: { amount: 100, validity: { days: 30 } } }] },
|
|
472
|
+
claim: { status: 'CLAIMED' },
|
|
473
|
+
want: { ok: true, status: 'ok' },
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
campaigns: { campaigns: [{ campaignId: 'c1', actionType: 'CLAIM_BENEFIT', claimStatus: 'CLAIMED' }] },
|
|
477
|
+
claim: undefined,
|
|
478
|
+
want: { ok: true, status: 'already' },
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
campaigns: { campaigns: [] },
|
|
482
|
+
claim: undefined,
|
|
483
|
+
want: { ok: false, status: 'error' },
|
|
484
|
+
},
|
|
485
|
+
] as const
|
|
486
|
+
for (const c of cases) {
|
|
487
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
488
|
+
route(calls, (url) => {
|
|
489
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
490
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
491
|
+
if (url.includes('/claim')) return jsonResponse(c.claim ?? {})
|
|
492
|
+
if (url.includes('/sash/api/v1/me/campaigns')) return jsonResponse(c.campaigns)
|
|
493
|
+
if (url.includes('/sash/api/v2/me/usage')) return jsonResponse({ userQuota: { remaining: 100 } })
|
|
494
|
+
return jsonResponse({}, 500)
|
|
495
|
+
})
|
|
496
|
+
const r = await createSupplier(cn)(env).checkinNow!('u1')
|
|
497
|
+
assert.equal(r.ok, c.want.ok)
|
|
498
|
+
assert.equal(r.status, c.want.status)
|
|
499
|
+
if (c.want.status === 'ok') assert.match(String(r.message), /\+100 Credits/)
|
|
500
|
+
}
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
test('签到:409 视为今天已领(幂等,不是失败)', async () => {
|
|
504
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
505
|
+
route(calls, (url) => {
|
|
506
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
507
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
508
|
+
if (url.includes('/claim')) return jsonResponse({ message: 'ALREADY_CLAIMED' }, 409)
|
|
509
|
+
if (url.includes('/sash/api/v1/me/campaigns')) {
|
|
510
|
+
return jsonResponse({ campaigns: [{ campaignId: 'c1', actionType: 'CLAIM_BENEFIT', claimStatus: 'CLAIMABLE' }] })
|
|
511
|
+
}
|
|
512
|
+
return jsonResponse({}, 500)
|
|
513
|
+
})
|
|
514
|
+
const r = await createSupplier(cn)(env).checkinNow!('u1')
|
|
515
|
+
assert.equal(r.ok, true)
|
|
516
|
+
assert.equal(r.status, 'already')
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
test('国际版积分:缺设备风控头时给出可执行的解释,而不是「今天没活动」', async () => {
|
|
520
|
+
const { env, calls } = harness({ cred: { nickname: 'N', pat: PAT } })
|
|
521
|
+
route(calls, (url) => {
|
|
522
|
+
if (url.includes('/api/v1/jobToken/exchange')) return jsonResponse({ token: 'sash' })
|
|
523
|
+
if (url.includes('/algo/api/v3/user/jobToken')) return jobTokenResponse()
|
|
524
|
+
return jsonResponse({ code: 'UNAUTHORIZED', message: 'missing cookie header' }, 401)
|
|
525
|
+
})
|
|
526
|
+
const m = createSupplier(global)(env)
|
|
527
|
+
m.status()
|
|
528
|
+
await new Promise((r) => setTimeout(r, 60))
|
|
529
|
+
// 积分读不到 → -1(不是 0)
|
|
530
|
+
assert.equal(m.status().accounts[0]!.credits, -1)
|
|
531
|
+
// 签到会把原因带出来
|
|
532
|
+
const r = await m.checkinNow!('u1')
|
|
533
|
+
assert.equal(r.ok, false)
|
|
534
|
+
assert.match(String(r.message), /设备风控组件/)
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------- 流解析
|
|
538
|
+
|
|
539
|
+
test('parseStreamDelta:正常帧取 delta/usage,业务错误帧抛出可分类的错误', () => {
|
|
540
|
+
const ok = parseStreamDelta(JSON.stringify({
|
|
541
|
+
statusCodeValue: 200,
|
|
542
|
+
body: JSON.stringify({ choices: [{ delta: { content: 'hi', reasoning_content: '想' } }], usage: { prompt_tokens: 3, completion_tokens: 1 } }),
|
|
543
|
+
}))
|
|
544
|
+
assert.equal(ok?.content, 'hi')
|
|
545
|
+
assert.equal(ok?.reasoningContent, '想')
|
|
546
|
+
assert.equal(ok?.inputTokens, 3)
|
|
547
|
+
|
|
548
|
+
assert.throws(() => parseStreamDelta(JSON.stringify({
|
|
549
|
+
statusCodeValue: 200,
|
|
550
|
+
body: JSON.stringify({ code: '118', message: 'credits exhausted' }),
|
|
551
|
+
})), /credits exhausted/)
|
|
552
|
+
})
|