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.
@@ -0,0 +1,992 @@
1
+ /**
2
+ * Qoder 上游客户端 —— cosy 签名请求、jobToken 交换、模型目录、SSE 聊天、用量、签到。
3
+ *
4
+ * 端点(CN / Global 各自一个网关主机,chat/models/jobToken 共用它):
5
+ *
6
+ * ```text
7
+ * gateway chat models jobToken
8
+ * CN gateway.qoder.com.cn /algo/api/v2/service/pro/sse/agent_chat_generation /algo/api/v2/model/list /algo/api/v3/user/jobToken
9
+ * Global api1.qoder.sh (同上) (同上) (同上)
10
+ * ```
11
+ *
12
+ * 两个区域还各有一个 `openapi` 主机(`openapi.qoder.com.cn` / `openapi.qoder.sh`),
13
+ * 承载 `sash` 那族接口(用量 `/sash/api/v2/me/usage`、活动
14
+ * `/sash/api/v1/me/campaigns`)。
15
+ *
16
+ * **两条身份链并存,不要混用**(实测,两个区域都验证过):
17
+ *
18
+ * | 面 | 认什么 |
19
+ * | --- | --- |
20
+ * | chat / models(cosy 协议面) | `Authorization: Bearer COSY.<payload>.<sig>` + `cosy-*` 头 |
21
+ * | usage / campaigns(sash 面) | `Authorization: Bearer <jobToken.securityOauthToken>`,**不收** cosy 头 |
22
+ *
23
+ * 拿错凭据的症状是「聊天正常、积分永远 401」,而令牌明明没过期 —— 所以这里是
24
+ * 两套请求构造,不是一套加分支。
25
+ *
26
+ * 协议来源:dingminhua/dsh-connect-qoder(MIT)的 `src/upstream.ts`,其端点为
27
+ * 社区多方实测(qoder2api 系列 + 官方 SDK 产物核对)。
28
+ *
29
+ * @module dsh-router-qoder/upstream
30
+ */
31
+ import {
32
+ buildPayloadB64,
33
+ composeBearer,
34
+ cosyEncode,
35
+ currentRfc1123Date,
36
+ deriveFingerprint,
37
+ identityFromJobToken,
38
+ newCosySession,
39
+ pathSigFrom,
40
+ signLegacy,
41
+ signRequest,
42
+ type CosyFingerprint,
43
+ type CosySession,
44
+ } from './cosy.ts'
45
+ import {
46
+ applyModelKey,
47
+ buildQoderMessages,
48
+ createChatBodyTemplate,
49
+ deepClone,
50
+ extractLatestUserPrompt,
51
+ } from './messages.ts'
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // 区域
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export type QoderRegion = 'cn' | 'global'
58
+
59
+ export interface QoderRegionConfig {
60
+ /** 网关主机(chat / models / jobToken 共用)。 */
61
+ gateway: string
62
+ /** OpenAPI 主机(用量 / 活动 / 领取)。 */
63
+ openapi: string
64
+ /** SSE 聊天端点(含固定查询串)。 */
65
+ chatPath: string
66
+ /** 模型目录端点。 */
67
+ modelsPath: string
68
+ /** jobToken 交换端点。 */
69
+ jobTokenPath: string
70
+ /** 活动列表端点(签到走它 —— 路径里没有「签到」二字,见下方说明)。 */
71
+ campaignsPath: string
72
+ /**
73
+ * 该区域的 sash 接口是否需要**设备风控头**(`Cosy-MachineToken/Code/Type`)。
74
+ *
75
+ * 国际版实测需要:活动与用量只下发给带这三个头的请求,缺了是 401 而不是 404。
76
+ * 那三件套来自本机 Qoder 客户端的 UMID 组件 —— 本插件**不**去伪造它,拿不到
77
+ * 就如实报「该区域需要本机 Qoder 客户端的设备组件」,而不是假装「今天没有活动」。
78
+ */
79
+ requiresDeviceRisk: boolean
80
+ }
81
+
82
+ const CHAT_PATH = '/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1'
83
+ const MODELS_PATH = '/algo/api/v2/model/list?Encode=1'
84
+ const JOB_TOKEN_PATH = '/algo/api/v3/user/jobToken?Encode=1'
85
+ /**
86
+ * 每日签到走 `sash` 的**活动**接口,路径里没有「签到」二字(叫 campaigns)。
87
+ * 照着 checkin / signin / reward 猜是猜不到的(实测猜了 13 个全 404)——
88
+ * 注意是 **v1**,用量那套是 v2。
89
+ */
90
+ const CAMPAIGNS_PATH = '/sash/api/v1/me/campaigns'
91
+ const USAGE_PATH = '/sash/api/v2/me/usage'
92
+ /** sash 面要求的客户端标识(参考实现如此,缺了会被拒)。 */
93
+ const SASH_CLIENT_TYPE = '10'
94
+ const SASH_USER_AGENT = 'Qoder'
95
+
96
+ export const QODER_REGIONS: Readonly<Record<QoderRegion, QoderRegionConfig>> = {
97
+ cn: {
98
+ gateway: 'https://gateway.qoder.com.cn',
99
+ openapi: 'https://openapi.qoder.com.cn',
100
+ chatPath: CHAT_PATH,
101
+ modelsPath: MODELS_PATH,
102
+ jobTokenPath: JOB_TOKEN_PATH,
103
+ campaignsPath: CAMPAIGNS_PATH,
104
+ requiresDeviceRisk: false,
105
+ },
106
+ global: {
107
+ gateway: 'https://api1.qoder.sh',
108
+ openapi: 'https://openapi.qoder.sh',
109
+ chatPath: CHAT_PATH,
110
+ modelsPath: MODELS_PATH,
111
+ jobTokenPath: JOB_TOKEN_PATH,
112
+ campaignsPath: CAMPAIGNS_PATH,
113
+ requiresDeviceRisk: true,
114
+ },
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 错误与分类
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /** 上游失败的种类(供应商层再映射成 AccountState)。 */
122
+ export type QoderErrorKind =
123
+ | 'network' // 传输层(TLS/EOF/reset/超时)
124
+ | 'auth' // 401/403:凭据被拒 / 会话过期
125
+ | 'rate-limited' // 429
126
+ | 'invalid-request' // 400:请求本身不合法(原地重试无意义)
127
+ | 'content-policy' // 内容审核拒绝
128
+ | 'quota-exhausted' // 业务码表示额度/配额耗尽
129
+ | 'transient' // 418/5xx/排队:瞬时
130
+ | 'unknown'
131
+
132
+ export class QoderError extends Error {
133
+ readonly kind: QoderErrorKind
134
+ readonly status: number
135
+ readonly code: string | undefined
136
+ constructor(kind: QoderErrorKind, message: string, status = 0, code?: string) {
137
+ super(message)
138
+ this.name = 'QoderError'
139
+ this.kind = kind
140
+ this.status = status
141
+ this.code = code
142
+ }
143
+ }
144
+
145
+ export function isQoderError(error: unknown): error is QoderError {
146
+ return error instanceof QoderError
147
+ }
148
+
149
+ /** 我们的 `BadRequest` 之外,所有 `QoderError` 都已经带 kind,这里只做透传收口。 */
150
+ function asQoderError(error: unknown): QoderError {
151
+ if (isQoderError(error)) return error
152
+ const message = error instanceof Error ? error.message : String(error)
153
+ return new QoderError('network', message, 0)
154
+ }
155
+
156
+ const CONTENT_POLICY_MARKERS = [
157
+ 'DataInspectionFailed',
158
+ 'inappropriate content',
159
+ 'input text data may contain',
160
+ 'ContentFilter',
161
+ 'SensitiveContent',
162
+ ]
163
+
164
+ function isContentPolicy(detail: string): boolean {
165
+ return CONTENT_POLICY_MARKERS.some((marker) => detail.includes(marker))
166
+ }
167
+
168
+ /** 官方码表里的「用量与配额」族 —— 等周期重置或补额度,**不是**凭据问题。 */
169
+ const QUOTA_CODES: ReadonlySet<string> = new Set([
170
+ '110', // 达到当日用量限制
171
+ '113', // 用量配额耗尽
172
+ '115', // 免费用户配额
173
+ '116', // 团队管理员 Credits 耗尽
174
+ '117', // 团队成员 Credits 耗尽
175
+ '118', // 个人 Credits 耗尽
176
+ '119', // 该模型的免费额度用完
177
+ '122', // Billing Group Credits 到顶
178
+ '402',
179
+ '6004',
180
+ ])
181
+ /** 416 范围/结构无法满足 · 430 能力不支持 · 80411 输入过长 · 80412 媒体过多。 */
182
+ const REQUEST_CODES: ReadonlySet<string> = new Set(['416', '430', '80411', '80412'])
183
+ /** 500 请求失败 / 10408 超时 / 10500 模型服务内部错误 / 10605 排队。 */
184
+ const TRANSIENT_CODES: ReadonlySet<string> = new Set(['500', '10408', '10500', '10605'])
185
+
186
+ function classifyBusinessCode(code: string): QoderErrorKind {
187
+ if (code === '105') return 'auth' // 登录态或 Access Token 已过期
188
+ if (QUOTA_CODES.has(code)) return 'quota-exhausted'
189
+ if (code === '406') return 'content-policy' // 敏感内容或模型拒绝
190
+ if (REQUEST_CODES.has(code)) return 'invalid-request'
191
+ if (TRANSIENT_CODES.has(code)) return 'transient'
192
+ return 'unknown'
193
+ }
194
+
195
+ function classifyHttpStatus(status: number, detail: string): QoderErrorKind {
196
+ if (isContentPolicy(detail)) return 'content-policy'
197
+ if (status === 401 || status === 403) return 'auth'
198
+ if (status === 429) return 'rate-limited'
199
+ if (status === 400) return 'invalid-request'
200
+ if (status === 418 || status >= 500) return 'transient'
201
+ return 'unknown'
202
+ }
203
+
204
+ function parseJson(text: string): unknown {
205
+ try {
206
+ return JSON.parse(text) as unknown
207
+ } catch {
208
+ return undefined
209
+ }
210
+ }
211
+
212
+ /** 取业务码(字符串或整数两种写法都归一成字符串)。 */
213
+ function businessCodeOf(detail: string): string | undefined {
214
+ const parsed = parseJson(detail.trim())
215
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
216
+ const raw = (parsed as Record<string, unknown>).code
217
+ if (typeof raw === 'string' && raw !== '') return raw
218
+ if (typeof raw === 'number' && Number.isInteger(raw)) return String(raw)
219
+ return undefined
220
+ }
221
+
222
+ /**
223
+ * 取**最深一层认识的**业务码。
224
+ *
225
+ * 上游的失败帧会层层套:外层 `{code:403, message:"{\"code\":\"10605\",…}"}` ——
226
+ * 外层那个 403 是 HTTP 语义,真码(10605 排队)藏在 message 里。只看最外层就会
227
+ * 把「排队一分钟」判成「凭据被拒」,进而把一个几分钟就自愈的号判死。
228
+ *
229
+ * 三条纪律:只沿 `message` 往里走(不遍历 error/data/result,否则会把外层 HTTP
230
+ * 码当业务码);认不出的码记下来继续找;找不到就回落 HTTP 状态分类。
231
+ */
232
+ const MAX_CODE_NESTING = 6
233
+
234
+ export function deepestBusinessCode(detail: string): string | undefined {
235
+ let text = detail.trim()
236
+ let fallback: string | undefined
237
+ for (let depth = 0; depth < MAX_CODE_NESTING; depth += 1) {
238
+ if (!text.startsWith('{')) break
239
+ const parsed = parseJson(text)
240
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) break
241
+ const record = parsed as Record<string, unknown>
242
+ const code = businessCodeOf(text)
243
+ if (code !== undefined) {
244
+ if (classifyBusinessCode(code) !== 'unknown') return code
245
+ if (fallback === undefined) fallback = code
246
+ }
247
+ const message = record.message
248
+ if (typeof message !== 'string') break
249
+ text = message.trim()
250
+ }
251
+ return fallback
252
+ }
253
+
254
+ /**
255
+ * 一次失败的分类:**业务码优先于 HTTP 状态**。
256
+ *
257
+ * 实测:当日用量用尽回的是 `HTTP 403 {"code":"110","Billing daily count exceeded"}`。
258
+ * 只看状态会判成「凭据被拒」,于是「今天用满、明天自愈」的号被要求换 PAT。
259
+ */
260
+ export function classifyFailure(status: number, detail: string): QoderErrorKind {
261
+ const code = deepestBusinessCode(detail)
262
+ if (code !== undefined) {
263
+ const byCode = classifyBusinessCode(code)
264
+ if (byCode !== 'unknown') return byCode
265
+ }
266
+ return classifyHttpStatus(status, detail)
267
+ }
268
+
269
+ function truncate(text: string, max: number): string {
270
+ return text.length <= max ? text : `${text.slice(0, max)}…`
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // 类型
275
+ // ---------------------------------------------------------------------------
276
+
277
+ export interface QoderJobToken {
278
+ id: string
279
+ name: string
280
+ expireTime: number
281
+ securityOauthToken: string
282
+ refreshToken: string
283
+ userType: string
284
+ }
285
+
286
+ /** 会话 + 两族令牌。 */
287
+ export interface QoderSession {
288
+ cosy: CosySession
289
+ fingerprint: CosyFingerprint
290
+ jobToken: QoderJobToken
291
+ /** 会话到期时刻(ms);0 = 未知。 */
292
+ expireAtMs: number
293
+ /** sash 面专用令牌(`POST /api/v1/jobToken/exchange` 换来的 `token`)。 */
294
+ sashToken?: string
295
+ sashExpireAtMs?: number
296
+ }
297
+
298
+ export interface QoderModel {
299
+ key: string
300
+ displayName: string
301
+ enable: boolean
302
+ isDefault: boolean
303
+ isVl: boolean
304
+ isReasoning: boolean
305
+ isFree: boolean
306
+ priceFactor: number | undefined
307
+ maxInputTokens: number | undefined
308
+ maxOutputTokens: number | undefined
309
+ /** 上游声明的思考档位(`thinking_config.efforts`)。 */
310
+ efforts: string[]
311
+ }
312
+
313
+ /** 用量(`sash/api/v2/me/usage` 的 qoderUsage 形状)。 */
314
+ export interface QoderUsage {
315
+ userType?: string
316
+ expiresAt?: number
317
+ /** 基础包。 */
318
+ userQuota?: { total?: number; used?: number; remaining?: number }
319
+ /** 加量包。 */
320
+ addOnQuota?: { total?: number; used?: number; remaining?: number }
321
+ /** 企业资源包。 */
322
+ orgResourcePackage?: { used?: number; cap?: number; remaining?: number; available?: boolean }
323
+ isQuotaExceeded: boolean
324
+ }
325
+
326
+ /** 一条活动(只取签到用得到的字段)。 */
327
+ export interface QoderCampaign {
328
+ campaignId: string
329
+ actionType: string
330
+ claimStatus: string
331
+ amount?: number
332
+ validityDays?: number
333
+ endAt?: number
334
+ }
335
+
336
+ /** 签到结果。 */
337
+ export type QoderCheckinOutcome =
338
+ | { status: 'claimed'; amount?: number; validityDays?: number }
339
+ | { status: 'already-claimed' }
340
+ | { status: 'unavailable'; message: string }
341
+
342
+ /** SSE 一帧的增量。 */
343
+ export interface QoderDelta {
344
+ role: string
345
+ content: string
346
+ reasoningContent: string
347
+ toolCalls: unknown[] | undefined
348
+ inputTokens: number
349
+ outputTokens: number
350
+ cachedTokens?: number
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // 请求体
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /** jobToken 交换请求体(PAT 与 `drt-` refresh token 二选一,整体 cosy 编码)。 */
358
+ export function jobTokenBody(credential: string): string {
359
+ const isRefresh = credential.startsWith('drt-')
360
+ const inner = {
361
+ personalToken: isRefresh ? '' : credential,
362
+ securityOauthToken: '',
363
+ refreshToken: isRefresh ? credential : '',
364
+ needRefresh: isRefresh,
365
+ authInfo: {},
366
+ }
367
+ const outer = { payload: JSON.stringify(inner), encodeVersion: '1' }
368
+ return cosyEncode(new TextEncoder().encode(JSON.stringify(outer)))
369
+ }
370
+
371
+ /**
372
+ * 解析一条 SSE 帧。
373
+ *
374
+ * 帧形状:`data: {"headers":{…},"body":"<内层 JSON>","statusCodeValue":200}`
375
+ * 内层:`{"choices":[{"delta":{…}}],"usage":{…}}`,或业务错误
376
+ * `{"code":"115","message":"…"}`。
377
+ *
378
+ * 返回 `undefined` = 这一帧没有可消费内容(`[DONE]` 之后的空帧、心跳等)。
379
+ */
380
+ export function parseStreamDelta(dataPayload: string): QoderDelta | undefined {
381
+ const wrapper = parseJson(dataPayload)
382
+ if (wrapper === null || typeof wrapper !== 'object' || Array.isArray(wrapper)) return undefined
383
+ const w = wrapper as Record<string, unknown>
384
+
385
+ // 信封层错误:HTTP 200 建流之后,上游把 provider 故障包在信封里投递
386
+ const scv = w.statusCodeValue
387
+ if (scv !== undefined && scv !== null && Number(scv) !== 200) {
388
+ const detail = typeof w.body === 'string' ? w.body : dataPayload.slice(0, 400)
389
+ throw new QoderError(
390
+ classifyFailure(Number(scv), detail),
391
+ `上游信封错误: HTTP ${scv} ${truncate(detail, 300)}`,
392
+ Number(scv),
393
+ deepestBusinessCode(detail),
394
+ )
395
+ }
396
+
397
+ const innerStr = w.body
398
+ if (typeof innerStr !== 'string' || innerStr === '') return undefined
399
+ const inner = parseJson(innerStr)
400
+ if (inner === null || typeof inner !== 'object' || Array.isArray(inner)) return undefined
401
+ const payload = inner as Record<string, unknown>
402
+
403
+ // 业务错误:{"code":"115","message":"…"}
404
+ if (typeof payload.code === 'string' && payload.code !== '' && payload.code !== '0') {
405
+ throw new QoderError(
406
+ classifyBusinessCode(payload.code),
407
+ `上游业务错误 code=${payload.code}: ${String(payload.message ?? '')}`,
408
+ 0,
409
+ payload.code,
410
+ )
411
+ }
412
+
413
+ const usage = payload.usage
414
+ const inputTokens = readCount2(usage, 'prompt_tokens')
415
+ const outputTokens = readCount2(usage, 'completion_tokens')
416
+ const cachedTokens = readCachedTokens(usage)
417
+ const tokens = cachedTokens === undefined
418
+ ? { inputTokens, outputTokens }
419
+ : { inputTokens, outputTokens, cachedTokens }
420
+
421
+ const choices = Array.isArray(payload.choices) ? (payload.choices as unknown[]) : []
422
+ for (const ch of choices) {
423
+ if (ch === null || typeof ch !== 'object') continue
424
+ const delta = (ch as Record<string, unknown>).delta
425
+ if (delta === null || typeof delta !== 'object') continue
426
+ const d = delta as Record<string, unknown>
427
+ const role = typeof d.role === 'string' ? d.role : ''
428
+ const content = typeof d.content === 'string' ? d.content : ''
429
+ const reasoning = typeof d.reasoning_content === 'string' ? d.reasoning_content : ''
430
+ const toolCalls = Array.isArray(d.tool_calls) && d.tool_calls.length > 0 ? (d.tool_calls as unknown[]) : undefined
431
+ if (role !== '' || content !== '' || reasoning !== '' || toolCalls !== undefined) {
432
+ return { role, content, reasoningContent: reasoning, toolCalls, ...tokens }
433
+ }
434
+ }
435
+ if (inputTokens > 0 || outputTokens > 0) {
436
+ return { role: '', content: '', reasoningContent: '', toolCalls: undefined, ...tokens }
437
+ }
438
+ return undefined
439
+ }
440
+
441
+ function readCount2(usage: unknown, key: string): number {
442
+ if (usage === null || typeof usage !== 'object') return 0
443
+ const v = (usage as Record<string, unknown>)[key]
444
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0
445
+ }
446
+
447
+ /**
448
+ * 读缓存命中的输入 token 数。三种拼法(OpenAI / DeepSeek / Kimi)取**第一个存在**
449
+ * 的那个,不相加。
450
+ *
451
+ * 它是 `prompt_tokens` 的**子集**,不是额外增量 —— 加进去就把命中的前缀算两遍。
452
+ * 返回 `undefined` 表示上游这帧没报,**不要**折成 0:那会让「没报」和「命中 0」
453
+ * 变成同一个数字,百分比就不可信了。
454
+ */
455
+ function readCachedTokens(usage: unknown): number | undefined {
456
+ if (usage === null || typeof usage !== 'object') return undefined
457
+ const record = usage as Record<string, unknown>
458
+ const details = record.prompt_tokens_details
459
+ if (details !== null && typeof details === 'object' && !Array.isArray(details)) {
460
+ const fromDetails = readNonNegative((details as Record<string, unknown>).cached_tokens)
461
+ if (fromDetails !== undefined) return fromDetails
462
+ }
463
+ const fromHit = readNonNegative(record.prompt_cache_hit_tokens)
464
+ if (fromHit !== undefined) return fromHit
465
+ return readNonNegative(record.cached_tokens)
466
+ }
467
+
468
+ function readNonNegative(value: unknown): number | undefined {
469
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
470
+ }
471
+
472
+ /** 解析用量响应(兼容 `qoderUsage` 内层与平铺两种形状)。 */
473
+ export function parseUsage(data: Record<string, unknown>): QoderUsage | undefined {
474
+ const inner = data.qoderUsage
475
+ const source = inner !== null && typeof inner === 'object' && !Array.isArray(inner)
476
+ ? inner as Record<string, unknown>
477
+ : data
478
+ const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
479
+ const bucket = (value: unknown): { total?: number; used?: number; remaining?: number } | undefined => {
480
+ if (value === null || typeof value !== 'object') return undefined
481
+ const v = value as Record<string, unknown>
482
+ return { total: num(v.total), used: num(v.used), remaining: num(v.remaining) }
483
+ }
484
+ const out: QoderUsage = { isQuotaExceeded: source.isQuotaExceeded === true }
485
+ const userType = source.userType
486
+ if (typeof userType === 'string') out.userType = userType
487
+ const expiresAt = num(source.expiresAt)
488
+ if (expiresAt !== undefined) out.expiresAt = expiresAt
489
+ const userQuota = bucket(source.userQuota)
490
+ if (userQuota !== undefined) out.userQuota = userQuota
491
+ const addOn = bucket(source.addOnQuota)
492
+ if (addOn !== undefined) out.addOnQuota = addOn
493
+ const org = source.orgResourcePackage
494
+ if (org !== null && typeof org === 'object') {
495
+ const o = org as Record<string, unknown>
496
+ out.orgResourcePackage = { used: num(o.used), cap: num(o.cap), remaining: num(o.remaining), available: o.available === true }
497
+ }
498
+ return out
499
+ }
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // 客户端
503
+ // ---------------------------------------------------------------------------
504
+
505
+ const DEFAULT_HEADERS: Record<string, string> = {
506
+ 'cosy-data-policy': 'agree',
507
+ 'content-type': 'application/json',
508
+ 'cosy-clienttype': '5',
509
+ 'login-version': 'v2',
510
+ 'user-agent': 'Go-http-client/2.0',
511
+ 'cosy-scene': 'assistant',
512
+ 'cosy-business-product': 'ide',
513
+ 'cosy-business-type': 'agent',
514
+ }
515
+
516
+ export interface QoderClientOptions {
517
+ log?: (message: string) => void
518
+ fetch?: typeof fetch
519
+ }
520
+
521
+ /**
522
+ * 一个区域的 Qoder 客户端。
523
+ *
524
+ * 会话按**凭据**缓存(账号池里多个 PAT 各有各的 cosy 会话,用一个字段会互相覆盖)。
525
+ */
526
+ export class QoderClient {
527
+ readonly region: QoderRegion
528
+ readonly config: QoderRegionConfig
529
+ private readonly log: (message: string) => void
530
+ private readonly fetchImpl: typeof fetch
531
+ private readonly sessions = new Map<string, QoderSession>()
532
+
533
+ constructor(region: QoderRegion, options: QoderClientOptions = {}) {
534
+ this.region = region
535
+ this.config = QODER_REGIONS[region]
536
+ this.log = options.log ?? (() => {})
537
+ this.fetchImpl = options.fetch ?? globalThis.fetch
538
+ }
539
+
540
+ /** 会话是否还可用(提前 5 分钟视为过期,给续期留时间)。 */
541
+ private sessionValid(credential: string, now = Date.now()): boolean {
542
+ const s = this.sessions.get(credential)
543
+ if (s === undefined) return false
544
+ return s.expireAtMs - now > 5 * 60_000
545
+ }
546
+
547
+ /** 丢掉某个凭据的会话(凭据换掉 / 被判失效时用)。 */
548
+ forget(credential: string): void {
549
+ this.sessions.delete(credential)
550
+ }
551
+
552
+ /**
553
+ * 用 PAT(`pt-…`)或 refresh token(`drt-…`)换 jobToken,并建立 cosy 会话。
554
+ *
555
+ * 这一步用**旧式签名**(`date` + `signature: md5("cosy&<secret>&<date>")`)而不是
556
+ * chat 那套 Bearer,头里还要带设备指纹三件套。指纹以凭据为种子派生:这时候
557
+ * 还没拿到 uid,但同一凭据必须得到同一个指纹。
558
+ */
559
+ async exchangeJobToken(credential: string): Promise<QoderJobToken> {
560
+ const fingerprint = deriveFingerprint('', credential)
561
+ const body = jobTokenBody(credential)
562
+ const date = currentRfc1123Date()
563
+ const url = `${this.config.gateway}${this.config.jobTokenPath}`
564
+ let resp: Response
565
+ try {
566
+ resp = await this.fetchImpl(url, {
567
+ method: 'POST',
568
+ headers: {
569
+ 'cosy-machinetoken': fingerprint.machineToken,
570
+ 'cosy-machinetype': fingerprint.machineType,
571
+ 'login-version': 'v2',
572
+ appcode: 'cosy',
573
+ accept: 'application/json',
574
+ 'accept-encoding': 'identity',
575
+ 'cosy-version': '0.1.43',
576
+ 'cosy-clienttype': '5',
577
+ date,
578
+ signature: signLegacy(date),
579
+ 'content-type': 'application/json',
580
+ 'cosy-machineid': fingerprint.machineId,
581
+ 'user-agent': 'Go-http-client/2.0',
582
+ },
583
+ body,
584
+ })
585
+ } catch (error: unknown) {
586
+ throw asQoderError(error)
587
+ }
588
+ const text = await resp.text()
589
+ if (resp.status !== 200) {
590
+ throw new QoderError(
591
+ classifyFailure(resp.status, text),
592
+ `jobToken 交换失败: HTTP ${resp.status} ${truncate(text, 200)}`,
593
+ resp.status,
594
+ deepestBusinessCode(text),
595
+ )
596
+ }
597
+ const data = parseJson(text)
598
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
599
+ throw new QoderError('unknown', `jobToken 响应不是 JSON: ${truncate(text, 200)}`, resp.status)
600
+ }
601
+ const raw = data as Record<string, unknown>
602
+ const jobToken: QoderJobToken = {
603
+ id: String(raw.id ?? ''),
604
+ name: String(raw.name ?? ''),
605
+ expireTime: Number(raw.expireTime ?? 0),
606
+ securityOauthToken: String(raw.securityOauthToken ?? ''),
607
+ refreshToken: String(raw.refreshToken ?? ''),
608
+ userType: String(raw.userType ?? 'personal_standard'),
609
+ }
610
+ if (jobToken.id === '' && jobToken.securityOauthToken === '') {
611
+ throw new QoderError('auth', `jobToken 交换响应缺少身份字段: ${truncate(text, 200)}`, resp.status)
612
+ }
613
+ const session: QoderSession = {
614
+ cosy: newCosySession(identityFromJobToken(raw), fingerprint),
615
+ fingerprint,
616
+ jobToken,
617
+ expireAtMs: jobToken.expireTime > 0 ? jobToken.expireTime : 0,
618
+ }
619
+ // sash 那一族顺手换一枚,且**必须 await**:不等它落地,紧接着的第一次积分/
620
+ // 签到会读到空令牌而 401 —— 那个 401 与「真被拒」长得一模一样(实测第一次
621
+ // 401、第二次 200),排查极贵。多一次往返换「第一次就对」。
622
+ await this.refreshSashToken(credential, session)
623
+ this.sessions.set(credential, session)
624
+ this.log(`[${this.region}] jobToken ok: ${jobToken.name || jobToken.id}`)
625
+ return jobToken
626
+ }
627
+
628
+ /**
629
+ * 换 sash 面专用令牌(`POST {openapi}/api/v1/jobToken/exchange`)。
630
+ *
631
+ * **不抛**:换不到就让 sash 面回落到 `securityOauthToken`(国内版两族等价);
632
+ * 这一支失败只该让「积分/签到读不到」,不该把 chat 一起拖挂 —— 两条链路。
633
+ *
634
+ * body 的键是 `personal_token`:`personal_access_token` 那种拼写上游回 400。
635
+ */
636
+ private async refreshSashToken(credential: string, session: QoderSession): Promise<void> {
637
+ try {
638
+ const resp = await this.fetchImpl(`${this.config.openapi}/api/v1/jobToken/exchange`, {
639
+ method: 'POST',
640
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
641
+ body: JSON.stringify({ personal_token: credential }),
642
+ })
643
+ const text = await resp.text()
644
+ if (resp.status !== 200) {
645
+ this.log(`[${this.region}] sash 令牌交换失败 HTTP ${resp.status}(回落到旧令牌)`)
646
+ return
647
+ }
648
+ const data = parseJson(text)
649
+ const raw = data !== null && typeof data === 'object' && !Array.isArray(data)
650
+ ? data as Record<string, unknown>
651
+ : {}
652
+ // 上游在这点上有过三种拼写,全试
653
+ const token = typeof raw.token === 'string' && raw.token !== ''
654
+ ? raw.token
655
+ : typeof raw.job_token === 'string' && raw.job_token !== ''
656
+ ? raw.job_token
657
+ : typeof raw.jobToken === 'string' && raw.jobToken !== '' ? raw.jobToken : ''
658
+ if (token === '') {
659
+ this.log(`[${this.region}] sash 令牌响应里没有 token 字段`)
660
+ return
661
+ }
662
+ session.sashToken = token
663
+ const expiry = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Number.NaN
664
+ session.sashExpireAtMs = Number.isFinite(expiry) ? expiry : 0
665
+ } catch (error: unknown) {
666
+ this.log(`[${this.region}] sash 令牌交换异常(回落到旧令牌): ${String((error as Error)?.message ?? error).slice(0, 120)}`)
667
+ }
668
+ }
669
+
670
+ /** 确保有可用会话(过期才重新交换)。 */
671
+ async ensureSession(credential: string): Promise<QoderSession> {
672
+ if (this.sessionValid(credential)) return this.sessions.get(credential)!
673
+ await this.exchangeJobToken(credential)
674
+ return this.sessions.get(credential)!
675
+ }
676
+
677
+ /** 用一次真实交换验证 PAT,返回账号显示名(`addApiKey` 用它)。 */
678
+ async verifyCredential(credential: string): Promise<{ id: string; name: string; userType: string }> {
679
+ const jobToken = await this.exchangeJobToken(credential)
680
+ return { id: jobToken.id, name: jobToken.name, userType: jobToken.userType }
681
+ }
682
+
683
+ /**
684
+ * cosy 协议面的 GET(models 用):与 chat 同一套 Bearer,body 为空串参与签名。
685
+ */
686
+ private async cosyGet(session: QoderSession, url: string): Promise<Record<string, unknown>> {
687
+ const pathSig = pathSigFrom(url)
688
+ const payloadB64 = buildPayloadB64(session.cosy.info)
689
+ const date = String(Math.floor(Date.now() / 1000))
690
+ const sig = signRequest(payloadB64, session.cosy.cosyKey, date, '', pathSig)
691
+ let resp: Response
692
+ try {
693
+ resp = await this.fetchImpl(url, {
694
+ headers: {
695
+ ...DEFAULT_HEADERS,
696
+ 'cosy-machinetype': session.cosy.machineType,
697
+ 'cosy-date': date,
698
+ 'cosy-user': session.cosy.identity.uid,
699
+ 'cosy-key': session.cosy.cosyKey,
700
+ accept: 'application/json',
701
+ authorization: composeBearer(payloadB64, sig),
702
+ 'cosy-version': '0.1.43',
703
+ 'cosy-machineid': session.cosy.machineId,
704
+ 'cosy-machinetoken': session.cosy.machineToken,
705
+ },
706
+ })
707
+ } catch (error: unknown) {
708
+ throw asQoderError(error)
709
+ }
710
+ const text = await resp.text()
711
+ if (resp.status !== 200) {
712
+ throw new QoderError(
713
+ classifyFailure(resp.status, text),
714
+ `models 请求失败: HTTP ${resp.status} ${truncate(text, 200)}`,
715
+ resp.status,
716
+ deepestBusinessCode(text),
717
+ )
718
+ }
719
+ const data = parseJson(text)
720
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
721
+ throw new QoderError('unknown', `models 响应不是 JSON: ${truncate(text, 200)}`, resp.status)
722
+ }
723
+ return data as Record<string, unknown>
724
+ }
725
+
726
+ /**
727
+ * 模型目录。响应 `{chat:[{key, display_name, enable, is_vl, price_factor, …}], …}`。
728
+ *
729
+ * 过滤掉上游的 `auto`:它是该区的**默认路由**(请求时才决定解析成哪个模型),
730
+ * 价格与上下文都由别人临时决定,不是可以选择、也可以如实描述的模型。
731
+ */
732
+ async fetchModels(credential: string): Promise<QoderModel[]> {
733
+ const session = await this.ensureSession(credential)
734
+ const data = await this.cosyGet(session, `${this.config.gateway}${this.config.modelsPath}`)
735
+ const rows = Array.isArray(data.chat) ? (data.chat as unknown[]) : []
736
+ const out: QoderModel[] = []
737
+ for (const row of rows) {
738
+ if (row === null || typeof row !== 'object') continue
739
+ const raw = row as Record<string, unknown>
740
+ const key = typeof raw.key === 'string' ? raw.key : ''
741
+ const displayName = typeof raw.display_name === 'string' ? raw.display_name : ''
742
+ if (key === '' || displayName === '') continue
743
+ if (key.toLowerCase() === 'auto') continue
744
+ const thinking = raw.thinking_config
745
+ const efforts: string[] = []
746
+ if (thinking !== null && typeof thinking === 'object') {
747
+ const list = (thinking as Record<string, unknown>).efforts
748
+ if (Array.isArray(list)) {
749
+ for (const e of list) if (typeof e === 'string' && e !== '') efforts.push(e)
750
+ }
751
+ }
752
+ out.push({
753
+ key,
754
+ displayName,
755
+ enable: raw.enable !== false,
756
+ isDefault: raw.is_default === true,
757
+ isVl: raw.is_vl === true,
758
+ isReasoning: raw.is_reasoning === true,
759
+ isFree: raw.is_free === true,
760
+ priceFactor: typeof raw.price_factor === 'number' ? raw.price_factor : undefined,
761
+ maxInputTokens: typeof raw.max_input_tokens === 'number' ? raw.max_input_tokens : undefined,
762
+ maxOutputTokens: typeof raw.max_output_tokens === 'number' ? raw.max_output_tokens : undefined,
763
+ efforts,
764
+ })
765
+ }
766
+ return out
767
+ }
768
+
769
+ /**
770
+ * 建一条聊天 SSE 流(**原样**返回上游响应体,交给 sse.ts 转 OpenAI 格式)。
771
+ *
772
+ * 请求体是 Qoder 私有模板(见 messages.ts),签名的 body 段是**cosy 编码后的
773
+ * 密文**而不是原文 —— 这是最容易搞错的一处:签原文会得到 `Signature invalid`。
774
+ */
775
+ async openChat(
776
+ credential: string,
777
+ modelKey: string,
778
+ incoming: Array<Record<string, unknown>>,
779
+ options: { tools?: unknown[]; reasoningEffort?: string; isVl?: boolean; signal?: AbortSignal },
780
+ ): Promise<ReadableStream<Uint8Array>> {
781
+ const session = await this.ensureSession(credential)
782
+ const template = deepClone(createChatBodyTemplate())
783
+ applyModelKey(template, modelKey, session.jobToken.userType, options.isVl === true)
784
+ if (options.reasoningEffort !== undefined && options.reasoningEffort !== '') {
785
+ // 官方客户端两处都写,照抄:只写一个可能被某个区域忽略
786
+ template.reasoningEffort = options.reasoningEffort
787
+ template.parameters = { ...(template.parameters ?? {}), reasoning_effort: options.reasoningEffort }
788
+ }
789
+ const prompt = extractLatestUserPrompt(incoming)
790
+ const toolsEnabled = Array.isArray(options.tools) && options.tools.length > 0
791
+ template.messages = buildQoderMessages(incoming, prompt, toolsEnabled) as unknown as unknown[]
792
+ if (toolsEnabled) template.tools = options.tools
793
+ template.request_id = randomId()
794
+ template.chat_record_id = template.request_id
795
+ template.request_set_id = randomId()
796
+ template.session_id = randomId()
797
+ template.business.id = randomId()
798
+ template.business.name = prompt.slice(0, 30) || 'hi'
799
+ template.business.begin_at = Date.now()
800
+
801
+ const body = JSON.stringify(template)
802
+ const url = `${this.config.gateway}${this.config.chatPath}`
803
+ const encoded = cosyEncode(new TextEncoder().encode(body))
804
+ const date = String(Math.floor(Date.now() / 1000))
805
+ const payloadB64 = buildPayloadB64(session.cosy.info)
806
+ const sig = signRequest(payloadB64, session.cosy.cosyKey, date, encoded, pathSigFrom(url))
807
+ let resp: Response
808
+ try {
809
+ resp = await this.fetchImpl(url, {
810
+ method: 'POST',
811
+ headers: {
812
+ ...DEFAULT_HEADERS,
813
+ 'cosy-machinetype': session.cosy.machineType,
814
+ 'cosy-date': date,
815
+ 'cosy-user': session.cosy.identity.uid,
816
+ 'cosy-key': session.cosy.cosyKey,
817
+ 'cache-control': 'no-cache',
818
+ accept: 'text/event-stream',
819
+ authorization: composeBearer(payloadB64, sig),
820
+ 'cosy-version': '0.1.43',
821
+ 'cosy-machineid': session.cosy.machineId,
822
+ 'cosy-machinetoken': session.cosy.machineToken,
823
+ 'x-model-key': modelKey,
824
+ 'x-model-source': 'system',
825
+ },
826
+ body: encoded,
827
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
828
+ })
829
+ } catch (error: unknown) {
830
+ throw asQoderError(error)
831
+ }
832
+ if (resp.status !== 200) {
833
+ const text = await resp.text()
834
+ // 用 classifyFailure 而不是只看状态:chat 面的「当日用量用尽」正是从这里
835
+ // 进来的(实测 HTTP 403 + `{"code":"110"}`),只看状态会判成凭据被拒。
836
+ throw new QoderError(
837
+ classifyFailure(resp.status, text),
838
+ `聊天请求失败: HTTP ${resp.status} ${truncate(text, 300)}`,
839
+ resp.status,
840
+ deepestBusinessCode(text),
841
+ )
842
+ }
843
+ if (resp.body === null) throw new QoderError('transient', '聊天响应没有 body', resp.status)
844
+ return resp.body
845
+ }
846
+
847
+ /** sash 面的 GET:`Authorization: Bearer <sashToken || securityOauthToken>`。 */
848
+ private async sashGet(session: QoderSession, url: string): Promise<Record<string, unknown>> {
849
+ const token = this.sashBearer(session)
850
+ if (token === '') {
851
+ throw new QoderError('auth', '两次令牌交换都没拿到可用令牌,无法访问 sash 接口', 401)
852
+ }
853
+ let resp: Response
854
+ try {
855
+ resp = await this.fetchImpl(url, {
856
+ headers: {
857
+ accept: 'application/json',
858
+ authorization: `Bearer ${token}`,
859
+ 'cosy-clienttype': SASH_CLIENT_TYPE,
860
+ 'user-agent': SASH_USER_AGENT,
861
+ },
862
+ })
863
+ } catch (error: unknown) {
864
+ throw asQoderError(error)
865
+ }
866
+ return this.readSash(resp, url)
867
+ }
868
+
869
+ private async readSash(resp: Response, url: string): Promise<Record<string, unknown>> {
870
+ const text = await resp.text()
871
+ if (resp.status !== 200 && resp.status !== 201) {
872
+ // 国际版 sash 面缺设备风控头时是 401(不是 404)—— 这时给出可执行的解释,
873
+ // 而不是一句「凭据被拒」把用户引去换一个没问题的 PAT。
874
+ if (this.config.requiresDeviceRisk && (resp.status === 401 || resp.status === 403)) {
875
+ throw new QoderError(
876
+ 'auth',
877
+ `该区域的积分/签到需要本机 Qoder 客户端的设备风控组件(${url}),本插件不伪造它`,
878
+ resp.status,
879
+ deepestBusinessCode(text),
880
+ )
881
+ }
882
+ throw new QoderError(
883
+ classifyFailure(resp.status, text),
884
+ `sash 请求失败: HTTP ${resp.status} ${truncate(text, 200)}`,
885
+ resp.status,
886
+ deepestBusinessCode(text),
887
+ )
888
+ }
889
+ const data = parseJson(text)
890
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) {
891
+ throw new QoderError('unknown', `sash 响应不是 JSON: ${truncate(text, 200)}`, resp.status)
892
+ }
893
+ return data as Record<string, unknown>
894
+ }
895
+
896
+ private sashBearer(session: QoderSession): string {
897
+ if (typeof session.sashToken === 'string' && session.sashToken !== '') return session.sashToken
898
+ return session.jobToken.securityOauthToken
899
+ }
900
+
901
+ /** 剩余积分(基础包 + 加量包 + 企业包)。读不到返回 undefined(不是 0)。 */
902
+ async fetchUsage(credential: string): Promise<QoderUsage | undefined> {
903
+ const session = await this.ensureSession(credential)
904
+ const data = await this.sashGet(session, `${this.config.openapi}${USAGE_PATH}`)
905
+ return parseUsage(data)
906
+ }
907
+
908
+ /** 活动列表(签到挑用)。 */
909
+ async fetchCampaigns(credential: string): Promise<QoderCampaign[]> {
910
+ const session = await this.ensureSession(credential)
911
+ const data = await this.sashGet(session, `${this.config.openapi}${this.config.campaignsPath}?forceRefresh=true`)
912
+ const rows = Array.isArray(data.campaigns)
913
+ ? (data.campaigns as unknown[])
914
+ : Array.isArray((data.data as Record<string, unknown> | undefined)?.campaigns)
915
+ ? ((data.data as Record<string, unknown>).campaigns as unknown[])
916
+ : []
917
+ const out: QoderCampaign[] = []
918
+ for (const row of rows) {
919
+ if (row === null || typeof row !== 'object') continue
920
+ const raw = row as Record<string, unknown>
921
+ const campaignId = String(raw.campaignId ?? '')
922
+ if (campaignId === '') continue
923
+ const benefit = raw.benefit
924
+ const b = benefit !== null && typeof benefit === 'object' ? benefit as Record<string, unknown> : {}
925
+ const validity = b.validity
926
+ const v = validity !== null && typeof validity === 'object' ? validity as Record<string, unknown> : {}
927
+ out.push({
928
+ campaignId,
929
+ actionType: String(raw.actionType ?? ''),
930
+ claimStatus: String(raw.claimStatus ?? ''),
931
+ ...(typeof b.amount === 'number' ? { amount: b.amount } : {}),
932
+ ...(typeof v.days === 'number' ? { validityDays: v.days } : {}),
933
+ ...(typeof raw.endAt === 'number' ? { endAt: raw.endAt } : {}),
934
+ })
935
+ }
936
+ return out
937
+ }
938
+
939
+ /**
940
+ * 领取今天那份每日 Credits。
941
+ *
942
+ * 一次调用自足:先拉活动列表找出可领的那条,再领取。三种结果分得很清 ——
943
+ * `claimed` / `already-claimed`(幂等,不是错误)/ `unavailable`(今天上游没
944
+ * 下发活动,比如企业号或未开窗)。
945
+ */
946
+ async claimDailyCredits(credential: string): Promise<QoderCheckinOutcome> {
947
+ const campaigns = await this.fetchCampaigns(credential)
948
+ const daily = campaigns.find((c) => c.actionType === 'CLAIM_BENEFIT')
949
+ const claimable = campaigns.find((c) => c.actionType === 'CLAIM_BENEFIT' && c.claimStatus === 'CLAIMABLE')
950
+ if (claimable === undefined) {
951
+ return daily?.claimStatus === 'CLAIMED'
952
+ ? { status: 'already-claimed' }
953
+ : { status: 'unavailable', message: '上游今日没有下发可领取的签到活动' }
954
+ }
955
+ const session = await this.ensureSession(credential)
956
+ const url = `${this.config.openapi}${this.config.campaignsPath}/${encodeURIComponent(claimable.campaignId)}/claim`
957
+ const token = this.sashBearer(session)
958
+ if (token === '') throw new QoderError('auth', '没有可用令牌,无法领取', 401)
959
+ let resp: Response
960
+ try {
961
+ resp = await this.fetchImpl(url, {
962
+ method: 'POST',
963
+ headers: {
964
+ authorization: `Bearer ${token}`,
965
+ 'cosy-clienttype': SASH_CLIENT_TYPE,
966
+ accept: 'application/json',
967
+ 'accept-language': 'zh-CN',
968
+ 'user-agent': SASH_USER_AGENT,
969
+ 'content-type': 'application/json',
970
+ origin: this.config.openapi,
971
+ },
972
+ body: '{}',
973
+ })
974
+ } catch (error: unknown) {
975
+ throw asQoderError(error)
976
+ }
977
+ if (resp.status === 409) return { status: 'already-claimed' } // 重复领取是幂等的
978
+ const data = await this.readSash(resp, url)
979
+ if (String(data.status ?? '') === 'CLAIMED') {
980
+ return {
981
+ status: 'claimed',
982
+ ...(claimable.amount === undefined ? {} : { amount: claimable.amount }),
983
+ ...(claimable.validityDays === undefined ? {} : { validityDays: claimable.validityDays }),
984
+ }
985
+ }
986
+ return { status: 'unavailable', message: `签到未完成(上游状态 ${String(data.status ?? '未知')})` }
987
+ }
988
+ }
989
+
990
+ function randomId(): string {
991
+ return globalThis.crypto.randomUUID()
992
+ }