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/upstream.ts
ADDED
|
@@ -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
|
+
}
|