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/lib/index.js
ADDED
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
import { createCipheriv, createHash, publicEncrypt, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
// src/cosy.ts
|
|
3
|
+
/**
|
|
4
|
+
* Qoder「cosy」协议原语 —— 自定义 base64、请求签名、RSA/AES 会话、设备指纹。
|
|
5
|
+
*
|
|
6
|
+
* 这里只有**无状态纯函数 + 加密原语**,一行网络调用都没有;流程在 upstream.ts。
|
|
7
|
+
*
|
|
8
|
+
* 协议来源(社区逆向 + 官方产物核对,均为 MIT):
|
|
9
|
+
* - dingminhua/dsh-connect-qoder 的 `src/cosy.ts`(本文件的移植来源,逐字节对齐);
|
|
10
|
+
* - fengyinxia/qoder2api 的 `qoder_auth.py`(PAT→jobToken 交换、cosy 签名、
|
|
11
|
+
* 自定义 base64、RSA/AES 会话构建,CN 网关实测可用);
|
|
12
|
+
* - x_Zhengyuuuui/qoder2api(Go)的 `internal/cosy/*`(同一协议 + 指纹派生 +
|
|
13
|
+
* CN/Global 双区域端点)。
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ 这份文件里几乎所有常量都是**上游的字节**(字母表、公钥、签名格式、
|
|
16
|
+
* 会话载荷的键序)。它们看起来可以「整理得更整齐」,但每一个都直接决定
|
|
17
|
+
* 网关认不认这个请求 —— 改任何一个字母都会变成 401 `Signature invalid`。
|
|
18
|
+
*/
|
|
19
|
+
/** cosy 自定义 base64 字母表(与 qoder2api 逐字节一致)。 */
|
|
20
|
+
const CUSTOM_ALPHABET = "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
|
|
21
|
+
const STD_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
22
|
+
const stdToCustom = /* @__PURE__ */ new Map();
|
|
23
|
+
const customToStd = /* @__PURE__ */ new Map();
|
|
24
|
+
for (let i = 0; i < 64; i += 1) {
|
|
25
|
+
stdToCustom.set(STD_ALPHABET[i], CUSTOM_ALPHABET[i]);
|
|
26
|
+
customToStd.set(CUSTOM_ALPHABET[i], STD_ALPHABET[i]);
|
|
27
|
+
}
|
|
28
|
+
stdToCustom.set("=", "$");
|
|
29
|
+
customToStd.set("$", "=");
|
|
30
|
+
/**
|
|
31
|
+
* cosy 自定义 base64 编码:标准 base64 → 按 `n/3` 三块重排 → 逐字符换字母表。
|
|
32
|
+
*
|
|
33
|
+
* 重排是 `std.slice(n-a) + std.slice(a, n-a) + std.slice(0, a)`,`a = floor(n/3)`。
|
|
34
|
+
* 这不是「混淆一下」:网关按同样的顺序反解,顺序错一位整段就是乱码。
|
|
35
|
+
*/
|
|
36
|
+
function cosyEncode(plaintext) {
|
|
37
|
+
const std = Buffer.from(plaintext).toString("base64");
|
|
38
|
+
const n = std.length;
|
|
39
|
+
const a = Math.floor(n / 3);
|
|
40
|
+
const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
|
|
41
|
+
let out = "";
|
|
42
|
+
for (const ch of rearranged) {
|
|
43
|
+
const mapped = stdToCustom.get(ch);
|
|
44
|
+
if (mapped === void 0) throw new Error(`cosy encode: char out of alphabet: ${ch}`);
|
|
45
|
+
out += mapped;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** RFC1123 的当前 UTC 时刻(旧式签名的 `date` 头)。 */
|
|
50
|
+
function currentRfc1123Date() {
|
|
51
|
+
return (/* @__PURE__ */ new Date()).toUTCString();
|
|
52
|
+
}
|
|
53
|
+
function md5Hex(input) {
|
|
54
|
+
return createHash("md5").update(input, "utf8").digest("hex");
|
|
55
|
+
}
|
|
56
|
+
function sha512Base64Url(input) {
|
|
57
|
+
return createHash("sha512").update(input, "utf8").digest("base64url");
|
|
58
|
+
}
|
|
59
|
+
const COSY_APPCODE = "cosy";
|
|
60
|
+
/** base64("war, war never changes") —— appcode 的密钥,名字就是这串字面量。 */
|
|
61
|
+
const COSY_SECRET_B64 = "d2FyLCB3YXIgbmV2ZXIgY2hhbmdlcw==";
|
|
62
|
+
/** 旧式请求签名(jobToken 交换用):`md5("cosy&<secret>&<rfc1123date>")`。 */
|
|
63
|
+
function signLegacy(date) {
|
|
64
|
+
return md5Hex(`${COSY_APPCODE}&${COSY_SECRET_B64}&${date}`);
|
|
65
|
+
}
|
|
66
|
+
/** 网关公钥(客户端用它加密会话临时密钥)。与 qoder2api / Go 克隆一致。 */
|
|
67
|
+
const SERVER_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
|
|
68
|
+
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
|
|
69
|
+
4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
|
|
70
|
+
6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
|
|
71
|
+
XcW+ML9FoCI6AOvOzwIDAQAB
|
|
72
|
+
-----END PUBLIC KEY-----`;
|
|
73
|
+
/** cosy 版本号(客户端身份的一部分,与 qoder2api 实测一致)。 */
|
|
74
|
+
const COSY_VERSION = "0.1.43";
|
|
75
|
+
/** AES-128-CBC + PKCS7(IV = key,上游如此)。 */
|
|
76
|
+
function aesCbcEncrypt(plain, key) {
|
|
77
|
+
const iv = key;
|
|
78
|
+
const blockSize = 16;
|
|
79
|
+
const padLen = blockSize - plain.length % blockSize;
|
|
80
|
+
const padded = new Uint8Array(plain.length + padLen);
|
|
81
|
+
padded.set(plain);
|
|
82
|
+
padded.fill(padLen, plain.length);
|
|
83
|
+
const cipher = createCipheriv("aes-128-cbc", key, iv);
|
|
84
|
+
return new Uint8Array(Buffer.concat([cipher.update(padded), cipher.final()]));
|
|
85
|
+
}
|
|
86
|
+
/** RSA PKCS1v1.5 加密(用网关公钥加密会话临时密钥)。 */
|
|
87
|
+
function rsaEncrypt(plain) {
|
|
88
|
+
const out = publicEncrypt({
|
|
89
|
+
key: SERVER_PUBKEY_PEM,
|
|
90
|
+
padding: 1
|
|
91
|
+
}, Buffer.from(plain));
|
|
92
|
+
return new Uint8Array(out);
|
|
93
|
+
}
|
|
94
|
+
/** 从 jobToken 响应构建会话身份(字段名见 upstream 的交换实现)。 */
|
|
95
|
+
function identityFromJobToken(jobToken) {
|
|
96
|
+
return {
|
|
97
|
+
name: String(jobToken.name ?? ""),
|
|
98
|
+
aid: String(jobToken.id ?? ""),
|
|
99
|
+
uid: String(jobToken.id ?? ""),
|
|
100
|
+
yx_uid: "",
|
|
101
|
+
organization_id: "",
|
|
102
|
+
organization_name: "",
|
|
103
|
+
user_type: String(jobToken.userType ?? "personal_standard"),
|
|
104
|
+
security_oauth_token: String(jobToken.securityOauthToken ?? ""),
|
|
105
|
+
refresh_token: String(jobToken.refreshToken ?? "")
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** 身份载荷(键序固定:上游按这份 JSON 原样 AES,键序变了密文就变了)。 */
|
|
109
|
+
function identityPayload(identity) {
|
|
110
|
+
return {
|
|
111
|
+
name: identity.name,
|
|
112
|
+
aid: identity.aid,
|
|
113
|
+
uid: identity.uid,
|
|
114
|
+
yx_uid: identity.yx_uid,
|
|
115
|
+
organization_id: identity.organization_id,
|
|
116
|
+
organization_name: identity.organization_name,
|
|
117
|
+
user_type: identity.user_type,
|
|
118
|
+
security_oauth_token: identity.security_oauth_token,
|
|
119
|
+
refresh_token: identity.refresh_token
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** 新会话:随机临时密钥 → RSA 得 cosyKey → AES 身份载荷得 info。 */
|
|
123
|
+
function newCosySession(identity, fingerprint) {
|
|
124
|
+
const tempKey = new Uint8Array(Buffer.from(randomBytes(16).toString("hex").slice(0, 16), "utf8"));
|
|
125
|
+
const cosyKey = Buffer.from(rsaEncrypt(tempKey)).toString("base64");
|
|
126
|
+
const payload = JSON.stringify(identityPayload(identity));
|
|
127
|
+
return {
|
|
128
|
+
tempKey,
|
|
129
|
+
cosyKey,
|
|
130
|
+
info: Buffer.from(aesCbcEncrypt(new Uint8Array(Buffer.from(payload, "utf8")), tempKey)).toString("base64"),
|
|
131
|
+
identity,
|
|
132
|
+
machineId: fingerprint.machineId,
|
|
133
|
+
machineToken: fingerprint.machineToken,
|
|
134
|
+
machineType: fingerprint.machineType
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Bearer 载荷:`{cosyVersion, ideVersion, info, requestId, version}` 的**标准**
|
|
139
|
+
* base64,键按字典序排(上游解析时依赖这个顺序,与 qoder2api 一致)。
|
|
140
|
+
*
|
|
141
|
+
* 注意这里是标准 base64 而不是 {@link cosyEncode} —— 两个编码用在两个不同地方,
|
|
142
|
+
* 混用会得到「签名对、载荷读不出」这种最难查的 401。
|
|
143
|
+
*/
|
|
144
|
+
function buildPayloadB64(info) {
|
|
145
|
+
const payload = {
|
|
146
|
+
cosyVersion: COSY_VERSION,
|
|
147
|
+
ideVersion: "",
|
|
148
|
+
info,
|
|
149
|
+
requestId: randomUUID(),
|
|
150
|
+
version: "v1"
|
|
151
|
+
};
|
|
152
|
+
const sorted = Object.fromEntries(Object.entries(payload).sort(([a], [b]) => a < b ? -1 : 1));
|
|
153
|
+
return Buffer.from(JSON.stringify(sorted)).toString("base64");
|
|
154
|
+
}
|
|
155
|
+
/** 请求签名:`md5(payloadB64 \n cosyKey \n date \n body \n pathWithoutAlgo)`。 */
|
|
156
|
+
function signRequest(payloadB64, cosyKey, cosyDate, body, pathSig) {
|
|
157
|
+
return md5Hex(`${payloadB64}\n${cosyKey}\n${cosyDate}\n${body}\n${pathSig}`);
|
|
158
|
+
}
|
|
159
|
+
/** `Authorization: Bearer COSY.<payloadB64>.<sig>`。 */
|
|
160
|
+
function composeBearer(payloadB64, sig) {
|
|
161
|
+
return `Bearer COSY.${payloadB64}.${sig}`;
|
|
162
|
+
}
|
|
163
|
+
/** 签名用的路径:去掉 `/algo` 前缀、丢掉查询串。 */
|
|
164
|
+
function pathSigFrom(fullUrl) {
|
|
165
|
+
let path;
|
|
166
|
+
try {
|
|
167
|
+
path = new URL(fullUrl).pathname;
|
|
168
|
+
} catch {
|
|
169
|
+
path = fullUrl;
|
|
170
|
+
}
|
|
171
|
+
return path.startsWith("/algo") ? path.slice(5) : path;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* 派生设备指纹:以账号 UID(还没有 UID 时用凭据本身)为种子单向哈希。
|
|
175
|
+
*
|
|
176
|
+
* 同一个账号每次派生结果必须一致 —— 网关把这三件套当「这台机器」,每次请求都换
|
|
177
|
+
* 一个新身份反而是异常信号。`uid` 未知时用 `cred:<PAT>` 兜底,保证同一凭据稳定。
|
|
178
|
+
*/
|
|
179
|
+
function deriveFingerprint(uid, credential, installSalt) {
|
|
180
|
+
const seed = uid !== "" ? uid : `cred:${credential}`;
|
|
181
|
+
const salted = installSalt ? `${seed}|salt:${installSalt}` : seed;
|
|
182
|
+
return {
|
|
183
|
+
machineId: md5Hex(`machine:${salted}`),
|
|
184
|
+
machineType: md5Hex(`machinetype:${salted}`).slice(0, 18),
|
|
185
|
+
machineToken: sha512Base64Url(`machinetoken:${salted}`).slice(0, 43)
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// src/messages.ts
|
|
189
|
+
/**
|
|
190
|
+
* 一份全新的请求体。**每个请求都要新建**(ID 字段必须各自唯一,复用会被上游
|
|
191
|
+
* 当成重复请求)。
|
|
192
|
+
*/
|
|
193
|
+
function createChatBodyTemplate() {
|
|
194
|
+
return {
|
|
195
|
+
request_id: "",
|
|
196
|
+
request_set_id: "",
|
|
197
|
+
chat_record_id: "",
|
|
198
|
+
stream: true,
|
|
199
|
+
chat_task: "FREE_INPUT",
|
|
200
|
+
chat_context: {
|
|
201
|
+
extra: {
|
|
202
|
+
modelConfig: {
|
|
203
|
+
is_reasoning: false,
|
|
204
|
+
key: "lite"
|
|
205
|
+
},
|
|
206
|
+
originalContent: {
|
|
207
|
+
type: "text",
|
|
208
|
+
text: ""
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
text: {
|
|
212
|
+
type: "text",
|
|
213
|
+
text: ""
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
session_id: "",
|
|
217
|
+
source: 1,
|
|
218
|
+
version: "3",
|
|
219
|
+
aliyun_user_type: "personal_standard",
|
|
220
|
+
session_type: "qodercli",
|
|
221
|
+
agent_id: "agent_common",
|
|
222
|
+
task_id: "common",
|
|
223
|
+
model_config: {
|
|
224
|
+
key: "lite",
|
|
225
|
+
is_vl: false,
|
|
226
|
+
is_reasoning: false,
|
|
227
|
+
source: "system"
|
|
228
|
+
},
|
|
229
|
+
messages: [],
|
|
230
|
+
business: {
|
|
231
|
+
id: "",
|
|
232
|
+
name: "hi",
|
|
233
|
+
begin_at: 0
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** 模型 key / 图像能力 / 用户类型三处一起改(漏一处就会被网关当成别的模型)。 */
|
|
238
|
+
function applyModelKey(body, key, userType, isVl) {
|
|
239
|
+
body.model_config.key = key;
|
|
240
|
+
body.model_config.is_vl = isVl;
|
|
241
|
+
body.chat_context.extra.modelConfig.key = key;
|
|
242
|
+
body.aliyun_user_type = userType;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* 每条消息都要带的「假回执」。
|
|
246
|
+
*
|
|
247
|
+
* 上游把消息当成**历史回执**读:缺 `response_meta` / `reasoning_content_signature`
|
|
248
|
+
* 的 assistant 消息会被当成格式异常的记录。这里给一份全零的合法骨架,与官方
|
|
249
|
+
* 客户端的形状一致。
|
|
250
|
+
*/
|
|
251
|
+
function blankResponseMeta() {
|
|
252
|
+
return {
|
|
253
|
+
id: "",
|
|
254
|
+
usage: {
|
|
255
|
+
prompt_tokens: 0,
|
|
256
|
+
completion_tokens: 0,
|
|
257
|
+
total_tokens: 0,
|
|
258
|
+
completion_tokens_details: { reasoning_tokens: 0 },
|
|
259
|
+
prompt_tokens_details: { cached_tokens: 0 }
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function structuredMessage(role, text) {
|
|
264
|
+
return {
|
|
265
|
+
role,
|
|
266
|
+
content: text,
|
|
267
|
+
response_meta: blankResponseMeta(),
|
|
268
|
+
reasoning_content_signature: ""
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function userMessage(text, images = []) {
|
|
272
|
+
const parts = [];
|
|
273
|
+
for (const url of images) parts.push({
|
|
274
|
+
type: "image_url",
|
|
275
|
+
image_url: { url }
|
|
276
|
+
});
|
|
277
|
+
if (text.trim()) parts.push({
|
|
278
|
+
type: "text",
|
|
279
|
+
text
|
|
280
|
+
});
|
|
281
|
+
if (parts.length === 0) parts.push({
|
|
282
|
+
type: "text",
|
|
283
|
+
text: ""
|
|
284
|
+
});
|
|
285
|
+
return {
|
|
286
|
+
role: "user",
|
|
287
|
+
content: "",
|
|
288
|
+
contents: parts,
|
|
289
|
+
response_meta: blankResponseMeta(),
|
|
290
|
+
reasoning_content_signature: ""
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function toolMessage(message, text) {
|
|
294
|
+
const out = structuredMessage("tool", text);
|
|
295
|
+
if (typeof message.name === "string") out.name = message.name;
|
|
296
|
+
if (typeof message.tool_call_id === "string") out.tool_call_id = message.tool_call_id;
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
/** 取一条 OpenAI 消息的纯文本(content 字符串 / parts 数组 / value 字段)。 */
|
|
300
|
+
function normalizeMessageText(message) {
|
|
301
|
+
const content = message.content;
|
|
302
|
+
if (typeof content === "string") return content;
|
|
303
|
+
if (Array.isArray(content)) {
|
|
304
|
+
const parts = [];
|
|
305
|
+
for (const part of content) {
|
|
306
|
+
if (typeof part === "string") {
|
|
307
|
+
parts.push(part);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (part !== null && typeof part === "object") {
|
|
311
|
+
const p = part;
|
|
312
|
+
if (typeof p.text === "string") parts.push(p.text);
|
|
313
|
+
else if (typeof p.value === "string") parts.push(p.value);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return parts.join("");
|
|
317
|
+
}
|
|
318
|
+
return "";
|
|
319
|
+
}
|
|
320
|
+
/** user 消息里的图片 URL(只认 `image_url.url` part)。 */
|
|
321
|
+
function extractMessageImages(message) {
|
|
322
|
+
if (message.role !== "user") return [];
|
|
323
|
+
const content = message.content;
|
|
324
|
+
if (!Array.isArray(content)) return [];
|
|
325
|
+
const urls = [];
|
|
326
|
+
for (const part of content) {
|
|
327
|
+
if (part === null || typeof part !== "object") continue;
|
|
328
|
+
const iu = part.image_url;
|
|
329
|
+
if (iu !== null && typeof iu === "object") {
|
|
330
|
+
const url = iu.url;
|
|
331
|
+
if (typeof url === "string") urls.push(url);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return urls;
|
|
335
|
+
}
|
|
336
|
+
const TOOL_CALLS_TEXT_RE = /Tool calls:\s*(\[[\s\S]*?\])/;
|
|
337
|
+
/** 从文本里认回 `Tool calls: [...]`(有的宿主把 tool_calls 渲染成了文本)。 */
|
|
338
|
+
function parseToolCallsText(text) {
|
|
339
|
+
if (!text) return void 0;
|
|
340
|
+
const m = TOOL_CALLS_TEXT_RE.exec(text);
|
|
341
|
+
if (!m?.[1]) return void 0;
|
|
342
|
+
try {
|
|
343
|
+
const parsed = JSON.parse(m[1]);
|
|
344
|
+
if (Array.isArray(parsed) && parsed.every((item) => item !== null && typeof item === "object")) return normalizeToolCalls(parsed);
|
|
345
|
+
} catch {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/** `{id, name, input}` 形状 → OpenAI 的 `{id, type, function:{name, arguments}}`。 */
|
|
350
|
+
function normalizeToolCalls(raw) {
|
|
351
|
+
return raw.map((tc) => {
|
|
352
|
+
if (tc !== null && typeof tc === "object" && !("function" in tc)) {
|
|
353
|
+
const r = tc;
|
|
354
|
+
if (r.id && r.name && r.input) return {
|
|
355
|
+
id: r.id,
|
|
356
|
+
type: "function",
|
|
357
|
+
function: {
|
|
358
|
+
name: r.name,
|
|
359
|
+
arguments: JSON.stringify(r.input)
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return tc;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* 这条 assistant 消息后面有没有**真的** tool 结果。
|
|
368
|
+
*
|
|
369
|
+
* 只有「有回执」的 tool_calls 才能结构化下发:网关要求 assistant 的 tool_calls
|
|
370
|
+
* 与 tool 消息配对出现,一条没有回执的 tool_calls 会被判成断链(与 OpenAI 的
|
|
371
|
+
* 422 同类)。没有回执时改渲染成文本,模型仍然读得到。
|
|
372
|
+
*/
|
|
373
|
+
function hasResolvedToolResponse(messages, assistantIndex) {
|
|
374
|
+
const message = messages[assistantIndex];
|
|
375
|
+
if (message === void 0 || message.role !== "assistant") return false;
|
|
376
|
+
const tc = message.tool_calls;
|
|
377
|
+
if (!(Array.isArray(tc) && tc.length > 0) && !parseToolCallsText(normalizeMessageText(message))) return false;
|
|
378
|
+
for (let i = assistantIndex + 1; i < messages.length; i += 1) {
|
|
379
|
+
const next = messages[i];
|
|
380
|
+
if (next?.role === "tool") return true;
|
|
381
|
+
if (next !== void 0 && (next.role === "assistant" || next.role === "user" || next.role === "system")) return false;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
function renderToolCalls(toolCalls) {
|
|
386
|
+
return `Tool calls:\n${JSON.stringify(toolCalls)}`;
|
|
387
|
+
}
|
|
388
|
+
function renderToolResult(message, text) {
|
|
389
|
+
const name = typeof message.name === "string" ? message.name : "";
|
|
390
|
+
const toolCallId = typeof message.tool_call_id === "string" ? message.tool_call_id : "";
|
|
391
|
+
let out = "Tool result";
|
|
392
|
+
if (name) out += ` (${name})`;
|
|
393
|
+
if (toolCallId) out += ` [${toolCallId}]`;
|
|
394
|
+
if (text.trim()) out += `:\n${text}`;
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* 一条 OpenAI 消息 → Qoder 消息。`null` = 这条不携带任何内容(跳过)。
|
|
399
|
+
*
|
|
400
|
+
* `toolsEnabled` 表示这个请求带了 tools;`allowStructuredToolCalls` 表示这条
|
|
401
|
+
* assistant 的 tool_calls 有配对回执、可以结构化下发(见
|
|
402
|
+
* {@link hasResolvedToolResponse})。
|
|
403
|
+
*/
|
|
404
|
+
function convertIncomingMessage(message, toolsEnabled, allowStructuredToolCalls) {
|
|
405
|
+
const role = typeof message.role === "string" ? message.role : "user";
|
|
406
|
+
const text = normalizeMessageText(message);
|
|
407
|
+
let structuredToolCalls;
|
|
408
|
+
if (toolsEnabled && allowStructuredToolCalls) {
|
|
409
|
+
const tc = Array.isArray(message.tool_calls) && message.tool_calls.length > 0 ? message.tool_calls : parseToolCallsText(text);
|
|
410
|
+
if (tc) structuredToolCalls = tc;
|
|
411
|
+
}
|
|
412
|
+
if (role === "assistant" && structuredToolCalls) {
|
|
413
|
+
const out = structuredMessage("assistant", text);
|
|
414
|
+
out.tool_calls = structuredToolCalls;
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
if (role === "tool") {
|
|
418
|
+
if (toolsEnabled) return toolMessage(message, text);
|
|
419
|
+
return structuredMessage("user", renderToolResult(message, text));
|
|
420
|
+
}
|
|
421
|
+
let finalText = text;
|
|
422
|
+
if (role === "assistant" && !toolsEnabled && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) finalText = [text, renderToolCalls(message.tool_calls)].filter(Boolean).join("\n\n");
|
|
423
|
+
const images = role === "user" ? extractMessageImages(message) : [];
|
|
424
|
+
if (!finalText.trim() && images.length === 0) return null;
|
|
425
|
+
if (role === "user") return userMessage(images.length > 0 ? finalText.split("\n\n").filter((s) => !s.trim().startsWith("[image] ")).join("\n\n") : finalText, images);
|
|
426
|
+
return structuredMessage(role, finalText);
|
|
427
|
+
}
|
|
428
|
+
/** OpenAI messages → Qoder messages。 */
|
|
429
|
+
function buildQoderMessages(incoming, prompt, toolsEnabled) {
|
|
430
|
+
const rebuilt = [];
|
|
431
|
+
for (let i = 0; i < incoming.length; i += 1) {
|
|
432
|
+
const allowStructured = toolsEnabled && hasResolvedToolResponse(incoming, i);
|
|
433
|
+
const converted = convertIncomingMessage(incoming[i], toolsEnabled, allowStructured);
|
|
434
|
+
if (converted !== null) rebuilt.push(converted);
|
|
435
|
+
}
|
|
436
|
+
if (rebuilt.length === 0 && prompt.trim()) rebuilt.push(userMessage(prompt));
|
|
437
|
+
return rebuilt;
|
|
438
|
+
}
|
|
439
|
+
/** 最后一条 user 消息的纯文本(`business.name` 用它,官方客户端也这么填)。 */
|
|
440
|
+
function extractLatestUserPrompt(messages) {
|
|
441
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
442
|
+
const m = messages[i];
|
|
443
|
+
if (m?.role === "user") {
|
|
444
|
+
const text = normalizeMessageText(m);
|
|
445
|
+
if (text.trim()) return text;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return "";
|
|
449
|
+
}
|
|
450
|
+
/** 深拷贝(模板必须逐请求独立,否则并发请求会互相改对方的 ID)。 */
|
|
451
|
+
function deepClone(value) {
|
|
452
|
+
return structuredClone(value);
|
|
453
|
+
}
|
|
454
|
+
// src/upstream.ts
|
|
455
|
+
/**
|
|
456
|
+
* Qoder 上游客户端 —— cosy 签名请求、jobToken 交换、模型目录、SSE 聊天、用量、签到。
|
|
457
|
+
*
|
|
458
|
+
* 端点(CN / Global 各自一个网关主机,chat/models/jobToken 共用它):
|
|
459
|
+
*
|
|
460
|
+
* ```text
|
|
461
|
+
* gateway chat models jobToken
|
|
462
|
+
* CN gateway.qoder.com.cn /algo/api/v2/service/pro/sse/agent_chat_generation /algo/api/v2/model/list /algo/api/v3/user/jobToken
|
|
463
|
+
* Global api1.qoder.sh (同上) (同上) (同上)
|
|
464
|
+
* ```
|
|
465
|
+
*
|
|
466
|
+
* 两个区域还各有一个 `openapi` 主机(`openapi.qoder.com.cn` / `openapi.qoder.sh`),
|
|
467
|
+
* 承载 `sash` 那族接口(用量 `/sash/api/v2/me/usage`、活动
|
|
468
|
+
* `/sash/api/v1/me/campaigns`)。
|
|
469
|
+
*
|
|
470
|
+
* **两条身份链并存,不要混用**(实测,两个区域都验证过):
|
|
471
|
+
*
|
|
472
|
+
* | 面 | 认什么 |
|
|
473
|
+
* | --- | --- |
|
|
474
|
+
* | chat / models(cosy 协议面) | `Authorization: Bearer COSY.<payload>.<sig>` + `cosy-*` 头 |
|
|
475
|
+
* | usage / campaigns(sash 面) | `Authorization: Bearer <jobToken.securityOauthToken>`,**不收** cosy 头 |
|
|
476
|
+
*
|
|
477
|
+
* 拿错凭据的症状是「聊天正常、积分永远 401」,而令牌明明没过期 —— 所以这里是
|
|
478
|
+
* 两套请求构造,不是一套加分支。
|
|
479
|
+
*
|
|
480
|
+
* 协议来源:dingminhua/dsh-connect-qoder(MIT)的 `src/upstream.ts`,其端点为
|
|
481
|
+
* 社区多方实测(qoder2api 系列 + 官方 SDK 产物核对)。
|
|
482
|
+
*
|
|
483
|
+
* @module dsh-router-qoder/upstream
|
|
484
|
+
*/
|
|
485
|
+
const CHAT_PATH = "/algo/api/v2/service/pro/sse/agent_chat_generation?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1";
|
|
486
|
+
const MODELS_PATH = "/algo/api/v2/model/list?Encode=1";
|
|
487
|
+
const JOB_TOKEN_PATH = "/algo/api/v3/user/jobToken?Encode=1";
|
|
488
|
+
/**
|
|
489
|
+
* 每日签到走 `sash` 的**活动**接口,路径里没有「签到」二字(叫 campaigns)。
|
|
490
|
+
* 照着 checkin / signin / reward 猜是猜不到的(实测猜了 13 个全 404)——
|
|
491
|
+
* 注意是 **v1**,用量那套是 v2。
|
|
492
|
+
*/
|
|
493
|
+
const CAMPAIGNS_PATH = "/sash/api/v1/me/campaigns";
|
|
494
|
+
const USAGE_PATH = "/sash/api/v2/me/usage";
|
|
495
|
+
/** sash 面要求的客户端标识(参考实现如此,缺了会被拒)。 */
|
|
496
|
+
const SASH_CLIENT_TYPE = "10";
|
|
497
|
+
const SASH_USER_AGENT = "Qoder";
|
|
498
|
+
const QODER_REGIONS = {
|
|
499
|
+
cn: {
|
|
500
|
+
gateway: "https://gateway.qoder.com.cn",
|
|
501
|
+
openapi: "https://openapi.qoder.com.cn",
|
|
502
|
+
chatPath: CHAT_PATH,
|
|
503
|
+
modelsPath: MODELS_PATH,
|
|
504
|
+
jobTokenPath: JOB_TOKEN_PATH,
|
|
505
|
+
campaignsPath: CAMPAIGNS_PATH,
|
|
506
|
+
requiresDeviceRisk: false
|
|
507
|
+
},
|
|
508
|
+
global: {
|
|
509
|
+
gateway: "https://api1.qoder.sh",
|
|
510
|
+
openapi: "https://openapi.qoder.sh",
|
|
511
|
+
chatPath: CHAT_PATH,
|
|
512
|
+
modelsPath: MODELS_PATH,
|
|
513
|
+
jobTokenPath: JOB_TOKEN_PATH,
|
|
514
|
+
campaignsPath: CAMPAIGNS_PATH,
|
|
515
|
+
requiresDeviceRisk: true
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
var QoderError = class extends Error {
|
|
519
|
+
kind;
|
|
520
|
+
status;
|
|
521
|
+
code;
|
|
522
|
+
constructor(kind, message, status = 0, code) {
|
|
523
|
+
super(message);
|
|
524
|
+
this.name = "QoderError";
|
|
525
|
+
this.kind = kind;
|
|
526
|
+
this.status = status;
|
|
527
|
+
this.code = code;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
function isQoderError(error) {
|
|
531
|
+
return error instanceof QoderError;
|
|
532
|
+
}
|
|
533
|
+
/** 我们的 `BadRequest` 之外,所有 `QoderError` 都已经带 kind,这里只做透传收口。 */
|
|
534
|
+
function asQoderError(error) {
|
|
535
|
+
if (isQoderError(error)) return error;
|
|
536
|
+
return new QoderError("network", error instanceof Error ? error.message : String(error), 0);
|
|
537
|
+
}
|
|
538
|
+
const CONTENT_POLICY_MARKERS = [
|
|
539
|
+
"DataInspectionFailed",
|
|
540
|
+
"inappropriate content",
|
|
541
|
+
"input text data may contain",
|
|
542
|
+
"ContentFilter",
|
|
543
|
+
"SensitiveContent"
|
|
544
|
+
];
|
|
545
|
+
function isContentPolicy(detail) {
|
|
546
|
+
return CONTENT_POLICY_MARKERS.some((marker) => detail.includes(marker));
|
|
547
|
+
}
|
|
548
|
+
/** 官方码表里的「用量与配额」族 —— 等周期重置或补额度,**不是**凭据问题。 */
|
|
549
|
+
const QUOTA_CODES = /* @__PURE__ */ new Set([
|
|
550
|
+
"110",
|
|
551
|
+
"113",
|
|
552
|
+
"115",
|
|
553
|
+
"116",
|
|
554
|
+
"117",
|
|
555
|
+
"118",
|
|
556
|
+
"119",
|
|
557
|
+
"122",
|
|
558
|
+
"402",
|
|
559
|
+
"6004"
|
|
560
|
+
]);
|
|
561
|
+
/** 416 范围/结构无法满足 · 430 能力不支持 · 80411 输入过长 · 80412 媒体过多。 */
|
|
562
|
+
const REQUEST_CODES = /* @__PURE__ */ new Set([
|
|
563
|
+
"416",
|
|
564
|
+
"430",
|
|
565
|
+
"80411",
|
|
566
|
+
"80412"
|
|
567
|
+
]);
|
|
568
|
+
/** 500 请求失败 / 10408 超时 / 10500 模型服务内部错误 / 10605 排队。 */
|
|
569
|
+
const TRANSIENT_CODES = /* @__PURE__ */ new Set([
|
|
570
|
+
"500",
|
|
571
|
+
"10408",
|
|
572
|
+
"10500",
|
|
573
|
+
"10605"
|
|
574
|
+
]);
|
|
575
|
+
function classifyBusinessCode(code) {
|
|
576
|
+
if (code === "105") return "auth";
|
|
577
|
+
if (QUOTA_CODES.has(code)) return "quota-exhausted";
|
|
578
|
+
if (code === "406") return "content-policy";
|
|
579
|
+
if (REQUEST_CODES.has(code)) return "invalid-request";
|
|
580
|
+
if (TRANSIENT_CODES.has(code)) return "transient";
|
|
581
|
+
return "unknown";
|
|
582
|
+
}
|
|
583
|
+
function classifyHttpStatus(status, detail) {
|
|
584
|
+
if (isContentPolicy(detail)) return "content-policy";
|
|
585
|
+
if (status === 401 || status === 403) return "auth";
|
|
586
|
+
if (status === 429) return "rate-limited";
|
|
587
|
+
if (status === 400) return "invalid-request";
|
|
588
|
+
if (status === 418 || status >= 500) return "transient";
|
|
589
|
+
return "unknown";
|
|
590
|
+
}
|
|
591
|
+
function parseJson(text) {
|
|
592
|
+
try {
|
|
593
|
+
return JSON.parse(text);
|
|
594
|
+
} catch {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
/** 取业务码(字符串或整数两种写法都归一成字符串)。 */
|
|
599
|
+
function businessCodeOf(detail) {
|
|
600
|
+
const parsed = parseJson(detail.trim());
|
|
601
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
|
|
602
|
+
const raw = parsed.code;
|
|
603
|
+
if (typeof raw === "string" && raw !== "") return raw;
|
|
604
|
+
if (typeof raw === "number" && Number.isInteger(raw)) return String(raw);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* 取**最深一层认识的**业务码。
|
|
608
|
+
*
|
|
609
|
+
* 上游的失败帧会层层套:外层 `{code:403, message:"{\"code\":\"10605\",…}"}` ——
|
|
610
|
+
* 外层那个 403 是 HTTP 语义,真码(10605 排队)藏在 message 里。只看最外层就会
|
|
611
|
+
* 把「排队一分钟」判成「凭据被拒」,进而把一个几分钟就自愈的号判死。
|
|
612
|
+
*
|
|
613
|
+
* 三条纪律:只沿 `message` 往里走(不遍历 error/data/result,否则会把外层 HTTP
|
|
614
|
+
* 码当业务码);认不出的码记下来继续找;找不到就回落 HTTP 状态分类。
|
|
615
|
+
*/
|
|
616
|
+
const MAX_CODE_NESTING = 6;
|
|
617
|
+
function deepestBusinessCode(detail) {
|
|
618
|
+
let text = detail.trim();
|
|
619
|
+
let fallback;
|
|
620
|
+
for (let depth = 0; depth < MAX_CODE_NESTING; depth += 1) {
|
|
621
|
+
if (!text.startsWith("{")) break;
|
|
622
|
+
const parsed = parseJson(text);
|
|
623
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) break;
|
|
624
|
+
const record = parsed;
|
|
625
|
+
const code = businessCodeOf(text);
|
|
626
|
+
if (code !== void 0) {
|
|
627
|
+
if (classifyBusinessCode(code) !== "unknown") return code;
|
|
628
|
+
if (fallback === void 0) fallback = code;
|
|
629
|
+
}
|
|
630
|
+
const message = record.message;
|
|
631
|
+
if (typeof message !== "string") break;
|
|
632
|
+
text = message.trim();
|
|
633
|
+
}
|
|
634
|
+
return fallback;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* 一次失败的分类:**业务码优先于 HTTP 状态**。
|
|
638
|
+
*
|
|
639
|
+
* 实测:当日用量用尽回的是 `HTTP 403 {"code":"110","Billing daily count exceeded"}`。
|
|
640
|
+
* 只看状态会判成「凭据被拒」,于是「今天用满、明天自愈」的号被要求换 PAT。
|
|
641
|
+
*/
|
|
642
|
+
function classifyFailure(status, detail) {
|
|
643
|
+
const code = deepestBusinessCode(detail);
|
|
644
|
+
if (code !== void 0) {
|
|
645
|
+
const byCode = classifyBusinessCode(code);
|
|
646
|
+
if (byCode !== "unknown") return byCode;
|
|
647
|
+
}
|
|
648
|
+
return classifyHttpStatus(status, detail);
|
|
649
|
+
}
|
|
650
|
+
function truncate(text, max) {
|
|
651
|
+
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
652
|
+
}
|
|
653
|
+
/** jobToken 交换请求体(PAT 与 `drt-` refresh token 二选一,整体 cosy 编码)。 */
|
|
654
|
+
function jobTokenBody(credential) {
|
|
655
|
+
const isRefresh = credential.startsWith("drt-");
|
|
656
|
+
const outer = {
|
|
657
|
+
payload: JSON.stringify({
|
|
658
|
+
personalToken: isRefresh ? "" : credential,
|
|
659
|
+
securityOauthToken: "",
|
|
660
|
+
refreshToken: isRefresh ? credential : "",
|
|
661
|
+
needRefresh: isRefresh,
|
|
662
|
+
authInfo: {}
|
|
663
|
+
}),
|
|
664
|
+
encodeVersion: "1"
|
|
665
|
+
};
|
|
666
|
+
return cosyEncode(new TextEncoder().encode(JSON.stringify(outer)));
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* 解析一条 SSE 帧。
|
|
670
|
+
*
|
|
671
|
+
* 帧形状:`data: {"headers":{…},"body":"<内层 JSON>","statusCodeValue":200}`
|
|
672
|
+
* 内层:`{"choices":[{"delta":{…}}],"usage":{…}}`,或业务错误
|
|
673
|
+
* `{"code":"115","message":"…"}`。
|
|
674
|
+
*
|
|
675
|
+
* 返回 `undefined` = 这一帧没有可消费内容(`[DONE]` 之后的空帧、心跳等)。
|
|
676
|
+
*/
|
|
677
|
+
function parseStreamDelta(dataPayload) {
|
|
678
|
+
const wrapper = parseJson(dataPayload);
|
|
679
|
+
if (wrapper === null || typeof wrapper !== "object" || Array.isArray(wrapper)) return void 0;
|
|
680
|
+
const w = wrapper;
|
|
681
|
+
const scv = w.statusCodeValue;
|
|
682
|
+
if (scv !== void 0 && scv !== null && Number(scv) !== 200) {
|
|
683
|
+
const detail = typeof w.body === "string" ? w.body : dataPayload.slice(0, 400);
|
|
684
|
+
throw new QoderError(classifyFailure(Number(scv), detail), `上游信封错误: HTTP ${scv} ${truncate(detail, 300)}`, Number(scv), deepestBusinessCode(detail));
|
|
685
|
+
}
|
|
686
|
+
const innerStr = w.body;
|
|
687
|
+
if (typeof innerStr !== "string" || innerStr === "") return void 0;
|
|
688
|
+
const inner = parseJson(innerStr);
|
|
689
|
+
if (inner === null || typeof inner !== "object" || Array.isArray(inner)) return void 0;
|
|
690
|
+
const payload = inner;
|
|
691
|
+
if (typeof payload.code === "string" && payload.code !== "" && payload.code !== "0") throw new QoderError(classifyBusinessCode(payload.code), `上游业务错误 code=${payload.code}: ${String(payload.message ?? "")}`, 0, payload.code);
|
|
692
|
+
const usage = payload.usage;
|
|
693
|
+
const inputTokens = readCount2(usage, "prompt_tokens");
|
|
694
|
+
const outputTokens = readCount2(usage, "completion_tokens");
|
|
695
|
+
const cachedTokens = readCachedTokens(usage);
|
|
696
|
+
const tokens = cachedTokens === void 0 ? {
|
|
697
|
+
inputTokens,
|
|
698
|
+
outputTokens
|
|
699
|
+
} : {
|
|
700
|
+
inputTokens,
|
|
701
|
+
outputTokens,
|
|
702
|
+
cachedTokens
|
|
703
|
+
};
|
|
704
|
+
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
|
705
|
+
for (const ch of choices) {
|
|
706
|
+
if (ch === null || typeof ch !== "object") continue;
|
|
707
|
+
const delta = ch.delta;
|
|
708
|
+
if (delta === null || typeof delta !== "object") continue;
|
|
709
|
+
const d = delta;
|
|
710
|
+
const role = typeof d.role === "string" ? d.role : "";
|
|
711
|
+
const content = typeof d.content === "string" ? d.content : "";
|
|
712
|
+
const reasoning = typeof d.reasoning_content === "string" ? d.reasoning_content : "";
|
|
713
|
+
const toolCalls = Array.isArray(d.tool_calls) && d.tool_calls.length > 0 ? d.tool_calls : void 0;
|
|
714
|
+
if (role !== "" || content !== "" || reasoning !== "" || toolCalls !== void 0) return {
|
|
715
|
+
role,
|
|
716
|
+
content,
|
|
717
|
+
reasoningContent: reasoning,
|
|
718
|
+
toolCalls,
|
|
719
|
+
...tokens
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
if (inputTokens > 0 || outputTokens > 0) return {
|
|
723
|
+
role: "",
|
|
724
|
+
content: "",
|
|
725
|
+
reasoningContent: "",
|
|
726
|
+
toolCalls: void 0,
|
|
727
|
+
...tokens
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function readCount2(usage, key) {
|
|
731
|
+
if (usage === null || typeof usage !== "object") return 0;
|
|
732
|
+
const v = usage[key];
|
|
733
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* 读缓存命中的输入 token 数。三种拼法(OpenAI / DeepSeek / Kimi)取**第一个存在**
|
|
737
|
+
* 的那个,不相加。
|
|
738
|
+
*
|
|
739
|
+
* 它是 `prompt_tokens` 的**子集**,不是额外增量 —— 加进去就把命中的前缀算两遍。
|
|
740
|
+
* 返回 `undefined` 表示上游这帧没报,**不要**折成 0:那会让「没报」和「命中 0」
|
|
741
|
+
* 变成同一个数字,百分比就不可信了。
|
|
742
|
+
*/
|
|
743
|
+
function readCachedTokens(usage) {
|
|
744
|
+
if (usage === null || typeof usage !== "object") return void 0;
|
|
745
|
+
const record = usage;
|
|
746
|
+
const details = record.prompt_tokens_details;
|
|
747
|
+
if (details !== null && typeof details === "object" && !Array.isArray(details)) {
|
|
748
|
+
const fromDetails = readNonNegative(details.cached_tokens);
|
|
749
|
+
if (fromDetails !== void 0) return fromDetails;
|
|
750
|
+
}
|
|
751
|
+
const fromHit = readNonNegative(record.prompt_cache_hit_tokens);
|
|
752
|
+
if (fromHit !== void 0) return fromHit;
|
|
753
|
+
return readNonNegative(record.cached_tokens);
|
|
754
|
+
}
|
|
755
|
+
function readNonNegative(value) {
|
|
756
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
757
|
+
}
|
|
758
|
+
/** 解析用量响应(兼容 `qoderUsage` 内层与平铺两种形状)。 */
|
|
759
|
+
function parseUsage(data) {
|
|
760
|
+
const inner = data.qoderUsage;
|
|
761
|
+
const source = inner !== null && typeof inner === "object" && !Array.isArray(inner) ? inner : data;
|
|
762
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
763
|
+
const bucket = (value) => {
|
|
764
|
+
if (value === null || typeof value !== "object") return void 0;
|
|
765
|
+
const v = value;
|
|
766
|
+
return {
|
|
767
|
+
total: num(v.total),
|
|
768
|
+
used: num(v.used),
|
|
769
|
+
remaining: num(v.remaining)
|
|
770
|
+
};
|
|
771
|
+
};
|
|
772
|
+
const out = { isQuotaExceeded: source.isQuotaExceeded === true };
|
|
773
|
+
const userType = source.userType;
|
|
774
|
+
if (typeof userType === "string") out.userType = userType;
|
|
775
|
+
const expiresAt = num(source.expiresAt);
|
|
776
|
+
if (expiresAt !== void 0) out.expiresAt = expiresAt;
|
|
777
|
+
const userQuota = bucket(source.userQuota);
|
|
778
|
+
if (userQuota !== void 0) out.userQuota = userQuota;
|
|
779
|
+
const addOn = bucket(source.addOnQuota);
|
|
780
|
+
if (addOn !== void 0) out.addOnQuota = addOn;
|
|
781
|
+
const org = source.orgResourcePackage;
|
|
782
|
+
if (org !== null && typeof org === "object") {
|
|
783
|
+
const o = org;
|
|
784
|
+
out.orgResourcePackage = {
|
|
785
|
+
used: num(o.used),
|
|
786
|
+
cap: num(o.cap),
|
|
787
|
+
remaining: num(o.remaining),
|
|
788
|
+
available: o.available === true
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
return out;
|
|
792
|
+
}
|
|
793
|
+
const DEFAULT_HEADERS = {
|
|
794
|
+
"cosy-data-policy": "agree",
|
|
795
|
+
"content-type": "application/json",
|
|
796
|
+
"cosy-clienttype": "5",
|
|
797
|
+
"login-version": "v2",
|
|
798
|
+
"user-agent": "Go-http-client/2.0",
|
|
799
|
+
"cosy-scene": "assistant",
|
|
800
|
+
"cosy-business-product": "ide",
|
|
801
|
+
"cosy-business-type": "agent"
|
|
802
|
+
};
|
|
803
|
+
/**
|
|
804
|
+
* 一个区域的 Qoder 客户端。
|
|
805
|
+
*
|
|
806
|
+
* 会话按**凭据**缓存(账号池里多个 PAT 各有各的 cosy 会话,用一个字段会互相覆盖)。
|
|
807
|
+
*/
|
|
808
|
+
var QoderClient = class {
|
|
809
|
+
region;
|
|
810
|
+
config;
|
|
811
|
+
log;
|
|
812
|
+
fetchImpl;
|
|
813
|
+
sessions = /* @__PURE__ */ new Map();
|
|
814
|
+
constructor(region, options = {}) {
|
|
815
|
+
this.region = region;
|
|
816
|
+
this.config = QODER_REGIONS[region];
|
|
817
|
+
this.log = options.log ?? (() => {});
|
|
818
|
+
this.fetchImpl = options.fetch ?? globalThis.fetch;
|
|
819
|
+
}
|
|
820
|
+
/** 会话是否还可用(提前 5 分钟视为过期,给续期留时间)。 */
|
|
821
|
+
sessionValid(credential, now = Date.now()) {
|
|
822
|
+
const s = this.sessions.get(credential);
|
|
823
|
+
if (s === void 0) return false;
|
|
824
|
+
return s.expireAtMs - now > 3e5;
|
|
825
|
+
}
|
|
826
|
+
/** 丢掉某个凭据的会话(凭据换掉 / 被判失效时用)。 */
|
|
827
|
+
forget(credential) {
|
|
828
|
+
this.sessions.delete(credential);
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* 用 PAT(`pt-…`)或 refresh token(`drt-…`)换 jobToken,并建立 cosy 会话。
|
|
832
|
+
*
|
|
833
|
+
* 这一步用**旧式签名**(`date` + `signature: md5("cosy&<secret>&<date>")`)而不是
|
|
834
|
+
* chat 那套 Bearer,头里还要带设备指纹三件套。指纹以凭据为种子派生:这时候
|
|
835
|
+
* 还没拿到 uid,但同一凭据必须得到同一个指纹。
|
|
836
|
+
*/
|
|
837
|
+
async exchangeJobToken(credential) {
|
|
838
|
+
const fingerprint = deriveFingerprint("", credential);
|
|
839
|
+
const body = jobTokenBody(credential);
|
|
840
|
+
const date = currentRfc1123Date();
|
|
841
|
+
const url = `${this.config.gateway}${this.config.jobTokenPath}`;
|
|
842
|
+
let resp;
|
|
843
|
+
try {
|
|
844
|
+
resp = await this.fetchImpl(url, {
|
|
845
|
+
method: "POST",
|
|
846
|
+
headers: {
|
|
847
|
+
"cosy-machinetoken": fingerprint.machineToken,
|
|
848
|
+
"cosy-machinetype": fingerprint.machineType,
|
|
849
|
+
"login-version": "v2",
|
|
850
|
+
appcode: "cosy",
|
|
851
|
+
accept: "application/json",
|
|
852
|
+
"accept-encoding": "identity",
|
|
853
|
+
"cosy-version": "0.1.43",
|
|
854
|
+
"cosy-clienttype": "5",
|
|
855
|
+
date,
|
|
856
|
+
signature: signLegacy(date),
|
|
857
|
+
"content-type": "application/json",
|
|
858
|
+
"cosy-machineid": fingerprint.machineId,
|
|
859
|
+
"user-agent": "Go-http-client/2.0"
|
|
860
|
+
},
|
|
861
|
+
body
|
|
862
|
+
});
|
|
863
|
+
} catch (error) {
|
|
864
|
+
throw asQoderError(error);
|
|
865
|
+
}
|
|
866
|
+
const text = await resp.text();
|
|
867
|
+
if (resp.status !== 200) throw new QoderError(classifyFailure(resp.status, text), `jobToken 交换失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, deepestBusinessCode(text));
|
|
868
|
+
const data = parseJson(text);
|
|
869
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) throw new QoderError("unknown", `jobToken 响应不是 JSON: ${truncate(text, 200)}`, resp.status);
|
|
870
|
+
const raw = data;
|
|
871
|
+
const jobToken = {
|
|
872
|
+
id: String(raw.id ?? ""),
|
|
873
|
+
name: String(raw.name ?? ""),
|
|
874
|
+
expireTime: Number(raw.expireTime ?? 0),
|
|
875
|
+
securityOauthToken: String(raw.securityOauthToken ?? ""),
|
|
876
|
+
refreshToken: String(raw.refreshToken ?? ""),
|
|
877
|
+
userType: String(raw.userType ?? "personal_standard")
|
|
878
|
+
};
|
|
879
|
+
if (jobToken.id === "" && jobToken.securityOauthToken === "") throw new QoderError("auth", `jobToken 交换响应缺少身份字段: ${truncate(text, 200)}`, resp.status);
|
|
880
|
+
const session = {
|
|
881
|
+
cosy: newCosySession(identityFromJobToken(raw), fingerprint),
|
|
882
|
+
fingerprint,
|
|
883
|
+
jobToken,
|
|
884
|
+
expireAtMs: jobToken.expireTime > 0 ? jobToken.expireTime : 0
|
|
885
|
+
};
|
|
886
|
+
await this.refreshSashToken(credential, session);
|
|
887
|
+
this.sessions.set(credential, session);
|
|
888
|
+
this.log(`[${this.region}] jobToken ok: ${jobToken.name || jobToken.id}`);
|
|
889
|
+
return jobToken;
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* 换 sash 面专用令牌(`POST {openapi}/api/v1/jobToken/exchange`)。
|
|
893
|
+
*
|
|
894
|
+
* **不抛**:换不到就让 sash 面回落到 `securityOauthToken`(国内版两族等价);
|
|
895
|
+
* 这一支失败只该让「积分/签到读不到」,不该把 chat 一起拖挂 —— 两条链路。
|
|
896
|
+
*
|
|
897
|
+
* body 的键是 `personal_token`:`personal_access_token` 那种拼写上游回 400。
|
|
898
|
+
*/
|
|
899
|
+
async refreshSashToken(credential, session) {
|
|
900
|
+
try {
|
|
901
|
+
const resp = await this.fetchImpl(`${this.config.openapi}/api/v1/jobToken/exchange`, {
|
|
902
|
+
method: "POST",
|
|
903
|
+
headers: {
|
|
904
|
+
"content-type": "application/json",
|
|
905
|
+
accept: "application/json"
|
|
906
|
+
},
|
|
907
|
+
body: JSON.stringify({ personal_token: credential })
|
|
908
|
+
});
|
|
909
|
+
const text = await resp.text();
|
|
910
|
+
if (resp.status !== 200) {
|
|
911
|
+
this.log(`[${this.region}] sash 令牌交换失败 HTTP ${resp.status}(回落到旧令牌)`);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const data = parseJson(text);
|
|
915
|
+
const raw = data !== null && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
916
|
+
const token = typeof raw.token === "string" && raw.token !== "" ? raw.token : typeof raw.job_token === "string" && raw.job_token !== "" ? raw.job_token : typeof raw.jobToken === "string" && raw.jobToken !== "" ? raw.jobToken : "";
|
|
917
|
+
if (token === "") {
|
|
918
|
+
this.log(`[${this.region}] sash 令牌响应里没有 token 字段`);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
session.sashToken = token;
|
|
922
|
+
const expiry = typeof raw.expires_at === "string" ? Date.parse(raw.expires_at) : NaN;
|
|
923
|
+
session.sashExpireAtMs = Number.isFinite(expiry) ? expiry : 0;
|
|
924
|
+
} catch (error) {
|
|
925
|
+
this.log(`[${this.region}] sash 令牌交换异常(回落到旧令牌): ${String(error?.message ?? error).slice(0, 120)}`);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/** 确保有可用会话(过期才重新交换)。 */
|
|
929
|
+
async ensureSession(credential) {
|
|
930
|
+
if (this.sessionValid(credential)) return this.sessions.get(credential);
|
|
931
|
+
await this.exchangeJobToken(credential);
|
|
932
|
+
return this.sessions.get(credential);
|
|
933
|
+
}
|
|
934
|
+
/** 用一次真实交换验证 PAT,返回账号显示名(`addApiKey` 用它)。 */
|
|
935
|
+
async verifyCredential(credential) {
|
|
936
|
+
const jobToken = await this.exchangeJobToken(credential);
|
|
937
|
+
return {
|
|
938
|
+
id: jobToken.id,
|
|
939
|
+
name: jobToken.name,
|
|
940
|
+
userType: jobToken.userType
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* cosy 协议面的 GET(models 用):与 chat 同一套 Bearer,body 为空串参与签名。
|
|
945
|
+
*/
|
|
946
|
+
async cosyGet(session, url) {
|
|
947
|
+
const pathSig = pathSigFrom(url);
|
|
948
|
+
const payloadB64 = buildPayloadB64(session.cosy.info);
|
|
949
|
+
const date = String(Math.floor(Date.now() / 1e3));
|
|
950
|
+
const sig = signRequest(payloadB64, session.cosy.cosyKey, date, "", pathSig);
|
|
951
|
+
let resp;
|
|
952
|
+
try {
|
|
953
|
+
resp = await this.fetchImpl(url, { headers: {
|
|
954
|
+
...DEFAULT_HEADERS,
|
|
955
|
+
"cosy-machinetype": session.cosy.machineType,
|
|
956
|
+
"cosy-date": date,
|
|
957
|
+
"cosy-user": session.cosy.identity.uid,
|
|
958
|
+
"cosy-key": session.cosy.cosyKey,
|
|
959
|
+
accept: "application/json",
|
|
960
|
+
authorization: composeBearer(payloadB64, sig),
|
|
961
|
+
"cosy-version": "0.1.43",
|
|
962
|
+
"cosy-machineid": session.cosy.machineId,
|
|
963
|
+
"cosy-machinetoken": session.cosy.machineToken
|
|
964
|
+
} });
|
|
965
|
+
} catch (error) {
|
|
966
|
+
throw asQoderError(error);
|
|
967
|
+
}
|
|
968
|
+
const text = await resp.text();
|
|
969
|
+
if (resp.status !== 200) throw new QoderError(classifyFailure(resp.status, text), `models 请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, deepestBusinessCode(text));
|
|
970
|
+
const data = parseJson(text);
|
|
971
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) throw new QoderError("unknown", `models 响应不是 JSON: ${truncate(text, 200)}`, resp.status);
|
|
972
|
+
return data;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* 模型目录。响应 `{chat:[{key, display_name, enable, is_vl, price_factor, …}], …}`。
|
|
976
|
+
*
|
|
977
|
+
* 过滤掉上游的 `auto`:它是该区的**默认路由**(请求时才决定解析成哪个模型),
|
|
978
|
+
* 价格与上下文都由别人临时决定,不是可以选择、也可以如实描述的模型。
|
|
979
|
+
*/
|
|
980
|
+
async fetchModels(credential) {
|
|
981
|
+
const session = await this.ensureSession(credential);
|
|
982
|
+
const data = await this.cosyGet(session, `${this.config.gateway}${this.config.modelsPath}`);
|
|
983
|
+
const rows = Array.isArray(data.chat) ? data.chat : [];
|
|
984
|
+
const out = [];
|
|
985
|
+
for (const row of rows) {
|
|
986
|
+
if (row === null || typeof row !== "object") continue;
|
|
987
|
+
const raw = row;
|
|
988
|
+
const key = typeof raw.key === "string" ? raw.key : "";
|
|
989
|
+
const displayName = typeof raw.display_name === "string" ? raw.display_name : "";
|
|
990
|
+
if (key === "" || displayName === "") continue;
|
|
991
|
+
if (key.toLowerCase() === "auto") continue;
|
|
992
|
+
const thinking = raw.thinking_config;
|
|
993
|
+
const efforts = [];
|
|
994
|
+
if (thinking !== null && typeof thinking === "object") {
|
|
995
|
+
const list = thinking.efforts;
|
|
996
|
+
if (Array.isArray(list)) {
|
|
997
|
+
for (const e of list) if (typeof e === "string" && e !== "") efforts.push(e);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
out.push({
|
|
1001
|
+
key,
|
|
1002
|
+
displayName,
|
|
1003
|
+
enable: raw.enable !== false,
|
|
1004
|
+
isDefault: raw.is_default === true,
|
|
1005
|
+
isVl: raw.is_vl === true,
|
|
1006
|
+
isReasoning: raw.is_reasoning === true,
|
|
1007
|
+
isFree: raw.is_free === true,
|
|
1008
|
+
priceFactor: typeof raw.price_factor === "number" ? raw.price_factor : void 0,
|
|
1009
|
+
maxInputTokens: typeof raw.max_input_tokens === "number" ? raw.max_input_tokens : void 0,
|
|
1010
|
+
maxOutputTokens: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : void 0,
|
|
1011
|
+
efforts
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
return out;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* 建一条聊天 SSE 流(**原样**返回上游响应体,交给 sse.ts 转 OpenAI 格式)。
|
|
1018
|
+
*
|
|
1019
|
+
* 请求体是 Qoder 私有模板(见 messages.ts),签名的 body 段是**cosy 编码后的
|
|
1020
|
+
* 密文**而不是原文 —— 这是最容易搞错的一处:签原文会得到 `Signature invalid`。
|
|
1021
|
+
*/
|
|
1022
|
+
async openChat(credential, modelKey, incoming, options) {
|
|
1023
|
+
const session = await this.ensureSession(credential);
|
|
1024
|
+
const template = deepClone(createChatBodyTemplate());
|
|
1025
|
+
applyModelKey(template, modelKey, session.jobToken.userType, options.isVl === true);
|
|
1026
|
+
if (options.reasoningEffort !== void 0 && options.reasoningEffort !== "") {
|
|
1027
|
+
template.reasoningEffort = options.reasoningEffort;
|
|
1028
|
+
template.parameters = {
|
|
1029
|
+
...template.parameters ?? {},
|
|
1030
|
+
reasoning_effort: options.reasoningEffort
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
const prompt = extractLatestUserPrompt(incoming);
|
|
1034
|
+
const toolsEnabled = Array.isArray(options.tools) && options.tools.length > 0;
|
|
1035
|
+
template.messages = buildQoderMessages(incoming, prompt, toolsEnabled);
|
|
1036
|
+
if (toolsEnabled) template.tools = options.tools;
|
|
1037
|
+
template.request_id = randomId();
|
|
1038
|
+
template.chat_record_id = template.request_id;
|
|
1039
|
+
template.request_set_id = randomId();
|
|
1040
|
+
template.session_id = randomId();
|
|
1041
|
+
template.business.id = randomId();
|
|
1042
|
+
template.business.name = prompt.slice(0, 30) || "hi";
|
|
1043
|
+
template.business.begin_at = Date.now();
|
|
1044
|
+
const body = JSON.stringify(template);
|
|
1045
|
+
const url = `${this.config.gateway}${this.config.chatPath}`;
|
|
1046
|
+
const encoded = cosyEncode(new TextEncoder().encode(body));
|
|
1047
|
+
const date = String(Math.floor(Date.now() / 1e3));
|
|
1048
|
+
const payloadB64 = buildPayloadB64(session.cosy.info);
|
|
1049
|
+
const sig = signRequest(payloadB64, session.cosy.cosyKey, date, encoded, pathSigFrom(url));
|
|
1050
|
+
let resp;
|
|
1051
|
+
try {
|
|
1052
|
+
resp = await this.fetchImpl(url, {
|
|
1053
|
+
method: "POST",
|
|
1054
|
+
headers: {
|
|
1055
|
+
...DEFAULT_HEADERS,
|
|
1056
|
+
"cosy-machinetype": session.cosy.machineType,
|
|
1057
|
+
"cosy-date": date,
|
|
1058
|
+
"cosy-user": session.cosy.identity.uid,
|
|
1059
|
+
"cosy-key": session.cosy.cosyKey,
|
|
1060
|
+
"cache-control": "no-cache",
|
|
1061
|
+
accept: "text/event-stream",
|
|
1062
|
+
authorization: composeBearer(payloadB64, sig),
|
|
1063
|
+
"cosy-version": "0.1.43",
|
|
1064
|
+
"cosy-machineid": session.cosy.machineId,
|
|
1065
|
+
"cosy-machinetoken": session.cosy.machineToken,
|
|
1066
|
+
"x-model-key": modelKey,
|
|
1067
|
+
"x-model-source": "system"
|
|
1068
|
+
},
|
|
1069
|
+
body: encoded,
|
|
1070
|
+
...options.signal === void 0 ? {} : { signal: options.signal }
|
|
1071
|
+
});
|
|
1072
|
+
} catch (error) {
|
|
1073
|
+
throw asQoderError(error);
|
|
1074
|
+
}
|
|
1075
|
+
if (resp.status !== 200) {
|
|
1076
|
+
const text = await resp.text();
|
|
1077
|
+
throw new QoderError(classifyFailure(resp.status, text), `聊天请求失败: HTTP ${resp.status} ${truncate(text, 300)}`, resp.status, deepestBusinessCode(text));
|
|
1078
|
+
}
|
|
1079
|
+
if (resp.body === null) throw new QoderError("transient", "聊天响应没有 body", resp.status);
|
|
1080
|
+
return resp.body;
|
|
1081
|
+
}
|
|
1082
|
+
/** sash 面的 GET:`Authorization: Bearer <sashToken || securityOauthToken>`。 */
|
|
1083
|
+
async sashGet(session, url) {
|
|
1084
|
+
const token = this.sashBearer(session);
|
|
1085
|
+
if (token === "") throw new QoderError("auth", "两次令牌交换都没拿到可用令牌,无法访问 sash 接口", 401);
|
|
1086
|
+
let resp;
|
|
1087
|
+
try {
|
|
1088
|
+
resp = await this.fetchImpl(url, { headers: {
|
|
1089
|
+
accept: "application/json",
|
|
1090
|
+
authorization: `Bearer ${token}`,
|
|
1091
|
+
"cosy-clienttype": SASH_CLIENT_TYPE,
|
|
1092
|
+
"user-agent": SASH_USER_AGENT
|
|
1093
|
+
} });
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
throw asQoderError(error);
|
|
1096
|
+
}
|
|
1097
|
+
return this.readSash(resp, url);
|
|
1098
|
+
}
|
|
1099
|
+
async readSash(resp, url) {
|
|
1100
|
+
const text = await resp.text();
|
|
1101
|
+
if (resp.status !== 200 && resp.status !== 201) {
|
|
1102
|
+
if (this.config.requiresDeviceRisk && (resp.status === 401 || resp.status === 403)) throw new QoderError("auth", `该区域的积分/签到需要本机 Qoder 客户端的设备风控组件(${url}),本插件不伪造它`, resp.status, deepestBusinessCode(text));
|
|
1103
|
+
throw new QoderError(classifyFailure(resp.status, text), `sash 请求失败: HTTP ${resp.status} ${truncate(text, 200)}`, resp.status, deepestBusinessCode(text));
|
|
1104
|
+
}
|
|
1105
|
+
const data = parseJson(text);
|
|
1106
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) throw new QoderError("unknown", `sash 响应不是 JSON: ${truncate(text, 200)}`, resp.status);
|
|
1107
|
+
return data;
|
|
1108
|
+
}
|
|
1109
|
+
sashBearer(session) {
|
|
1110
|
+
if (typeof session.sashToken === "string" && session.sashToken !== "") return session.sashToken;
|
|
1111
|
+
return session.jobToken.securityOauthToken;
|
|
1112
|
+
}
|
|
1113
|
+
/** 剩余积分(基础包 + 加量包 + 企业包)。读不到返回 undefined(不是 0)。 */
|
|
1114
|
+
async fetchUsage(credential) {
|
|
1115
|
+
const session = await this.ensureSession(credential);
|
|
1116
|
+
return parseUsage(await this.sashGet(session, `${this.config.openapi}${USAGE_PATH}`));
|
|
1117
|
+
}
|
|
1118
|
+
/** 活动列表(签到挑用)。 */
|
|
1119
|
+
async fetchCampaigns(credential) {
|
|
1120
|
+
const session = await this.ensureSession(credential);
|
|
1121
|
+
const data = await this.sashGet(session, `${this.config.openapi}${this.config.campaignsPath}?forceRefresh=true`);
|
|
1122
|
+
const rows = Array.isArray(data.campaigns) ? data.campaigns : Array.isArray(data.data?.campaigns) ? data.data.campaigns : [];
|
|
1123
|
+
const out = [];
|
|
1124
|
+
for (const row of rows) {
|
|
1125
|
+
if (row === null || typeof row !== "object") continue;
|
|
1126
|
+
const raw = row;
|
|
1127
|
+
const campaignId = String(raw.campaignId ?? "");
|
|
1128
|
+
if (campaignId === "") continue;
|
|
1129
|
+
const benefit = raw.benefit;
|
|
1130
|
+
const b = benefit !== null && typeof benefit === "object" ? benefit : {};
|
|
1131
|
+
const validity = b.validity;
|
|
1132
|
+
const v = validity !== null && typeof validity === "object" ? validity : {};
|
|
1133
|
+
out.push({
|
|
1134
|
+
campaignId,
|
|
1135
|
+
actionType: String(raw.actionType ?? ""),
|
|
1136
|
+
claimStatus: String(raw.claimStatus ?? ""),
|
|
1137
|
+
...typeof b.amount === "number" ? { amount: b.amount } : {},
|
|
1138
|
+
...typeof v.days === "number" ? { validityDays: v.days } : {},
|
|
1139
|
+
...typeof raw.endAt === "number" ? { endAt: raw.endAt } : {}
|
|
1140
|
+
});
|
|
1141
|
+
}
|
|
1142
|
+
return out;
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* 领取今天那份每日 Credits。
|
|
1146
|
+
*
|
|
1147
|
+
* 一次调用自足:先拉活动列表找出可领的那条,再领取。三种结果分得很清 ——
|
|
1148
|
+
* `claimed` / `already-claimed`(幂等,不是错误)/ `unavailable`(今天上游没
|
|
1149
|
+
* 下发活动,比如企业号或未开窗)。
|
|
1150
|
+
*/
|
|
1151
|
+
async claimDailyCredits(credential) {
|
|
1152
|
+
const campaigns = await this.fetchCampaigns(credential);
|
|
1153
|
+
const daily = campaigns.find((c) => c.actionType === "CLAIM_BENEFIT");
|
|
1154
|
+
const claimable = campaigns.find((c) => c.actionType === "CLAIM_BENEFIT" && c.claimStatus === "CLAIMABLE");
|
|
1155
|
+
if (claimable === void 0) return daily?.claimStatus === "CLAIMED" ? { status: "already-claimed" } : {
|
|
1156
|
+
status: "unavailable",
|
|
1157
|
+
message: "上游今日没有下发可领取的签到活动"
|
|
1158
|
+
};
|
|
1159
|
+
const session = await this.ensureSession(credential);
|
|
1160
|
+
const url = `${this.config.openapi}${this.config.campaignsPath}/${encodeURIComponent(claimable.campaignId)}/claim`;
|
|
1161
|
+
const token = this.sashBearer(session);
|
|
1162
|
+
if (token === "") throw new QoderError("auth", "没有可用令牌,无法领取", 401);
|
|
1163
|
+
let resp;
|
|
1164
|
+
try {
|
|
1165
|
+
resp = await this.fetchImpl(url, {
|
|
1166
|
+
method: "POST",
|
|
1167
|
+
headers: {
|
|
1168
|
+
authorization: `Bearer ${token}`,
|
|
1169
|
+
"cosy-clienttype": SASH_CLIENT_TYPE,
|
|
1170
|
+
accept: "application/json",
|
|
1171
|
+
"accept-language": "zh-CN",
|
|
1172
|
+
"user-agent": SASH_USER_AGENT,
|
|
1173
|
+
"content-type": "application/json",
|
|
1174
|
+
origin: this.config.openapi
|
|
1175
|
+
},
|
|
1176
|
+
body: "{}"
|
|
1177
|
+
});
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
throw asQoderError(error);
|
|
1180
|
+
}
|
|
1181
|
+
if (resp.status === 409) return { status: "already-claimed" };
|
|
1182
|
+
const data = await this.readSash(resp, url);
|
|
1183
|
+
if (String(data.status ?? "") === "CLAIMED") return {
|
|
1184
|
+
status: "claimed",
|
|
1185
|
+
...claimable.amount === void 0 ? {} : { amount: claimable.amount },
|
|
1186
|
+
...claimable.validityDays === void 0 ? {} : { validityDays: claimable.validityDays }
|
|
1187
|
+
};
|
|
1188
|
+
return {
|
|
1189
|
+
status: "unavailable",
|
|
1190
|
+
message: `签到未完成(上游状态 ${String(data.status ?? "未知")})`
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
function randomId() {
|
|
1195
|
+
return globalThis.crypto.randomUUID();
|
|
1196
|
+
}
|
|
1197
|
+
// src/sse.ts
|
|
1198
|
+
/**
|
|
1199
|
+
* Qoder SSE → OpenAI SSE。
|
|
1200
|
+
*
|
|
1201
|
+
* 上游的帧是**一个信封套一层 OpenAI 形状**:
|
|
1202
|
+
*
|
|
1203
|
+
* ```text
|
|
1204
|
+
* data: {"headers":{…},"body":"{\"choices\":[{\"delta\":{\"content\":\"你\"}}]}","statusCodeValue":200}
|
|
1205
|
+
* ```
|
|
1206
|
+
*
|
|
1207
|
+
* 而 dsh-router 只接受 OpenAI SSE(它会把这个流 pipe 给客户端,也会在客户端要
|
|
1208
|
+
* 非流式时自己聚合)。所以这一层把信封拆开、把 delta 重新包成 OpenAI chunk,
|
|
1209
|
+
* 并在收尾补上 usage 与 `[DONE]`。
|
|
1210
|
+
*
|
|
1211
|
+
* 为什么要自己拼满这些字段:宿主聚合非流式响应时读的是
|
|
1212
|
+
* `choices[0].delta.tool_calls[].function.name` 与 `usage`,缺 `id`/`model`/
|
|
1213
|
+
* `created` 会让它拼出一个没有名字的工具调用(下游报 `unknown tool ""`)。
|
|
1214
|
+
*/
|
|
1215
|
+
/** 把一条 delta 转成 OpenAI chunk。 */
|
|
1216
|
+
function chunkOf(id, created, model, delta, finishReason = null) {
|
|
1217
|
+
return `data: ${JSON.stringify({
|
|
1218
|
+
id,
|
|
1219
|
+
object: "chat.completion.chunk",
|
|
1220
|
+
created,
|
|
1221
|
+
model,
|
|
1222
|
+
choices: [{
|
|
1223
|
+
index: 0,
|
|
1224
|
+
delta,
|
|
1225
|
+
finish_reason: finishReason
|
|
1226
|
+
}]
|
|
1227
|
+
})}\n\n`;
|
|
1228
|
+
}
|
|
1229
|
+
/** delta → OpenAI `delta` 对象(只带上游真的给了的字段)。 */
|
|
1230
|
+
function deltaPayload(delta, first) {
|
|
1231
|
+
const out = {};
|
|
1232
|
+
if (first && delta.role !== "") out.role = delta.role;
|
|
1233
|
+
if (delta.content !== "") out.content = delta.content;
|
|
1234
|
+
if (delta.reasoningContent !== "") out.reasoning_content = delta.reasoningContent;
|
|
1235
|
+
if (delta.toolCalls !== void 0) out.tool_calls = delta.toolCalls;
|
|
1236
|
+
return Object.keys(out).length === 0 ? void 0 : out;
|
|
1237
|
+
}
|
|
1238
|
+
/** usage 帧:OpenAI 的最后一帧 `choices: []` + `usage`。 */
|
|
1239
|
+
function usageFrame(id, created, model, delta) {
|
|
1240
|
+
const usage = {
|
|
1241
|
+
prompt_tokens: delta.inputTokens,
|
|
1242
|
+
completion_tokens: delta.outputTokens,
|
|
1243
|
+
total_tokens: delta.inputTokens + delta.outputTokens
|
|
1244
|
+
};
|
|
1245
|
+
if (delta.cachedTokens !== void 0) usage.prompt_tokens_details = { cached_tokens: delta.cachedTokens };
|
|
1246
|
+
return `data: ${JSON.stringify({
|
|
1247
|
+
id,
|
|
1248
|
+
object: "chat.completion.chunk",
|
|
1249
|
+
created,
|
|
1250
|
+
model,
|
|
1251
|
+
choices: [],
|
|
1252
|
+
usage
|
|
1253
|
+
})}\n\n`;
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* 包装上游响应体。返回的流**始终**以 `data: [DONE]\n\n` 收尾(正常与被拒都一样),
|
|
1257
|
+
* 这样客户端不会挂在那儿等一个永远不来的结束帧。
|
|
1258
|
+
*/
|
|
1259
|
+
function toOpenAiSse(body, options) {
|
|
1260
|
+
const encoder = new TextEncoder();
|
|
1261
|
+
const decoder = new TextDecoder();
|
|
1262
|
+
const id = `chatcmpl-${globalThis.crypto.randomUUID()}`;
|
|
1263
|
+
const created = Math.floor(Date.now() / 1e3);
|
|
1264
|
+
let reader;
|
|
1265
|
+
return new ReadableStream({
|
|
1266
|
+
start: async (ctrl) => {
|
|
1267
|
+
const emit = (text) => {
|
|
1268
|
+
try {
|
|
1269
|
+
ctrl.enqueue(encoder.encode(text));
|
|
1270
|
+
} catch {}
|
|
1271
|
+
};
|
|
1272
|
+
emit(chunkOf(id, created, options.model, { role: "assistant" }));
|
|
1273
|
+
reader = body.getReader();
|
|
1274
|
+
let buffer = "";
|
|
1275
|
+
let sawUsage = false;
|
|
1276
|
+
let finished = false;
|
|
1277
|
+
try {
|
|
1278
|
+
for (;;) {
|
|
1279
|
+
const { done, value } = await reader.read();
|
|
1280
|
+
if (done) break;
|
|
1281
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1282
|
+
const lines = buffer.split("\n");
|
|
1283
|
+
buffer = lines.pop() ?? "";
|
|
1284
|
+
for (const line of lines) {
|
|
1285
|
+
const trimmed = line.trim();
|
|
1286
|
+
if (trimmed === "" || !trimmed.startsWith("data:")) continue;
|
|
1287
|
+
const payload = trimmed.slice(5).trim();
|
|
1288
|
+
if (payload === "" || payload === "[DONE]") continue;
|
|
1289
|
+
let delta;
|
|
1290
|
+
try {
|
|
1291
|
+
delta = parseStreamDelta(payload);
|
|
1292
|
+
} catch (error) {
|
|
1293
|
+
const qoderError = isQoderError(error) ? error : void 0;
|
|
1294
|
+
emit(`data: ${JSON.stringify({ error: {
|
|
1295
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1296
|
+
type: qoderError?.kind ?? "upstream_error",
|
|
1297
|
+
code: qoderError?.code ?? null
|
|
1298
|
+
} })}\n\n`);
|
|
1299
|
+
if (qoderError !== void 0) options.onLateError?.(qoderError);
|
|
1300
|
+
finished = true;
|
|
1301
|
+
break;
|
|
1302
|
+
}
|
|
1303
|
+
if (delta === void 0) continue;
|
|
1304
|
+
if (delta.inputTokens > 0 || delta.outputTokens > 0) sawUsage = true;
|
|
1305
|
+
const payloadDelta = deltaPayload(delta, true);
|
|
1306
|
+
if (payloadDelta !== void 0) emit(chunkOf(id, created, options.model, payloadDelta));
|
|
1307
|
+
if (delta.inputTokens > 0 || delta.outputTokens > 0) emit(usageFrame(id, created, options.model, delta));
|
|
1308
|
+
}
|
|
1309
|
+
if (finished) break;
|
|
1310
|
+
}
|
|
1311
|
+
if (!finished) {
|
|
1312
|
+
emit(chunkOf(id, created, options.model, {}, "stop"));
|
|
1313
|
+
if (!sawUsage);
|
|
1314
|
+
}
|
|
1315
|
+
} catch (error) {
|
|
1316
|
+
const qoderError = isQoderError(error) ? error : void 0;
|
|
1317
|
+
emit(`data: ${JSON.stringify({ error: {
|
|
1318
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1319
|
+
type: qoderError?.kind ?? "stream_error"
|
|
1320
|
+
} })}\n\n`);
|
|
1321
|
+
if (qoderError !== void 0) options.onLateError?.(qoderError);
|
|
1322
|
+
} finally {
|
|
1323
|
+
try {
|
|
1324
|
+
reader.releaseLock();
|
|
1325
|
+
} catch {}
|
|
1326
|
+
emit("data: [DONE]\n\n");
|
|
1327
|
+
try {
|
|
1328
|
+
ctrl.close();
|
|
1329
|
+
} catch {}
|
|
1330
|
+
}
|
|
1331
|
+
},
|
|
1332
|
+
/** 取消必须往下传:不实现它,客户端断开后上游 TCP 连接会一直挂着。 */
|
|
1333
|
+
cancel: (reason) => reader?.cancel(reason)
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
// src/core.ts
|
|
1337
|
+
/** 积分缓存 10 分钟(与 codebuddy 同口径)。 */
|
|
1338
|
+
const CREDITS_TTL_MS = 6e5;
|
|
1339
|
+
/** 拿不到积分时报它(**不是 0**):核心据此保留上次持久化的值。 */
|
|
1340
|
+
const CREDITS_UNKNOWN = -1;
|
|
1341
|
+
/** 上游错误种类 → 契约的账号状态。 */
|
|
1342
|
+
function stateOf(kind) {
|
|
1343
|
+
switch (kind) {
|
|
1344
|
+
case "auth": return "session_dead";
|
|
1345
|
+
case "quota-exhausted": return "quota";
|
|
1346
|
+
case "rate-limited": return "rate_limit";
|
|
1347
|
+
case "transient": return "rate_limit";
|
|
1348
|
+
case "content-policy":
|
|
1349
|
+
case "invalid-request": return "bad_request";
|
|
1350
|
+
case "network": return "transport";
|
|
1351
|
+
default: return "unknown";
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
/** 剩余额度:基础包 + 加量包 + 企业包(缺哪个算哪个;都缺 = undefined)。 */
|
|
1355
|
+
function remainingOf(usage) {
|
|
1356
|
+
const parts = [];
|
|
1357
|
+
for (const bucket of [
|
|
1358
|
+
usage.userQuota,
|
|
1359
|
+
usage.addOnQuota,
|
|
1360
|
+
usage.orgResourcePackage
|
|
1361
|
+
]) if (bucket !== void 0 && typeof bucket.remaining === "number" && Number.isFinite(bucket.remaining)) parts.push(bucket.remaining);
|
|
1362
|
+
if (parts.length === 0) return void 0;
|
|
1363
|
+
return Math.round(parts.reduce((a, b) => a + b, 0) * 100) / 100;
|
|
1364
|
+
}
|
|
1365
|
+
/** 消息里有没有图片(决定 `model_config.is_vl`)。只看 user 消息的 parts。 */
|
|
1366
|
+
function hasImageInput(messages) {
|
|
1367
|
+
for (const m of messages) {
|
|
1368
|
+
if (m === null || typeof m !== "object") continue;
|
|
1369
|
+
const msg = m;
|
|
1370
|
+
if (msg.role !== "user") continue;
|
|
1371
|
+
const content = msg.content;
|
|
1372
|
+
if (!Array.isArray(content)) continue;
|
|
1373
|
+
for (const part of content) {
|
|
1374
|
+
if (part === null || typeof part !== "object") continue;
|
|
1375
|
+
const p = part;
|
|
1376
|
+
if (p.type === "image_url" || p.type === "input_image") return true;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
/** 按 profile 造一个供应商模块。 */
|
|
1382
|
+
function createSupplier(p) {
|
|
1383
|
+
return function factory(env) {
|
|
1384
|
+
const id = p.id;
|
|
1385
|
+
const creds = env.credentials;
|
|
1386
|
+
const store = env.store;
|
|
1387
|
+
const log = env.log;
|
|
1388
|
+
const client = new QoderClient(p.region, { log });
|
|
1389
|
+
/** 积分缓存:uid → { value, at }。积分本身由核心持久化,这里只管内存 TTL。 */
|
|
1390
|
+
const creditsCache = /* @__PURE__ */ new Map();
|
|
1391
|
+
/** 正在拉积分的 uid:并发 status() 共享同一次请求。 */
|
|
1392
|
+
const creditsInflight = /* @__PURE__ */ new Set();
|
|
1393
|
+
/** 模型缓存 + 并发去重。 */
|
|
1394
|
+
let modelsCache;
|
|
1395
|
+
let modelsInflight;
|
|
1396
|
+
function listUids() {
|
|
1397
|
+
return creds.list(id);
|
|
1398
|
+
}
|
|
1399
|
+
function getCred(uid) {
|
|
1400
|
+
return creds.get(id, uid);
|
|
1401
|
+
}
|
|
1402
|
+
/** 账号顺序:池顺序优先,未配置按凭证原始顺序。 */
|
|
1403
|
+
function orderedUids() {
|
|
1404
|
+
const all = listUids();
|
|
1405
|
+
const order = store.get(id).poolOrder;
|
|
1406
|
+
return [...order.filter((u) => all.includes(u)), ...all.filter((u) => !order.includes(u))];
|
|
1407
|
+
}
|
|
1408
|
+
/** 当前别名前缀(核心可能在 store 里覆盖默认值)。 */
|
|
1409
|
+
function currentAlias() {
|
|
1410
|
+
return store.get(id).alias || id;
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* 剥离本供应商的 alias 前缀。
|
|
1414
|
+
*
|
|
1415
|
+
* 只剥自己的(`qoder/xxx`),模型 id 自带的斜杠保留 —— 否则上游的
|
|
1416
|
+
* `org/name` 会被剥成 `name`,请求必然 404。
|
|
1417
|
+
*/
|
|
1418
|
+
function stripAlias(model) {
|
|
1419
|
+
const alias = currentAlias();
|
|
1420
|
+
return alias !== "" && model.startsWith(`${alias}/`) ? model.slice(alias.length + 1) : model;
|
|
1421
|
+
}
|
|
1422
|
+
async function refreshCredits(uid) {
|
|
1423
|
+
if (creditsInflight.has(uid)) return void 0;
|
|
1424
|
+
const cred = getCred(uid);
|
|
1425
|
+
if (cred === void 0) return void 0;
|
|
1426
|
+
creditsInflight.add(uid);
|
|
1427
|
+
try {
|
|
1428
|
+
const usage = await client.fetchUsage(cred.pat);
|
|
1429
|
+
if (usage === void 0) return void 0;
|
|
1430
|
+
const value = remainingOf(usage);
|
|
1431
|
+
if (value === void 0) return void 0;
|
|
1432
|
+
creditsCache.set(uid, {
|
|
1433
|
+
value,
|
|
1434
|
+
at: Date.now()
|
|
1435
|
+
});
|
|
1436
|
+
return value;
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
if (isQoderError(error) && error.kind === "auth") log(`${p.errPrefix} credits ${uid}: ${error.message}`);
|
|
1439
|
+
return;
|
|
1440
|
+
} finally {
|
|
1441
|
+
creditsInflight.delete(uid);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
async function fetchModelsFromUpstream() {
|
|
1445
|
+
for (const uid of orderedUids()) {
|
|
1446
|
+
const cred = getCred(uid);
|
|
1447
|
+
if (cred === void 0) continue;
|
|
1448
|
+
try {
|
|
1449
|
+
const list = await client.fetchModels(cred.pat);
|
|
1450
|
+
if (list.length > 0) return list;
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
log(`${p.errPrefix} models ${uid}: ${error.message.slice(0, 160)}`);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return [];
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* 上游模型 → 契约的 `ModelInfo`。
|
|
1459
|
+
*
|
|
1460
|
+
* `id` 是上游 key(`qmodel_38max`)—— 组合里存的、请求里发的都是它;
|
|
1461
|
+
* `name` 带上游的 `display_name`(`Qwen3.8-Max`),面板拿它做主标签。
|
|
1462
|
+
* 不带 name 的话,面板上只剩一串猜不出含义的 key,「获取模型」之后
|
|
1463
|
+
* 用户仍然不知道自己勾的是哪个模型。`context_length` 按 k 报,与别的插件一致。
|
|
1464
|
+
*/
|
|
1465
|
+
function toModelInfo(m) {
|
|
1466
|
+
return m.maxInputTokens === void 0 ? {
|
|
1467
|
+
id: m.key,
|
|
1468
|
+
name: m.displayName
|
|
1469
|
+
} : {
|
|
1470
|
+
id: m.key,
|
|
1471
|
+
name: m.displayName,
|
|
1472
|
+
context_length: Math.round(m.maxInputTokens / 1e3)
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
async function allModels(force) {
|
|
1476
|
+
if (!force && modelsCache !== void 0) return modelsCache.map(toModelInfo);
|
|
1477
|
+
if (modelsInflight !== void 0) return (await modelsInflight).map(toModelInfo);
|
|
1478
|
+
modelsInflight = fetchModelsFromUpstream().then((list) => {
|
|
1479
|
+
if (list.length > 0) modelsCache = list;
|
|
1480
|
+
return modelsCache ?? [];
|
|
1481
|
+
}).catch(() => modelsCache ?? []).finally(() => {
|
|
1482
|
+
modelsInflight = void 0;
|
|
1483
|
+
});
|
|
1484
|
+
const list = await modelsInflight;
|
|
1485
|
+
return (list.length > 0 ? list : p.fallbackModels.map((m) => ({
|
|
1486
|
+
key: m.id,
|
|
1487
|
+
displayName: m.name ?? m.id,
|
|
1488
|
+
enable: true,
|
|
1489
|
+
isDefault: false,
|
|
1490
|
+
isVl: false,
|
|
1491
|
+
isReasoning: false,
|
|
1492
|
+
isFree: false,
|
|
1493
|
+
priceFactor: void 0,
|
|
1494
|
+
maxInputTokens: m.context_length === void 0 ? void 0 : m.context_length * 1e3,
|
|
1495
|
+
maxOutputTokens: void 0,
|
|
1496
|
+
efforts: []
|
|
1497
|
+
}))).map(toModelInfo);
|
|
1498
|
+
}
|
|
1499
|
+
/** 装一个不落盘的新账号(uid 按前缀递增,跳过已占用的)。 */
|
|
1500
|
+
function allocateUid() {
|
|
1501
|
+
let n = listUids().length + 1;
|
|
1502
|
+
let uid = `${p.uidPrefix}-${n}`;
|
|
1503
|
+
while (getCred(uid) !== void 0) uid = `${p.uidPrefix}-${++n}`;
|
|
1504
|
+
return uid;
|
|
1505
|
+
}
|
|
1506
|
+
return {
|
|
1507
|
+
id,
|
|
1508
|
+
name: p.name,
|
|
1509
|
+
priority: p.priority,
|
|
1510
|
+
icon: p.icon,
|
|
1511
|
+
apiKeyHint: p.apiKeyHint,
|
|
1512
|
+
status: () => {
|
|
1513
|
+
const now = Date.now();
|
|
1514
|
+
const accounts = orderedUids().map((uid) => {
|
|
1515
|
+
const cred = getCred(uid);
|
|
1516
|
+
const cached = creditsCache.get(uid);
|
|
1517
|
+
if (cached === void 0 || now - cached.at > CREDITS_TTL_MS) refreshCredits(uid);
|
|
1518
|
+
return {
|
|
1519
|
+
uid,
|
|
1520
|
+
nickname: cred?.nickname || p.defaultNickname,
|
|
1521
|
+
credits: cached?.value ?? CREDITS_UNKNOWN,
|
|
1522
|
+
state: cred === void 0 ? "session_dead" : "ok"
|
|
1523
|
+
};
|
|
1524
|
+
});
|
|
1525
|
+
return {
|
|
1526
|
+
id,
|
|
1527
|
+
name: p.name,
|
|
1528
|
+
accounts
|
|
1529
|
+
};
|
|
1530
|
+
},
|
|
1531
|
+
listModels: (force) => allModels(!!force),
|
|
1532
|
+
/**
|
|
1533
|
+
* 添加账号:粘贴 PAT。
|
|
1534
|
+
*
|
|
1535
|
+
* 这里**真的去交换一次 jobToken** 才落盘 —— 一个打错一个字符的 PAT 存进去
|
|
1536
|
+
* 之后,面板上只会显示「凭据失效」,用户不知道是当初就错了还是后来过期了。
|
|
1537
|
+
* 交换顺带拿到显示名,比 `qd-1` 这种占位名有用。
|
|
1538
|
+
*/
|
|
1539
|
+
addApiKey: async (input) => {
|
|
1540
|
+
const pat = normalizePat(input.apiKey);
|
|
1541
|
+
if (pat === "") return {
|
|
1542
|
+
ok: false,
|
|
1543
|
+
error: "PAT 不能为空"
|
|
1544
|
+
};
|
|
1545
|
+
for (const uid of listUids()) if (getCred(uid)?.pat === pat) return {
|
|
1546
|
+
ok: false,
|
|
1547
|
+
error: `这个 PAT 已经添加过了(${uid})`
|
|
1548
|
+
};
|
|
1549
|
+
try {
|
|
1550
|
+
const verified = await client.verifyCredential(pat);
|
|
1551
|
+
const uid = allocateUid();
|
|
1552
|
+
const nickname = input.name.trim() || verified.name || p.defaultNickname;
|
|
1553
|
+
creds.save(id, uid, {
|
|
1554
|
+
nickname,
|
|
1555
|
+
pat,
|
|
1556
|
+
...verified.id === "" ? {} : { userId: verified.id }
|
|
1557
|
+
});
|
|
1558
|
+
log(`${p.errPrefix} account added: ${uid} (${nickname})`);
|
|
1559
|
+
return {
|
|
1560
|
+
ok: true,
|
|
1561
|
+
account: {
|
|
1562
|
+
uid,
|
|
1563
|
+
nickname
|
|
1564
|
+
}
|
|
1565
|
+
};
|
|
1566
|
+
} catch (error) {
|
|
1567
|
+
return {
|
|
1568
|
+
ok: false,
|
|
1569
|
+
error: `PAT 校验失败:${error instanceof Error ? error.message : String(error)}`
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
},
|
|
1573
|
+
removeLink: (uid) => {
|
|
1574
|
+
const cred = getCred(uid);
|
|
1575
|
+
if (cred === void 0) return Promise.resolve(false);
|
|
1576
|
+
creds.remove(id, uid);
|
|
1577
|
+
creditsCache.delete(uid);
|
|
1578
|
+
client.forget(cred.pat);
|
|
1579
|
+
return Promise.resolve(true);
|
|
1580
|
+
},
|
|
1581
|
+
/** 对单个账号调一次上游。选号/冷却/换号是核心的活,这里只报结果。 */
|
|
1582
|
+
async chatOnce(uid, lv, req) {
|
|
1583
|
+
const base = stripAlias(req.model);
|
|
1584
|
+
if (base === "") return {
|
|
1585
|
+
ok: false,
|
|
1586
|
+
state: "no_such_model",
|
|
1587
|
+
message: `unknown model ${JSON.stringify(req.model)}`
|
|
1588
|
+
};
|
|
1589
|
+
const cred = getCred(uid);
|
|
1590
|
+
if (cred === void 0) return {
|
|
1591
|
+
ok: false,
|
|
1592
|
+
state: "no_such_model",
|
|
1593
|
+
message: `unknown account ${JSON.stringify(uid)}`
|
|
1594
|
+
};
|
|
1595
|
+
let messages;
|
|
1596
|
+
let tools;
|
|
1597
|
+
try {
|
|
1598
|
+
const body = JSON.parse(req.rawBody);
|
|
1599
|
+
messages = (Array.isArray(body.messages) ? body.messages : []).filter((m) => m !== null && typeof m === "object");
|
|
1600
|
+
tools = Array.isArray(body.tools) && body.tools.length > 0 ? body.tools : void 0;
|
|
1601
|
+
} catch {
|
|
1602
|
+
return {
|
|
1603
|
+
ok: false,
|
|
1604
|
+
state: "bad_request",
|
|
1605
|
+
message: "请求体不是合法 JSON"
|
|
1606
|
+
};
|
|
1607
|
+
}
|
|
1608
|
+
const effort = lv === "auto" || lv === "" || lv === "none" || lv === "off" ? void 0 : lv;
|
|
1609
|
+
try {
|
|
1610
|
+
return {
|
|
1611
|
+
ok: true,
|
|
1612
|
+
stream: toOpenAiSse(await client.openChat(cred.pat, base, messages, {
|
|
1613
|
+
...tools === void 0 ? {} : { tools },
|
|
1614
|
+
...effort === void 0 ? {} : { reasoningEffort: effort },
|
|
1615
|
+
isVl: hasImageInput(messages)
|
|
1616
|
+
}), {
|
|
1617
|
+
model: base,
|
|
1618
|
+
onLateError: (error) => {
|
|
1619
|
+
env.onLateFailure?.(uid, base, stateOf(error.kind), error.message);
|
|
1620
|
+
}
|
|
1621
|
+
})
|
|
1622
|
+
};
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
if (isQoderError(error)) {
|
|
1625
|
+
if (error.kind === "auth") client.forget(cred.pat);
|
|
1626
|
+
return {
|
|
1627
|
+
ok: false,
|
|
1628
|
+
state: stateOf(error.kind),
|
|
1629
|
+
message: `${p.errPrefix} ${error.message}`
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
return {
|
|
1633
|
+
ok: false,
|
|
1634
|
+
state: "unknown",
|
|
1635
|
+
message: error.message
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
},
|
|
1639
|
+
/**
|
|
1640
|
+
* 签到:领取今天那份每日 Credits。
|
|
1641
|
+
*
|
|
1642
|
+
* 上游把它做成「活动」(campaigns),不是一个叫签到/checkin 的端点。
|
|
1643
|
+
* `already-claimed` 是**成功**(幂等),不是错误。
|
|
1644
|
+
*/
|
|
1645
|
+
checkinNow: async (uid) => {
|
|
1646
|
+
const cred = getCred(uid);
|
|
1647
|
+
if (cred === void 0) return {
|
|
1648
|
+
ok: false,
|
|
1649
|
+
status: "error",
|
|
1650
|
+
message: "凭证缺失"
|
|
1651
|
+
};
|
|
1652
|
+
try {
|
|
1653
|
+
const outcome = await client.claimDailyCredits(cred.pat);
|
|
1654
|
+
if (outcome.status === "claimed") {
|
|
1655
|
+
await refreshCredits(uid);
|
|
1656
|
+
const amount = outcome.amount === void 0 ? "" : `+${outcome.amount} Credits`;
|
|
1657
|
+
const days = outcome.validityDays === void 0 ? "" : `(${outcome.validityDays} 天有效)`;
|
|
1658
|
+
log(`${p.errPrefix} checkin ${uid}: claimed ${amount}`);
|
|
1659
|
+
return {
|
|
1660
|
+
ok: true,
|
|
1661
|
+
status: "ok",
|
|
1662
|
+
message: `${amount}${days}` || "签到成功"
|
|
1663
|
+
};
|
|
1664
|
+
}
|
|
1665
|
+
if (outcome.status === "already-claimed") {
|
|
1666
|
+
await refreshCredits(uid);
|
|
1667
|
+
return {
|
|
1668
|
+
ok: true,
|
|
1669
|
+
status: "already",
|
|
1670
|
+
message: "今日已签到"
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
return {
|
|
1674
|
+
ok: false,
|
|
1675
|
+
status: "error",
|
|
1676
|
+
message: outcome.message
|
|
1677
|
+
};
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1680
|
+
log(`${p.errPrefix} checkin ${uid}: ${message.slice(0, 160)}`);
|
|
1681
|
+
return {
|
|
1682
|
+
ok: false,
|
|
1683
|
+
status: "error",
|
|
1684
|
+
message: `${p.errPrefix} ${message}`
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
},
|
|
1688
|
+
dispose: () => {
|
|
1689
|
+
creditsCache.clear();
|
|
1690
|
+
creditsInflight.clear();
|
|
1691
|
+
modelsCache = void 0;
|
|
1692
|
+
modelsInflight = void 0;
|
|
1693
|
+
}
|
|
1694
|
+
};
|
|
1695
|
+
};
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* 把用户粘进来的输入当作 PAT(容忍带 `Bearer ` 前缀或首尾空白)。
|
|
1699
|
+
*
|
|
1700
|
+
* 面板上粘贴时很容易带上从文档里复制来的 `Bearer `,那不是令牌的一部分,
|
|
1701
|
+
* 直接当 PAT 用只会得到一个难以理解的 401。
|
|
1702
|
+
*/
|
|
1703
|
+
function normalizePat(input) {
|
|
1704
|
+
return input.trim().replace(/^Bearer\s+/i, "").trim();
|
|
1705
|
+
}
|
|
1706
|
+
// src/icon.ts
|
|
1707
|
+
/**
|
|
1708
|
+
* qoder 族供应商共用的面板图标 —— 内联 SVG data URI。
|
|
1709
|
+
*
|
|
1710
|
+
* 为什么内联:图标不该依赖另一个服务活着,更不该依赖特定端口。国内版与国际版
|
|
1711
|
+
* 同族,共用同一张图(只有 name 不同)。
|
|
1712
|
+
*/
|
|
1713
|
+
const QODER_ICON = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgd2lkdGg9IjEyOCIgaGVpZ2h0PSIxMjgiPjxyZWN0IHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgcng9IjE0IiBmaWxsPSIjMmI2YmZmIi8+PGNpcmNsZSBjeD0iMzIiIGN5PSIzMSIgcj0iMTUiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSI2Ii8+PHJlY3QgeD0iMzgiIHk9IjM3IiB3aWR0aD0iMTIiIGhlaWdodD0iNiIgcng9IjMiIHRyYW5zZm9ybT0icm90YXRlKDQ1IDM4IDM3KSIgZmlsbD0iI2ZmZmZmZiIvPjwvc3ZnPg==";
|
|
1714
|
+
const profile$1 = {
|
|
1715
|
+
id: "qoder",
|
|
1716
|
+
name: "Qoder",
|
|
1717
|
+
region: "cn",
|
|
1718
|
+
icon: QODER_ICON,
|
|
1719
|
+
priority: -2,
|
|
1720
|
+
uidPrefix: "qd",
|
|
1721
|
+
defaultNickname: "Qoder",
|
|
1722
|
+
errPrefix: "qoder",
|
|
1723
|
+
apiKeyHint: "粘贴 Qoder 个人访问令牌(PAT,形如 pt-…)。在哪拿:登录 qoder.cn / qoder.com.cn 控制台 → 设置 → 个人访问令牌(旧版页面叫「API 令牌」)→ 创建令牌,复制只显示一次的 pt-…。令牌按区域签发,国内版的 pt-… 在国际版卡片上无效(反之亦然)。插件只在本机用它换 jobToken,之后的请求都带签名会话;PAT 不会发给 Qoder 网关以外的任何地方。",
|
|
1724
|
+
fallbackModels: []
|
|
1725
|
+
};
|
|
1726
|
+
const profile = {
|
|
1727
|
+
id: "qoder-global",
|
|
1728
|
+
name: "Qoder Global",
|
|
1729
|
+
region: "global",
|
|
1730
|
+
icon: QODER_ICON,
|
|
1731
|
+
priority: -2,
|
|
1732
|
+
uidPrefix: "qdg",
|
|
1733
|
+
defaultNickname: "Qoder Global",
|
|
1734
|
+
errPrefix: "qoder-global",
|
|
1735
|
+
apiKeyHint: "粘贴 Qoder 国际版的个人访问令牌(PAT,形如 pt-…)。在哪拿:登录 qoder.com 控制台 → Settings → Personal Access Tokens → 创建(复制只显示一次的 pt-…)。令牌按区域签发,国际版的 pt-… 在国内版卡片上无效(反之亦然)。国际版的积分与每日签到还需要本机安装 Qoder 客户端(设备风控组件),只有 PAT 时聊天与模型可用,积分/签到会如实报错。",
|
|
1736
|
+
fallbackModels: []
|
|
1737
|
+
};
|
|
1738
|
+
// src/index.ts
|
|
1739
|
+
const name = "dsh-router-qoder";
|
|
1740
|
+
/** 本插件注册的全部供应商(profile → 工厂)。 */
|
|
1741
|
+
const SUPPLIERS = [[profile$1.id, createSupplier(profile$1)], [profile.id, createSupplier(profile)]];
|
|
1742
|
+
/** 读取当前 router.suppliers 聚合表(同一 live 对象,可追加)。 */
|
|
1743
|
+
function currentSuppliers(ctx) {
|
|
1744
|
+
const c = ctx;
|
|
1745
|
+
return c.get?.("router.suppliers") ?? c.router?.suppliers;
|
|
1746
|
+
}
|
|
1747
|
+
function apply(ctx) {
|
|
1748
|
+
ctx.inject(["router.suppliers"], (sctx) => {
|
|
1749
|
+
const suppliers = currentSuppliers(sctx);
|
|
1750
|
+
if (!suppliers) return void 0;
|
|
1751
|
+
const added = [];
|
|
1752
|
+
for (const [id, factory] of SUPPLIERS) {
|
|
1753
|
+
if (suppliers[id]) continue;
|
|
1754
|
+
suppliers[id] = factory;
|
|
1755
|
+
added.push(id);
|
|
1756
|
+
}
|
|
1757
|
+
if (added.length > 0) {
|
|
1758
|
+
ctx.emit("internal/service", "router.suppliers", suppliers);
|
|
1759
|
+
ctx.logger?.info?.(`[dsh-router-qoder] registered router.suppliers: ${added.join(", ")}`);
|
|
1760
|
+
}
|
|
1761
|
+
return () => {
|
|
1762
|
+
for (const [id] of SUPPLIERS) delete suppliers[id];
|
|
1763
|
+
};
|
|
1764
|
+
});
|
|
1765
|
+
}
|
|
1766
|
+
export { apply, name };
|