dsh-codearts 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2223 @@
1
+ import { createHash, createHmac, createPrivateKey, createSign, generateKeyPairSync, randomBytes } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import http from "node:http";
6
+ import { existsSync } from "node:fs";
7
+ //#region \0rolldown/runtime.js
8
+ var __defProp = Object.defineProperty;
9
+ var __exportAll = (all, no_symbols) => {
10
+ let target = {};
11
+ for (var name in all) __defProp(target, name, {
12
+ get: all[name],
13
+ enumerable: true
14
+ });
15
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
16
+ return target;
17
+ };
18
+ //#endregion
19
+ //#region src/dpop.ts
20
+ /**
21
+ * DPoP(RFC 9449)证明 JWT:ES256 + P-256,供 oauth2/tokens 请求头使用。
22
+ * 翻译自 codearts2api 的 internal/upstream/dpop.go。
23
+ *
24
+ * 关键约束:refresh_token 与首次换取时的 DPoP 公钥绑定,刷新时必须复用同一
25
+ * 私钥,否则 STS 报 InvalidDPoPHeader。因此该私钥必须随 refresh_token 一起落盘。
26
+ *
27
+ * @module dsh-codearts/dpop
28
+ */
29
+ const P256_ORDER = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551");
30
+ const P256_HALF = P256_ORDER >> 1n;
31
+ /** 生成新的 DPoP 私钥(JWK)。 */
32
+ function newDPoPPrivateJWK() {
33
+ const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" });
34
+ const jwk = privateKey.export({ format: "jwk" });
35
+ return {
36
+ kty: jwk.kty,
37
+ crv: jwk.crv,
38
+ x: jwk.x,
39
+ y: jwk.y,
40
+ d: jwk.d
41
+ };
42
+ }
43
+ function privateKeyFromJWK(jwk) {
44
+ return createPrivateKey({
45
+ key: jwk,
46
+ format: "jwk"
47
+ });
48
+ }
49
+ function b64u(buf) {
50
+ return Buffer.from(buf).toString("base64url");
51
+ }
52
+ /**
53
+ * 生成 DPoP proof JWT(htm=POST,htu=token 端点)。
54
+ * 低 S 归一化以兼容服务端校验。
55
+ */
56
+ function signDpopProof(jwk, htu) {
57
+ const key = privateKeyFromJWK(jwk);
58
+ const header = {
59
+ alg: "ES256",
60
+ typ: "dpop+jwt",
61
+ jwk: {
62
+ kty: "EC",
63
+ crv: "P-256",
64
+ x: jwk.x,
65
+ y: jwk.y
66
+ }
67
+ };
68
+ const headerJSON = Buffer.from(JSON.stringify(header));
69
+ const payload = {
70
+ htm: "POST",
71
+ htu,
72
+ iat: Math.floor(Date.now() / 1e3),
73
+ jti: randomBytes(16).toString("hex")
74
+ };
75
+ const payloadJSON = Buffer.from(JSON.stringify(payload));
76
+ const input = `${b64u(headerJSON)}.${b64u(payloadJSON)}`;
77
+ const sig = createSign("SHA256").update(input).sign({
78
+ key,
79
+ dsaEncoding: "ieee-p1363"
80
+ });
81
+ let s = sig.subarray(32, 64);
82
+ let sBig = BigInt(`0x${s.toString("hex")}`);
83
+ if (sBig > P256_HALF) {
84
+ sBig = P256_ORDER - sBig;
85
+ s = Buffer.from(sBig.toString(16).padStart(64, "0"), "hex");
86
+ }
87
+ return `${input}.${b64u(Buffer.concat([sig.subarray(0, 32), s]))}`;
88
+ }
89
+ //#endregion
90
+ //#region src/oauth.ts
91
+ /**
92
+ * CodeArts OAuth2(PKCE)登录 + 刷新,翻译自 codearts2api 的
93
+ * internal/server/oauth.go / cmd/login/main.go / internal/upstream/client.go。
94
+ *
95
+ * 登录:本地起 127.0.0.1 回调服务 → 浏览器跳 codearts.huaweicloud.com/authorize
96
+ * → 用户登录 → 华为回调带 code → 用 code + PKCE verifier + DPoP 私钥换
97
+ * security_token + 临时 AK/SK + refresh_token。
98
+ *
99
+ * 刷新:refresh_token 与 client_id、DPoP 私钥三者绑定,刷新时原样复用。
100
+ *
101
+ * @module dsh-codearts/oauth
102
+ */
103
+ const CLIENT_ID = "codearts-agent";
104
+ const PLUGIN_NAME = "snap_AIIDE";
105
+ const PLUGIN_VERSION = "5.2.0";
106
+ const SNAP_HOST$1 = "https://snap-access.cn-north-4.myhuaweicloud.com";
107
+ const STS_HOST = "https://sts.cn-north-4.myhuaweicloud.com";
108
+ const PORTAL = "https://codearts.huaweicloud.com/portal";
109
+ const REDIRECT_PATH = "/oauth/callback";
110
+ function defaultLoginConfig() {
111
+ return {
112
+ clientId: CLIENT_ID,
113
+ snapHost: SNAP_HOST$1,
114
+ stsHost: STS_HOST,
115
+ portal: PORTAL
116
+ };
117
+ }
118
+ function randomHex(n) {
119
+ return randomBytes(n).toString("hex");
120
+ }
121
+ function pkce() {
122
+ const verifier = base64url(randomBytes(64));
123
+ return {
124
+ verifier,
125
+ challenge: createHash("sha256").update(verifier).digest("base64url"),
126
+ method: "S256"
127
+ };
128
+ }
129
+ function base64url(buf) {
130
+ return buf.toString("base64url");
131
+ }
132
+ /** 构造华为授权页 URL(浏览器打开,用户登录后回调本机)。 */
133
+ function buildAuthorizeURL(cfg, ticketId, challenge, method, port) {
134
+ const q = new URLSearchParams();
135
+ q.set("theme", "2");
136
+ q.set("locale", "zh-cn");
137
+ q.set("uri_scheme", cfg.clientId);
138
+ q.set("client_id", cfg.clientId);
139
+ q.set("port", String(port));
140
+ q.set("code_challenge", challenge);
141
+ q.set("code_challenge_method", "SHA-256");
142
+ q.set("ticket_id", ticketId);
143
+ q.set("plugin-name", PLUGIN_NAME);
144
+ q.set("plugin-version", PLUGIN_VERSION);
145
+ return `${cfg.portal}/authorize?${q.toString()}`;
146
+ }
147
+ /** 换 token 请求体:application/x-www-form-urlencoded(与华为 STS 一致)。 */
148
+ function buildTokenBody(cfg, params) {
149
+ return new URLSearchParams({
150
+ client_id: cfg.clientId,
151
+ ...params
152
+ }).toString();
153
+ }
154
+ /** 用授权 code 换 token。 */
155
+ async function exchangeCode(cfg, code, verifier, port, dpopJWK, abort) {
156
+ const htu = `${cfg.stsHost}/v1/oauth2/tokens`;
157
+ const dpop = signDpopProof(dpopJWK, htu);
158
+ const body = buildTokenBody(cfg, {
159
+ code,
160
+ code_verifier: verifier,
161
+ grant_type: "authorization_code",
162
+ redirect_uri: `http://127.0.0.1:${port}${REDIRECT_PATH}`
163
+ });
164
+ const res = await fetch(`${htu}`, {
165
+ method: "POST",
166
+ headers: {
167
+ "Content-Type": "application/x-www-form-urlencoded",
168
+ "DPoP": dpop,
169
+ "plugin-name": PLUGIN_NAME,
170
+ "plugin-version": PLUGIN_VERSION
171
+ },
172
+ body,
173
+ signal: abort
174
+ });
175
+ if (!res.ok) {
176
+ const text = await res.text();
177
+ throw new Error(`exchange code failed: ${res.status} ${text}`);
178
+ }
179
+ return parseToken(await res.json(), dpopJWK, cfg.clientId);
180
+ }
181
+ /** 用 refresh_token 续期。 */
182
+ async function refreshToken(cfg, prev, abort) {
183
+ const htu = `${cfg.stsHost}/v1/oauth2/tokens`;
184
+ const dpop = signDpopProof(prev.dpopPrivateKey, htu);
185
+ const body = buildTokenBody(cfg, {
186
+ grant_type: "refresh_token",
187
+ refresh_token: prev.refreshToken
188
+ });
189
+ const res = await fetch(`${htu}`, {
190
+ method: "POST",
191
+ headers: {
192
+ "Content-Type": "application/x-www-form-urlencoded",
193
+ "DPoP": dpop,
194
+ "plugin-name": PLUGIN_NAME,
195
+ "plugin-version": PLUGIN_VERSION
196
+ },
197
+ body,
198
+ signal: abort
199
+ });
200
+ if (!res.ok) {
201
+ const text = await res.text();
202
+ throw new Error(`refresh token failed: ${res.status} ${text}`);
203
+ }
204
+ return parseToken(await res.json(), prev.dpopPrivateKey, prev.clientId, prev);
205
+ }
206
+ function parseToken(json, dpopJWK, clientId, prev) {
207
+ const cred = json.credentials ?? {};
208
+ return {
209
+ userId: json.user_id ?? prev?.userId ?? "",
210
+ userName: json.user_name ?? prev?.userName ?? "",
211
+ domainId: json.domain_id ?? prev?.domainId ?? "",
212
+ securityToken: cred.security_token ?? cred.SecurityToken ?? "",
213
+ accessKeyId: cred.access_key_id ?? cred.AccessKeyID ?? "",
214
+ secretAccessKey: cred.secret_access_key ?? cred.SecretAccessKey ?? "",
215
+ expiration: cred.expiration ?? cred.Expiration ?? "",
216
+ refreshToken: json.refresh_token ?? json.RefreshToken ?? prev?.refreshToken ?? "",
217
+ clientId,
218
+ dpopPrivateKey: dpopJWK
219
+ };
220
+ }
221
+ async function startLogin(cfg = defaultLoginConfig()) {
222
+ const ticketId = randomHex(16);
223
+ const { verifier, challenge, method } = pkce();
224
+ const dpopJWK = newDPoPPrivateJWK();
225
+ const codeCh = await new Promise((resolve) => {
226
+ const server = http.createServer();
227
+ const waiters = [];
228
+ server.on("request", (req, res) => {
229
+ const u = new URL(req.url ?? "/", "http://127.0.0.1");
230
+ if (u.pathname !== REDIRECT_PATH) {
231
+ res.writeHead(404).end();
232
+ return;
233
+ }
234
+ const code = u.searchParams.get("code");
235
+ const redirect = u.searchParams.get("redirect");
236
+ if (!code && redirect) {
237
+ res.writeHead(307, { Location: redirect }).end();
238
+ return;
239
+ }
240
+ if (!code) {
241
+ let body = "";
242
+ req.on("data", (c) => body += c);
243
+ req.on("end", () => {
244
+ finish(new URLSearchParams(body).get("code"));
245
+ });
246
+ return;
247
+ }
248
+ finish(code);
249
+ function finish(c) {
250
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
251
+ res.end("<h3>CodeArts 登录成功,可关闭此页面。</h3>");
252
+ if (c) for (const w of waiters.splice(0)) w(c);
253
+ }
254
+ });
255
+ server.listen(0, "127.0.0.1", () => {
256
+ const port = server.address().port;
257
+ resolve({
258
+ port,
259
+ server,
260
+ take: () => new Promise((r) => waiters.push(r))
261
+ });
262
+ });
263
+ });
264
+ const url = buildAuthorizeURL(cfg, ticketId, challenge, method, codeCh.port);
265
+ const promise = (async () => {
266
+ const timer = setTimeout(() => codeCh.server.close(), 3e5);
267
+ try {
268
+ return await exchangeCode(cfg, await codeCh.take(), verifier, codeCh.port, dpopJWK);
269
+ } finally {
270
+ clearTimeout(timer);
271
+ codeCh.server.close();
272
+ }
273
+ })();
274
+ return {
275
+ url,
276
+ port: codeCh.port,
277
+ promise,
278
+ close: () => codeCh.server.close()
279
+ };
280
+ }
281
+ //#endregion
282
+ //#region src/auth.ts
283
+ /**
284
+ * CodeArts credential store.
285
+ *
286
+ * 真实 CodeArts 桌面端聊天需要:STS `security_token` + 临时 AK/SK(HMAC 签名)
287
+ * + refresh_token(绑定 DPoP 私钥用于续期)。本模块持久化这全套 OAuth 凭证,
288
+ * 并提供自动续期。同时也保留"手动粘贴 security_token"的兜底(但无 AK/SK 时只能
289
+ * 作为降级态,聊天会被 APIG 拒)。
290
+ *
291
+ * @module dsh-codearts/auth
292
+ */
293
+ /** 福利领取的上限;失败无所谓,卡住登录才是问题。 */
294
+ const CLAIM_TIMEOUT_MS = 15e3;
295
+ /** 两条凭证里较新的一条(`storedAt` 缺失视为最旧)。 */
296
+ function newerCredential(a, b) {
297
+ const at = typeof a.storedAt === "number" ? a.storedAt : 0;
298
+ return (typeof b.storedAt === "number" ? b.storedAt : 0) > at ? b : a;
299
+ }
300
+ /** 从 OAuthToken 落库。 */
301
+ function credentialFromOAuth(t, label) {
302
+ return {
303
+ token: t.securityToken,
304
+ accessKeyId: t.accessKeyId,
305
+ secretAccessKey: t.secretAccessKey,
306
+ refreshToken: t.refreshToken,
307
+ dpopPrivateKey: t.dpopPrivateKey,
308
+ clientId: t.clientId,
309
+ expiration: t.expiration,
310
+ userId: t.userId,
311
+ userName: t.userName,
312
+ domainId: t.domainId,
313
+ label: label ?? t.userName ?? t.userId,
314
+ storedAt: Date.now()
315
+ };
316
+ }
317
+ function codeartsAuthPath(home) {
318
+ const base = home ?? process.env.DSH_HOME ?? join(homedir(), ".dsh");
319
+ return join(base, ".codearts-auth.json");
320
+ }
321
+ async function loadCredential(path) {
322
+ try {
323
+ const raw = await readFile(path, "utf8");
324
+ const parsed = JSON.parse(raw);
325
+ if (typeof parsed.token !== "string" || parsed.token.length === 0) return void 0;
326
+ return {
327
+ ...parsed,
328
+ token: parsed.token
329
+ };
330
+ } catch {
331
+ return;
332
+ }
333
+ }
334
+ /** 原子写一个 JSON 文件(0600)。 */
335
+ async function writeJsonAtomic(path, value) {
336
+ await mkdir(dirname(path), { recursive: true });
337
+ const body = JSON.stringify(value, null, 2);
338
+ const tmp = `${path}.${process.pid}-${Math.random().toString(36).slice(2, 10)}.tmp`;
339
+ try {
340
+ await writeFile(tmp, body, { mode: 384 });
341
+ await chmod(tmp, 384).catch(() => {});
342
+ await rename(tmp, path);
343
+ } catch {
344
+ await unlink(tmp).catch(() => {});
345
+ await writeFile(path, body, { mode: 384 });
346
+ }
347
+ }
348
+ async function saveCredential(path, cred) {
349
+ await writeJsonAtomic(path, cred);
350
+ }
351
+ async function clearCredential(path) {
352
+ try {
353
+ await unlink(path);
354
+ } catch {}
355
+ }
356
+ /**
357
+ * 领取限时福利(幂等);无 AK/SK 时静默跳过。
358
+ *
359
+ * 必须带超时:领取失败不该拖住登录/续期。早先没传 signal,claimBenefit 的
360
+ * fetch 就没有任何超时 —— 福利接口一旦不响应,ensureFresh() 永远不返回,
361
+ * 跟着卡死的是整个聊天请求。
362
+ */
363
+ async function claimIfPossible(cred) {
364
+ if (!cred.accessKeyId || !cred.secretAccessKey) return;
365
+ try {
366
+ const { claimBenefit } = await Promise.resolve().then(() => upstream_exports);
367
+ await claimBenefit({
368
+ token: cred.token,
369
+ accessKeyId: cred.accessKeyId,
370
+ secretAccessKey: cred.secretAccessKey,
371
+ securityToken: cred.token
372
+ }, AbortSignal.timeout(CLAIM_TIMEOUT_MS));
373
+ } catch {}
374
+ }
375
+ /** 续期判定:距过期不足 5 分钟就去刷。 */
376
+ const REFRESH_SKEW_MS = 3e5;
377
+ var CodeArtsCredentialStore = class {
378
+ _current;
379
+ _path;
380
+ /** 正在进行的刷新。并发请求必须共用它,不能各刷一次。 */
381
+ _inflight;
382
+ /**
383
+ * 凭证代际。重登/清除会推进它,让还在飞的刷新发现自己过期,
384
+ * 从而不把刷新结果写回去覆盖掉刚拿到的新凭证。
385
+ */
386
+ _gen = 0;
387
+ /** 作废在飞的刷新;任何直接写凭证的路径都要先调它。 */
388
+ invalidateInflight() {
389
+ this._gen += 1;
390
+ this._inflight = void 0;
391
+ }
392
+ constructor(path) {
393
+ this._path = path;
394
+ }
395
+ setPath(path) {
396
+ this.invalidateInflight();
397
+ this._current = void 0;
398
+ this._path = path;
399
+ }
400
+ /** 给 shim 用:返回签名所需全套。 */
401
+ async resolve() {
402
+ const cred = await this.ensureFresh();
403
+ if (cred === void 0) return void 0;
404
+ return {
405
+ token: cred.token,
406
+ accessKeyId: cred.accessKeyId,
407
+ secretAccessKey: cred.secretAccessKey,
408
+ securityToken: cred.token
409
+ };
410
+ }
411
+ async current() {
412
+ return this._current ??= await loadCredential(this._path);
413
+ }
414
+ /** 从 OAuth 登录结果落库。 */
415
+ async setFromOAuth(t, label) {
416
+ this.invalidateInflight();
417
+ const cred = credentialFromOAuth(t, label);
418
+ await saveCredential(this._path, cred);
419
+ this._current = cred;
420
+ await this.claimIfPossible(cred);
421
+ }
422
+ /** 领取限时福利(幂等);无 AK/SK 时静默跳过。 */
423
+ async claimIfPossible(cred) {
424
+ await claimIfPossible(cred);
425
+ }
426
+ /** 手动粘贴 security_token(降级态,无 AK/SK)。 */
427
+ async set(token, label) {
428
+ this.invalidateInflight();
429
+ const cred = {
430
+ token: token.trim(),
431
+ label,
432
+ storedAt: Date.now()
433
+ };
434
+ await saveCredential(this._path, cred);
435
+ this._current = cred;
436
+ }
437
+ async clear() {
438
+ this.invalidateInflight();
439
+ this._current = void 0;
440
+ await clearCredential(this._path);
441
+ }
442
+ async hasCredential() {
443
+ return await this.current() !== void 0;
444
+ }
445
+ /**
446
+ * 自动续期:若距过期不足 5 分钟且具备 refresh 条件,则刷新并落库。
447
+ * 返回当前(可能已刷新)凭证。
448
+ */
449
+ async ensureFresh() {
450
+ const gen = this._gen;
451
+ const cred = this._current ?? await loadCredential(this._path);
452
+ if (this._gen !== gen) return this._current;
453
+ this._current = cred;
454
+ if (!cred) return void 0;
455
+ if (!cred.refreshToken || !cred.dpopPrivateKey || !cred.clientId) return cred;
456
+ const exp = cred.expiration ? Date.parse(cred.expiration) : NaN;
457
+ if (!(Number.isNaN(exp) ? false : exp - Date.now() < 3e5)) return cred;
458
+ return this._inflight ??= this.refresh(cred, {
459
+ refreshToken: cred.refreshToken,
460
+ dpopPrivateKey: cred.dpopPrivateKey,
461
+ clientId: cred.clientId
462
+ }).finally(() => {
463
+ this._inflight = void 0;
464
+ });
465
+ }
466
+ /** 真正去刷一次;失败则把凭证标成 stale 落库,让卡片提示重新登录。 */
467
+ async refresh(cred, mat) {
468
+ const gen = this._gen;
469
+ /**
470
+ * 这次刷新的结果还能不能往回写。光看代际号不够:重登是异步落库的,可能在
471
+ * 本次刷新启动**之后**才把 _current 换掉,那时代际号已经来不及拦。所以还要
472
+ * 确认「当前凭证仍然是这次刷新的那一份」。
473
+ */
474
+ const stillCurrent = () => this._gen === gen && this._current === cred;
475
+ try {
476
+ const next = credentialFromOAuth(await refreshToken(defaultLoginConfig(), {
477
+ userId: cred.userId ?? "",
478
+ userName: cred.userName ?? "",
479
+ domainId: "",
480
+ securityToken: cred.token,
481
+ accessKeyId: cred.accessKeyId ?? "",
482
+ secretAccessKey: cred.secretAccessKey ?? "",
483
+ expiration: cred.expiration ?? "",
484
+ refreshToken: mat.refreshToken,
485
+ clientId: mat.clientId,
486
+ dpopPrivateKey: mat.dpopPrivateKey
487
+ }), cred.label);
488
+ if (!stillCurrent()) return next;
489
+ await saveCredential(this._path, next);
490
+ this._current = next;
491
+ await this.claimIfPossible(next);
492
+ return next;
493
+ } catch {
494
+ cred.stale = true;
495
+ if (!stillCurrent()) return cred;
496
+ this._current = cred;
497
+ try {
498
+ await saveCredential(this._path, cred);
499
+ } catch {}
500
+ return cred;
501
+ }
502
+ }
503
+ };
504
+ /**
505
+ * 手工粘贴 token 的降级账号固定用这个 id:再粘一次是**覆盖**,而不是又加一个。
506
+ */
507
+ const MANUAL_ACCOUNT_ID = "manual";
508
+ function codeartsAccountsPath(home) {
509
+ const base = home ?? process.env.DSH_HOME ?? join(homedir(), ".dsh");
510
+ return join(base, ".codearts-accounts.json");
511
+ }
512
+ /**
513
+ * 账号的自然键,按稳定性依次退让:
514
+ *
515
+ * 1. `userId` —— 最理想,同一个人永远同一个键。
516
+ * 2. `userName`。
517
+ * 3. **DPoP 公钥**。实测上游的 token 响应里 `user_id` / `user_name` **都是空串**
518
+ * (2026-09-30 用真实账号核对),所以前两级拿不到。DPoP 密钥由每次登录生成、
519
+ * 续期时原样复用,因此「同一次登录」内稳定、不同账号必然不同 —— 比拿 token
520
+ * 做键好,因为 token 每次续期都会轮换。
521
+ * 4. 最后才退回 token 哈希(手工粘贴的降级凭证没有 DPoP 密钥)。
522
+ *
523
+ * 已知代价:上游不给稳定身份,所以**对同一个账号重新登录**会被当成新账号,
524
+ * 旧条目会自己失效(凭据被拒 → 停用)并可在卡片上删掉。
525
+ */
526
+ function accountIdOf(cred) {
527
+ if (typeof cred.userId === "string" && cred.userId !== "") return cred.userId;
528
+ if (typeof cred.userName === "string" && cred.userName !== "") return cred.userName;
529
+ const jwk = cred.dpopPrivateKey;
530
+ if (jwk !== void 0 && typeof jwk.x === "string" && typeof jwk.y === "string") return `k${createHash("sha256").update(`${jwk.x}.${jwk.y}`).digest("hex").slice(0, 15)}`;
531
+ return `t${createHash("sha256").update(cred.token).digest("hex").slice(0, 15)}`;
532
+ }
533
+ function summarizeAccount(id, cred) {
534
+ return {
535
+ id,
536
+ ...cred.label === void 0 ? {} : { label: cred.label },
537
+ ...cred.userName === void 0 ? {} : { userName: cred.userName },
538
+ ...cred.expiration === void 0 ? {} : { expiration: cred.expiration },
539
+ ...cred.storedAt === void 0 ? {} : { storedAt: cred.storedAt },
540
+ stale: cred.stale === true,
541
+ signable: typeof cred.accessKeyId === "string" && cred.accessKeyId !== "" && typeof cred.secretAccessKey === "string" && cred.secretAccessKey !== ""
542
+ };
543
+ }
544
+ /**
545
+ * 单个账号的内存状态与续期。
546
+ *
547
+ * 每个账号一份、互不干扰。`refresh_token` 是一次性的,上游在刷新时会轮换它:
548
+ * 同一账号内并发刷新会烧掉它、并让失败的那次把 `stale` 盖在成功那次刚拿到的
549
+ * 凭证上,登录态就此写死(本机真实踩过一次)。所以这里保留单凭证时代验证过的
550
+ * 「单飞 + 代际」两条保护,只是作用域缩到单个账号。
551
+ */
552
+ var AccountSlot = class {
553
+ _cred;
554
+ _inflight;
555
+ _gen = 0;
556
+ persist;
557
+ constructor(cred, persist) {
558
+ this._cred = cred;
559
+ this.persist = persist;
560
+ }
561
+ get credential() {
562
+ return this._cred;
563
+ }
564
+ /** 账号被替换或删除:作废在飞的刷新,别让它回头写回旧值。 */
565
+ invalidate() {
566
+ this._gen += 1;
567
+ this._inflight = void 0;
568
+ }
569
+ async ensureFresh() {
570
+ const cred = this._cred;
571
+ if (cred.stale === true) return cred;
572
+ if (!cred.refreshToken || !cred.dpopPrivateKey || !cred.clientId) return cred;
573
+ const exp = cred.expiration ? Date.parse(cred.expiration) : NaN;
574
+ if (!(Number.isNaN(exp) ? false : exp - Date.now() < REFRESH_SKEW_MS)) return cred;
575
+ return this._inflight ??= this.refresh(cred, {
576
+ refreshToken: cred.refreshToken,
577
+ dpopPrivateKey: cred.dpopPrivateKey,
578
+ clientId: cred.clientId
579
+ }).finally(() => {
580
+ this._inflight = void 0;
581
+ });
582
+ }
583
+ async refresh(cred, mat) {
584
+ const gen = this._gen;
585
+ const stillCurrent = () => this._gen === gen && this._cred === cred;
586
+ try {
587
+ const next = credentialFromOAuth(await refreshToken(defaultLoginConfig(), {
588
+ userId: cred.userId ?? "",
589
+ userName: cred.userName ?? "",
590
+ domainId: "",
591
+ securityToken: cred.token,
592
+ accessKeyId: cred.accessKeyId ?? "",
593
+ secretAccessKey: cred.secretAccessKey ?? "",
594
+ expiration: cred.expiration ?? "",
595
+ refreshToken: mat.refreshToken,
596
+ clientId: mat.clientId,
597
+ dpopPrivateKey: mat.dpopPrivateKey
598
+ }), cred.label);
599
+ if (!stillCurrent()) return next;
600
+ await this.persist(next);
601
+ this._cred = next;
602
+ await claimIfPossible(next);
603
+ return next;
604
+ } catch {
605
+ cred.stale = true;
606
+ if (!stillCurrent()) return cred;
607
+ this._cred = cred;
608
+ try {
609
+ await this.persist(cred);
610
+ } catch {}
611
+ return cred;
612
+ }
613
+ }
614
+ };
615
+ async function readAccountDocument(path) {
616
+ try {
617
+ const parsed = JSON.parse(await readFile(path, "utf8"));
618
+ if (typeof parsed !== "object" || parsed === null) return void 0;
619
+ const accounts = parsed.accounts ?? {};
620
+ const order = Array.isArray(parsed.order) ? parsed.order.filter((id) => typeof id === "string") : [];
621
+ for (const id of Object.keys(accounts)) if (!order.includes(id)) order.push(id);
622
+ return {
623
+ version: 1,
624
+ order,
625
+ accounts
626
+ };
627
+ } catch {
628
+ return;
629
+ }
630
+ }
631
+ /** 多账号凭证存储:一个账号一份槽位,共用一个文档文件。 */
632
+ var CodeArtsAccountStore = class {
633
+ _doc;
634
+ _slots = /* @__PURE__ */ new Map();
635
+ _loading;
636
+ /** 落库串行化:多个账号的续期可能同时想写同一个文档。 */
637
+ _writeChain = Promise.resolve();
638
+ path;
639
+ /** 旧版单凭证文件;新文档不存在时把它收编成第一个账号。 */
640
+ legacyPath;
641
+ constructor(path, legacyPath) {
642
+ this.path = path;
643
+ this.legacyPath = legacyPath ?? join(dirname(path), ".codearts-auth.json");
644
+ }
645
+ async loaded() {
646
+ this._loading ??= this.load();
647
+ return this._loading;
648
+ }
649
+ async load() {
650
+ const doc = await readAccountDocument(this.path);
651
+ if (doc !== void 0) {
652
+ this.adopt(doc);
653
+ return;
654
+ }
655
+ const legacy = await loadCredential(this.legacyPath);
656
+ const fresh = {
657
+ version: 1,
658
+ order: [],
659
+ accounts: {}
660
+ };
661
+ if (legacy !== void 0) {
662
+ const id = accountIdOf(legacy);
663
+ fresh.order.push(id);
664
+ fresh.accounts[id] = legacy;
665
+ }
666
+ this.adopt(fresh);
667
+ if (legacy !== void 0) await this.persistDoc();
668
+ }
669
+ adopt(doc) {
670
+ this._doc = doc;
671
+ this._slots.clear();
672
+ for (const [id, cred] of Object.entries(doc.accounts)) this._slots.set(id, new AccountSlot(cred, this.persisterFor(id)));
673
+ }
674
+ persisterFor(id) {
675
+ return async (cred) => {
676
+ if (this._doc === void 0) return;
677
+ this._doc.accounts[id] = cred;
678
+ await this.persistDoc();
679
+ };
680
+ }
681
+ persistDoc() {
682
+ const snapshot = this._doc;
683
+ const write = async () => {
684
+ if (snapshot !== void 0) await writeJsonAtomic(this.path, snapshot);
685
+ };
686
+ this._writeChain = this._writeChain.then(write, write);
687
+ return this._writeChain;
688
+ }
689
+ /** 按展示顺序返回账号摘要。 */
690
+ async list() {
691
+ await this.loaded();
692
+ const doc = this._doc;
693
+ return doc.order.filter((id) => doc.accounts[id] !== void 0).map((id) => summarizeAccount(id, doc.accounts[id]));
694
+ }
695
+ async ids() {
696
+ await this.loaded();
697
+ const doc = this._doc;
698
+ return doc.order.filter((id) => doc.accounts[id] !== void 0);
699
+ }
700
+ async credential(id) {
701
+ await this.loaded();
702
+ return this._doc.accounts[id];
703
+ }
704
+ /** 首个账号——卡片顶部与 CLI 的「当前」。 */
705
+ async current() {
706
+ const ids = await this.ids();
707
+ return ids.length === 0 ? void 0 : this._doc.accounts[ids[0]];
708
+ }
709
+ async hasCredential() {
710
+ return (await this.ids()).length > 0;
711
+ }
712
+ /** 新增或更新一个账号,返回它的 id。 */
713
+ async upsert(cred, id = accountIdOf(cred)) {
714
+ await this.loaded();
715
+ const doc = this._doc;
716
+ this._slots.get(id)?.invalidate();
717
+ doc.accounts[id] = cred;
718
+ if (!doc.order.includes(id)) doc.order.push(id);
719
+ this._slots.set(id, new AccountSlot(cred, this.persisterFor(id)));
720
+ await this.persistDoc();
721
+ await claimIfPossible(cred);
722
+ return id;
723
+ }
724
+ /**
725
+ * 从一次 OAuth 登录结果新增/更新账号。
726
+ *
727
+ * 「再登录一次」天然就是「添加账号」:同一账号是更新,别的账号就是新增。
728
+ *
729
+ * 传了 `identity`(来自 `fetchCallerIdentity` 的稳定 user_id)就用它当账号键 ——
730
+ * 这正是「对同一个账号重新登录会被当成新账号」的解药;拿不到身份时退回旧行为
731
+ * (DPoP 公钥当键),也就是每次登录各自成一条。
732
+ */
733
+ async upsertFromOAuth(t, label, identity) {
734
+ const cred = credentialFromOAuth(t, label);
735
+ if (identity !== void 0) {
736
+ cred.userId = identity.userId;
737
+ if (identity.userName !== "") cred.userName = identity.userName;
738
+ if (identity.domainId !== "") cred.domainId = identity.domainId;
739
+ }
740
+ return this.upsert(cred);
741
+ }
742
+ /**
743
+ * 把一条已有账号迁移到它的稳定身份键上(同键则合并)。
744
+ *
745
+ * 用在两处:登录后回填身份(老条目已经存在时合并掉),以及 `dsh-codearts identity --fix`
746
+ * 清理历史遗留的重复条目。合并时保留 `storedAt` 较新的一份凭证,位置沿用目标键原有的顺序。
747
+ *
748
+ * @returns 迁移结果:`merged` 表示并入了已存在的那条,`moved` 表示只换了键,`noop` 表示键没变
749
+ */
750
+ async adoptIdentity(id, identity) {
751
+ await this.loaded();
752
+ const doc = this._doc;
753
+ const existing = doc.accounts[id];
754
+ if (existing === void 0) return "noop";
755
+ const targetKey = identity.userId;
756
+ if (targetKey === "" || targetKey === id) {
757
+ doc.accounts[id] = {
758
+ ...existing,
759
+ userId: identity.userId === "" ? existing.userId : identity.userId,
760
+ ...identity.userName === "" ? {} : { userName: identity.userName },
761
+ ...identity.domainId === "" ? {} : { domainId: identity.domainId }
762
+ };
763
+ this._slots.get(id)?.invalidate();
764
+ this._slots.set(id, new AccountSlot(doc.accounts[id], this.persisterFor(id)));
765
+ await this.persistDoc();
766
+ return "noop";
767
+ }
768
+ const target = doc.accounts[targetKey];
769
+ const keep = target === void 0 ? existing : newerCredential(target, existing);
770
+ doc.accounts[targetKey] = {
771
+ ...keep,
772
+ userId: identity.userId,
773
+ ...identity.userName === "" ? {} : { userName: identity.userName },
774
+ ...identity.domainId === "" ? {} : { domainId: identity.domainId }
775
+ };
776
+ delete doc.accounts[id];
777
+ doc.order = doc.order.filter((existingId) => existingId !== id);
778
+ if (!doc.order.includes(targetKey)) doc.order.push(targetKey);
779
+ this._slots.get(id)?.invalidate();
780
+ this._slots.delete(id);
781
+ this._slots.get(targetKey)?.invalidate();
782
+ this._slots.set(targetKey, new AccountSlot(doc.accounts[targetKey], this.persisterFor(targetKey)));
783
+ await this.persistDoc();
784
+ return target === void 0 ? "moved" : "merged";
785
+ }
786
+ /** 手工粘贴 security_token(降级态,无 AK/SK)——固定覆盖同一个「手工」账号。 */
787
+ async set(token, label) {
788
+ await this.upsert({
789
+ token: token.trim(),
790
+ label,
791
+ storedAt: Date.now()
792
+ }, MANUAL_ACCOUNT_ID);
793
+ }
794
+ async remove(id) {
795
+ await this.loaded();
796
+ const doc = this._doc;
797
+ this._slots.get(id)?.invalidate();
798
+ this._slots.delete(id);
799
+ delete doc.accounts[id];
800
+ doc.order = doc.order.filter((existing) => existing !== id);
801
+ await this.persistDoc();
802
+ }
803
+ /** 清空全部账号(整体登出)。 */
804
+ async clear() {
805
+ await this.loaded();
806
+ for (const slot of this._slots.values()) slot.invalidate();
807
+ this._slots.clear();
808
+ this._doc = {
809
+ version: 1,
810
+ order: [],
811
+ accounts: {}
812
+ };
813
+ await this.persistDoc();
814
+ await clearCredential(this.legacyPath);
815
+ }
816
+ /** 给 shim 用:解析某账号的签名材料(内部含续期)。 */
817
+ async resolve(id) {
818
+ await this.loaded();
819
+ const slot = this._slots.get(id);
820
+ if (slot === void 0) return void 0;
821
+ const cred = await slot.ensureFresh();
822
+ return {
823
+ token: cred.token,
824
+ accessKeyId: cred.accessKeyId,
825
+ secretAccessKey: cred.secretAccessKey,
826
+ securityToken: cred.token
827
+ };
828
+ }
829
+ };
830
+ //#endregion
831
+ //#region src/catalog.ts
832
+ /**
833
+ * Static seed roster. Ids are the *upstream registered* spellings (case
834
+ * matters — `canonicalModel` in upstream.ts passes these through verbatim).
835
+ * Updated 2026-09 from codearts2api's known model set.
836
+ *
837
+ * maxTokens 注记(2026-09-30 实测,scripts/param-probe.mjs 直连上游二分验证):
838
+ * 免费福利 DeepSeek 模型的 max_tokens 服务端硬上限是 **65536**,更大的值
839
+ * (73728 / 98304 / 131072 / 384000 / 393216)一律流内报
840
+ * InferHub.001001005.400 "The request param is invalid, Please check it"。
841
+ * 此前的 384000/393216 是占位值:DSH 会把 catalog 的 maxTokens 作为
842
+ * max_tokens 原样发给上游,导致所有请求直接失败。
843
+ */
844
+ const FALLBACK_CODEARTS_MODELS = [
845
+ {
846
+ id: "GLM-5.2",
847
+ name: "GLM-5.2",
848
+ contextWindow: 128e3,
849
+ maxTokens: 8e3,
850
+ supportsImages: true
851
+ },
852
+ {
853
+ id: "GLM-5.1",
854
+ name: "GLM-5.1",
855
+ contextWindow: 128e3,
856
+ maxTokens: 8e3,
857
+ supportsImages: false
858
+ },
859
+ {
860
+ id: "GLM-4.7",
861
+ name: "GLM-4.7",
862
+ contextWindow: 128e3,
863
+ maxTokens: 8e3,
864
+ supportsImages: true
865
+ },
866
+ {
867
+ id: "OpenPangu-2.0-Pro",
868
+ name: "OpenPangu 2.0 Pro",
869
+ contextWindow: 32e3,
870
+ maxTokens: 4e3,
871
+ supportsImages: true
872
+ },
873
+ {
874
+ id: "OpenPangu-2.0-Flash",
875
+ name: "OpenPangu 2.0 Flash",
876
+ contextWindow: 32e3,
877
+ maxTokens: 4e3,
878
+ supportsImages: true
879
+ },
880
+ {
881
+ id: "Qwen3-VL-235B",
882
+ name: "Qwen3-VL-235B",
883
+ contextWindow: 128e3,
884
+ maxTokens: 8e3,
885
+ supportsImages: true
886
+ },
887
+ {
888
+ id: "Qwen3.5-397B-A17B-VL",
889
+ name: "Qwen3.5-397B-A17B-VL",
890
+ contextWindow: 128e3,
891
+ maxTokens: 8e3,
892
+ supportsImages: true
893
+ },
894
+ {
895
+ id: "Qwen3.6-27B-VL",
896
+ name: "Qwen3.6-27B-VL",
897
+ contextWindow: 128e3,
898
+ maxTokens: 8e3,
899
+ supportsImages: true
900
+ },
901
+ {
902
+ id: "deepseek-v4-flash-0731",
903
+ name: "DeepSeek-V4-Flash (免费)",
904
+ contextWindow: 131072,
905
+ maxTokens: 65536,
906
+ supportsImages: true
907
+ },
908
+ {
909
+ id: "deepseek-v4-pro-0813",
910
+ name: "DeepSeek-V4-Pro (免费)",
911
+ contextWindow: 131072,
912
+ maxTokens: 65536,
913
+ supportsImages: true
914
+ },
915
+ {
916
+ id: "deepseek-v4.1-flash",
917
+ name: "DeepSeek-V4.1-Flash (免费)",
918
+ contextWindow: 131072,
919
+ maxTokens: 65536,
920
+ supportsImages: true
921
+ },
922
+ {
923
+ id: "glm-5.3-flash",
924
+ name: "GLM-5.3-Flash (免费)",
925
+ contextWindow: 128e3,
926
+ maxTokens: 8e3,
927
+ supportsImages: true
928
+ }
929
+ ];
930
+ /** Mutable catalog shared by the shim's `/v1/models` and the adapter. */
931
+ var CodeArtsCatalog = class {
932
+ models = FALLBACK_CODEARTS_MODELS;
933
+ current() {
934
+ return this.models;
935
+ }
936
+ set(models) {
937
+ this.models = [...models];
938
+ }
939
+ };
940
+ /**
941
+ * Narrow a catalog to the user's enabled selection (allowlist of model ids).
942
+ * Same semantics as the codebuddy plugin: absent or empty => serve all.
943
+ */
944
+ function filterEnabledModels(models, enabled) {
945
+ if (enabled === void 0 || enabled.length === 0) return models;
946
+ const allow = new Set(enabled);
947
+ const kept = models.filter((model) => allow.has(model.id));
948
+ return kept.length === 0 ? models : kept;
949
+ }
950
+ const benefitLower = new Set([
951
+ "deepseek-v4-flash-0731",
952
+ "deepseek-v4-pro-0813",
953
+ "deepseek-v4.1-flash",
954
+ "glm-5.3-flash"
955
+ ].map((m) => m.toLowerCase()));
956
+ /**
957
+ * 动态发现确认的福利模型(小写 id)。
958
+ *
959
+ * 与 codearts2api 的差别:那边按账号隔离目录,本插件的聊天链路(shim → upstream)
960
+ * 拿不到账号上下文,所以这里取**并集**并保留种子兜底 —— 宁可多带一次
961
+ * `maas_type: benefit` 头,也不要漏带导致 404。套餐轮换后模型「转正」时,
962
+ * 下次发现会把它从这份集合里去掉。
963
+ */
964
+ const discoveredBenefit = /* @__PURE__ */ new Set();
965
+ /** 用一次发现的结果替换动态福利集合(传空数组表示「本轮确认没有福利模型」)。 */
966
+ function setDiscoveredBenefitModels(ids) {
967
+ discoveredBenefit.clear();
968
+ for (const id of ids) {
969
+ const key = id.trim().toLowerCase();
970
+ if (key !== "") discoveredBenefit.add(key);
971
+ }
972
+ }
973
+ /**
974
+ * 该模型是否走限时福利路由(按小写 ID 判定)。
975
+ *
976
+ * 发现结果优先于种子:目录里明确「不是福利」的模型(已转正)不会被种子重新标成福利,
977
+ * 因为发现成功时会把它从 `discoveredBenefit` 里剔除,而种子只在该集合为空时兜底。
978
+ */
979
+ function isBenefitModel(id) {
980
+ const key = id.trim().toLowerCase();
981
+ if (key === "") return false;
982
+ if (discoveredBenefit.has(key)) return true;
983
+ if (discoveredBenefit.size > 0) return false;
984
+ return benefitLower.has(key);
985
+ }
986
+ /**
987
+ * 合并多路来源的模型清单(内置 / 限时福利 / agent-center)。
988
+ *
989
+ * 规则对齐 codearts2api 的 `MergeModels`:
990
+ * - 按小写 id 去重(上游注册名大小写敏感,但同一模型的书写可能不一致);
991
+ * - `benefit` 取或:漏标一次就是一次 404;
992
+ * - 参数更全的一条优先(有 contextWindow > 没有,supportsImages 为真优先);
993
+ * - 输出按小写 id 排序,保证 `/v1/models` 在刷新前后顺序稳定。
994
+ */
995
+ function mergeModelLists(sets) {
996
+ const merged = /* @__PURE__ */ new Map();
997
+ for (const set of sets) for (const model of set) {
998
+ const id = model.id.trim();
999
+ if (id === "") continue;
1000
+ const key = id.toLowerCase();
1001
+ const normalized = {
1002
+ ...model,
1003
+ id
1004
+ };
1005
+ const current = merged.get(key);
1006
+ merged.set(key, current === void 0 ? normalized : richerModel(current, normalized));
1007
+ }
1008
+ return [...merged.values()].sort((a, b) => {
1009
+ const la = a.id.toLowerCase();
1010
+ const lb = b.id.toLowerCase();
1011
+ return la === lb ? a.id < b.id ? -1 : 1 : la < lb ? -1 : 1;
1012
+ });
1013
+ }
1014
+ /** 同一模型两条记录取更全的一条;`benefit` 永远取或。 */
1015
+ function richerModel(current, next) {
1016
+ const benefit = current.benefit === true || next.benefit === true;
1017
+ const keep = richerThan(next, current) ? next : current;
1018
+ const drop = keep === next ? current : next;
1019
+ return {
1020
+ id: keep.id,
1021
+ name: keep.name || drop.name || keep.id,
1022
+ contextWindow: keep.contextWindow > 0 ? keep.contextWindow : drop.contextWindow,
1023
+ maxTokens: keep.maxTokens > 0 ? keep.maxTokens : drop.maxTokens,
1024
+ supportsImages: keep.supportsImages || drop.supportsImages,
1025
+ ...benefit ? { benefit: true } : {}
1026
+ };
1027
+ }
1028
+ /** a 是否比 b 更值得保留:优先带参数,其次保留大小写更「像注册名」的一条。 */
1029
+ function richerThan(a, b) {
1030
+ if (a.contextWindow > 0 !== b.contextWindow > 0) return a.contextWindow > 0;
1031
+ if (a.maxTokens > 0 !== b.maxTokens > 0) return a.maxTokens > 0;
1032
+ return a.id !== a.id.toLowerCase() && b.id === b.id.toLowerCase();
1033
+ }
1034
+ /**
1035
+ * 用种子清单给**发现结果**补缺:种子只补,不覆盖。
1036
+ *
1037
+ * 为什么不能把种子一起丢进 `mergeModelLists`:那个函数按「参数更全/大小写更像注册名」
1038
+ * 选记录,种子里的占位值(例如 OpenPangu 的 ctx 32000 / max 4000)会凭大小写规则
1039
+ * 压过上游返回的真实值(ctx 524288 / max 131072)。所以顺序必须是
1040
+ * 「先合并发现结果 → 再用种子补 0 值字段与缺失条目」。
1041
+ */
1042
+ function fillFromSeed(discovered, seed = FALLBACK_CODEARTS_MODELS) {
1043
+ const byId = /* @__PURE__ */ new Map();
1044
+ for (const model of discovered) byId.set(model.id.toLowerCase(), { ...model });
1045
+ for (const fallback of seed) {
1046
+ const key = fallback.id.toLowerCase();
1047
+ const current = byId.get(key);
1048
+ if (current === void 0) {
1049
+ byId.set(key, { ...fallback });
1050
+ continue;
1051
+ }
1052
+ byId.set(key, {
1053
+ id: current.id,
1054
+ name: current.name || fallback.name,
1055
+ contextWindow: current.contextWindow > 0 ? current.contextWindow : fallback.contextWindow,
1056
+ maxTokens: current.maxTokens > 0 ? current.maxTokens : fallback.maxTokens,
1057
+ supportsImages: current.supportsImages || fallback.supportsImages,
1058
+ ...current.benefit === true ? { benefit: true } : {}
1059
+ });
1060
+ }
1061
+ return [...byId.values()].sort((a, b) => {
1062
+ const la = a.id.toLowerCase();
1063
+ const lb = b.id.toLowerCase();
1064
+ return la === lb ? a.id < b.id ? -1 : 1 : la < lb ? -1 : 1;
1065
+ });
1066
+ }
1067
+ //#endregion
1068
+ //#region src/signer.ts
1069
+ /**
1070
+ * 华为云 SDK-HMAC-SHA256 请求签名(AK/SK + 可选 STS security token)。
1071
+ * 翻译自 codearts2api 的 internal/upstream/signer.go,对齐华为云 AKSKSigner。
1072
+ *
1073
+ * 在 CodeArts 桌面端实测:聊天请求必须用 AK/SK 签名,光传 x-auth-token 会被
1074
+ * APIG 拒(APIG.0301 decrypt token fail)。
1075
+ *
1076
+ * @module dsh-codearts/signer
1077
+ */
1078
+ const ALGO = "SDK-HMAC-SHA256";
1079
+ function sha256Hex(buf) {
1080
+ return createHash("sha256").update(buf).digest("hex");
1081
+ }
1082
+ function hmacHex(key, msg) {
1083
+ return createHmac("sha256", key).update(msg).digest("hex");
1084
+ }
1085
+ /** 华为云 X-Sdk-Date 格式:20060102T150405Z(UTC,无分隔符、无毫秒)。 */
1086
+ function sdkDate(d) {
1087
+ const p = (n, w = 2) => String(n).padStart(w, "0");
1088
+ return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
1089
+ }
1090
+ function canonicalURI(path) {
1091
+ let out = path.split("/").map((s) => encodeURIComponent(s)).join("/");
1092
+ if (!out.endsWith("/")) out += "/";
1093
+ return out;
1094
+ }
1095
+ function canonicalQuery(raw) {
1096
+ if (!raw) return "";
1097
+ const params = new URLSearchParams(raw);
1098
+ const keys = [...new Set(params.keys())].sort();
1099
+ const parts = [];
1100
+ for (const k of keys) {
1101
+ const vals = params.getAll(k).sort();
1102
+ for (const v of vals) parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
1103
+ }
1104
+ return parts.join("&");
1105
+ }
1106
+ /**
1107
+ * 给一个 fetch Request 加上 X-Sdk-Date / X-Security-Token / X-Sdk-Content-Sha256
1108
+ * / Authorization 头。body 为请求体(用于 payload hash)。
1109
+ *
1110
+ * 注意:Host 头 fetch 不允许手动 set,但签名必须包含 host,所以我们在签名计算
1111
+ * 里从 URL 取 host 补一个虚拟 host 行,Authorization 写回即可(fetch 实际发出的
1112
+ * Host 与 URL.host 一致)。
1113
+ */
1114
+ function signRequest(req, body, cred) {
1115
+ const xDate = sdkDate(/* @__PURE__ */ new Date());
1116
+ req.headers.set("X-Sdk-Date", xDate);
1117
+ if (cred.securityToken) req.headers.set("X-Security-Token", cred.securityToken);
1118
+ const payloadHash = sha256Hex(body);
1119
+ req.headers.set("X-Sdk-Content-Sha256", payloadHash);
1120
+ const url = new URL(req.url);
1121
+ const host = url.host;
1122
+ const all = [];
1123
+ req.headers.forEach((v, k) => all.push([k.toLowerCase(), v.trim()]));
1124
+ all.push(["host", host]);
1125
+ all.sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
1126
+ let canonicalHeaders = "";
1127
+ const signed = [];
1128
+ for (const [k, v] of all) {
1129
+ canonicalHeaders += `${k}:${v}\n`;
1130
+ signed.push(k);
1131
+ }
1132
+ const signedHeaders = signed.join(";");
1133
+ const canonicalRequest = [
1134
+ req.method,
1135
+ canonicalURI(url.pathname),
1136
+ canonicalQuery(url.search.slice(1)),
1137
+ canonicalHeaders,
1138
+ signedHeaders,
1139
+ payloadHash
1140
+ ].join("\n");
1141
+ const stringToSign = [
1142
+ ALGO,
1143
+ xDate,
1144
+ sha256Hex(canonicalRequest)
1145
+ ].join("\n");
1146
+ const signature = hmacHex(cred.secretAccessKey, stringToSign);
1147
+ req.headers.set("Authorization", `${ALGO} Access=${cred.accessKeyId}, SignedHeaders=${signedHeaders}, Signature=${signature}`);
1148
+ }
1149
+ //#endregion
1150
+ //#region src/upstream.ts
1151
+ /**
1152
+ * CodeArts 真实聊天协议(翻译自 codearts2api 逆向,consumer 版 cn-north-4)。
1153
+ *
1154
+ * 聊天走 OpenAI 兼容端点:
1155
+ * POST /api/v2/chat/completions (snap-access.cn-north-4.myhuaweicloud.com)
1156
+ * 鉴权为 x-auth-token = STS security_token,并叠加华为云 AK/SK SDK-HMAC-SHA256
1157
+ * 签名(与官方 AgentKernel 一致)。请求体是标准 OpenAI 形状(role/content),
1158
+ * 响应 SSE 也是 OpenAI 增量(choices[].delta.content),但含有华为特有事件
1159
+ * (累计全文快照 + 结束态 error_code),本模块做兼容转换。
1160
+ *
1161
+ * @module dsh-codearts/upstream
1162
+ */
1163
+ var upstream_exports = /* @__PURE__ */ __exportAll({
1164
+ CONCURRENCY_CAP_CODE: () => CONCURRENCY_CAP_CODE,
1165
+ MAX_TOKENS_CEILING: () => MAX_TOKENS_CEILING,
1166
+ PARAM_INVALID_CODE: () => PARAM_INVALID_CODE,
1167
+ SNAP_HOST: () => SNAP_HOST,
1168
+ canonicalModel: () => canonicalModel,
1169
+ chatStream: () => chatStream,
1170
+ claimBenefit: () => claimBenefit,
1171
+ degradeTokenCeiling: () => degradeTokenCeiling,
1172
+ estimateTokens: () => estimateTokens,
1173
+ fetchCallerIdentity: () => fetchCallerIdentity,
1174
+ fetchModels: () => fetchModels,
1175
+ isConcurrencyCap: () => isConcurrencyCap,
1176
+ isParamInvalidFrame: () => isParamInvalidFrame,
1177
+ maxTokensCeilingFor: () => maxTokensCeilingFor,
1178
+ prepareChatBody: () => prepareChatBody,
1179
+ signedGet: () => signedGet
1180
+ });
1181
+ const SNAP_HOST = "https://snap-access.cn-north-4.myhuaweicloud.com";
1182
+ const EP_CHAT = "/api/v2/chat/completions";
1183
+ const BENEFIT_HOST = "https://opengw.developer.huaweicloud.com";
1184
+ const EP_BENEFIT_CLAIM = "/api/v1/benefit/claim";
1185
+ const EP_MODEL_BUILTIN = "/v1/model/builtin";
1186
+ const EP_BENEFIT_CONFIG = "/api/v1/gateway/config";
1187
+ const EP_AGENT_LIST = "/v1/agent-center/agents/useragents";
1188
+ const EP_AGENT_DETAIL = "/v1/agent-center/agents/detail";
1189
+ /**
1190
+ * 当前登录身份。**必须带 `/snap-manager` 前缀**:2026-09-30 实测
1191
+ * `/v1/current/user` 是 APIG.0101(未发布),`/snap-manager/v1/current/user`
1192
+ * 才返回 `{user_id, user_name, domain_id}`。codearts2api 的 const.go 里少了这段前缀
1193
+ * (它另有 STS 的 `/v5/caller-identity`,但那条对 STS 临时凭证返回 401)。
1194
+ */
1195
+ const EP_CURRENT_USER = "/snap-manager/v1/current/user";
1196
+ const STATIC_MODEL_MAP = {
1197
+ "snap-chat": "GLM-5.2",
1198
+ "glm-5.2": "GLM-5.2",
1199
+ "glm-5.1": "GLM-5.1",
1200
+ "glm-4.7": "GLM-4.7",
1201
+ "openpangu-2.0-pro": "OpenPangu-2.0-Pro",
1202
+ "openpangu-2.0-flash": "OpenPangu-2.0-Flash",
1203
+ "qwen3-vl-235b": "Qwen3-VL-235B",
1204
+ "qwen3.5-397b-a17b-vl": "Qwen3.5-397B-A17B-VL",
1205
+ "qwen3.6-27b-vl": "Qwen3.6-27B-VL",
1206
+ "deepseek-v4-flash": "deepseek-v4-flash-0731",
1207
+ "deepseek-v4-pro": "deepseek-v4-pro-0813",
1208
+ "glm-5.3-flash": "glm-5.3-flash"
1209
+ };
1210
+ function canonicalModel(id) {
1211
+ return STATIC_MODEL_MAP[id.toLowerCase()] ?? id;
1212
+ }
1213
+ function hex32() {
1214
+ return randomBytes(16).toString("hex");
1215
+ }
1216
+ function randomTraceId() {
1217
+ return hex32();
1218
+ }
1219
+ /** 上游对 max_tokens 的服务端硬上限(2026-09-30 直连二分实测)。 */
1220
+ const MAX_TOKENS_CEILING = 65536;
1221
+ /** token 估算的余量:混排误差 + 上游自己注入的会话头,宁可少给也别整轮失败。 */
1222
+ const PROMPT_ESTIMATE_MARGIN = 2048;
1223
+ /** 短于此的生成长度就没有实际意义了,此时直接报具体错误而不是硬挤。 */
1224
+ const MIN_USEFUL_OUTPUT_TOKENS = 1024;
1225
+ /**
1226
+ * 实测过的、**按模型**的 max_tokens 上限(小写 id → 上限)。
1227
+ *
1228
+ * 这个上限是模型相关的,不能用一个全局值:
1229
+ * - 免费(限时福利)DeepSeek 系列:> 65536 会被流内 InferHub.001001005.400
1230
+ * "The request param is invalid" 拒掉,而福利网关标称的是 393216 / 384000;
1231
+ * - GLM-5.2:2026-09-30 逐值直连验证过 8000/65536/131072 均正常,但
1232
+ * **2026-10-10 复测 131072 已经被拒**(同一个 InferHub.001001005.400),
1233
+ * 65536 仍然正常。也就是说这条上限由服务端控制、会漂,目录里留着的旧实测值
1234
+ * 不会报错,只会让该模型的每一轮在流的第一帧就失败 —— 所以复测要按日期记。
1235
+ *
1236
+ * 没测过的模型一律退回 `MAX_TOKENS_CEILING`(保守但可用),而不是照抄标称值 ——
1237
+ * DSH 会把 catalog 的 maxTokens 原样当 max_tokens 发出去,照抄标称值等于让请求失败。
1238
+ * 新增实测结果时往这里加一行即可(scripts/param-probe.mjs 与
1239
+ * scripts/probe-models.ts 是得到这些数字的工具)。
1240
+ */
1241
+ const MEASURED_MAX_TOKENS = {
1242
+ "deepseek-v4-flash-0731": 65536,
1243
+ "deepseek-v4-pro-0813": 65536,
1244
+ "deepseek-v4.1-flash": 65536,
1245
+ "glm-5.3-flash": 65536,
1246
+ "glm-5.2": 65536
1247
+ };
1248
+ /** 该模型可用的 max_tokens 上限(实测值与会话内学到的更低值取小)。 */
1249
+ function maxTokensCeilingFor(id) {
1250
+ const measured = measuredCeilingFor(id);
1251
+ const learned = learnedMaxTokens.get(ceilingKey(id));
1252
+ return learned === void 0 ? measured : Math.min(measured, learned);
1253
+ }
1254
+ /**
1255
+ * 上游的「参数非法」错误码。它是**流内**帧(HTTP 仍是 200),所以既不走
1256
+ * classify(),也不会告诉我们是哪个参数错、更不区分「max_tokens 太大」和
1257
+ * 「prompt 装不下」—— 一切得我们自己判。
1258
+ */
1259
+ const PARAM_INVALID_CODE = "InferHub.001001005.400";
1260
+ /** 一帧(chatStream 已把上游 error_code 转成 OpenAI error 帧)是不是那条「参数非法」。 */
1261
+ function isParamInvalidFrame(text) {
1262
+ return text.includes("InferHub.001001005.400") || text.includes("The request param is invalid");
1263
+ }
1264
+ /**
1265
+ * 本次进程内学到的上限(小写 id → 上限)。
1266
+ *
1267
+ * 上游会把「实测过」的值悄悄调低(GLM-5.2 的 131072 于 2026-10-10 被收),
1268
+ * 手工表必然滞后。所以撞到「参数非法」时就地折半降档重试,并把结果记在这里,
1269
+ * 免得该模型后面每一轮都先白撞一次。
1270
+ */
1271
+ const learnedMaxTokens = /* @__PURE__ */ new Map();
1272
+ /** 降档下限:再低就没有可用的生成长度了,此时宁可把具体错误报给用户。 */
1273
+ const MAX_TOKENS_FLOOR = 8192;
1274
+ function ceilingKey(id) {
1275
+ return id.trim().toLowerCase();
1276
+ }
1277
+ function measuredCeilingFor(id) {
1278
+ return MEASURED_MAX_TOKENS[ceilingKey(id)] ?? 65536;
1279
+ }
1280
+ /**
1281
+ * 把该模型的上限折半降一档(收到「参数非法」后重发之前调用)。
1282
+ * @returns 新的上限;已经降到底则返回 0,表示别再试了。
1283
+ */
1284
+ function degradeTokenCeiling(id) {
1285
+ const next = Math.floor(maxTokensCeilingFor(id) / 2);
1286
+ if (next < MAX_TOKENS_FLOOR) return 0;
1287
+ learnedMaxTokens.set(ceilingKey(id), next);
1288
+ return next;
1289
+ }
1290
+ /**
1291
+ * 粗估一段文本占多少 token:CJK 每字约 1,其余每 4 字符约 1。
1292
+ *
1293
+ * 只要保守就够用 —— 它的用途是「在我们这边就把装不下的情况判掉」,
1294
+ * 而不是精确计费(上游不会告诉我们超没超,只会回一句参数非法)。
1295
+ */
1296
+ function estimateTokens(text) {
1297
+ let cjk = 0;
1298
+ for (let i = 0; i < text.length; i += 1) {
1299
+ const code = text.charCodeAt(i);
1300
+ if (code >= 12352 && code <= 40959 || code >= 44032 && code <= 64511 || code >= 65280 && code <= 65376) cjk += 1;
1301
+ }
1302
+ return Math.ceil(cjk + (text.length - cjk) / 4);
1303
+ }
1304
+ /** 上游要求 chat_id 为 32 位小写十六进制。 */
1305
+ const CHAT_ID_RE = /^[0-9a-f]{32}$/;
1306
+ /** 稳定地把消息内容序列化成锚点文本(字符串原样,多模态数组走 JSON)。 */
1307
+ function canonicalContent(content) {
1308
+ if (typeof content === "string") return content;
1309
+ if (content === void 0 || content === null) return "";
1310
+ try {
1311
+ return JSON.stringify(content);
1312
+ } catch {
1313
+ return String(content);
1314
+ }
1315
+ }
1316
+ /**
1317
+ * 为一次对话派生稳定的 chat_id。
1318
+ *
1319
+ * 上游按 chat_id 计会话,而并发会话数有上限且回收很慢。若每个请求都新开一个
1320
+ * chat_id,多轮对话几轮之后就会撞上「并发会话数已达上限」——历史在增长,但
1321
+ * **首条 user 消息始终不变**,所以拿它当会话锚点:同一次对话的每一轮共用同一个
1322
+ * chat_id,只占一个会话。
1323
+ *
1324
+ * 客户端若显式给了 conversation_id(32 位 hex)则优先用它。
1325
+ */
1326
+ function conversationChatId(parsed) {
1327
+ const explicit = typeof parsed.conversation_id === "string" ? parsed.conversation_id.trim().toLowerCase() : "";
1328
+ if (CHAT_ID_RE.test(explicit)) return explicit;
1329
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
1330
+ for (const raw of messages) {
1331
+ const message = raw;
1332
+ if (message?.role !== "user") continue;
1333
+ let anchor = `${message.role}\u0000${canonicalContent(message.content)}`;
1334
+ if (Array.isArray(parsed.tools) && parsed.tools.length > 0) anchor += "\0tools";
1335
+ return createHash("sha256").update(anchor).digest("hex").slice(0, 32);
1336
+ }
1337
+ return hex32();
1338
+ }
1339
+ /**
1340
+ * 由 chat_id 派生稳定的会话标识,作为 `Session-Id` 头发给上游。
1341
+ * 与参考实现 codearts2api 的 sessionIDFor 一致(sha256 前 16 字节)。
1342
+ */
1343
+ function sessionIdFor(chatId) {
1344
+ return createHash("sha256").update(`codearts-session:${chatId}`).digest("hex").slice(0, 32);
1345
+ }
1346
+ /**
1347
+ * 把 OpenAI 风格 chat body 适配给 CodeArts /api/v2/chat/completions。
1348
+ * 该端点虽是 OpenAI 兼容,但网关按自己的 schema 严格校验参数。所以这里
1349
+ * 不做原样透传,而是:
1350
+ * 1. model 走 canonicalModel(大小写敏感)
1351
+ * 2. max_completion_tokens → max_tokens(华为只认后者),并钳到该模型的上限;
1352
+ * 再按 contextWindow 扣掉 prompt 估算,保证 prompt + max_tokens 装得下
1353
+ * (两者任一超限,上游都只会流内回 InferHub.001001005.400
1354
+ * "The request param is invalid, Please check it",不告诉我们是哪一个)
1355
+ * 3. 丢弃非白名单字段(stream_options / store / logprobs 等虽实测可容忍,
1356
+ * 但按 codearts2api 观察到的真实流量收窄,避免网关后续收紧)
1357
+ * 4. tools[].function 去掉 strict(OpenAI 专有)
1358
+ * 5. 注入 CodeArts 特有字段:chat_id(按对话派生,见 conversationChatId)、
1359
+ * prompt_cache_key、tool_stream
1360
+ *
1361
+ * @param contextWindow 该模型的上下文窗口(来自 catalog)。传 0 表示不知道,
1362
+ * 此时只做上限钳制、不做「装不下」判断。
1363
+ */
1364
+ function prepareChatBody(rawJson, contextWindow = 0) {
1365
+ let parsed = {};
1366
+ try {
1367
+ parsed = JSON.parse(rawJson);
1368
+ } catch {
1369
+ return {
1370
+ url: `${SNAP_HOST}${EP_CHAT}`,
1371
+ body: rawJson,
1372
+ model: "",
1373
+ chatId: ""
1374
+ };
1375
+ }
1376
+ const model = typeof parsed.model === "string" ? canonicalModel(parsed.model) : "";
1377
+ const chatId = conversationChatId(parsed);
1378
+ const body = {};
1379
+ for (const key of [
1380
+ "messages",
1381
+ "temperature",
1382
+ "top_p",
1383
+ "max_tokens",
1384
+ "stop",
1385
+ "presence_penalty",
1386
+ "frequency_penalty",
1387
+ "tools",
1388
+ "tool_choice",
1389
+ "user",
1390
+ "stream_options"
1391
+ ]) if (parsed[key] !== void 0) body[key] = parsed[key];
1392
+ if (body.stream_options === void 0 || typeof body.stream_options !== "object") body.stream_options = { include_usage: true };
1393
+ else body.stream_options.include_usage = true;
1394
+ if (body.max_tokens === void 0 && typeof parsed.max_completion_tokens === "number") body.max_tokens = parsed.max_completion_tokens;
1395
+ const ceiling = maxTokensCeilingFor(model);
1396
+ if (typeof body.max_tokens === "number") {
1397
+ let maxTokens = Math.min(body.max_tokens, ceiling);
1398
+ if (contextWindow > 0) {
1399
+ const promptTokens = estimateTokens(`${JSON.stringify(body.messages ?? "")}${JSON.stringify(body.tools ?? "")}`);
1400
+ const room = contextWindow - promptTokens - PROMPT_ESTIMATE_MARGIN;
1401
+ if (room < MIN_USEFUL_OUTPUT_TOKENS) {
1402
+ body.max_tokens = maxTokens;
1403
+ return {
1404
+ url: `${SNAP_HOST}${EP_CHAT}`,
1405
+ body: JSON.stringify(body),
1406
+ model,
1407
+ chatId,
1408
+ error: `上下文放不下:${model} 的窗口是 ${contextWindow} tokens,本次请求(含 ${Array.isArray(body.messages) ? body.messages.length : 0} 条消息与工具定义)估算约 ${promptTokens} tokens,剩余不足 ${MIN_USEFUL_OUTPUT_TOKENS} tokens 的生成余量。请新建会话或改用上下文更大的模型。`
1409
+ };
1410
+ }
1411
+ maxTokens = Math.min(maxTokens, room);
1412
+ }
1413
+ body.max_tokens = maxTokens;
1414
+ }
1415
+ if (Array.isArray(body.tools)) body.tools = body.tools.map((tool) => {
1416
+ if (tool && typeof tool === "object" && tool.function && typeof tool.function === "object") {
1417
+ const fn = { ...tool.function };
1418
+ delete fn.strict;
1419
+ return {
1420
+ ...tool,
1421
+ function: fn
1422
+ };
1423
+ }
1424
+ return tool;
1425
+ });
1426
+ body.model = model;
1427
+ body.stream = true;
1428
+ body.chat_id = chatId;
1429
+ body.prompt_cache_key = chatId;
1430
+ body.tool_stream = true;
1431
+ return {
1432
+ url: `${SNAP_HOST}${EP_CHAT}`,
1433
+ body: JSON.stringify(body),
1434
+ model,
1435
+ chatId
1436
+ };
1437
+ }
1438
+ /**
1439
+ * 免费档的「并发会话数已达上限」。实测 HTTP 400 + 这个错误码,上限是 3 个会话,
1440
+ * 而且被掐断/挂起的流要约 2 分钟才被网关回收。它不是请求错误,重发就有机会,
1441
+ * 所以 shim 要在本地排队重等,而不是把它变成 pi-ai 的重试风暴。
1442
+ */
1443
+ const CONCURRENCY_CAP_CODE = "TM.00001041";
1444
+ function isConcurrencyCap(result) {
1445
+ return !result.ok && result.message.includes("TM.00001041");
1446
+ }
1447
+ function classify(status, message) {
1448
+ const m = message.toLowerCase();
1449
+ if (status === 401 || status === 403) return "session_dead";
1450
+ if (status === 402) return "hard_credit";
1451
+ if (status === 429) return "soft_rate";
1452
+ if (status === 404) return "not_found";
1453
+ if (status >= 500) return "server";
1454
+ if (m.includes("not registered") || m.includes("benefit not found")) return "not_found";
1455
+ if (m.includes("tpm") || m.includes("并发")) return "soft_rate";
1456
+ return "client";
1457
+ }
1458
+ const enc = new TextEncoder();
1459
+ function openAIData(obj) {
1460
+ return enc.encode(`data: ${JSON.stringify(obj)}\n\n`);
1461
+ }
1462
+ /** OpenAI 流的终止帧是裸 `data: [DONE]`,不能走 JSON.stringify(否则会带引号)。 */
1463
+ function openAIDone() {
1464
+ return enc.encode("data: [DONE]\n\n");
1465
+ }
1466
+ /**
1467
+ * 把上游累计快照 SSE 转换成 OpenAI 增量流。
1468
+ *
1469
+ * 上游行为(reverse-engineering.md §5):
1470
+ * - {"type":"answer", ...} 起始
1471
+ * - {"text": "<累计全文>"} 全文快照(用替换语义)
1472
+ * - {"delta":{"content":"..."}} 真增量
1473
+ * - {"text":"[DONE]","error_code":"0"} 结束;error_code != 0 为错误
1474
+ */
1475
+ async function chatStream(credential, prepared, signal) {
1476
+ const traceId = randomTraceId();
1477
+ const bodyBuf = Buffer.from(prepared.body);
1478
+ const benefit = isBenefitModel(prepared.model);
1479
+ const req = new Request(prepared.url, {
1480
+ method: "POST",
1481
+ headers: {
1482
+ "Content-Type": "application/json",
1483
+ Accept: "text/event-stream",
1484
+ "x-auth-token": credential.token,
1485
+ "X-Security-Token": credential.securityToken,
1486
+ "x-snap-traceid": traceId,
1487
+ "X-Language": "zh-cn",
1488
+ "app-id": "CodeAgent3.0",
1489
+ "is_confidential": "false",
1490
+ ...benefit ? { "maas_type": "benefit" } : {}
1491
+ },
1492
+ body: bodyBuf
1493
+ });
1494
+ if (credential.accessKeyId && credential.secretAccessKey) signRequest(req, bodyBuf, {
1495
+ accessKeyId: credential.accessKeyId,
1496
+ secretAccessKey: credential.secretAccessKey,
1497
+ securityToken: credential.securityToken
1498
+ });
1499
+ if (prepared.chatId) {
1500
+ req.headers.set("Chat-Id", prepared.chatId);
1501
+ req.headers.set("Session-Id", sessionIdFor(prepared.chatId));
1502
+ }
1503
+ let resp;
1504
+ try {
1505
+ resp = await fetch(req, { signal });
1506
+ } catch (err) {
1507
+ const message = err instanceof Error ? err.message : String(err);
1508
+ if (signal?.aborted === true) return {
1509
+ ok: false,
1510
+ kind: "client",
1511
+ status: 0,
1512
+ message: "aborted by client"
1513
+ };
1514
+ return {
1515
+ ok: false,
1516
+ kind: "server",
1517
+ status: 0,
1518
+ message
1519
+ };
1520
+ }
1521
+ if (!resp.ok) {
1522
+ const text = await resp.text().catch(() => "");
1523
+ return {
1524
+ ok: false,
1525
+ kind: classify(resp.status, text),
1526
+ status: resp.status,
1527
+ message: text.slice(0, 400)
1528
+ };
1529
+ }
1530
+ const model = prepared.model;
1531
+ const upstream = resp.body;
1532
+ if (!upstream) return {
1533
+ ok: false,
1534
+ kind: "server",
1535
+ status: 0,
1536
+ message: "dsh-codearts: 上游无响应体"
1537
+ };
1538
+ const reader = upstream.getReader();
1539
+ const decoder = new TextDecoder();
1540
+ let lastText = "";
1541
+ let sentAny = false;
1542
+ let doneSent = false;
1543
+ let sseBuf = "";
1544
+ return {
1545
+ ok: true,
1546
+ stream: new ReadableStream({
1547
+ async pull(controller) {
1548
+ try {
1549
+ for (;;) {
1550
+ const { done, value } = await reader.read();
1551
+ if (done) {
1552
+ if (sentAny && !doneSent) controller.enqueue(openAIDone());
1553
+ controller.close();
1554
+ return;
1555
+ }
1556
+ sseBuf += decoder.decode(value, { stream: true });
1557
+ const lines = sseBuf.split("\n");
1558
+ sseBuf = lines.pop() ?? "";
1559
+ let emitted = false;
1560
+ for (const raw of lines) {
1561
+ const trimmed = raw.trim();
1562
+ if (!trimmed.startsWith("data:")) continue;
1563
+ const payload = trimmed.slice(5).trim();
1564
+ if (payload === "[DONE]") {
1565
+ sentAny = true;
1566
+ if (!doneSent) {
1567
+ doneSent = true;
1568
+ controller.enqueue(openAIDone());
1569
+ emitted = true;
1570
+ }
1571
+ continue;
1572
+ }
1573
+ if (!payload) continue;
1574
+ let ev;
1575
+ try {
1576
+ ev = JSON.parse(payload);
1577
+ } catch {
1578
+ continue;
1579
+ }
1580
+ if (ev.error_code && ev.error_code !== "0" && ev.error_code !== 0) {
1581
+ controller.enqueue(openAIData({
1582
+ choices: [{
1583
+ delta: { content: "" },
1584
+ finish_reason: "stop"
1585
+ }],
1586
+ error: {
1587
+ code: ev.error_code,
1588
+ message: ev.error_msg ?? "upstream error"
1589
+ }
1590
+ }));
1591
+ controller.enqueue(openAIDone());
1592
+ controller.close();
1593
+ return;
1594
+ }
1595
+ if (Array.isArray(ev.choices)) {
1596
+ sentAny = true;
1597
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(ev)}\n\n`));
1598
+ emitted = true;
1599
+ continue;
1600
+ }
1601
+ if (ev.delta?.content) {
1602
+ sentAny = true;
1603
+ controller.enqueue(openAIData({
1604
+ model,
1605
+ choices: [{
1606
+ delta: { content: ev.delta.content },
1607
+ finish_reason: null
1608
+ }]
1609
+ }));
1610
+ emitted = true;
1611
+ }
1612
+ if (typeof ev.text === "string" && ev.text !== "[DONE]") {
1613
+ const cur = ev.text;
1614
+ if (cur.length > lastText.length && cur.startsWith(lastText)) {
1615
+ const delta = cur.slice(lastText.length);
1616
+ sentAny = true;
1617
+ controller.enqueue(openAIData({
1618
+ model,
1619
+ choices: [{
1620
+ delta: { content: delta },
1621
+ finish_reason: null
1622
+ }]
1623
+ }));
1624
+ emitted = true;
1625
+ }
1626
+ lastText = cur;
1627
+ }
1628
+ }
1629
+ if (emitted) return;
1630
+ }
1631
+ } catch (err) {
1632
+ const message = err instanceof Error ? err.message : String(err);
1633
+ controller.enqueue(openAIData({ error: { message } }));
1634
+ controller.close();
1635
+ }
1636
+ },
1637
+ cancel() {
1638
+ reader.cancel().catch(() => {});
1639
+ }
1640
+ })
1641
+ };
1642
+ }
1643
+ /**
1644
+ * 查当前凭证对应的账号身份。
1645
+ *
1646
+ * 为什么需要它:登录响应里 `user_id` / `user_name` 都是空串(实测),所以插件原先
1647
+ * 只能拿 DPoP 公钥当账号标识 —— 代价是**对同一个账号重新登录会被当成新账号**。
1648
+ * 这个端点给出上游的稳定 `user_id`,重新登录就能命中同一条账号记录。
1649
+ *
1650
+ * 失败不应阻断登录:调用方按「拿不到身份就退回旧行为」处理。
1651
+ */
1652
+ async function fetchCallerIdentity(credential, signal) {
1653
+ const resp = await signedGet(`${SNAP_HOST}${EP_CURRENT_USER}`, credential, void 0, signal);
1654
+ if (!resp.ok) throw new Error(`identity HTTP ${resp.status} ${resp.message}`);
1655
+ const body = asRecord(parseJson(resp.text));
1656
+ const userId = firstString(body.user_id);
1657
+ if (userId === "") throw new Error("identity response carries no user_id");
1658
+ return {
1659
+ userId,
1660
+ userName: firstString(body.user_name),
1661
+ domainId: firstString(body.domain_id)
1662
+ };
1663
+ }
1664
+ /** 发现不到 contextWindow 时的保守默认值:只影响 DSH 的上下文计量条与截断余量。 */
1665
+ const UNKNOWN_CONTEXT_WINDOW = 32768;
1666
+ /** 发现不到 maxTokens 时的默认值(低于服务端上限,保证请求能过)。 */
1667
+ const UNKNOWN_MAX_TOKENS = 8e3;
1668
+ /** 带 AK/SK 签名的 GET;body 为空(签名按空载荷算)。 */
1669
+ async function signedGet(url, credential, agentType, signal) {
1670
+ const req = new Request(url, {
1671
+ method: "GET",
1672
+ headers: {
1673
+ "Content-Type": "application/json",
1674
+ "X-Language": "zh-cn",
1675
+ ...agentType === void 0 ? {} : { "Agent-Type": agentType },
1676
+ "X-Security-Token": credential.securityToken
1677
+ }
1678
+ });
1679
+ if (credential.accessKeyId && credential.secretAccessKey) signRequest(req, Buffer.from(""), {
1680
+ accessKeyId: credential.accessKeyId,
1681
+ secretAccessKey: credential.secretAccessKey,
1682
+ securityToken: credential.securityToken
1683
+ });
1684
+ let resp;
1685
+ try {
1686
+ resp = await fetch(req, { signal });
1687
+ } catch (error) {
1688
+ return {
1689
+ ok: false,
1690
+ status: 0,
1691
+ message: error instanceof Error ? error.message : String(error)
1692
+ };
1693
+ }
1694
+ const text = await resp.text().catch(() => "");
1695
+ if (!resp.ok) return {
1696
+ ok: false,
1697
+ status: resp.status,
1698
+ message: text.slice(0, 300)
1699
+ };
1700
+ return {
1701
+ ok: true,
1702
+ text
1703
+ };
1704
+ }
1705
+ function parseJson(text) {
1706
+ try {
1707
+ return JSON.parse(text);
1708
+ } catch {
1709
+ return;
1710
+ }
1711
+ }
1712
+ function positive(value) {
1713
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1714
+ }
1715
+ function firstString(...values) {
1716
+ for (const value of values) if (typeof value === "string" && value.trim() !== "") return value.trim();
1717
+ return "";
1718
+ }
1719
+ function asRecord(value) {
1720
+ return typeof value === "object" && value !== null ? value : {};
1721
+ }
1722
+ function asArray(value) {
1723
+ return Array.isArray(value) ? value : [];
1724
+ }
1725
+ /**
1726
+ * 内置模型:`GET /v1/model/builtin`(`Agent-Type: PromptCenter`)。
1727
+ * 实测响应 `{ count, builtinModels: [{ model_id, model_name, model_desc,
1728
+ * enable, display_enabled, supports_images, ... }] }` —— 注意它**不带**
1729
+ * context_window / max_tokens,那两项要靠合并种子或福利来源补。
1730
+ */
1731
+ async function fetchBuiltinModels(credential, signal) {
1732
+ const resp = await signedGet(`${SNAP_HOST}${EP_MODEL_BUILTIN}`, credential, "PromptCenter", signal);
1733
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.message}`);
1734
+ const body = asRecord(parseJson(resp.text));
1735
+ const out = [];
1736
+ for (const raw of asArray(body.builtinModels)) {
1737
+ const model = asRecord(raw);
1738
+ if (model.enable === false) continue;
1739
+ const id = firstString(model.model_id, model.model_name);
1740
+ if (id === "") continue;
1741
+ out.push({
1742
+ id,
1743
+ name: firstString(model.model_name, id),
1744
+ contextWindow: positive(model.context_window),
1745
+ maxTokens: positive(model.max_tokens),
1746
+ supportsImages: model.supports_images === true
1747
+ });
1748
+ }
1749
+ return out;
1750
+ }
1751
+ /**
1752
+ * 限时福利模型:`GET {opengw}/api/v1/gateway/config`。
1753
+ * 实测响应 `{ error_code: '0000', result: { base_url, models: [{ model_id,
1754
+ * model_name, context_window, max_tokens, sort }] } }`,整份标记福利。
1755
+ */
1756
+ async function fetchBenefitModels(credential, signal) {
1757
+ const resp = await signedGet(`${BENEFIT_HOST}${EP_BENEFIT_CONFIG}`, credential, void 0, signal);
1758
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.message}`);
1759
+ const body = asRecord(parseJson(resp.text));
1760
+ const code = firstString(body.error_code);
1761
+ if (code !== "" && code !== "0000") throw new Error(`gateway/config error_code=${code} ${firstString(body.error_msg)}`);
1762
+ const out = [];
1763
+ for (const raw of asArray(asRecord(body.result).models)) {
1764
+ const model = asRecord(raw);
1765
+ const id = firstString(model.model_id, model.model_name);
1766
+ if (id === "") continue;
1767
+ out.push({
1768
+ id,
1769
+ name: firstString(model.model_name, id),
1770
+ contextWindow: positive(model.context_window),
1771
+ maxTokens: positive(model.max_tokens),
1772
+ supportsImages: model.supports_images === true,
1773
+ benefit: true
1774
+ });
1775
+ }
1776
+ return out;
1777
+ }
1778
+ /**
1779
+ * agent-center 的模型清单:先 `useragents` 找一个 CodeAgent,再取 detail 的
1780
+ * `gpts.models`。过滤条件与官方客户端一致(`agent_name === 'CodeAgent'` 且
1781
+ * IDE 可见),退化为 primary,再退化为第一个。
1782
+ */
1783
+ async function fetchAgentModels(credential, signal) {
1784
+ const listResp = await signedGet(`${SNAP_HOST}${EP_AGENT_LIST}?offset=0&limit=100&is_primary_agent=true`, credential, "AgentCenter", signal);
1785
+ if (!listResp.ok) throw new Error(`agent list HTTP ${listResp.status} ${listResp.message}`);
1786
+ const agents = asArray(asRecord(parseJson(listResp.text)).agents).map(asRecord);
1787
+ let agentId = "";
1788
+ for (const agent of agents) {
1789
+ const alias = asRecord(agent.alias);
1790
+ if (firstString(agent.agent_name) === "CodeAgent" && firstString(alias.alias_zh_cn) === "智能体" && agent.show_in_ide === true) {
1791
+ agentId = firstString(agent.agent_id);
1792
+ if (agentId !== "") break;
1793
+ }
1794
+ }
1795
+ if (agentId === "") {
1796
+ for (const agent of agents) if (agent.is_primary_agent === true) {
1797
+ agentId = firstString(agent.agent_id);
1798
+ if (agentId !== "") break;
1799
+ }
1800
+ }
1801
+ if (agentId === "" && agents.length > 0) agentId = firstString(agents[0]?.agent_id);
1802
+ if (agentId === "") throw new Error("no agent found to read gpts.models from");
1803
+ const detailResp = await signedGet(`${SNAP_HOST}${EP_AGENT_DETAIL}?agent_id=${encodeURIComponent(agentId)}`, credential, "AgentCenter", signal);
1804
+ if (!detailResp.ok) throw new Error(`agent detail HTTP ${detailResp.status} ${detailResp.message}`);
1805
+ const gpts = asRecord(asRecord(parseJson(detailResp.text)).gpts);
1806
+ const out = [];
1807
+ for (const raw of asArray(gpts.models)) {
1808
+ const model = asRecord(raw);
1809
+ const params = asRecord(model.model_parameters);
1810
+ const id = firstString(params.model_id, model.model_id, model.model_alias, model.model_name);
1811
+ if (id === "") continue;
1812
+ out.push({
1813
+ id,
1814
+ name: firstString(model.model_name, model.model_alias, id),
1815
+ contextWindow: positive(params.context_window),
1816
+ maxTokens: positive(params.max_tokens),
1817
+ supportsImages: model.supports_images === true
1818
+ });
1819
+ }
1820
+ return out;
1821
+ }
1822
+ /**
1823
+ * 动态发现当前账号可用的模型清单(三路合并)。
1824
+ *
1825
+ * 与 codearts2api 的 `Client.FetchModels` 等价:内置、限时福利、agent-center 三路
1826
+ * 各自独立,任一路失败只记进 `errors` 不阻断 —— 福利网关抖动时仍能拿到内置模型。
1827
+ *
1828
+ * 上标的两个「实测 vs 标称」处理(很重要,别照搬标称值):
1829
+ * - **maxTokens 钳到该模型的实测上限**(`MEASURED_MAX_TOKENS`)。上游
1830
+ * `gateway/config` 给福利模型标的是 393216 / 384000,而聊天端点实测超过 65536
1831
+ * 就流内报 InferHub.001001005.400;DSH 会把 catalog 的 maxTokens 原样当
1832
+ * max_tokens 发出去,照抄标称值等于让每个请求都失败。
1833
+ * - contextWindow 用发现值(只影响 DSH 的上下文计量与截断余量,不影响路由)。
1834
+ *
1835
+ * @throws 三路全失败,或合并结果为空。
1836
+ */
1837
+ async function fetchModels(credential, signal) {
1838
+ const settled = await Promise.all([
1839
+ fetchBuiltinModels(credential, signal).then((models) => ({
1840
+ source: "builtin",
1841
+ models
1842
+ }), (error) => ({
1843
+ source: "builtin",
1844
+ error
1845
+ })),
1846
+ fetchBenefitModels(credential, signal).then((models) => ({
1847
+ source: "benefit",
1848
+ models
1849
+ }), (error) => ({
1850
+ source: "benefit",
1851
+ error
1852
+ })),
1853
+ fetchAgentModels(credential, signal).then((models) => ({
1854
+ source: "agent",
1855
+ models
1856
+ }), (error) => ({
1857
+ source: "agent",
1858
+ error
1859
+ }))
1860
+ ]);
1861
+ const sources = [];
1862
+ const errors = [];
1863
+ const discovered = [];
1864
+ for (const item of settled) if ("error" in item) errors.push({
1865
+ source: item.source,
1866
+ message: item.error.message
1867
+ });
1868
+ else {
1869
+ sources.push(item.source);
1870
+ discovered.push(item.models);
1871
+ }
1872
+ if (sources.length === 0) throw new Error(`model discovery failed on every source: ${errors.map((e) => `${e.source}: ${e.message}`).join("; ")}`);
1873
+ const merged = fillFromSeed(mergeModelLists(discovered));
1874
+ if (merged.length === 0) throw new Error("model discovery returned an empty catalog");
1875
+ return {
1876
+ models: merged.map((model) => ({
1877
+ ...model,
1878
+ contextWindow: model.contextWindow > 0 ? model.contextWindow : UNKNOWN_CONTEXT_WINDOW,
1879
+ maxTokens: Math.min(model.maxTokens > 0 ? model.maxTokens : UNKNOWN_MAX_TOKENS, maxTokensCeilingFor(model.id))
1880
+ })),
1881
+ sources,
1882
+ errors
1883
+ };
1884
+ }
1885
+ /**
1886
+ * 领取限时福利(幂等:已领取返回成功)。官方客户端打开模型菜单即调用。
1887
+ * 不领取时福利模型调用必报 InferHub.4004.200 benefit not found。
1888
+ * 需要 AK/SK 签名(凭证要带 accessKeyId/secretAccessKey)。
1889
+ */
1890
+ async function claimBenefit(credential, abort) {
1891
+ if (!credential.accessKeyId || !credential.secretAccessKey) return;
1892
+ const url = `${BENEFIT_HOST}${EP_BENEFIT_CLAIM}`;
1893
+ const bodyBuf = Buffer.from("{}");
1894
+ const req = new Request(url, {
1895
+ method: "POST",
1896
+ headers: {
1897
+ "Content-Type": "application/json",
1898
+ "X-Language": "zh-cn",
1899
+ "X-Security-Token": credential.securityToken
1900
+ },
1901
+ body: bodyBuf
1902
+ });
1903
+ signRequest(req, bodyBuf, {
1904
+ accessKeyId: credential.accessKeyId,
1905
+ secretAccessKey: credential.secretAccessKey,
1906
+ securityToken: credential.securityToken
1907
+ });
1908
+ const resp = await fetch(req, { signal: abort });
1909
+ const raw = await resp.text().catch(() => "");
1910
+ if (!resp.ok) throw new Error(`claim benefit failed: ${resp.status} ${raw.slice(0, 200)}`);
1911
+ try {
1912
+ const json = JSON.parse(raw);
1913
+ if (json.error_code && json.error_code !== "0000") throw new Error(`claim benefit rejected: ${json.error_code} ${json.error_msg ?? ""}`);
1914
+ } catch (e) {
1915
+ if (e instanceof SyntaxError) return;
1916
+ throw e;
1917
+ }
1918
+ }
1919
+ //#endregion
1920
+ //#region src/discovery.ts
1921
+ /**
1922
+ * 动态模型发现:把「已登录账号」变成「当前可用模型清单」。
1923
+ *
1924
+ * 为什么要单独一层:模型目录是**按账号**授予的(限时福利尤其如此:A 账号有、
1925
+ * B 账号没有),而插件对外只暴露一份目录。所以这里逐账号发现、取并集,再把
1926
+ * 结果提交给全局目录与福利集合。
1927
+ *
1928
+ * 失败一律不抛出:拉不到就继续用现有目录(冷启动时是种子清单),把失败原因
1929
+ * 带回去给 CLI / 日志展示。真正「不可用」的信号是 0 个账号。
1930
+ *
1931
+ * @module dsh-codearts/discovery
1932
+ */
1933
+ /**
1934
+ * 逐账号发现并合并(不改全局状态)。
1935
+ *
1936
+ * @param store 账号文档;逐个 `resolve()` 以拿到已续期的签名材料
1937
+ * @param signal 取消信号(宿主关停时用)
1938
+ */
1939
+ async function discoverModels(store, signal) {
1940
+ const ids = await store.ids();
1941
+ const accounts = [];
1942
+ for (const id of ids) {
1943
+ if (signal?.aborted === true) break;
1944
+ const summary = (await store.list()).find((account) => account.id === id);
1945
+ const label = summary?.label ?? summary?.userName;
1946
+ const resolved = await store.resolve(id);
1947
+ if (resolved === void 0) {
1948
+ accounts.push({
1949
+ accountId: id,
1950
+ ...label === void 0 ? {} : { label },
1951
+ ok: false,
1952
+ models: [],
1953
+ sources: [],
1954
+ errors: [{
1955
+ source: "agent",
1956
+ message: "credential unavailable (sign in again)"
1957
+ }]
1958
+ });
1959
+ continue;
1960
+ }
1961
+ try {
1962
+ const result = await fetchModels(resolved, signal);
1963
+ accounts.push({
1964
+ accountId: id,
1965
+ ...label === void 0 ? {} : { label },
1966
+ ok: true,
1967
+ models: result.models,
1968
+ sources: result.sources,
1969
+ errors: result.errors
1970
+ });
1971
+ } catch (error) {
1972
+ accounts.push({
1973
+ accountId: id,
1974
+ ...label === void 0 ? {} : { label },
1975
+ ok: false,
1976
+ models: [],
1977
+ sources: [],
1978
+ errors: [{
1979
+ source: "agent",
1980
+ message: error instanceof Error ? error.message : String(error)
1981
+ }]
1982
+ });
1983
+ }
1984
+ }
1985
+ const ok = accounts.filter((account) => account.ok);
1986
+ return {
1987
+ models: mergeModelLists(ok.map((account) => account.models)),
1988
+ accounts,
1989
+ anySucceeded: ok.length > 0
1990
+ };
1991
+ }
1992
+ /**
1993
+ * 发现并**提交**到全局目录:目录内容 + 福利集合。
1994
+ *
1995
+ * @returns 提交后的目录;没有任何账号成功时返回 `undefined`(调用方保留原目录)
1996
+ */
1997
+ async function refreshCatalog(store, catalog, signal) {
1998
+ const outcome = await discoverModels(store, signal);
1999
+ if (!outcome.anySucceeded || outcome.models.length === 0) return outcome;
2000
+ catalog.set(outcome.models);
2001
+ setDiscoveredBenefitModels(outcome.models.filter((model) => model.benefit === true).map((model) => model.id));
2002
+ return outcome;
2003
+ }
2004
+ //#endregion
2005
+ //#region src/usage.ts
2006
+ /**
2007
+ * 用量账本:把聊天流里的 usage 帧累计成「今天用了多少」。
2008
+ *
2009
+ * 为什么还要自己记:账号总额度有积分接口了(见 credits.ts,2026-10 从官方 Agent
2010
+ * 客户端挖出并实测通过),但**本机经插件发出的请求**仍需自己累计 —— 上游不按
2011
+ * 「这台机器」维度给统计。能拿到的真实数据有三样:
2012
+ * 1. 每个请求的 token `usage`(上游在流末尾回一帧 `{choices:[],usage:{...}}`);
2013
+ * 2. 插件自己知道的账号会话槽位占用(见 account-pool:每账号 3 个并发会话);
2014
+ * 3. 账号积分/额度(见 credits.ts)。
2015
+ *
2016
+ * 所以这里累计的是**本机经插件发出的请求**,与卡片上的积分区块互补 —— 一个是本机
2017
+ * 级、一个是账号级。官方额度明细在 `codearts.huaweicloud.com/portal/settings/personal-usage`。
2018
+ *
2019
+ * 落盘在 `$DSH_HOME/.codearts-usage.json`,按天存,保留最近 `RETENTION_DAYS` 天。
2020
+ *
2021
+ * @module dsh-codearts/usage
2022
+ */
2023
+ /** 用量账本路径:与账号文档同一层(`$DSH_HOME` 或 `~/.dsh`)。 */
2024
+ function codeartsUsagePath(home) {
2025
+ const base = home ?? process.env.DSH_HOME ?? join(homedir(), ".dsh");
2026
+ return join(base, ".codearts-usage.json");
2027
+ }
2028
+ function emptyTotals() {
2029
+ return {
2030
+ requests: 0,
2031
+ promptTokens: 0,
2032
+ completionTokens: 0,
2033
+ totalTokens: 0
2034
+ };
2035
+ }
2036
+ function addTotals(into, add) {
2037
+ into.requests += add.requests ?? 0;
2038
+ into.promptTokens += add.promptTokens ?? 0;
2039
+ into.completionTokens += add.completionTokens ?? 0;
2040
+ into.totalTokens += add.totalTokens ?? 0;
2041
+ }
2042
+ function mergeTotals(into, from) {
2043
+ addTotals(into, from);
2044
+ }
2045
+ /** 本地日期键 YYYY-MM-DD(不用 toISOString:那是 UTC,会把跨零点的用量算到前一天)。 */
2046
+ function localDateKey(at = Date.now()) {
2047
+ const d = new Date(at);
2048
+ const p = (n) => String(n).padStart(2, "0");
2049
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
2050
+ }
2051
+ function positiveInt(value) {
2052
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
2053
+ }
2054
+ /** 从上游 usage 帧里取三个计数字段(缺哪个算 0,total 缺失时用 prompt+completion 兜底)。 */
2055
+ function normalizeUsage(raw) {
2056
+ if (raw === void 0) return {
2057
+ promptTokens: 0,
2058
+ completionTokens: 0,
2059
+ totalTokens: 0
2060
+ };
2061
+ const promptTokens = positiveInt(raw.prompt_tokens);
2062
+ const completionTokens = positiveInt(raw.completion_tokens);
2063
+ const total = positiveInt(raw.total_tokens);
2064
+ return {
2065
+ promptTokens,
2066
+ completionTokens,
2067
+ totalTokens: total > 0 ? total : promptTokens + completionTokens
2068
+ };
2069
+ }
2070
+ /**
2071
+ * 用量账本。
2072
+ *
2073
+ * 记账分两步,因为一次请求可能只有其一:
2074
+ * - `noteRequest()` 在请求发出时调用(拿不到 usage 的流也要计入请求数);
2075
+ * - `noteUsage()` 在收到 usage 帧时调用(补 token 数)。
2076
+ */
2077
+ var CodeArtsUsageTracker = class {
2078
+ days = /* @__PURE__ */ new Map();
2079
+ lastAt;
2080
+ loaded = false;
2081
+ loading;
2082
+ saveTimer;
2083
+ saving = Promise.resolve();
2084
+ path;
2085
+ now;
2086
+ saveDebounceMs;
2087
+ constructor(options = {}) {
2088
+ this.path = options.path;
2089
+ this.now = options.now ?? (() => Date.now());
2090
+ this.saveDebounceMs = options.saveDebounceMs ?? 2e3;
2091
+ }
2092
+ /** 读账本(懒加载,只读一次)。 */
2093
+ async load() {
2094
+ this.loading ??= this.readFile();
2095
+ return this.loading;
2096
+ }
2097
+ async readFile() {
2098
+ if (this.loaded || this.path === void 0 || !existsSync(this.path)) {
2099
+ this.loaded = true;
2100
+ return;
2101
+ }
2102
+ try {
2103
+ const raw = await readFile(this.path, "utf8");
2104
+ const parsed = JSON.parse(raw);
2105
+ if (parsed.version !== 1 || typeof parsed.days !== "object" || parsed.days === null) return;
2106
+ for (const [date, day] of Object.entries(parsed.days)) {
2107
+ if (typeof day !== "object" || day === null) continue;
2108
+ const models = day.models ?? {};
2109
+ const accounts = day.accounts ?? {};
2110
+ this.days.set(date, {
2111
+ models,
2112
+ accounts
2113
+ });
2114
+ }
2115
+ this.prune(this.now());
2116
+ } catch {
2117
+ this.days.clear();
2118
+ } finally {
2119
+ this.loaded = true;
2120
+ }
2121
+ }
2122
+ day(dateKey) {
2123
+ let day = this.days.get(dateKey);
2124
+ if (day === void 0) {
2125
+ day = {
2126
+ models: {},
2127
+ accounts: {}
2128
+ };
2129
+ this.days.set(dateKey, day);
2130
+ }
2131
+ return day;
2132
+ }
2133
+ bucket(store, key) {
2134
+ let totals = store[key];
2135
+ if (totals === void 0) {
2136
+ totals = emptyTotals();
2137
+ store[key] = totals;
2138
+ }
2139
+ return totals;
2140
+ }
2141
+ /** 记一次请求(不等到 usage 帧)。 */
2142
+ noteRequest(accountId, model, at = this.now()) {
2143
+ const day = this.day(localDateKey(at));
2144
+ addTotals(this.bucket(day.models, model), { requests: 1 });
2145
+ addTotals(this.bucket(day.accounts, accountId), { requests: 1 });
2146
+ this.lastAt = at;
2147
+ this.prune(at);
2148
+ this.scheduleSave();
2149
+ }
2150
+ /** 记一次 usage 帧。 */
2151
+ noteUsage(accountId, model, raw, at = this.now()) {
2152
+ const usage = normalizeUsage(raw);
2153
+ if (usage.totalTokens === 0 && usage.promptTokens === 0 && usage.completionTokens === 0) return;
2154
+ const day = this.day(localDateKey(at));
2155
+ addTotals(this.bucket(day.models, model), usage);
2156
+ addTotals(this.bucket(day.accounts, accountId), usage);
2157
+ this.lastAt = at;
2158
+ this.prune(at);
2159
+ this.scheduleSave();
2160
+ }
2161
+ /** 丢弃保留期之外的天(按日期键比较即可,YYYY-MM-DD 是字典序)。 */
2162
+ prune(at) {
2163
+ if (this.days.size === 0) return;
2164
+ const cutoff = localDateKey(at - 5184e6);
2165
+ for (const date of [...this.days.keys()]) if (date < cutoff) this.days.delete(date);
2166
+ }
2167
+ /** 当前快照(卡片与 CLI 都读它)。 */
2168
+ snapshot(at = this.now()) {
2169
+ const todayKey = localDateKey(at);
2170
+ const today = this.days.get(todayKey);
2171
+ const allTime = emptyTotals();
2172
+ for (const day of this.days.values()) for (const totals of Object.values(day.models)) mergeTotals(allTime, totals);
2173
+ const models = today?.models ?? {};
2174
+ return {
2175
+ today: {
2176
+ ...emptyTotals(),
2177
+ ...today === void 0 ? {} : sumOf(models)
2178
+ },
2179
+ allTime,
2180
+ byModelToday: Object.entries(models).map(([id, totals]) => ({
2181
+ id,
2182
+ totals: { ...totals }
2183
+ })).sort((a, b) => b.totals.totalTokens - a.totals.totalTokens || (a.id < b.id ? -1 : 1)),
2184
+ byAccountToday: Object.fromEntries(Object.entries(today?.accounts ?? {}).map(([id, totals]) => [id, { ...totals }])),
2185
+ days: this.days.size,
2186
+ ...this.lastAt === void 0 ? {} : { lastAt: this.lastAt }
2187
+ };
2188
+ }
2189
+ scheduleSave() {
2190
+ if (this.path === void 0) return;
2191
+ if (this.saveTimer !== void 0) return;
2192
+ this.saveTimer = setTimeout(() => {
2193
+ this.saveTimer = void 0;
2194
+ this.save();
2195
+ }, this.saveDebounceMs);
2196
+ this.saveTimer.unref?.();
2197
+ }
2198
+ /** 立即落盘(关停时调用,别丢最后几秒的账)。 */
2199
+ async flush() {
2200
+ if (this.saveTimer !== void 0) {
2201
+ clearTimeout(this.saveTimer);
2202
+ this.saveTimer = void 0;
2203
+ }
2204
+ await this.save();
2205
+ }
2206
+ save() {
2207
+ if (this.path === void 0) return Promise.resolve();
2208
+ const doc = {
2209
+ version: 1,
2210
+ days: Object.fromEntries(this.days)
2211
+ };
2212
+ this.saving = this.saving.then(() => writeJsonAtomic(this.path, doc), () => writeJsonAtomic(this.path, doc));
2213
+ return this.saving;
2214
+ }
2215
+ };
2216
+ /** 一批桶的合计。 */
2217
+ function sumOf(store) {
2218
+ const out = emptyTotals();
2219
+ for (const totals of Object.values(store)) mergeTotals(out, totals);
2220
+ return out;
2221
+ }
2222
+ //#endregion
2223
+ export { codeartsAuthPath as C, codeartsAccountsPath as S, FALLBACK_CODEARTS_MODELS as _, SNAP_HOST as a, CodeArtsAccountStore as b, degradeTokenCeiling as c, isConcurrencyCap as d, isParamInvalidFrame as f, CodeArtsCatalog as g, signedGet as h, refreshCatalog as i, fetchCallerIdentity as l, prepareChatBody as m, codeartsUsagePath as n, canonicalModel as o, maxTokensCeilingFor as p, discoverModels as r, chatStream as s, CodeArtsUsageTracker as t, fetchModels as u, filterEnabledModels as v, startLogin as w, CodeArtsCredentialStore as x, isBenefitModel as y };